Merge "redo: don't die producing xml files if rev text export conversion fails"
[lhc/web/wiklou.git] / resources / lib / ooui / oojs-ui-core.js
1 /*!
2 * OOUI v0.31.3
3 * https://www.mediawiki.org/wiki/OOUI
4 *
5 * Copyright 2011–2019 OOUI Team and other contributors.
6 * Released under the MIT license
7 * http://oojs.mit-license.org
8 *
9 * Date: 2019-04-04T19:10:48Z
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 'ooui-' + 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,
215 * otherwise only match descendants
216 * @return {boolean} The node is in the list of target nodes
217 */
218 OO.ui.contains = function ( containers, contained, matchContainers ) {
219 var i;
220 if ( !Array.isArray( containers ) ) {
221 containers = [ containers ];
222 }
223 for ( i = containers.length - 1; i >= 0; i-- ) {
224 if (
225 ( matchContainers && contained === containers[ i ] ) ||
226 $.contains( containers[ i ], contained )
227 ) {
228 return true;
229 }
230 }
231 return false;
232 };
233
234 /**
235 * Return a function, that, as long as it continues to be invoked, will not
236 * be triggered. The function will be called after it stops being called for
237 * N milliseconds. If `immediate` is passed, trigger the function on the
238 * leading edge, instead of the trailing.
239 *
240 * Ported from: http://underscorejs.org/underscore.js
241 *
242 * @param {Function} func Function to debounce
243 * @param {number} [wait=0] Wait period in milliseconds
244 * @param {boolean} [immediate] Trigger on leading edge
245 * @return {Function} Debounced function
246 */
247 OO.ui.debounce = function ( func, wait, immediate ) {
248 var timeout;
249 return function () {
250 var context = this,
251 args = arguments,
252 later = function () {
253 timeout = null;
254 if ( !immediate ) {
255 func.apply( context, args );
256 }
257 };
258 if ( immediate && !timeout ) {
259 func.apply( context, args );
260 }
261 if ( !timeout || wait ) {
262 clearTimeout( timeout );
263 timeout = setTimeout( later, wait );
264 }
265 };
266 };
267
268 /**
269 * Puts a console warning with provided message.
270 *
271 * @param {string} message Message
272 */
273 OO.ui.warnDeprecation = function ( message ) {
274 if ( OO.getProp( window, 'console', 'warn' ) !== undefined ) {
275 // eslint-disable-next-line no-console
276 console.warn( message );
277 }
278 };
279
280 /**
281 * Returns a function, that, when invoked, will only be triggered at most once
282 * during a given window of time. If called again during that window, it will
283 * wait until the window ends and then trigger itself again.
284 *
285 * As it's not knowable to the caller whether the function will actually run
286 * when the wrapper is called, return values from the function are entirely
287 * discarded.
288 *
289 * @param {Function} func Function to throttle
290 * @param {number} wait Throttle window length, in milliseconds
291 * @return {Function} Throttled function
292 */
293 OO.ui.throttle = function ( func, wait ) {
294 var context, args, timeout,
295 previous = 0,
296 run = function () {
297 timeout = null;
298 previous = Date.now();
299 func.apply( context, args );
300 };
301 return function () {
302 // Check how long it's been since the last time the function was
303 // called, and whether it's more or less than the requested throttle
304 // period. If it's less, run the function immediately. If it's more,
305 // set a timeout for the remaining time -- but don't replace an
306 // existing timeout, since that'd indefinitely prolong the wait.
307 var remaining = wait - ( Date.now() - previous );
308 context = this;
309 args = arguments;
310 if ( remaining <= 0 ) {
311 // Note: unless wait was ridiculously large, this means we'll
312 // automatically run the first time the function was called in a
313 // given period. (If you provide a wait period larger than the
314 // current Unix timestamp, you *deserve* unexpected behavior.)
315 clearTimeout( timeout );
316 run();
317 } else if ( !timeout ) {
318 timeout = setTimeout( run, remaining );
319 }
320 };
321 };
322
323 /**
324 * A (possibly faster) way to get the current timestamp as an integer.
325 *
326 * @deprecated Since 0.31.1; use `Date.now()` instead.
327 * @return {number} Current timestamp, in milliseconds since the Unix epoch
328 */
329 OO.ui.now = function () {
330 OO.ui.warnDeprecation( 'OO.ui.now() is deprecated, use Date.now() instead' );
331 return Date.now();
332 };
333
334 /**
335 * Reconstitute a JavaScript object corresponding to a widget created by
336 * the PHP implementation.
337 *
338 * This is an alias for `OO.ui.Element.static.infuse()`.
339 *
340 * @param {string|HTMLElement|jQuery} idOrNode
341 * A DOM id (if a string) or node for the widget to infuse.
342 * @param {Object} [config] Configuration options
343 * @return {OO.ui.Element}
344 * The `OO.ui.Element` corresponding to this (infusable) document node.
345 */
346 OO.ui.infuse = function ( idOrNode, config ) {
347 return OO.ui.Element.static.infuse( idOrNode, config );
348 };
349
350 /**
351 * Get a localized message.
352 *
353 * After the message key, message parameters may optionally be passed. In the default
354 * implementation, any occurrences of $1 are replaced with the first parameter, $2 with the
355 * second parameter, etc.
356 * Alternative implementations of OO.ui.msg may use any substitution system they like, as long
357 * as they support unnamed, ordered message parameters.
358 *
359 * In environments that provide a localization system, this function should be overridden to
360 * return the message translated in the user's language. The default implementation always
361 * returns English messages. An example of doing this with
362 * [jQuery.i18n](https://github.com/wikimedia/jquery.i18n) follows.
363 *
364 * @example
365 * var i, iLen, button,
366 * messagePath = 'oojs-ui/dist/i18n/',
367 * languages = [ $.i18n().locale, 'ur', 'en' ],
368 * languageMap = {};
369 *
370 * for ( i = 0, iLen = languages.length; i < iLen; i++ ) {
371 * languageMap[ languages[ i ] ] = messagePath + languages[ i ].toLowerCase() + '.json';
372 * }
373 *
374 * $.i18n().load( languageMap ).done( function() {
375 * // Replace the built-in `msg` only once we've loaded the internationalization.
376 * // OOUI uses `OO.ui.deferMsg` for all initially-loaded messages. So long as
377 * // you put off creating any widgets until this promise is complete, no English
378 * // will be displayed.
379 * OO.ui.msg = $.i18n;
380 *
381 * // A button displaying "OK" in the default locale
382 * button = new OO.ui.ButtonWidget( {
383 * label: OO.ui.msg( 'ooui-dialog-message-accept' ),
384 * icon: 'check'
385 * } );
386 * $( document.body ).append( button.$element );
387 *
388 * // A button displaying "OK" in Urdu
389 * $.i18n().locale = 'ur';
390 * button = new OO.ui.ButtonWidget( {
391 * label: OO.ui.msg( 'ooui-dialog-message-accept' ),
392 * icon: 'check'
393 * } );
394 * $( document.body ).append( button.$element );
395 * } );
396 *
397 * @param {string} key Message key
398 * @param {...Mixed} [params] Message parameters
399 * @return {string} Translated message with parameters substituted
400 */
401 OO.ui.msg = function ( key ) {
402 // `OO.ui.msg.messages` is defined in code generated during the build process
403 var messages = OO.ui.msg.messages,
404 message = messages[ key ],
405 params = Array.prototype.slice.call( arguments, 1 );
406 if ( typeof message === 'string' ) {
407 // Perform $1 substitution
408 message = message.replace( /\$(\d+)/g, function ( unused, n ) {
409 var i = parseInt( n, 10 );
410 return params[ i - 1 ] !== undefined ? params[ i - 1 ] : '$' + n;
411 } );
412 } else {
413 // Return placeholder if message not found
414 message = '[' + key + ']';
415 }
416 return message;
417 };
418
419 /**
420 * Package a message and arguments for deferred resolution.
421 *
422 * Use this when you are statically specifying a message and the message may not yet be present.
423 *
424 * @param {string} key Message key
425 * @param {...Mixed} [params] Message parameters
426 * @return {Function} Function that returns the resolved message when executed
427 */
428 OO.ui.deferMsg = function () {
429 var args = arguments;
430 return function () {
431 return OO.ui.msg.apply( OO.ui, args );
432 };
433 };
434
435 /**
436 * Resolve a message.
437 *
438 * If the message is a function it will be executed, otherwise it will pass through directly.
439 *
440 * @param {Function|string} msg Deferred message, or message text
441 * @return {string} Resolved message
442 */
443 OO.ui.resolveMsg = function ( msg ) {
444 if ( typeof msg === 'function' ) {
445 return msg();
446 }
447 return msg;
448 };
449
450 /**
451 * @param {string} url
452 * @return {boolean}
453 */
454 OO.ui.isSafeUrl = function ( url ) {
455 // Keep this function in sync with php/Tag.php
456 var i, protocolWhitelist;
457
458 function stringStartsWith( haystack, needle ) {
459 return haystack.substr( 0, needle.length ) === needle;
460 }
461
462 protocolWhitelist = [
463 'bitcoin', 'ftp', 'ftps', 'geo', 'git', 'gopher', 'http', 'https', 'irc', 'ircs',
464 'magnet', 'mailto', 'mms', 'news', 'nntp', 'redis', 'sftp', 'sip', 'sips', 'sms', 'ssh',
465 'svn', 'tel', 'telnet', 'urn', 'worldwind', 'xmpp'
466 ];
467
468 if ( url === '' ) {
469 return true;
470 }
471
472 for ( i = 0; i < protocolWhitelist.length; i++ ) {
473 if ( stringStartsWith( url, protocolWhitelist[ i ] + ':' ) ) {
474 return true;
475 }
476 }
477
478 // This matches '//' too
479 if ( stringStartsWith( url, '/' ) || stringStartsWith( url, './' ) ) {
480 return true;
481 }
482 if ( stringStartsWith( url, '?' ) || stringStartsWith( url, '#' ) ) {
483 return true;
484 }
485
486 return false;
487 };
488
489 /**
490 * Check if the user has a 'mobile' device.
491 *
492 * For our purposes this means the user is primarily using an
493 * on-screen keyboard, touch input instead of a mouse and may
494 * have a physically small display.
495 *
496 * It is left up to implementors to decide how to compute this
497 * so the default implementation always returns false.
498 *
499 * @return {boolean} User is on a mobile device
500 */
501 OO.ui.isMobile = function () {
502 return false;
503 };
504
505 /**
506 * Get the additional spacing that should be taken into account when displaying elements that are
507 * clipped to the viewport, e.g. dropdown menus and popups. This is meant to be overridden to avoid
508 * such menus overlapping any fixed headers/toolbars/navigation used by the site.
509 *
510 * @return {Object} Object with the properties 'top', 'right', 'bottom', 'left', each representing
511 * the extra spacing from that edge of viewport (in pixels)
512 */
513 OO.ui.getViewportSpacing = function () {
514 return {
515 top: 0,
516 right: 0,
517 bottom: 0,
518 left: 0
519 };
520 };
521
522 /**
523 * Get the default overlay, which is used by various widgets when they are passed `$overlay: true`.
524 * See <https://www.mediawiki.org/wiki/OOUI/Concepts#Overlays>.
525 *
526 * @return {jQuery} Default overlay node
527 */
528 OO.ui.getDefaultOverlay = function () {
529 if ( !OO.ui.$defaultOverlay ) {
530 OO.ui.$defaultOverlay = $( '<div>' ).addClass( 'oo-ui-defaultOverlay' );
531 $( document.body ).append( OO.ui.$defaultOverlay );
532 }
533 return OO.ui.$defaultOverlay;
534 };
535
536 /**
537 * Message store for the default implementation of OO.ui.msg.
538 *
539 * Environments that provide a localization system should not use this, but should override
540 * OO.ui.msg altogether.
541 *
542 * @private
543 */
544 OO.ui.msg.messages = {
545 "ooui-outline-control-move-down": "Move item down",
546 "ooui-outline-control-move-up": "Move item up",
547 "ooui-outline-control-remove": "Remove item",
548 "ooui-toolbar-more": "More",
549 "ooui-toolgroup-expand": "More",
550 "ooui-toolgroup-collapse": "Fewer",
551 "ooui-item-remove": "Remove",
552 "ooui-dialog-message-accept": "OK",
553 "ooui-dialog-message-reject": "Cancel",
554 "ooui-dialog-process-error": "Something went wrong",
555 "ooui-dialog-process-dismiss": "Dismiss",
556 "ooui-dialog-process-retry": "Try again",
557 "ooui-dialog-process-continue": "Continue",
558 "ooui-combobox-button-label": "Dropdown for combobox",
559 "ooui-selectfile-button-select": "Select a file",
560 "ooui-selectfile-not-supported": "File selection is not supported",
561 "ooui-selectfile-placeholder": "No file is selected",
562 "ooui-selectfile-dragdrop-placeholder": "Drop file here",
563 "ooui-field-help": "Help"
564 };
565
566 /*!
567 * Mixin namespace.
568 */
569
570 /**
571 * Namespace for OOUI mixins.
572 *
573 * Mixins are named according to the type of object they are intended to
574 * be mixed in to. For example, OO.ui.mixin.GroupElement is intended to be
575 * mixed in to an instance of OO.ui.Element, and OO.ui.mixin.GroupWidget
576 * is intended to be mixed in to an instance of OO.ui.Widget.
577 *
578 * @class
579 * @singleton
580 */
581 OO.ui.mixin = {};
582
583 /**
584 * Each Element represents a rendering in the DOM—a button or an icon, for example, or anything
585 * that is visible to a user. Unlike {@link OO.ui.Widget widgets}, plain elements usually do not
586 * have events connected to them and can't be interacted with.
587 *
588 * @abstract
589 * @class
590 *
591 * @constructor
592 * @param {Object} [config] Configuration options
593 * @cfg {string[]} [classes] The names of the CSS classes to apply to the element. CSS styles are
594 * added to the top level (e.g., the outermost div) of the element. See the
595 * [OOUI documentation on MediaWiki][2] for an example.
596 * [2]: https://www.mediawiki.org/wiki/OOUI/Widgets/Buttons_and_Switches#cssExample
597 * @cfg {string} [id] The HTML id attribute used in the rendered tag.
598 * @cfg {string} [text] Text to insert
599 * @cfg {Array} [content] An array of content elements to append (after #text).
600 * Strings will be html-escaped; use an OO.ui.HtmlSnippet to append raw HTML.
601 * Instances of OO.ui.Element will have their $element appended.
602 * @cfg {jQuery} [$content] Content elements to append (after #text).
603 * @cfg {jQuery} [$element] Wrapper element. Defaults to a new element with #getTagName.
604 * @cfg {Mixed} [data] Custom data of any type or combination of types (e.g., string, number,
605 * array, object).
606 * Data can also be specified with the #setData method.
607 */
608 OO.ui.Element = function OoUiElement( config ) {
609 if ( OO.ui.isDemo ) {
610 this.initialConfig = config;
611 }
612 // Configuration initialization
613 config = config || {};
614
615 // Properties
616 this.$ = function () {
617 OO.ui.warnDeprecation( 'this.$ is deprecated, use global $ instead' );
618 return $.apply( this, arguments );
619 };
620 this.elementId = null;
621 this.visible = true;
622 this.data = config.data;
623 this.$element = config.$element ||
624 $( document.createElement( this.getTagName() ) );
625 this.elementGroup = null;
626
627 // Initialization
628 if ( Array.isArray( config.classes ) ) {
629 this.$element.addClass( config.classes );
630 }
631 if ( config.id ) {
632 this.setElementId( config.id );
633 }
634 if ( config.text ) {
635 this.$element.text( config.text );
636 }
637 if ( config.content ) {
638 // The `content` property treats plain strings as text; use an
639 // HtmlSnippet to append HTML content. `OO.ui.Element`s get their
640 // appropriate $element appended.
641 this.$element.append( config.content.map( function ( v ) {
642 if ( typeof v === 'string' ) {
643 // Escape string so it is properly represented in HTML.
644 // Don't create empty text nodes for empty strings.
645 return v ? document.createTextNode( v ) : undefined;
646 } else if ( v instanceof OO.ui.HtmlSnippet ) {
647 // Bypass escaping.
648 return v.toString();
649 } else if ( v instanceof OO.ui.Element ) {
650 return v.$element;
651 }
652 return v;
653 } ) );
654 }
655 if ( config.$content ) {
656 // The `$content` property treats plain strings as HTML.
657 this.$element.append( config.$content );
658 }
659 };
660
661 /* Setup */
662
663 OO.initClass( OO.ui.Element );
664
665 /* Static Properties */
666
667 /**
668 * The name of the HTML tag used by the element.
669 *
670 * The static value may be ignored if the #getTagName method is overridden.
671 *
672 * @static
673 * @inheritable
674 * @property {string}
675 */
676 OO.ui.Element.static.tagName = 'div';
677
678 /* Static Methods */
679
680 /**
681 * Reconstitute a JavaScript object corresponding to a widget created
682 * by the PHP implementation.
683 *
684 * @param {string|HTMLElement|jQuery} idOrNode
685 * A DOM id (if a string) or node for the widget to infuse.
686 * @param {Object} [config] Configuration options
687 * @return {OO.ui.Element}
688 * The `OO.ui.Element` corresponding to this (infusable) document node.
689 * For `Tag` objects emitted on the HTML side (used occasionally for content)
690 * the value returned is a newly-created Element wrapping around the existing
691 * DOM node.
692 */
693 OO.ui.Element.static.infuse = function ( idOrNode, config ) {
694 var obj = OO.ui.Element.static.unsafeInfuse( idOrNode, config, false );
695
696 if ( typeof idOrNode === 'string' ) {
697 // IDs deprecated since 0.29.7
698 OO.ui.warnDeprecation(
699 'Passing a string ID to infuse is deprecated. Use an HTMLElement or jQuery collection instead.'
700 );
701 }
702 // Verify that the type matches up.
703 // FIXME: uncomment after T89721 is fixed, see T90929.
704 /*
705 if ( !( obj instanceof this['class'] ) ) {
706 throw new Error( 'Infusion type mismatch!' );
707 }
708 */
709 return obj;
710 };
711
712 /**
713 * Implementation helper for `infuse`; skips the type check and has an
714 * extra property so that only the top-level invocation touches the DOM.
715 *
716 * @private
717 * @param {string|HTMLElement|jQuery} idOrNode
718 * @param {Object} [config] Configuration options
719 * @param {jQuery.Promise} [domPromise] A promise that will be resolved
720 * when the top-level widget of this infusion is inserted into DOM,
721 * replacing the original node; only used internally.
722 * @return {OO.ui.Element}
723 */
724 OO.ui.Element.static.unsafeInfuse = function ( idOrNode, config, domPromise ) {
725 // look for a cached result of a previous infusion.
726 var id, $elem, error, data, cls, parts, parent, obj, top, state, infusedChildren;
727 if ( typeof idOrNode === 'string' ) {
728 id = idOrNode;
729 $elem = $( document.getElementById( id ) );
730 } else {
731 $elem = $( idOrNode );
732 id = $elem.attr( 'id' );
733 }
734 if ( !$elem.length ) {
735 if ( typeof idOrNode === 'string' ) {
736 error = 'Widget not found: ' + idOrNode;
737 } else if ( idOrNode && idOrNode.selector ) {
738 error = 'Widget not found: ' + idOrNode.selector;
739 } else {
740 error = 'Widget not found';
741 }
742 throw new Error( error );
743 }
744 if ( $elem[ 0 ].oouiInfused ) {
745 $elem = $elem[ 0 ].oouiInfused;
746 }
747 data = $elem.data( 'ooui-infused' );
748 if ( data ) {
749 // cached!
750 if ( data === true ) {
751 throw new Error( 'Circular dependency! ' + id );
752 }
753 if ( domPromise ) {
754 // Pick up dynamic state, like focus, value of form inputs, scroll position, etc.
755 state = data.constructor.static.gatherPreInfuseState( $elem, data );
756 // Restore dynamic state after the new element is re-inserted into DOM under
757 // infused parent.
758 domPromise.done( data.restorePreInfuseState.bind( data, state ) );
759 infusedChildren = $elem.data( 'ooui-infused-children' );
760 if ( infusedChildren && infusedChildren.length ) {
761 infusedChildren.forEach( function ( data ) {
762 var state = data.constructor.static.gatherPreInfuseState( $elem, data );
763 domPromise.done( data.restorePreInfuseState.bind( data, state ) );
764 } );
765 }
766 }
767 return data;
768 }
769 data = $elem.attr( 'data-ooui' );
770 if ( !data ) {
771 throw new Error( 'No infusion data found: ' + id );
772 }
773 try {
774 data = JSON.parse( data );
775 } catch ( _ ) {
776 data = null;
777 }
778 if ( !( data && data._ ) ) {
779 throw new Error( 'No valid infusion data found: ' + id );
780 }
781 if ( data._ === 'Tag' ) {
782 // Special case: this is a raw Tag; wrap existing node, don't rebuild.
783 return new OO.ui.Element( $.extend( {}, config, { $element: $elem } ) );
784 }
785 parts = data._.split( '.' );
786 cls = OO.getProp.apply( OO, [ window ].concat( parts ) );
787 if ( cls === undefined ) {
788 throw new Error( 'Unknown widget type: id: ' + id + ', class: ' + data._ );
789 }
790
791 // Verify that we're creating an OO.ui.Element instance
792 parent = cls.parent;
793
794 while ( parent !== undefined ) {
795 if ( parent === OO.ui.Element ) {
796 // Safe
797 break;
798 }
799
800 parent = parent.parent;
801 }
802
803 if ( parent !== OO.ui.Element ) {
804 throw new Error( 'Unknown widget type: id: ' + id + ', class: ' + data._ );
805 }
806
807 if ( !domPromise ) {
808 top = $.Deferred();
809 domPromise = top.promise();
810 }
811 $elem.data( 'ooui-infused', true ); // prevent loops
812 data.id = id; // implicit
813 infusedChildren = [];
814 data = OO.copy( data, null, function deserialize( value ) {
815 var infused;
816 if ( OO.isPlainObject( value ) ) {
817 if ( value.tag ) {
818 infused = OO.ui.Element.static.unsafeInfuse( value.tag, config, domPromise );
819 infusedChildren.push( infused );
820 // Flatten the structure
821 infusedChildren.push.apply(
822 infusedChildren,
823 infused.$element.data( 'ooui-infused-children' ) || []
824 );
825 infused.$element.removeData( 'ooui-infused-children' );
826 return infused;
827 }
828 if ( value.html !== undefined ) {
829 return new OO.ui.HtmlSnippet( value.html );
830 }
831 }
832 } );
833 // allow widgets to reuse parts of the DOM
834 data = cls.static.reusePreInfuseDOM( $elem[ 0 ], data );
835 // pick up dynamic state, like focus, value of form inputs, scroll position, etc.
836 state = cls.static.gatherPreInfuseState( $elem[ 0 ], data );
837 // rebuild widget
838 // eslint-disable-next-line new-cap
839 obj = new cls( $.extend( {}, config, data ) );
840 // If anyone is holding a reference to the old DOM element,
841 // let's allow them to OO.ui.infuse() it and do what they expect, see T105828.
842 // Do not use jQuery.data(), as using it on detached nodes leaks memory in 1.x line by design.
843 $elem[ 0 ].oouiInfused = obj.$element;
844 // now replace old DOM with this new DOM.
845 if ( top ) {
846 // An efficient constructor might be able to reuse the entire DOM tree of the original
847 // element, so only mutate the DOM if we need to.
848 if ( $elem[ 0 ] !== obj.$element[ 0 ] ) {
849 $elem.replaceWith( obj.$element );
850 }
851 top.resolve();
852 }
853 obj.$element.data( 'ooui-infused', obj );
854 obj.$element.data( 'ooui-infused-children', infusedChildren );
855 // set the 'data-ooui' attribute so we can identify infused widgets
856 obj.$element.attr( 'data-ooui', '' );
857 // restore dynamic state after the new element is inserted into DOM
858 domPromise.done( obj.restorePreInfuseState.bind( obj, state ) );
859 return obj;
860 };
861
862 /**
863 * Pick out parts of `node`'s DOM to be reused when infusing a widget.
864 *
865 * This method **must not** make any changes to the DOM, only find interesting pieces and add them
866 * to `config` (which should then be returned). Actual DOM juggling should then be done by the
867 * constructor, which will be given the enhanced config.
868 *
869 * @protected
870 * @param {HTMLElement} node
871 * @param {Object} config
872 * @return {Object}
873 */
874 OO.ui.Element.static.reusePreInfuseDOM = function ( node, config ) {
875 return config;
876 };
877
878 /**
879 * Gather the dynamic state (focus, value of form inputs, scroll position, etc.) of an HTML DOM
880 * node (and its children) that represent an Element of the same class and the given configuration,
881 * generated by the PHP implementation.
882 *
883 * This method is called just before `node` is detached from the DOM. The return value of this
884 * function will be passed to #restorePreInfuseState after the newly created widget's #$element
885 * is inserted into DOM to replace `node`.
886 *
887 * @protected
888 * @param {HTMLElement} node
889 * @param {Object} config
890 * @return {Object}
891 */
892 OO.ui.Element.static.gatherPreInfuseState = function () {
893 return {};
894 };
895
896 /**
897 * Get a jQuery function within a specific document.
898 *
899 * @static
900 * @param {jQuery|HTMLElement|HTMLDocument|Window} context Context to bind the function to
901 * @param {jQuery} [$iframe] HTML iframe element that contains the document, omit if document is
902 * not in an iframe
903 * @return {Function} Bound jQuery function
904 */
905 OO.ui.Element.static.getJQuery = function ( context, $iframe ) {
906 function wrapper( selector ) {
907 return $( selector, wrapper.context );
908 }
909
910 wrapper.context = this.getDocument( context );
911
912 if ( $iframe ) {
913 wrapper.$iframe = $iframe;
914 }
915
916 return wrapper;
917 };
918
919 /**
920 * Get the document of an element.
921 *
922 * @static
923 * @param {jQuery|HTMLElement|HTMLDocument|Window} obj Object to get the document for
924 * @return {HTMLDocument|null} Document object
925 */
926 OO.ui.Element.static.getDocument = function ( obj ) {
927 // jQuery - selections created "offscreen" won't have a context, so .context isn't reliable
928 return ( obj[ 0 ] && obj[ 0 ].ownerDocument ) ||
929 // Empty jQuery selections might have a context
930 obj.context ||
931 // HTMLElement
932 obj.ownerDocument ||
933 // Window
934 obj.document ||
935 // HTMLDocument
936 ( obj.nodeType === Node.DOCUMENT_NODE && obj ) ||
937 null;
938 };
939
940 /**
941 * Get the window of an element or document.
942 *
943 * @static
944 * @param {jQuery|HTMLElement|HTMLDocument|Window} obj Context to get the window for
945 * @return {Window} Window object
946 */
947 OO.ui.Element.static.getWindow = function ( obj ) {
948 var doc = this.getDocument( obj );
949 return doc.defaultView;
950 };
951
952 /**
953 * Get the direction of an element or document.
954 *
955 * @static
956 * @param {jQuery|HTMLElement|HTMLDocument|Window} obj Context to get the direction for
957 * @return {string} Text direction, either 'ltr' or 'rtl'
958 */
959 OO.ui.Element.static.getDir = function ( obj ) {
960 var isDoc, isWin;
961
962 if ( obj instanceof $ ) {
963 obj = obj[ 0 ];
964 }
965 isDoc = obj.nodeType === Node.DOCUMENT_NODE;
966 isWin = obj.document !== undefined;
967 if ( isDoc || isWin ) {
968 if ( isWin ) {
969 obj = obj.document;
970 }
971 obj = obj.body;
972 }
973 return $( obj ).css( 'direction' );
974 };
975
976 /**
977 * Get the offset between two frames.
978 *
979 * TODO: Make this function not use recursion.
980 *
981 * @static
982 * @param {Window} from Window of the child frame
983 * @param {Window} [to=window] Window of the parent frame
984 * @param {Object} [offset] Offset to start with, used internally
985 * @return {Object} Offset object, containing left and top properties
986 */
987 OO.ui.Element.static.getFrameOffset = function ( from, to, offset ) {
988 var i, len, frames, frame, rect;
989
990 if ( !to ) {
991 to = window;
992 }
993 if ( !offset ) {
994 offset = { top: 0, left: 0 };
995 }
996 if ( from.parent === from ) {
997 return offset;
998 }
999
1000 // Get iframe element
1001 frames = from.parent.document.getElementsByTagName( 'iframe' );
1002 for ( i = 0, len = frames.length; i < len; i++ ) {
1003 if ( frames[ i ].contentWindow === from ) {
1004 frame = frames[ i ];
1005 break;
1006 }
1007 }
1008
1009 // Recursively accumulate offset values
1010 if ( frame ) {
1011 rect = frame.getBoundingClientRect();
1012 offset.left += rect.left;
1013 offset.top += rect.top;
1014 if ( from !== to ) {
1015 this.getFrameOffset( from.parent, offset );
1016 }
1017 }
1018 return offset;
1019 };
1020
1021 /**
1022 * Get the offset between two elements.
1023 *
1024 * The two elements may be in a different frame, but in that case the frame $element is in must
1025 * be contained in the frame $anchor is in.
1026 *
1027 * @static
1028 * @param {jQuery} $element Element whose position to get
1029 * @param {jQuery} $anchor Element to get $element's position relative to
1030 * @return {Object} Translated position coordinates, containing top and left properties
1031 */
1032 OO.ui.Element.static.getRelativePosition = function ( $element, $anchor ) {
1033 var iframe, iframePos,
1034 pos = $element.offset(),
1035 anchorPos = $anchor.offset(),
1036 elementDocument = this.getDocument( $element ),
1037 anchorDocument = this.getDocument( $anchor );
1038
1039 // If $element isn't in the same document as $anchor, traverse up
1040 while ( elementDocument !== anchorDocument ) {
1041 iframe = elementDocument.defaultView.frameElement;
1042 if ( !iframe ) {
1043 throw new Error( '$element frame is not contained in $anchor frame' );
1044 }
1045 iframePos = $( iframe ).offset();
1046 pos.left += iframePos.left;
1047 pos.top += iframePos.top;
1048 elementDocument = iframe.ownerDocument;
1049 }
1050 pos.left -= anchorPos.left;
1051 pos.top -= anchorPos.top;
1052 return pos;
1053 };
1054
1055 /**
1056 * Get element border sizes.
1057 *
1058 * @static
1059 * @param {HTMLElement} el Element to measure
1060 * @return {Object} Dimensions object with `top`, `left`, `bottom` and `right` properties
1061 */
1062 OO.ui.Element.static.getBorders = function ( el ) {
1063 var doc = el.ownerDocument,
1064 win = doc.defaultView,
1065 style = win.getComputedStyle( el, null ),
1066 $el = $( el ),
1067 top = parseFloat( style ? style.borderTopWidth : $el.css( 'borderTopWidth' ) ) || 0,
1068 left = parseFloat( style ? style.borderLeftWidth : $el.css( 'borderLeftWidth' ) ) || 0,
1069 bottom = parseFloat( style ? style.borderBottomWidth : $el.css( 'borderBottomWidth' ) ) || 0,
1070 right = parseFloat( style ? style.borderRightWidth : $el.css( 'borderRightWidth' ) ) || 0;
1071
1072 return {
1073 top: top,
1074 left: left,
1075 bottom: bottom,
1076 right: right
1077 };
1078 };
1079
1080 /**
1081 * Get dimensions of an element or window.
1082 *
1083 * @static
1084 * @param {HTMLElement|Window} el Element to measure
1085 * @return {Object} Dimensions object with `borders`, `scroll`, `scrollbar` and `rect` properties
1086 */
1087 OO.ui.Element.static.getDimensions = function ( el ) {
1088 var $el, $win,
1089 doc = el.ownerDocument || el.document,
1090 win = doc.defaultView;
1091
1092 if ( win === el || el === doc.documentElement ) {
1093 $win = $( win );
1094 return {
1095 borders: { top: 0, left: 0, bottom: 0, right: 0 },
1096 scroll: {
1097 top: $win.scrollTop(),
1098 left: $win.scrollLeft()
1099 },
1100 scrollbar: { right: 0, bottom: 0 },
1101 rect: {
1102 top: 0,
1103 left: 0,
1104 bottom: $win.innerHeight(),
1105 right: $win.innerWidth()
1106 }
1107 };
1108 } else {
1109 $el = $( el );
1110 return {
1111 borders: this.getBorders( el ),
1112 scroll: {
1113 top: $el.scrollTop(),
1114 left: $el.scrollLeft()
1115 },
1116 scrollbar: {
1117 right: $el.innerWidth() - el.clientWidth,
1118 bottom: $el.innerHeight() - el.clientHeight
1119 },
1120 rect: el.getBoundingClientRect()
1121 };
1122 }
1123 };
1124
1125 /**
1126 * Get the number of pixels that an element's content is scrolled to the left.
1127 *
1128 * Adapted from <https://github.com/othree/jquery.rtl-scroll-type>.
1129 * Original code copyright 2012 Wei-Ko Kao, licensed under the MIT License.
1130 *
1131 * This function smooths out browser inconsistencies (nicely described in the README at
1132 * <https://github.com/othree/jquery.rtl-scroll-type>) and produces a result consistent
1133 * with Firefox's 'scrollLeft', which seems the sanest.
1134 *
1135 * @static
1136 * @method
1137 * @param {HTMLElement|Window} el Element to measure
1138 * @return {number} Scroll position from the left.
1139 * If the element's direction is LTR, this is a positive number between `0` (initial scroll
1140 * position) and `el.scrollWidth - el.clientWidth` (furthest possible scroll position).
1141 * If the element's direction is RTL, this is a negative number between `0` (initial scroll
1142 * position) and `-el.scrollWidth + el.clientWidth` (furthest possible scroll position).
1143 */
1144 OO.ui.Element.static.getScrollLeft = ( function () {
1145 var rtlScrollType = null;
1146
1147 function test() {
1148 var $definer = $( '<div>' ).attr( {
1149 dir: 'rtl',
1150 style: 'font-size: 14px; width: 4px; height: 1px; position: absolute; top: -1000px; overflow: scroll;'
1151 } ).text( 'ABCD' ),
1152 definer = $definer[ 0 ];
1153
1154 $definer.appendTo( 'body' );
1155 if ( definer.scrollLeft > 0 ) {
1156 // Safari, Chrome
1157 rtlScrollType = 'default';
1158 } else {
1159 definer.scrollLeft = 1;
1160 if ( definer.scrollLeft === 0 ) {
1161 // Firefox, old Opera
1162 rtlScrollType = 'negative';
1163 } else {
1164 // Internet Explorer, Edge
1165 rtlScrollType = 'reverse';
1166 }
1167 }
1168 $definer.remove();
1169 }
1170
1171 return function getScrollLeft( el ) {
1172 var isRoot = el.window === el ||
1173 el === el.ownerDocument.body ||
1174 el === el.ownerDocument.documentElement,
1175 scrollLeft = isRoot ? $( window ).scrollLeft() : el.scrollLeft,
1176 // All browsers use the correct scroll type ('negative') on the root, so don't
1177 // do any fixups when looking at the root element
1178 direction = isRoot ? 'ltr' : $( el ).css( 'direction' );
1179
1180 if ( direction === 'rtl' ) {
1181 if ( rtlScrollType === null ) {
1182 test();
1183 }
1184 if ( rtlScrollType === 'reverse' ) {
1185 scrollLeft = -scrollLeft;
1186 } else if ( rtlScrollType === 'default' ) {
1187 scrollLeft = scrollLeft - el.scrollWidth + el.clientWidth;
1188 }
1189 }
1190
1191 return scrollLeft;
1192 };
1193 }() );
1194
1195 /**
1196 * Get the root scrollable element of given element's document.
1197 *
1198 * On Blink-based browsers (Chrome etc.), `document.documentElement` can't be used to get or set
1199 * the scrollTop property; instead we have to use `document.body`. Changing and testing the value
1200 * lets us use 'body' or 'documentElement' based on what is working.
1201 *
1202 * https://code.google.com/p/chromium/issues/detail?id=303131
1203 *
1204 * @static
1205 * @param {HTMLElement} el Element to find root scrollable parent for
1206 * @return {HTMLElement} Scrollable parent, `document.body` or `document.documentElement`
1207 * depending on browser
1208 */
1209 OO.ui.Element.static.getRootScrollableElement = function ( el ) {
1210 var scrollTop, body;
1211
1212 if ( OO.ui.scrollableElement === undefined ) {
1213 body = el.ownerDocument.body;
1214 scrollTop = body.scrollTop;
1215 body.scrollTop = 1;
1216
1217 // In some browsers (observed in Chrome 56 on Linux Mint 18.1),
1218 // body.scrollTop doesn't become exactly 1, but a fractional value like 0.76
1219 if ( Math.round( body.scrollTop ) === 1 ) {
1220 body.scrollTop = scrollTop;
1221 OO.ui.scrollableElement = 'body';
1222 } else {
1223 OO.ui.scrollableElement = 'documentElement';
1224 }
1225 }
1226
1227 return el.ownerDocument[ OO.ui.scrollableElement ];
1228 };
1229
1230 /**
1231 * Get closest scrollable container.
1232 *
1233 * Traverses up until either a scrollable element or the root is reached, in which case the root
1234 * scrollable element will be returned (see #getRootScrollableElement).
1235 *
1236 * @static
1237 * @param {HTMLElement} el Element to find scrollable container for
1238 * @param {string} [dimension] Dimension of scrolling to look for; `x`, `y` or omit for either
1239 * @return {HTMLElement} Closest scrollable container
1240 */
1241 OO.ui.Element.static.getClosestScrollableContainer = function ( el, dimension ) {
1242 var i, val,
1243 // Browsers do not correctly return the computed value of 'overflow' when 'overflow-x' and
1244 // 'overflow-y' have different values, so we need to check the separate properties.
1245 props = [ 'overflow-x', 'overflow-y' ],
1246 $parent = $( el ).parent();
1247
1248 if ( dimension === 'x' || dimension === 'y' ) {
1249 props = [ 'overflow-' + dimension ];
1250 }
1251
1252 // Special case for the document root (which doesn't really have any scrollable container,
1253 // since it is the ultimate scrollable container, but this is probably saner than null or
1254 // exception).
1255 if ( $( el ).is( 'html, body' ) ) {
1256 return this.getRootScrollableElement( el );
1257 }
1258
1259 while ( $parent.length ) {
1260 if ( $parent[ 0 ] === this.getRootScrollableElement( el ) ) {
1261 return $parent[ 0 ];
1262 }
1263 i = props.length;
1264 while ( i-- ) {
1265 val = $parent.css( props[ i ] );
1266 // We assume that elements with 'overflow' (in any direction) set to 'hidden' will
1267 // never be scrolled in that direction, but they can actually be scrolled
1268 // programatically. The user can unintentionally perform a scroll in such case even if
1269 // the application doesn't scroll programatically, e.g. when jumping to an anchor, or
1270 // when using built-in find functionality.
1271 // This could cause funny issues...
1272 if ( val === 'auto' || val === 'scroll' ) {
1273 return $parent[ 0 ];
1274 }
1275 }
1276 $parent = $parent.parent();
1277 }
1278 // The element is unattached... return something mostly sane
1279 return this.getRootScrollableElement( el );
1280 };
1281
1282 /**
1283 * Scroll element into view.
1284 *
1285 * @static
1286 * @param {HTMLElement} el Element to scroll into view
1287 * @param {Object} [config] Configuration options
1288 * @param {string} [config.duration='fast'] jQuery animation duration value
1289 * @param {string} [config.direction] Scroll in only one direction, e.g. 'x' or 'y', omit
1290 * to scroll in both directions
1291 * @return {jQuery.Promise} Promise which resolves when the scroll is complete
1292 */
1293 OO.ui.Element.static.scrollIntoView = function ( el, config ) {
1294 var position, animations, container, $container, elementDimensions, containerDimensions,
1295 $window,
1296 deferred = $.Deferred();
1297
1298 // Configuration initialization
1299 config = config || {};
1300
1301 animations = {};
1302 container = this.getClosestScrollableContainer( el, config.direction );
1303 $container = $( container );
1304 elementDimensions = this.getDimensions( el );
1305 containerDimensions = this.getDimensions( container );
1306 $window = $( this.getWindow( el ) );
1307
1308 // Compute the element's position relative to the container
1309 if ( $container.is( 'html, body' ) ) {
1310 // If the scrollable container is the root, this is easy
1311 position = {
1312 top: elementDimensions.rect.top,
1313 bottom: $window.innerHeight() - elementDimensions.rect.bottom,
1314 left: elementDimensions.rect.left,
1315 right: $window.innerWidth() - elementDimensions.rect.right
1316 };
1317 } else {
1318 // Otherwise, we have to subtract el's coordinates from container's coordinates
1319 position = {
1320 top: elementDimensions.rect.top -
1321 ( containerDimensions.rect.top + containerDimensions.borders.top ),
1322 bottom: containerDimensions.rect.bottom - containerDimensions.borders.bottom -
1323 containerDimensions.scrollbar.bottom - elementDimensions.rect.bottom,
1324 left: elementDimensions.rect.left -
1325 ( containerDimensions.rect.left + containerDimensions.borders.left ),
1326 right: containerDimensions.rect.right - containerDimensions.borders.right -
1327 containerDimensions.scrollbar.right - elementDimensions.rect.right
1328 };
1329 }
1330
1331 if ( !config.direction || config.direction === 'y' ) {
1332 if ( position.top < 0 ) {
1333 animations.scrollTop = containerDimensions.scroll.top + position.top;
1334 } else if ( position.top > 0 && position.bottom < 0 ) {
1335 animations.scrollTop = containerDimensions.scroll.top +
1336 Math.min( position.top, -position.bottom );
1337 }
1338 }
1339 if ( !config.direction || config.direction === 'x' ) {
1340 if ( position.left < 0 ) {
1341 animations.scrollLeft = containerDimensions.scroll.left + position.left;
1342 } else if ( position.left > 0 && position.right < 0 ) {
1343 animations.scrollLeft = containerDimensions.scroll.left +
1344 Math.min( position.left, -position.right );
1345 }
1346 }
1347 if ( !$.isEmptyObject( animations ) ) {
1348 // eslint-disable-next-line no-jquery/no-animate
1349 $container.stop( true ).animate( animations, config.duration === undefined ?
1350 'fast' : config.duration );
1351 $container.queue( function ( next ) {
1352 deferred.resolve();
1353 next();
1354 } );
1355 } else {
1356 deferred.resolve();
1357 }
1358 return deferred.promise();
1359 };
1360
1361 /**
1362 * Force the browser to reconsider whether it really needs to render scrollbars inside the element
1363 * and reserve space for them, because it probably doesn't.
1364 *
1365 * Workaround primarily for <https://code.google.com/p/chromium/issues/detail?id=387290>, but also
1366 * similar bugs in other browsers. "Just" forcing a reflow is not sufficient in all cases, we need
1367 * to first actually detach (or hide, but detaching is simpler) all children, *then* force a
1368 * reflow, and then reattach (or show) them back.
1369 *
1370 * @static
1371 * @param {HTMLElement} el Element to reconsider the scrollbars on
1372 */
1373 OO.ui.Element.static.reconsiderScrollbars = function ( el ) {
1374 var i, len, scrollLeft, scrollTop, nodes = [];
1375 // Save scroll position
1376 scrollLeft = el.scrollLeft;
1377 scrollTop = el.scrollTop;
1378 // Detach all children
1379 while ( el.firstChild ) {
1380 nodes.push( el.firstChild );
1381 el.removeChild( el.firstChild );
1382 }
1383 // Force reflow
1384 // eslint-disable-next-line no-void
1385 void el.offsetHeight;
1386 // Reattach all children
1387 for ( i = 0, len = nodes.length; i < len; i++ ) {
1388 el.appendChild( nodes[ i ] );
1389 }
1390 // Restore scroll position (no-op if scrollbars disappeared)
1391 el.scrollLeft = scrollLeft;
1392 el.scrollTop = scrollTop;
1393 };
1394
1395 /* Methods */
1396
1397 /**
1398 * Toggle visibility of an element.
1399 *
1400 * @param {boolean} [show] Make element visible, omit to toggle visibility
1401 * @fires visible
1402 * @chainable
1403 * @return {OO.ui.Element} The element, for chaining
1404 */
1405 OO.ui.Element.prototype.toggle = function ( show ) {
1406 show = show === undefined ? !this.visible : !!show;
1407
1408 if ( show !== this.isVisible() ) {
1409 this.visible = show;
1410 this.$element.toggleClass( 'oo-ui-element-hidden', !this.visible );
1411 this.emit( 'toggle', show );
1412 }
1413
1414 return this;
1415 };
1416
1417 /**
1418 * Check if element is visible.
1419 *
1420 * @return {boolean} element is visible
1421 */
1422 OO.ui.Element.prototype.isVisible = function () {
1423 return this.visible;
1424 };
1425
1426 /**
1427 * Get element data.
1428 *
1429 * @return {Mixed} Element data
1430 */
1431 OO.ui.Element.prototype.getData = function () {
1432 return this.data;
1433 };
1434
1435 /**
1436 * Set element data.
1437 *
1438 * @param {Mixed} data Element data
1439 * @chainable
1440 * @return {OO.ui.Element} The element, for chaining
1441 */
1442 OO.ui.Element.prototype.setData = function ( data ) {
1443 this.data = data;
1444 return this;
1445 };
1446
1447 /**
1448 * Set the element has an 'id' attribute.
1449 *
1450 * @param {string} id
1451 * @chainable
1452 * @return {OO.ui.Element} The element, for chaining
1453 */
1454 OO.ui.Element.prototype.setElementId = function ( id ) {
1455 this.elementId = id;
1456 this.$element.attr( 'id', id );
1457 return this;
1458 };
1459
1460 /**
1461 * Ensure that the element has an 'id' attribute, setting it to an unique value if it's missing,
1462 * and return its value.
1463 *
1464 * @return {string}
1465 */
1466 OO.ui.Element.prototype.getElementId = function () {
1467 if ( this.elementId === null ) {
1468 this.setElementId( OO.ui.generateElementId() );
1469 }
1470 return this.elementId;
1471 };
1472
1473 /**
1474 * Check if element supports one or more methods.
1475 *
1476 * @param {string|string[]} methods Method or list of methods to check
1477 * @return {boolean} All methods are supported
1478 */
1479 OO.ui.Element.prototype.supports = function ( methods ) {
1480 var i, len,
1481 support = 0;
1482
1483 methods = Array.isArray( methods ) ? methods : [ methods ];
1484 for ( i = 0, len = methods.length; i < len; i++ ) {
1485 if ( typeof this[ methods[ i ] ] === 'function' ) {
1486 support++;
1487 }
1488 }
1489
1490 return methods.length === support;
1491 };
1492
1493 /**
1494 * Update the theme-provided classes.
1495 *
1496 * @localdoc This is called in element mixins and widget classes any time state changes.
1497 * Updating is debounced, minimizing overhead of changing multiple attributes and
1498 * guaranteeing that theme updates do not occur within an element's constructor
1499 */
1500 OO.ui.Element.prototype.updateThemeClasses = function () {
1501 OO.ui.theme.queueUpdateElementClasses( this );
1502 };
1503
1504 /**
1505 * Get the HTML tag name.
1506 *
1507 * Override this method to base the result on instance information.
1508 *
1509 * @return {string} HTML tag name
1510 */
1511 OO.ui.Element.prototype.getTagName = function () {
1512 return this.constructor.static.tagName;
1513 };
1514
1515 /**
1516 * Check if the element is attached to the DOM
1517 *
1518 * @return {boolean} The element is attached to the DOM
1519 */
1520 OO.ui.Element.prototype.isElementAttached = function () {
1521 return $.contains( this.getElementDocument(), this.$element[ 0 ] );
1522 };
1523
1524 /**
1525 * Get the DOM document.
1526 *
1527 * @return {HTMLDocument} Document object
1528 */
1529 OO.ui.Element.prototype.getElementDocument = function () {
1530 // Don't cache this in other ways either because subclasses could can change this.$element
1531 return OO.ui.Element.static.getDocument( this.$element );
1532 };
1533
1534 /**
1535 * Get the DOM window.
1536 *
1537 * @return {Window} Window object
1538 */
1539 OO.ui.Element.prototype.getElementWindow = function () {
1540 return OO.ui.Element.static.getWindow( this.$element );
1541 };
1542
1543 /**
1544 * Get closest scrollable container.
1545 *
1546 * @return {HTMLElement} Closest scrollable container
1547 */
1548 OO.ui.Element.prototype.getClosestScrollableElementContainer = function () {
1549 return OO.ui.Element.static.getClosestScrollableContainer( this.$element[ 0 ] );
1550 };
1551
1552 /**
1553 * Get group element is in.
1554 *
1555 * @return {OO.ui.mixin.GroupElement|null} Group element, null if none
1556 */
1557 OO.ui.Element.prototype.getElementGroup = function () {
1558 return this.elementGroup;
1559 };
1560
1561 /**
1562 * Set group element is in.
1563 *
1564 * @param {OO.ui.mixin.GroupElement|null} group Group element, null if none
1565 * @chainable
1566 * @return {OO.ui.Element} The element, for chaining
1567 */
1568 OO.ui.Element.prototype.setElementGroup = function ( group ) {
1569 this.elementGroup = group;
1570 return this;
1571 };
1572
1573 /**
1574 * Scroll element into view.
1575 *
1576 * @param {Object} [config] Configuration options
1577 * @return {jQuery.Promise} Promise which resolves when the scroll is complete
1578 */
1579 OO.ui.Element.prototype.scrollElementIntoView = function ( config ) {
1580 if (
1581 !this.isElementAttached() ||
1582 !this.isVisible() ||
1583 ( this.getElementGroup() && !this.getElementGroup().isVisible() )
1584 ) {
1585 return $.Deferred().resolve();
1586 }
1587 return OO.ui.Element.static.scrollIntoView( this.$element[ 0 ], config );
1588 };
1589
1590 /**
1591 * Restore the pre-infusion dynamic state for this widget.
1592 *
1593 * This method is called after #$element has been inserted into DOM. The parameter is the return
1594 * value of #gatherPreInfuseState.
1595 *
1596 * @protected
1597 * @param {Object} state
1598 */
1599 OO.ui.Element.prototype.restorePreInfuseState = function () {
1600 };
1601
1602 /**
1603 * Wraps an HTML snippet for use with configuration values which default
1604 * to strings. This bypasses the default html-escaping done to string
1605 * values.
1606 *
1607 * @class
1608 *
1609 * @constructor
1610 * @param {string} [content] HTML content
1611 */
1612 OO.ui.HtmlSnippet = function OoUiHtmlSnippet( content ) {
1613 // Properties
1614 this.content = content;
1615 };
1616
1617 /* Setup */
1618
1619 OO.initClass( OO.ui.HtmlSnippet );
1620
1621 /* Methods */
1622
1623 /**
1624 * Render into HTML.
1625 *
1626 * @return {string} Unchanged HTML snippet.
1627 */
1628 OO.ui.HtmlSnippet.prototype.toString = function () {
1629 return this.content;
1630 };
1631
1632 /**
1633 * Layouts are containers for elements and are used to arrange other widgets of arbitrary type in
1634 * a way that is centrally controlled and can be updated dynamically. Layouts can be, and usually
1635 * are, combined.
1636 * See {@link OO.ui.FieldsetLayout FieldsetLayout}, {@link OO.ui.FieldLayout FieldLayout},
1637 * {@link OO.ui.FormLayout FormLayout}, {@link OO.ui.PanelLayout PanelLayout},
1638 * {@link OO.ui.StackLayout StackLayout}, {@link OO.ui.PageLayout PageLayout},
1639 * {@link OO.ui.HorizontalLayout HorizontalLayout}, and {@link OO.ui.BookletLayout BookletLayout}
1640 * for more information and examples.
1641 *
1642 * @abstract
1643 * @class
1644 * @extends OO.ui.Element
1645 * @mixins OO.EventEmitter
1646 *
1647 * @constructor
1648 * @param {Object} [config] Configuration options
1649 */
1650 OO.ui.Layout = function OoUiLayout( config ) {
1651 // Configuration initialization
1652 config = config || {};
1653
1654 // Parent constructor
1655 OO.ui.Layout.parent.call( this, config );
1656
1657 // Mixin constructors
1658 OO.EventEmitter.call( this );
1659
1660 // Initialization
1661 this.$element.addClass( 'oo-ui-layout' );
1662 };
1663
1664 /* Setup */
1665
1666 OO.inheritClass( OO.ui.Layout, OO.ui.Element );
1667 OO.mixinClass( OO.ui.Layout, OO.EventEmitter );
1668
1669 /* Methods */
1670
1671 /**
1672 * Reset scroll offsets
1673 *
1674 * @chainable
1675 * @return {OO.ui.Layout} The layout, for chaining
1676 */
1677 OO.ui.Layout.prototype.resetScroll = function () {
1678 this.$element[ 0 ].scrollTop = 0;
1679 // TODO: Reset scrollLeft in an RTL-aware manner, see OO.ui.Element.static.getScrollLeft.
1680
1681 return this;
1682 };
1683
1684 /**
1685 * Widgets are compositions of one or more OOUI elements that users can both view
1686 * and interact with. All widgets can be configured and modified via a standard API,
1687 * and their state can change dynamically according to a model.
1688 *
1689 * @abstract
1690 * @class
1691 * @extends OO.ui.Element
1692 * @mixins OO.EventEmitter
1693 *
1694 * @constructor
1695 * @param {Object} [config] Configuration options
1696 * @cfg {boolean} [disabled=false] Disable the widget. Disabled widgets cannot be used and their
1697 * appearance reflects this state.
1698 */
1699 OO.ui.Widget = function OoUiWidget( config ) {
1700 // Initialize config
1701 config = $.extend( { disabled: false }, config );
1702
1703 // Parent constructor
1704 OO.ui.Widget.parent.call( this, config );
1705
1706 // Mixin constructors
1707 OO.EventEmitter.call( this );
1708
1709 // Properties
1710 this.disabled = null;
1711 this.wasDisabled = null;
1712
1713 // Initialization
1714 this.$element.addClass( 'oo-ui-widget' );
1715 this.setDisabled( !!config.disabled );
1716 };
1717
1718 /* Setup */
1719
1720 OO.inheritClass( OO.ui.Widget, OO.ui.Element );
1721 OO.mixinClass( OO.ui.Widget, OO.EventEmitter );
1722
1723 /* Events */
1724
1725 /**
1726 * @event disable
1727 *
1728 * A 'disable' event is emitted when the disabled state of the widget changes
1729 * (i.e. on disable **and** enable).
1730 *
1731 * @param {boolean} disabled Widget is disabled
1732 */
1733
1734 /**
1735 * @event toggle
1736 *
1737 * A 'toggle' event is emitted when the visibility of the widget changes.
1738 *
1739 * @param {boolean} visible Widget is visible
1740 */
1741
1742 /* Methods */
1743
1744 /**
1745 * Check if the widget is disabled.
1746 *
1747 * @return {boolean} Widget is disabled
1748 */
1749 OO.ui.Widget.prototype.isDisabled = function () {
1750 return this.disabled;
1751 };
1752
1753 /**
1754 * Set the 'disabled' state of the widget.
1755 *
1756 * When a widget is disabled, it cannot be used and its appearance is updated to reflect this state.
1757 *
1758 * @param {boolean} disabled Disable widget
1759 * @chainable
1760 * @return {OO.ui.Widget} The widget, for chaining
1761 */
1762 OO.ui.Widget.prototype.setDisabled = function ( disabled ) {
1763 var isDisabled;
1764
1765 this.disabled = !!disabled;
1766 isDisabled = this.isDisabled();
1767 if ( isDisabled !== this.wasDisabled ) {
1768 this.$element.toggleClass( 'oo-ui-widget-disabled', isDisabled );
1769 this.$element.toggleClass( 'oo-ui-widget-enabled', !isDisabled );
1770 this.$element.attr( 'aria-disabled', isDisabled.toString() );
1771 this.emit( 'disable', isDisabled );
1772 this.updateThemeClasses();
1773 }
1774 this.wasDisabled = isDisabled;
1775
1776 return this;
1777 };
1778
1779 /**
1780 * Update the disabled state, in case of changes in parent widget.
1781 *
1782 * @chainable
1783 * @return {OO.ui.Widget} The widget, for chaining
1784 */
1785 OO.ui.Widget.prototype.updateDisabled = function () {
1786 this.setDisabled( this.disabled );
1787 return this;
1788 };
1789
1790 /**
1791 * Get an ID of a labelable node which is part of this widget, if any, to be used for `<label for>`
1792 * value.
1793 *
1794 * If this function returns null, the widget should have a meaningful #simulateLabelClick method
1795 * instead.
1796 *
1797 * @return {string|null} The ID of the labelable element
1798 */
1799 OO.ui.Widget.prototype.getInputId = function () {
1800 return null;
1801 };
1802
1803 /**
1804 * Simulate the behavior of clicking on a label (a HTML `<label>` element) bound to this input.
1805 * HTML only allows `<label>` to act on specific "labelable" elements; complex widgets might need to
1806 * override this method to provide intuitive, accessible behavior.
1807 *
1808 * By default, this does nothing. OO.ui.mixin.TabIndexedElement overrides it for focusable widgets.
1809 * Individual widgets may override it too.
1810 *
1811 * This method is called by OO.ui.LabelWidget and OO.ui.FieldLayout. It should not be called
1812 * directly.
1813 */
1814 OO.ui.Widget.prototype.simulateLabelClick = function () {
1815 };
1816
1817 /**
1818 * Theme logic.
1819 *
1820 * @abstract
1821 * @class
1822 *
1823 * @constructor
1824 */
1825 OO.ui.Theme = function OoUiTheme() {
1826 this.elementClassesQueue = [];
1827 this.debouncedUpdateQueuedElementClasses = OO.ui.debounce( this.updateQueuedElementClasses );
1828 };
1829
1830 /* Setup */
1831
1832 OO.initClass( OO.ui.Theme );
1833
1834 /* Methods */
1835
1836 /**
1837 * Get a list of classes to be applied to a widget.
1838 *
1839 * The 'on' and 'off' lists combined MUST contain keys for all classes the theme adds or removes,
1840 * otherwise state transitions will not work properly.
1841 *
1842 * @param {OO.ui.Element} element Element for which to get classes
1843 * @return {Object.<string,string[]>} Categorized class names with `on` and `off` lists
1844 */
1845 OO.ui.Theme.prototype.getElementClasses = function () {
1846 return { on: [], off: [] };
1847 };
1848
1849 /**
1850 * Update CSS classes provided by the theme.
1851 *
1852 * For elements with theme logic hooks, this should be called any time there's a state change.
1853 *
1854 * @param {OO.ui.Element} element Element for which to update classes
1855 */
1856 OO.ui.Theme.prototype.updateElementClasses = function ( element ) {
1857 var $elements = $( [] ),
1858 classes = this.getElementClasses( element );
1859
1860 if ( element.$icon ) {
1861 $elements = $elements.add( element.$icon );
1862 }
1863 if ( element.$indicator ) {
1864 $elements = $elements.add( element.$indicator );
1865 }
1866
1867 $elements
1868 .removeClass( classes.off )
1869 .addClass( classes.on );
1870 };
1871
1872 /**
1873 * @private
1874 */
1875 OO.ui.Theme.prototype.updateQueuedElementClasses = function () {
1876 var i;
1877 for ( i = 0; i < this.elementClassesQueue.length; i++ ) {
1878 this.updateElementClasses( this.elementClassesQueue[ i ] );
1879 }
1880 // Clear the queue
1881 this.elementClassesQueue = [];
1882 };
1883
1884 /**
1885 * Queue #updateElementClasses to be called for this element.
1886 *
1887 * @localdoc QUnit tests override this method to directly call #queueUpdateElementClasses,
1888 * to make them synchronous.
1889 *
1890 * @param {OO.ui.Element} element Element for which to update classes
1891 */
1892 OO.ui.Theme.prototype.queueUpdateElementClasses = function ( element ) {
1893 // Keep items in the queue unique. Use lastIndexOf to start checking from the end because that's
1894 // the most common case (this method is often called repeatedly for the same element).
1895 if ( this.elementClassesQueue.lastIndexOf( element ) !== -1 ) {
1896 return;
1897 }
1898 this.elementClassesQueue.push( element );
1899 this.debouncedUpdateQueuedElementClasses();
1900 };
1901
1902 /**
1903 * Get the transition duration in milliseconds for dialogs opening/closing
1904 *
1905 * The dialog should be fully rendered this many milliseconds after the
1906 * ready process has executed.
1907 *
1908 * @return {number} Transition duration in milliseconds
1909 */
1910 OO.ui.Theme.prototype.getDialogTransitionDuration = function () {
1911 return 0;
1912 };
1913
1914 /**
1915 * The TabIndexedElement class is an attribute mixin used to add additional functionality to an
1916 * element created by another class. The mixin provides a ‘tabIndex’ property, which specifies the
1917 * order in which users will navigate through the focusable elements via the Tab key.
1918 *
1919 * @example
1920 * // TabIndexedElement is mixed into the ButtonWidget class
1921 * // to provide a tabIndex property.
1922 * var button1 = new OO.ui.ButtonWidget( {
1923 * label: 'fourth',
1924 * tabIndex: 4
1925 * } ),
1926 * button2 = new OO.ui.ButtonWidget( {
1927 * label: 'second',
1928 * tabIndex: 2
1929 * } ),
1930 * button3 = new OO.ui.ButtonWidget( {
1931 * label: 'third',
1932 * tabIndex: 3
1933 * } ),
1934 * button4 = new OO.ui.ButtonWidget( {
1935 * label: 'first',
1936 * tabIndex: 1
1937 * } );
1938 * $( document.body ).append(
1939 * button1.$element,
1940 * button2.$element,
1941 * button3.$element,
1942 * button4.$element
1943 * );
1944 *
1945 * @abstract
1946 * @class
1947 *
1948 * @constructor
1949 * @param {Object} [config] Configuration options
1950 * @cfg {jQuery} [$tabIndexed] The element that should use the tabindex functionality. By default,
1951 * the functionality is applied to the element created by the class ($element). If a different
1952 * element is specified, the tabindex functionality will be applied to it instead.
1953 * @cfg {string|number|null} [tabIndex=0] Number that specifies the element’s position in the
1954 * tab-navigation order (e.g., 1 for the first focusable element). Use 0 to use the default
1955 * navigation order; use -1 to remove the element from the tab-navigation flow.
1956 */
1957 OO.ui.mixin.TabIndexedElement = function OoUiMixinTabIndexedElement( config ) {
1958 // Configuration initialization
1959 config = $.extend( { tabIndex: 0 }, config );
1960
1961 // Properties
1962 this.$tabIndexed = null;
1963 this.tabIndex = null;
1964
1965 // Events
1966 this.connect( this, {
1967 disable: 'onTabIndexedElementDisable'
1968 } );
1969
1970 // Initialization
1971 this.setTabIndex( config.tabIndex );
1972 this.setTabIndexedElement( config.$tabIndexed || this.$element );
1973 };
1974
1975 /* Setup */
1976
1977 OO.initClass( OO.ui.mixin.TabIndexedElement );
1978
1979 /* Methods */
1980
1981 /**
1982 * Set the element that should use the tabindex functionality.
1983 *
1984 * This method is used to retarget a tabindex mixin so that its functionality applies
1985 * to the specified element. If an element is currently using the functionality, the mixin’s
1986 * effect on that element is removed before the new element is set up.
1987 *
1988 * @param {jQuery} $tabIndexed Element that should use the tabindex functionality
1989 * @chainable
1990 * @return {OO.ui.Element} The element, for chaining
1991 */
1992 OO.ui.mixin.TabIndexedElement.prototype.setTabIndexedElement = function ( $tabIndexed ) {
1993 var tabIndex = this.tabIndex;
1994 // Remove attributes from old $tabIndexed
1995 this.setTabIndex( null );
1996 // Force update of new $tabIndexed
1997 this.$tabIndexed = $tabIndexed;
1998 this.tabIndex = tabIndex;
1999 return this.updateTabIndex();
2000 };
2001
2002 /**
2003 * Set the value of the tabindex.
2004 *
2005 * @param {string|number|null} tabIndex Tabindex value, or `null` for no tabindex
2006 * @chainable
2007 * @return {OO.ui.Element} The element, for chaining
2008 */
2009 OO.ui.mixin.TabIndexedElement.prototype.setTabIndex = function ( tabIndex ) {
2010 tabIndex = /^-?\d+$/.test( tabIndex ) ? Number( tabIndex ) : null;
2011
2012 if ( this.tabIndex !== tabIndex ) {
2013 this.tabIndex = tabIndex;
2014 this.updateTabIndex();
2015 }
2016
2017 return this;
2018 };
2019
2020 /**
2021 * Update the `tabindex` attribute, in case of changes to tab index or
2022 * disabled state.
2023 *
2024 * @private
2025 * @chainable
2026 * @return {OO.ui.Element} The element, for chaining
2027 */
2028 OO.ui.mixin.TabIndexedElement.prototype.updateTabIndex = function () {
2029 if ( this.$tabIndexed ) {
2030 if ( this.tabIndex !== null ) {
2031 // Do not index over disabled elements
2032 this.$tabIndexed.attr( {
2033 tabindex: this.isDisabled() ? -1 : this.tabIndex,
2034 // Support: ChromeVox and NVDA
2035 // These do not seem to inherit aria-disabled from parent elements
2036 'aria-disabled': this.isDisabled().toString()
2037 } );
2038 } else {
2039 this.$tabIndexed.removeAttr( 'tabindex aria-disabled' );
2040 }
2041 }
2042 return this;
2043 };
2044
2045 /**
2046 * Handle disable events.
2047 *
2048 * @private
2049 * @param {boolean} disabled Element is disabled
2050 */
2051 OO.ui.mixin.TabIndexedElement.prototype.onTabIndexedElementDisable = function () {
2052 this.updateTabIndex();
2053 };
2054
2055 /**
2056 * Get the value of the tabindex.
2057 *
2058 * @return {number|null} Tabindex value
2059 */
2060 OO.ui.mixin.TabIndexedElement.prototype.getTabIndex = function () {
2061 return this.tabIndex;
2062 };
2063
2064 /**
2065 * Get an ID of a focusable element of this widget, if any, to be used for `<label for>` value.
2066 *
2067 * If the element already has an ID then that is returned, otherwise unique ID is
2068 * generated, set on the element, and returned.
2069 *
2070 * @return {string|null} The ID of the focusable element
2071 */
2072 OO.ui.mixin.TabIndexedElement.prototype.getInputId = function () {
2073 var id;
2074
2075 if ( !this.$tabIndexed ) {
2076 return null;
2077 }
2078 if ( !this.isLabelableNode( this.$tabIndexed ) ) {
2079 return null;
2080 }
2081
2082 id = this.$tabIndexed.attr( 'id' );
2083 if ( id === undefined ) {
2084 id = OO.ui.generateElementId();
2085 this.$tabIndexed.attr( 'id', id );
2086 }
2087
2088 return id;
2089 };
2090
2091 /**
2092 * Whether the node is 'labelable' according to the HTML spec
2093 * (i.e., whether it can be interacted with through a `<label for="…">`).
2094 * See: <https://html.spec.whatwg.org/multipage/forms.html#category-label>.
2095 *
2096 * @private
2097 * @param {jQuery} $node
2098 * @return {boolean}
2099 */
2100 OO.ui.mixin.TabIndexedElement.prototype.isLabelableNode = function ( $node ) {
2101 var
2102 labelableTags = [ 'button', 'meter', 'output', 'progress', 'select', 'textarea' ],
2103 tagName = ( $node.prop( 'tagName' ) || '' ).toLowerCase();
2104
2105 if ( tagName === 'input' && $node.attr( 'type' ) !== 'hidden' ) {
2106 return true;
2107 }
2108 if ( labelableTags.indexOf( tagName ) !== -1 ) {
2109 return true;
2110 }
2111 return false;
2112 };
2113
2114 /**
2115 * Focus this element.
2116 *
2117 * @chainable
2118 * @return {OO.ui.Element} The element, for chaining
2119 */
2120 OO.ui.mixin.TabIndexedElement.prototype.focus = function () {
2121 if ( !this.isDisabled() ) {
2122 this.$tabIndexed.trigger( 'focus' );
2123 }
2124 return this;
2125 };
2126
2127 /**
2128 * Blur this element.
2129 *
2130 * @chainable
2131 * @return {OO.ui.Element} The element, for chaining
2132 */
2133 OO.ui.mixin.TabIndexedElement.prototype.blur = function () {
2134 this.$tabIndexed.trigger( 'blur' );
2135 return this;
2136 };
2137
2138 /**
2139 * @inheritdoc OO.ui.Widget
2140 */
2141 OO.ui.mixin.TabIndexedElement.prototype.simulateLabelClick = function () {
2142 this.focus();
2143 };
2144
2145 /**
2146 * ButtonElement is often mixed into other classes to generate a button, which is a clickable
2147 * interface element that can be configured with access keys for keyboard interaction.
2148 * See the [OOUI documentation on MediaWiki] [1] for examples.
2149 *
2150 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Buttons_and_Switches#Buttons
2151 *
2152 * @abstract
2153 * @class
2154 *
2155 * @constructor
2156 * @param {Object} [config] Configuration options
2157 * @cfg {jQuery} [$button] The button element created by the class.
2158 * If this configuration is omitted, the button element will use a generated `<a>`.
2159 * @cfg {boolean} [framed=true] Render the button with a frame
2160 */
2161 OO.ui.mixin.ButtonElement = function OoUiMixinButtonElement( config ) {
2162 // Configuration initialization
2163 config = config || {};
2164
2165 // Properties
2166 this.$button = null;
2167 this.framed = null;
2168 this.active = config.active !== undefined && config.active;
2169 this.onDocumentMouseUpHandler = this.onDocumentMouseUp.bind( this );
2170 this.onMouseDownHandler = this.onMouseDown.bind( this );
2171 this.onDocumentKeyUpHandler = this.onDocumentKeyUp.bind( this );
2172 this.onKeyDownHandler = this.onKeyDown.bind( this );
2173 this.onClickHandler = this.onClick.bind( this );
2174 this.onKeyPressHandler = this.onKeyPress.bind( this );
2175
2176 // Initialization
2177 this.$element.addClass( 'oo-ui-buttonElement' );
2178 this.toggleFramed( config.framed === undefined || config.framed );
2179 this.setButtonElement( config.$button || $( '<a>' ) );
2180 };
2181
2182 /* Setup */
2183
2184 OO.initClass( OO.ui.mixin.ButtonElement );
2185
2186 /* Static Properties */
2187
2188 /**
2189 * Cancel mouse down events.
2190 *
2191 * This property is usually set to `true` to prevent the focus from changing when the button is
2192 * clicked.
2193 * Classes such as {@link OO.ui.mixin.DraggableElement DraggableElement} and
2194 * {@link OO.ui.ButtonOptionWidget ButtonOptionWidget} use a value of `false` so that dragging
2195 * behavior is possible and mousedown events can be handled by a parent widget.
2196 *
2197 * @static
2198 * @inheritable
2199 * @property {boolean}
2200 */
2201 OO.ui.mixin.ButtonElement.static.cancelButtonMouseDownEvents = true;
2202
2203 /* Events */
2204
2205 /**
2206 * A 'click' event is emitted when the button element is clicked.
2207 *
2208 * @event click
2209 */
2210
2211 /* Methods */
2212
2213 /**
2214 * Set the button element.
2215 *
2216 * This method is used to retarget a button mixin so that its functionality applies to
2217 * the specified button element instead of the one created by the class. If a button element
2218 * is already set, the method will remove the mixin’s effect on that element.
2219 *
2220 * @param {jQuery} $button Element to use as button
2221 */
2222 OO.ui.mixin.ButtonElement.prototype.setButtonElement = function ( $button ) {
2223 if ( this.$button ) {
2224 this.$button
2225 .removeClass( 'oo-ui-buttonElement-button' )
2226 .removeAttr( 'role accesskey' )
2227 .off( {
2228 mousedown: this.onMouseDownHandler,
2229 keydown: this.onKeyDownHandler,
2230 click: this.onClickHandler,
2231 keypress: this.onKeyPressHandler
2232 } );
2233 }
2234
2235 this.$button = $button
2236 .addClass( 'oo-ui-buttonElement-button' )
2237 .on( {
2238 mousedown: this.onMouseDownHandler,
2239 keydown: this.onKeyDownHandler,
2240 click: this.onClickHandler,
2241 keypress: this.onKeyPressHandler
2242 } );
2243
2244 // Add `role="button"` on `<a>` elements, where it's needed
2245 // `toUpperCase()` is added for XHTML documents
2246 if ( this.$button.prop( 'tagName' ).toUpperCase() === 'A' ) {
2247 this.$button.attr( 'role', 'button' );
2248 }
2249 };
2250
2251 /**
2252 * Handles mouse down events.
2253 *
2254 * @protected
2255 * @param {jQuery.Event} e Mouse down event
2256 * @return {undefined/boolean} False to prevent default if event is handled
2257 */
2258 OO.ui.mixin.ButtonElement.prototype.onMouseDown = function ( e ) {
2259 if ( this.isDisabled() || e.which !== OO.ui.MouseButtons.LEFT ) {
2260 return;
2261 }
2262 this.$element.addClass( 'oo-ui-buttonElement-pressed' );
2263 // Run the mouseup handler no matter where the mouse is when the button is let go, so we can
2264 // reliably remove the pressed class
2265 this.getElementDocument().addEventListener( 'mouseup', this.onDocumentMouseUpHandler, true );
2266 // Prevent change of focus unless specifically configured otherwise
2267 if ( this.constructor.static.cancelButtonMouseDownEvents ) {
2268 return false;
2269 }
2270 };
2271
2272 /**
2273 * Handles document mouse up events.
2274 *
2275 * @protected
2276 * @param {MouseEvent} e Mouse up event
2277 */
2278 OO.ui.mixin.ButtonElement.prototype.onDocumentMouseUp = function ( e ) {
2279 if ( this.isDisabled() || e.which !== OO.ui.MouseButtons.LEFT ) {
2280 return;
2281 }
2282 this.$element.removeClass( 'oo-ui-buttonElement-pressed' );
2283 // Stop listening for mouseup, since we only needed this once
2284 this.getElementDocument().removeEventListener( 'mouseup', this.onDocumentMouseUpHandler, true );
2285 };
2286
2287 /**
2288 * Handles mouse click events.
2289 *
2290 * @protected
2291 * @param {jQuery.Event} e Mouse click event
2292 * @fires click
2293 * @return {undefined/boolean} False to prevent default if event is handled
2294 */
2295 OO.ui.mixin.ButtonElement.prototype.onClick = function ( e ) {
2296 if ( !this.isDisabled() && e.which === OO.ui.MouseButtons.LEFT ) {
2297 if ( this.emit( 'click' ) ) {
2298 return false;
2299 }
2300 }
2301 };
2302
2303 /**
2304 * Handles key down events.
2305 *
2306 * @protected
2307 * @param {jQuery.Event} e Key down event
2308 */
2309 OO.ui.mixin.ButtonElement.prototype.onKeyDown = function ( e ) {
2310 if ( this.isDisabled() || ( e.which !== OO.ui.Keys.SPACE && e.which !== OO.ui.Keys.ENTER ) ) {
2311 return;
2312 }
2313 this.$element.addClass( 'oo-ui-buttonElement-pressed' );
2314 // Run the keyup handler no matter where the key is when the button is let go, so we can
2315 // reliably remove the pressed class
2316 this.getElementDocument().addEventListener( 'keyup', this.onDocumentKeyUpHandler, true );
2317 };
2318
2319 /**
2320 * Handles document key up events.
2321 *
2322 * @protected
2323 * @param {KeyboardEvent} e Key up event
2324 */
2325 OO.ui.mixin.ButtonElement.prototype.onDocumentKeyUp = function ( e ) {
2326 if ( this.isDisabled() || ( e.which !== OO.ui.Keys.SPACE && e.which !== OO.ui.Keys.ENTER ) ) {
2327 return;
2328 }
2329 this.$element.removeClass( 'oo-ui-buttonElement-pressed' );
2330 // Stop listening for keyup, since we only needed this once
2331 this.getElementDocument().removeEventListener( 'keyup', this.onDocumentKeyUpHandler, true );
2332 };
2333
2334 /**
2335 * Handles key press events.
2336 *
2337 * @protected
2338 * @param {jQuery.Event} e Key press event
2339 * @fires click
2340 * @return {undefined/boolean} False to prevent default if event is handled
2341 */
2342 OO.ui.mixin.ButtonElement.prototype.onKeyPress = function ( e ) {
2343 if ( !this.isDisabled() && ( e.which === OO.ui.Keys.SPACE || e.which === OO.ui.Keys.ENTER ) ) {
2344 if ( this.emit( 'click' ) ) {
2345 return false;
2346 }
2347 }
2348 };
2349
2350 /**
2351 * Check if button has a frame.
2352 *
2353 * @return {boolean} Button is framed
2354 */
2355 OO.ui.mixin.ButtonElement.prototype.isFramed = function () {
2356 return this.framed;
2357 };
2358
2359 /**
2360 * Render the button with or without a frame. Omit the `framed` parameter to toggle the button frame
2361 * on and off.
2362 *
2363 * @param {boolean} [framed] Make button framed, omit to toggle
2364 * @chainable
2365 * @return {OO.ui.Element} The element, for chaining
2366 */
2367 OO.ui.mixin.ButtonElement.prototype.toggleFramed = function ( framed ) {
2368 framed = framed === undefined ? !this.framed : !!framed;
2369 if ( framed !== this.framed ) {
2370 this.framed = framed;
2371 this.$element
2372 .toggleClass( 'oo-ui-buttonElement-frameless', !framed )
2373 .toggleClass( 'oo-ui-buttonElement-framed', framed );
2374 this.updateThemeClasses();
2375 }
2376
2377 return this;
2378 };
2379
2380 /**
2381 * Set the button's active state.
2382 *
2383 * The active state can be set on:
2384 *
2385 * - {@link OO.ui.ButtonOptionWidget ButtonOptionWidget} when it is selected
2386 * - {@link OO.ui.ToggleButtonWidget ToggleButtonWidget} when it is toggle on
2387 * - {@link OO.ui.ButtonWidget ButtonWidget} when clicking the button would only refresh the page
2388 *
2389 * @protected
2390 * @param {boolean} value Make button active
2391 * @chainable
2392 * @return {OO.ui.Element} The element, for chaining
2393 */
2394 OO.ui.mixin.ButtonElement.prototype.setActive = function ( value ) {
2395 this.active = !!value;
2396 this.$element.toggleClass( 'oo-ui-buttonElement-active', this.active );
2397 this.updateThemeClasses();
2398 return this;
2399 };
2400
2401 /**
2402 * Check if the button is active
2403 *
2404 * @protected
2405 * @return {boolean} The button is active
2406 */
2407 OO.ui.mixin.ButtonElement.prototype.isActive = function () {
2408 return this.active;
2409 };
2410
2411 /**
2412 * Any OOUI widget that contains other widgets (such as {@link OO.ui.ButtonWidget buttons} or
2413 * {@link OO.ui.OptionWidget options}) mixes in GroupElement. Adding, removing, and clearing
2414 * items from the group is done through the interface the class provides.
2415 * For more information, please see the [OOUI documentation on MediaWiki] [1].
2416 *
2417 * [1]: https://www.mediawiki.org/wiki/OOUI/Elements/Groups
2418 *
2419 * @abstract
2420 * @mixins OO.EmitterList
2421 * @class
2422 *
2423 * @constructor
2424 * @param {Object} [config] Configuration options
2425 * @cfg {jQuery} [$group] The container element created by the class. If this configuration
2426 * is omitted, the group element will use a generated `<div>`.
2427 */
2428 OO.ui.mixin.GroupElement = function OoUiMixinGroupElement( config ) {
2429 // Configuration initialization
2430 config = config || {};
2431
2432 // Mixin constructors
2433 OO.EmitterList.call( this, config );
2434
2435 // Properties
2436 this.$group = null;
2437
2438 // Initialization
2439 this.setGroupElement( config.$group || $( '<div>' ) );
2440 };
2441
2442 /* Setup */
2443
2444 OO.mixinClass( OO.ui.mixin.GroupElement, OO.EmitterList );
2445
2446 /* Events */
2447
2448 /**
2449 * @event change
2450 *
2451 * A change event is emitted when the set of selected items changes.
2452 *
2453 * @param {OO.ui.Element[]} items Items currently in the group
2454 */
2455
2456 /* Methods */
2457
2458 /**
2459 * Set the group element.
2460 *
2461 * If an element is already set, items will be moved to the new element.
2462 *
2463 * @param {jQuery} $group Element to use as group
2464 */
2465 OO.ui.mixin.GroupElement.prototype.setGroupElement = function ( $group ) {
2466 var i, len;
2467
2468 this.$group = $group;
2469 for ( i = 0, len = this.items.length; i < len; i++ ) {
2470 this.$group.append( this.items[ i ].$element );
2471 }
2472 };
2473
2474 /**
2475 * Find an item by its data.
2476 *
2477 * Only the first item with matching data will be returned. To return all matching items,
2478 * use the #findItemsFromData method.
2479 *
2480 * @param {Object} data Item data to search for
2481 * @return {OO.ui.Element|null} Item with equivalent data, `null` if none exists
2482 */
2483 OO.ui.mixin.GroupElement.prototype.findItemFromData = function ( data ) {
2484 var i, len, item,
2485 hash = OO.getHash( data );
2486
2487 for ( i = 0, len = this.items.length; i < len; i++ ) {
2488 item = this.items[ i ];
2489 if ( hash === OO.getHash( item.getData() ) ) {
2490 return item;
2491 }
2492 }
2493
2494 return null;
2495 };
2496
2497 /**
2498 * Find items by their data.
2499 *
2500 * All items with matching data will be returned. To return only the first match, use the
2501 * #findItemFromData method instead.
2502 *
2503 * @param {Object} data Item data to search for
2504 * @return {OO.ui.Element[]} Items with equivalent data
2505 */
2506 OO.ui.mixin.GroupElement.prototype.findItemsFromData = function ( data ) {
2507 var i, len, item,
2508 hash = OO.getHash( data ),
2509 items = [];
2510
2511 for ( i = 0, len = this.items.length; i < len; i++ ) {
2512 item = this.items[ i ];
2513 if ( hash === OO.getHash( item.getData() ) ) {
2514 items.push( item );
2515 }
2516 }
2517
2518 return items;
2519 };
2520
2521 /**
2522 * Add items to the group.
2523 *
2524 * Items will be added to the end of the group array unless the optional `index` parameter
2525 * specifies a different insertion point. Adding an existing item will move it to the end of the
2526 * array or the point specified by the `index`.
2527 *
2528 * @param {OO.ui.Element[]} items An array of items to add to the group
2529 * @param {number} [index] Index of the insertion point
2530 * @chainable
2531 * @return {OO.ui.Element} The element, for chaining
2532 */
2533 OO.ui.mixin.GroupElement.prototype.addItems = function ( items, index ) {
2534
2535 if ( items.length === 0 ) {
2536 return this;
2537 }
2538
2539 // Mixin method
2540 OO.EmitterList.prototype.addItems.call( this, items, index );
2541
2542 this.emit( 'change', this.getItems() );
2543 return this;
2544 };
2545
2546 /**
2547 * @inheritdoc
2548 */
2549 OO.ui.mixin.GroupElement.prototype.moveItem = function ( items, newIndex ) {
2550 // insertItemElements expects this.items to not have been modified yet, so call before the mixin
2551 this.insertItemElements( items, newIndex );
2552
2553 // Mixin method
2554 newIndex = OO.EmitterList.prototype.moveItem.call( this, items, newIndex );
2555
2556 return newIndex;
2557 };
2558
2559 /**
2560 * @inheritdoc
2561 */
2562 OO.ui.mixin.GroupElement.prototype.insertItem = function ( item, index ) {
2563 item.setElementGroup( this );
2564 this.insertItemElements( item, index );
2565
2566 // Mixin method
2567 index = OO.EmitterList.prototype.insertItem.call( this, item, index );
2568
2569 return index;
2570 };
2571
2572 /**
2573 * Insert elements into the group
2574 *
2575 * @private
2576 * @param {OO.ui.Element} itemWidget Item to insert
2577 * @param {number} index Insertion index
2578 */
2579 OO.ui.mixin.GroupElement.prototype.insertItemElements = function ( itemWidget, index ) {
2580 if ( index === undefined || index < 0 || index >= this.items.length ) {
2581 this.$group.append( itemWidget.$element );
2582 } else if ( index === 0 ) {
2583 this.$group.prepend( itemWidget.$element );
2584 } else {
2585 this.items[ index ].$element.before( itemWidget.$element );
2586 }
2587 };
2588
2589 /**
2590 * Remove the specified items from a group.
2591 *
2592 * Removed items are detached (not removed) from the DOM so that they may be reused.
2593 * To remove all items from a group, you may wish to use the #clearItems method instead.
2594 *
2595 * @param {OO.ui.Element[]} items An array of items to remove
2596 * @chainable
2597 * @return {OO.ui.Element} The element, for chaining
2598 */
2599 OO.ui.mixin.GroupElement.prototype.removeItems = function ( items ) {
2600 var i, len, item, index;
2601
2602 if ( items.length === 0 ) {
2603 return this;
2604 }
2605
2606 // Remove specific items elements
2607 for ( i = 0, len = items.length; i < len; i++ ) {
2608 item = items[ i ];
2609 index = this.items.indexOf( item );
2610 if ( index !== -1 ) {
2611 item.setElementGroup( null );
2612 item.$element.detach();
2613 }
2614 }
2615
2616 // Mixin method
2617 OO.EmitterList.prototype.removeItems.call( this, items );
2618
2619 this.emit( 'change', this.getItems() );
2620 return this;
2621 };
2622
2623 /**
2624 * Clear all items from the group.
2625 *
2626 * Cleared items are detached from the DOM, not removed, so that they may be reused.
2627 * To remove only a subset of items from a group, use the #removeItems method.
2628 *
2629 * @chainable
2630 * @return {OO.ui.Element} The element, for chaining
2631 */
2632 OO.ui.mixin.GroupElement.prototype.clearItems = function () {
2633 var i, len;
2634
2635 // Remove all item elements
2636 for ( i = 0, len = this.items.length; i < len; i++ ) {
2637 this.items[ i ].setElementGroup( null );
2638 this.items[ i ].$element.detach();
2639 }
2640
2641 // Mixin method
2642 OO.EmitterList.prototype.clearItems.call( this );
2643
2644 this.emit( 'change', this.getItems() );
2645 return this;
2646 };
2647
2648 /**
2649 * LabelElement is often mixed into other classes to generate a label, which
2650 * helps identify the function of an interface element.
2651 * See the [OOUI documentation on MediaWiki] [1] for more information.
2652 *
2653 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Icons,_Indicators,_and_Labels#Labels
2654 *
2655 * @abstract
2656 * @class
2657 *
2658 * @constructor
2659 * @param {Object} [config] Configuration options
2660 * @cfg {jQuery} [$label] The label element created by the class. If this
2661 * configuration is omitted, the label element will use a generated `<span>`.
2662 * @cfg {jQuery|string|Function|OO.ui.HtmlSnippet} [label] The label text. The label can be
2663 * specified as a plaintext string, a jQuery selection of elements, or a function that will
2664 * produce a string in the future. See the [OOUI documentation on MediaWiki] [2] for examples.
2665 * [2]: https://www.mediawiki.org/wiki/OOUI/Widgets/Icons,_Indicators,_and_Labels#Labels
2666 * @cfg {boolean} [invisibleLabel] Whether the label should be visually hidden (but still
2667 * accessible to screen-readers).
2668 */
2669 OO.ui.mixin.LabelElement = function OoUiMixinLabelElement( config ) {
2670 // Configuration initialization
2671 config = config || {};
2672
2673 // Properties
2674 this.$label = null;
2675 this.label = null;
2676 this.invisibleLabel = null;
2677
2678 // Initialization
2679 this.setLabel( config.label || this.constructor.static.label );
2680 this.setLabelElement( config.$label || $( '<span>' ) );
2681 this.setInvisibleLabel( config.invisibleLabel );
2682 };
2683
2684 /* Setup */
2685
2686 OO.initClass( OO.ui.mixin.LabelElement );
2687
2688 /* Events */
2689
2690 /**
2691 * @event labelChange
2692 * @param {string} value
2693 */
2694
2695 /* Static Properties */
2696
2697 /**
2698 * The label text. The label can be specified as a plaintext string, a function that will
2699 * produce a string in the future, or `null` for no label. The static value will
2700 * be overridden if a label is specified with the #label config option.
2701 *
2702 * @static
2703 * @inheritable
2704 * @property {string|Function|null}
2705 */
2706 OO.ui.mixin.LabelElement.static.label = null;
2707
2708 /* Static methods */
2709
2710 /**
2711 * Highlight the first occurrence of the query in the given text
2712 *
2713 * @param {string} text Text
2714 * @param {string} query Query to find
2715 * @param {Function} [compare] Optional string comparator, e.g. Intl.Collator().compare
2716 * @return {jQuery} Text with the first match of the query
2717 * sub-string wrapped in highlighted span
2718 */
2719 OO.ui.mixin.LabelElement.static.highlightQuery = function ( text, query, compare ) {
2720 var i, tLen, qLen,
2721 offset = -1,
2722 $result = $( '<span>' );
2723
2724 if ( compare ) {
2725 tLen = text.length;
2726 qLen = query.length;
2727 for ( i = 0; offset === -1 && i <= tLen - qLen; i++ ) {
2728 if ( compare( query, text.slice( i, i + qLen ) ) === 0 ) {
2729 offset = i;
2730 }
2731 }
2732 } else {
2733 offset = text.toLowerCase().indexOf( query.toLowerCase() );
2734 }
2735
2736 if ( !query.length || offset === -1 ) {
2737 $result.text( text );
2738 } else {
2739 $result.append(
2740 document.createTextNode( text.slice( 0, offset ) ),
2741 $( '<span>' )
2742 .addClass( 'oo-ui-labelElement-label-highlight' )
2743 .text( text.slice( offset, offset + query.length ) ),
2744 document.createTextNode( text.slice( offset + query.length ) )
2745 );
2746 }
2747 return $result.contents();
2748 };
2749
2750 /* Methods */
2751
2752 /**
2753 * Set the label element.
2754 *
2755 * If an element is already set, it will be cleaned up before setting up the new element.
2756 *
2757 * @param {jQuery} $label Element to use as label
2758 */
2759 OO.ui.mixin.LabelElement.prototype.setLabelElement = function ( $label ) {
2760 if ( this.$label ) {
2761 this.$label.removeClass( 'oo-ui-labelElement-label' ).empty();
2762 }
2763
2764 this.$label = $label.addClass( 'oo-ui-labelElement-label' );
2765 this.setLabelContent( this.label );
2766 };
2767
2768 /**
2769 * Set the label.
2770 *
2771 * An empty string will result in the label being hidden. A string containing only whitespace will
2772 * be converted to a single `&nbsp;`.
2773 *
2774 * @param {jQuery|string|OO.ui.HtmlSnippet|Function|null} label Label nodes; text; a function that
2775 * returns nodes or text; or null for no label
2776 * @chainable
2777 * @return {OO.ui.Element} The element, for chaining
2778 */
2779 OO.ui.mixin.LabelElement.prototype.setLabel = function ( label ) {
2780 label = typeof label === 'function' ? OO.ui.resolveMsg( label ) : label;
2781 label = ( ( typeof label === 'string' || label instanceof $ ) && label.length ) || ( label instanceof OO.ui.HtmlSnippet && label.toString().length ) ? label : null;
2782
2783 if ( this.label !== label ) {
2784 if ( this.$label ) {
2785 this.setLabelContent( label );
2786 }
2787 this.label = label;
2788 this.emit( 'labelChange' );
2789 }
2790
2791 this.$element.toggleClass( 'oo-ui-labelElement', !!this.label && !this.invisibleLabel );
2792
2793 return this;
2794 };
2795
2796 /**
2797 * Set whether the label should be visually hidden (but still accessible to screen-readers).
2798 *
2799 * @param {boolean} invisibleLabel
2800 * @chainable
2801 * @return {OO.ui.Element} The element, for chaining
2802 */
2803 OO.ui.mixin.LabelElement.prototype.setInvisibleLabel = function ( invisibleLabel ) {
2804 invisibleLabel = !!invisibleLabel;
2805
2806 if ( this.invisibleLabel !== invisibleLabel ) {
2807 this.invisibleLabel = invisibleLabel;
2808 this.emit( 'labelChange' );
2809 }
2810
2811 this.$label.toggleClass( 'oo-ui-labelElement-invisible', this.invisibleLabel );
2812 // Pretend that there is no label, a lot of CSS has been written with this assumption
2813 this.$element.toggleClass( 'oo-ui-labelElement', !!this.label && !this.invisibleLabel );
2814
2815 return this;
2816 };
2817
2818 /**
2819 * Set the label as plain text with a highlighted query
2820 *
2821 * @param {string} text Text label to set
2822 * @param {string} query Substring of text to highlight
2823 * @param {Function} [compare] Optional string comparator, e.g. Intl.Collator().compare
2824 * @chainable
2825 * @return {OO.ui.Element} The element, for chaining
2826 */
2827 OO.ui.mixin.LabelElement.prototype.setHighlightedQuery = function ( text, query, compare ) {
2828 return this.setLabel( this.constructor.static.highlightQuery( text, query, compare ) );
2829 };
2830
2831 /**
2832 * Get the label.
2833 *
2834 * @return {jQuery|string|Function|null} Label nodes; text; a function that returns nodes or
2835 * text; or null for no label
2836 */
2837 OO.ui.mixin.LabelElement.prototype.getLabel = function () {
2838 return this.label;
2839 };
2840
2841 /**
2842 * Set the content of the label.
2843 *
2844 * Do not call this method until after the label element has been set by #setLabelElement.
2845 *
2846 * @private
2847 * @param {jQuery|string|Function|null} label Label nodes; text; a function that returns nodes or
2848 * text; or null for no label
2849 */
2850 OO.ui.mixin.LabelElement.prototype.setLabelContent = function ( label ) {
2851 if ( typeof label === 'string' ) {
2852 if ( label.match( /^\s*$/ ) ) {
2853 // Convert whitespace only string to a single non-breaking space
2854 this.$label.html( '&nbsp;' );
2855 } else {
2856 this.$label.text( label );
2857 }
2858 } else if ( label instanceof OO.ui.HtmlSnippet ) {
2859 this.$label.html( label.toString() );
2860 } else if ( label instanceof $ ) {
2861 this.$label.empty().append( label );
2862 } else {
2863 this.$label.empty();
2864 }
2865 };
2866
2867 /**
2868 * IconElement is often mixed into other classes to generate an icon.
2869 * Icons are graphics, about the size of normal text. They are used to aid the user
2870 * in locating a control or to convey information in a space-efficient way. See the
2871 * [OOUI documentation on MediaWiki] [1] for a list of icons
2872 * included in the library.
2873 *
2874 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Icons,_Indicators,_and_Labels#Icons
2875 *
2876 * @abstract
2877 * @class
2878 *
2879 * @constructor
2880 * @param {Object} [config] Configuration options
2881 * @cfg {jQuery} [$icon] The icon element created by the class. If this configuration is omitted,
2882 * the icon element will use a generated `<span>`. To use a different HTML tag, or to specify that
2883 * the icon element be set to an existing icon instead of the one generated by this class, set a
2884 * value using a jQuery selection. For example:
2885 *
2886 * // Use a <div> tag instead of a <span>
2887 * $icon: $( '<div>' )
2888 * // Use an existing icon element instead of the one generated by the class
2889 * $icon: this.$element
2890 * // Use an icon element from a child widget
2891 * $icon: this.childwidget.$element
2892 * @cfg {Object|string} [icon=''] The symbolic name of the icon (e.g., ‘remove’ or ‘menu’), or a
2893 * map of symbolic names. A map is used for i18n purposes and contains a `default` icon
2894 * name and additional names keyed by language code. The `default` name is used when no icon is
2895 * keyed by the user's language.
2896 *
2897 * Example of an i18n map:
2898 *
2899 * { default: 'bold-a', en: 'bold-b', de: 'bold-f' }
2900 * See the [OOUI documentation on MediaWiki] [2] for a list of icons included in the library.
2901 * [2]: https://www.mediawiki.org/wiki/OOUI/Widgets/Icons,_Indicators,_and_Labels#Icons
2902 */
2903 OO.ui.mixin.IconElement = function OoUiMixinIconElement( config ) {
2904 // Configuration initialization
2905 config = config || {};
2906
2907 // Properties
2908 this.$icon = null;
2909 this.icon = null;
2910
2911 // Initialization
2912 this.setIcon( config.icon || this.constructor.static.icon );
2913 this.setIconElement( config.$icon || $( '<span>' ) );
2914 };
2915
2916 /* Setup */
2917
2918 OO.initClass( OO.ui.mixin.IconElement );
2919
2920 /* Static Properties */
2921
2922 /**
2923 * The symbolic name of the icon (e.g., ‘remove’ or ‘menu’), or a map of symbolic names. A map
2924 * is used for i18n purposes and contains a `default` icon name and additional names keyed by
2925 * language code. The `default` name is used when no icon is keyed by the user's language.
2926 *
2927 * Example of an i18n map:
2928 *
2929 * { default: 'bold-a', en: 'bold-b', de: 'bold-f' }
2930 *
2931 * Note: the static property will be overridden if the #icon configuration is used.
2932 *
2933 * @static
2934 * @inheritable
2935 * @property {Object|string}
2936 */
2937 OO.ui.mixin.IconElement.static.icon = null;
2938
2939 /**
2940 * The icon title, displayed when users move the mouse over the icon. The value can be text, a
2941 * function that returns title text, or `null` for no title.
2942 *
2943 * The static property will be overridden if the #iconTitle configuration is used.
2944 *
2945 * @static
2946 * @inheritable
2947 * @property {string|Function|null}
2948 */
2949 OO.ui.mixin.IconElement.static.iconTitle = null;
2950
2951 /* Methods */
2952
2953 /**
2954 * Set the icon element. This method is used to retarget an icon mixin so that its functionality
2955 * applies to the specified icon element instead of the one created by the class. If an icon
2956 * element is already set, the mixin’s effect on that element is removed. Generated CSS classes
2957 * and mixin methods will no longer affect the element.
2958 *
2959 * @param {jQuery} $icon Element to use as icon
2960 */
2961 OO.ui.mixin.IconElement.prototype.setIconElement = function ( $icon ) {
2962 if ( this.$icon ) {
2963 this.$icon
2964 .removeClass( 'oo-ui-iconElement-icon oo-ui-icon-' + this.icon )
2965 .removeAttr( 'title' );
2966 }
2967
2968 this.$icon = $icon
2969 .addClass( 'oo-ui-iconElement-icon' )
2970 .toggleClass( 'oo-ui-iconElement-noIcon', !this.icon )
2971 .toggleClass( 'oo-ui-icon-' + this.icon, !!this.icon );
2972 if ( this.iconTitle !== null ) {
2973 this.$icon.attr( 'title', this.iconTitle );
2974 }
2975
2976 this.updateThemeClasses();
2977 };
2978
2979 /**
2980 * Set icon by symbolic name (e.g., ‘remove’ or ‘menu’). Use `null` to remove an icon.
2981 * The icon parameter can also be set to a map of icon names. See the #icon config setting
2982 * for an example.
2983 *
2984 * @param {Object|string|null} icon A symbolic icon name, a {@link #icon map of icon names} keyed
2985 * by language code, or `null` to remove the icon.
2986 * @chainable
2987 * @return {OO.ui.Element} The element, for chaining
2988 */
2989 OO.ui.mixin.IconElement.prototype.setIcon = function ( icon ) {
2990 icon = OO.isPlainObject( icon ) ? OO.ui.getLocalValue( icon, null, 'default' ) : icon;
2991 icon = typeof icon === 'string' && icon.trim().length ? icon.trim() : null;
2992
2993 if ( this.icon !== icon ) {
2994 if ( this.$icon ) {
2995 if ( this.icon !== null ) {
2996 this.$icon.removeClass( 'oo-ui-icon-' + this.icon );
2997 }
2998 if ( icon !== null ) {
2999 this.$icon.addClass( 'oo-ui-icon-' + icon );
3000 }
3001 }
3002 this.icon = icon;
3003 }
3004
3005 this.$element.toggleClass( 'oo-ui-iconElement', !!this.icon );
3006 if ( this.$icon ) {
3007 this.$icon.toggleClass( 'oo-ui-iconElement-noIcon', !this.icon );
3008 }
3009 this.updateThemeClasses();
3010
3011 return this;
3012 };
3013
3014 /**
3015 * Get the symbolic name of the icon.
3016 *
3017 * @return {string} Icon name
3018 */
3019 OO.ui.mixin.IconElement.prototype.getIcon = function () {
3020 return this.icon;
3021 };
3022
3023 /**
3024 * Get the icon title. The title text is displayed when a user moves the mouse over the icon.
3025 *
3026 * @return {string} Icon title text
3027 * @deprecated
3028 */
3029 OO.ui.mixin.IconElement.prototype.getIconTitle = function () {
3030 return this.iconTitle;
3031 };
3032
3033 /**
3034 * IndicatorElement is often mixed into other classes to generate an indicator.
3035 * Indicators are small graphics that are generally used in two ways:
3036 *
3037 * - To draw attention to the status of an item. For example, an indicator might be
3038 * used to show that an item in a list has errors that need to be resolved.
3039 * - To clarify the function of a control that acts in an exceptional way (a button
3040 * that opens a menu instead of performing an action directly, for example).
3041 *
3042 * For a list of indicators included in the library, please see the
3043 * [OOUI documentation on MediaWiki] [1].
3044 *
3045 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Icons,_Indicators,_and_Labels#Indicators
3046 *
3047 * @abstract
3048 * @class
3049 *
3050 * @constructor
3051 * @param {Object} [config] Configuration options
3052 * @cfg {jQuery} [$indicator] The indicator element created by the class. If this
3053 * configuration is omitted, the indicator element will use a generated `<span>`.
3054 * @cfg {string} [indicator] Symbolic name of the indicator (e.g., ‘clear’ or ‘down’).
3055 * See the [OOUI documentation on MediaWiki][2] for a list of indicators included
3056 * in the library.
3057 * [2]: https://www.mediawiki.org/wiki/OOUI/Widgets/Icons,_Indicators,_and_Labels#Indicators
3058 */
3059 OO.ui.mixin.IndicatorElement = function OoUiMixinIndicatorElement( config ) {
3060 // Configuration initialization
3061 config = config || {};
3062
3063 // Properties
3064 this.$indicator = null;
3065 this.indicator = null;
3066
3067 // Initialization
3068 this.setIndicator( config.indicator || this.constructor.static.indicator );
3069 this.setIndicatorElement( config.$indicator || $( '<span>' ) );
3070 };
3071
3072 /* Setup */
3073
3074 OO.initClass( OO.ui.mixin.IndicatorElement );
3075
3076 /* Static Properties */
3077
3078 /**
3079 * Symbolic name of the indicator (e.g., ‘clear’ or ‘down’).
3080 * The static property will be overridden if the #indicator configuration is used.
3081 *
3082 * @static
3083 * @inheritable
3084 * @property {string|null}
3085 */
3086 OO.ui.mixin.IndicatorElement.static.indicator = null;
3087
3088 /**
3089 * A text string used as the indicator title, a function that returns title text, or `null`
3090 * for no title. The static property will be overridden if the #indicatorTitle configuration is
3091 * used.
3092 *
3093 * @static
3094 * @inheritable
3095 * @property {string|Function|null}
3096 */
3097 OO.ui.mixin.IndicatorElement.static.indicatorTitle = null;
3098
3099 /* Methods */
3100
3101 /**
3102 * Set the indicator element.
3103 *
3104 * If an element is already set, it will be cleaned up before setting up the new element.
3105 *
3106 * @param {jQuery} $indicator Element to use as indicator
3107 */
3108 OO.ui.mixin.IndicatorElement.prototype.setIndicatorElement = function ( $indicator ) {
3109 if ( this.$indicator ) {
3110 this.$indicator
3111 .removeClass( 'oo-ui-indicatorElement-indicator oo-ui-indicator-' + this.indicator )
3112 .removeAttr( 'title' );
3113 }
3114
3115 this.$indicator = $indicator
3116 .addClass( 'oo-ui-indicatorElement-indicator' )
3117 .toggleClass( 'oo-ui-indicatorElement-noIndicator', !this.indicator )
3118 .toggleClass( 'oo-ui-indicator-' + this.indicator, !!this.indicator );
3119 if ( this.indicatorTitle !== null ) {
3120 this.$indicator.attr( 'title', this.indicatorTitle );
3121 }
3122
3123 this.updateThemeClasses();
3124 };
3125
3126 /**
3127 * Set the indicator by its symbolic name: ‘clear’, ‘down’, ‘required’, ‘search’, ‘up’. Use `null`
3128 * to remove the indicator.
3129 *
3130 * @param {string|null} indicator Symbolic name of indicator, or `null` for no indicator
3131 * @chainable
3132 * @return {OO.ui.Element} The element, for chaining
3133 */
3134 OO.ui.mixin.IndicatorElement.prototype.setIndicator = function ( indicator ) {
3135 indicator = typeof indicator === 'string' && indicator.length ? indicator.trim() : null;
3136
3137 if ( this.indicator !== indicator ) {
3138 if ( this.$indicator ) {
3139 if ( this.indicator !== null ) {
3140 this.$indicator.removeClass( 'oo-ui-indicator-' + this.indicator );
3141 }
3142 if ( indicator !== null ) {
3143 this.$indicator.addClass( 'oo-ui-indicator-' + indicator );
3144 }
3145 }
3146 this.indicator = indicator;
3147 }
3148
3149 this.$element.toggleClass( 'oo-ui-indicatorElement', !!this.indicator );
3150 if ( this.$indicator ) {
3151 this.$indicator.toggleClass( 'oo-ui-indicatorElement-noIndicator', !this.indicator );
3152 }
3153 this.updateThemeClasses();
3154
3155 return this;
3156 };
3157
3158 /**
3159 * Get the symbolic name of the indicator (e.g., ‘clear’ or ‘down’).
3160 *
3161 * @return {string} Symbolic name of indicator
3162 */
3163 OO.ui.mixin.IndicatorElement.prototype.getIndicator = function () {
3164 return this.indicator;
3165 };
3166
3167 /**
3168 * Get the indicator title.
3169 *
3170 * The title is displayed when a user moves the mouse over the indicator.
3171 *
3172 * @return {string} Indicator title text
3173 * @deprecated
3174 */
3175 OO.ui.mixin.IndicatorElement.prototype.getIndicatorTitle = function () {
3176 return this.indicatorTitle;
3177 };
3178
3179 /**
3180 * The FlaggedElement class is an attribute mixin, meaning that it is used to add
3181 * additional functionality to an element created by another class. The class provides
3182 * a ‘flags’ property assigned the name (or an array of names) of styling flags,
3183 * which are used to customize the look and feel of a widget to better describe its
3184 * importance and functionality.
3185 *
3186 * The library currently contains the following styling flags for general use:
3187 *
3188 * - **progressive**: Progressive styling is applied to convey that the widget will move the user
3189 * forward in a process.
3190 * - **destructive**: Destructive styling is applied to convey that the widget will remove
3191 * something.
3192 *
3193 * The flags affect the appearance of the buttons:
3194 *
3195 * @example
3196 * // FlaggedElement is mixed into ButtonWidget to provide styling flags
3197 * var button1 = new OO.ui.ButtonWidget( {
3198 * label: 'Progressive',
3199 * flags: 'progressive'
3200 * } ),
3201 * button2 = new OO.ui.ButtonWidget( {
3202 * label: 'Destructive',
3203 * flags: 'destructive'
3204 * } );
3205 * $( document.body ).append( button1.$element, button2.$element );
3206 *
3207 * {@link OO.ui.ActionWidget ActionWidgets}, which are a special kind of button that execute an
3208 * action, use these flags: **primary** and **safe**.
3209 * Please see the [OOUI documentation on MediaWiki] [1] for more information.
3210 *
3211 * [1]: https://www.mediawiki.org/wiki/OOUI/Elements/Flagged
3212 *
3213 * @abstract
3214 * @class
3215 *
3216 * @constructor
3217 * @param {Object} [config] Configuration options
3218 * @cfg {string|string[]} [flags] The name or names of the flags (e.g., 'progressive' or 'primary')
3219 * to apply.
3220 * Please see the [OOUI documentation on MediaWiki] [2] for more information about available flags.
3221 * [2]: https://www.mediawiki.org/wiki/OOUI/Elements/Flagged
3222 * @cfg {jQuery} [$flagged] The flagged element. By default,
3223 * the flagged functionality is applied to the element created by the class ($element).
3224 * If a different element is specified, the flagged functionality will be applied to it instead.
3225 */
3226 OO.ui.mixin.FlaggedElement = function OoUiMixinFlaggedElement( config ) {
3227 // Configuration initialization
3228 config = config || {};
3229
3230 // Properties
3231 this.flags = {};
3232 this.$flagged = null;
3233
3234 // Initialization
3235 this.setFlags( config.flags );
3236 this.setFlaggedElement( config.$flagged || this.$element );
3237 };
3238
3239 /* Events */
3240
3241 /**
3242 * @event flag
3243 * A flag event is emitted when the #clearFlags or #setFlags methods are used. The `changes`
3244 * parameter contains the name of each modified flag and indicates whether it was
3245 * added or removed.
3246 *
3247 * @param {Object.<string,boolean>} changes Object keyed by flag name. A Boolean `true` indicates
3248 * that the flag was added, `false` that the flag was removed.
3249 */
3250
3251 /* Methods */
3252
3253 /**
3254 * Set the flagged element.
3255 *
3256 * This method is used to retarget a flagged mixin so that its functionality applies to the
3257 * specified element.
3258 * If an element is already set, the method will remove the mixin’s effect on that element.
3259 *
3260 * @param {jQuery} $flagged Element that should be flagged
3261 */
3262 OO.ui.mixin.FlaggedElement.prototype.setFlaggedElement = function ( $flagged ) {
3263 var classNames = Object.keys( this.flags ).map( function ( flag ) {
3264 return 'oo-ui-flaggedElement-' + flag;
3265 } );
3266
3267 if ( this.$flagged ) {
3268 this.$flagged.removeClass( classNames );
3269 }
3270
3271 this.$flagged = $flagged.addClass( classNames );
3272 };
3273
3274 /**
3275 * Check if the specified flag is set.
3276 *
3277 * @param {string} flag Name of flag
3278 * @return {boolean} The flag is set
3279 */
3280 OO.ui.mixin.FlaggedElement.prototype.hasFlag = function ( flag ) {
3281 // This may be called before the constructor, thus before this.flags is set
3282 return this.flags && ( flag in this.flags );
3283 };
3284
3285 /**
3286 * Get the names of all flags set.
3287 *
3288 * @return {string[]} Flag names
3289 */
3290 OO.ui.mixin.FlaggedElement.prototype.getFlags = function () {
3291 // This may be called before the constructor, thus before this.flags is set
3292 return Object.keys( this.flags || {} );
3293 };
3294
3295 /**
3296 * Clear all flags.
3297 *
3298 * @chainable
3299 * @return {OO.ui.Element} The element, for chaining
3300 * @fires flag
3301 */
3302 OO.ui.mixin.FlaggedElement.prototype.clearFlags = function () {
3303 var flag, className,
3304 changes = {},
3305 remove = [],
3306 classPrefix = 'oo-ui-flaggedElement-';
3307
3308 for ( flag in this.flags ) {
3309 className = classPrefix + flag;
3310 changes[ flag ] = false;
3311 delete this.flags[ flag ];
3312 remove.push( className );
3313 }
3314
3315 if ( this.$flagged ) {
3316 this.$flagged.removeClass( remove );
3317 }
3318
3319 this.updateThemeClasses();
3320 this.emit( 'flag', changes );
3321
3322 return this;
3323 };
3324
3325 /**
3326 * Add one or more flags.
3327 *
3328 * @param {string|string[]|Object.<string, boolean>} flags A flag name, an array of flag names,
3329 * or an object keyed by flag name with a boolean value that indicates whether the flag should
3330 * be added (`true`) or removed (`false`).
3331 * @chainable
3332 * @return {OO.ui.Element} The element, for chaining
3333 * @fires flag
3334 */
3335 OO.ui.mixin.FlaggedElement.prototype.setFlags = function ( flags ) {
3336 var i, len, flag, className,
3337 changes = {},
3338 add = [],
3339 remove = [],
3340 classPrefix = 'oo-ui-flaggedElement-';
3341
3342 if ( typeof flags === 'string' ) {
3343 className = classPrefix + flags;
3344 // Set
3345 if ( !this.flags[ flags ] ) {
3346 this.flags[ flags ] = true;
3347 add.push( className );
3348 }
3349 } else if ( Array.isArray( flags ) ) {
3350 for ( i = 0, len = flags.length; i < len; i++ ) {
3351 flag = flags[ i ];
3352 className = classPrefix + flag;
3353 // Set
3354 if ( !this.flags[ flag ] ) {
3355 changes[ flag ] = true;
3356 this.flags[ flag ] = true;
3357 add.push( className );
3358 }
3359 }
3360 } else if ( OO.isPlainObject( flags ) ) {
3361 for ( flag in flags ) {
3362 className = classPrefix + flag;
3363 if ( flags[ flag ] ) {
3364 // Set
3365 if ( !this.flags[ flag ] ) {
3366 changes[ flag ] = true;
3367 this.flags[ flag ] = true;
3368 add.push( className );
3369 }
3370 } else {
3371 // Remove
3372 if ( this.flags[ flag ] ) {
3373 changes[ flag ] = false;
3374 delete this.flags[ flag ];
3375 remove.push( className );
3376 }
3377 }
3378 }
3379 }
3380
3381 if ( this.$flagged ) {
3382 this.$flagged
3383 .addClass( add )
3384 .removeClass( remove );
3385 }
3386
3387 this.updateThemeClasses();
3388 this.emit( 'flag', changes );
3389
3390 return this;
3391 };
3392
3393 /**
3394 * TitledElement is mixed into other classes to provide a `title` attribute.
3395 * Titles are rendered by the browser and are made visible when the user moves
3396 * the mouse over the element. Titles are not visible on touch devices.
3397 *
3398 * @example
3399 * // TitledElement provides a `title` attribute to the
3400 * // ButtonWidget class.
3401 * var button = new OO.ui.ButtonWidget( {
3402 * label: 'Button with Title',
3403 * title: 'I am a button'
3404 * } );
3405 * $( document.body ).append( button.$element );
3406 *
3407 * @abstract
3408 * @class
3409 *
3410 * @constructor
3411 * @param {Object} [config] Configuration options
3412 * @cfg {jQuery} [$titled] The element to which the `title` attribute is applied.
3413 * If this config is omitted, the title functionality is applied to $element, the
3414 * element created by the class.
3415 * @cfg {string|Function} [title] The title text or a function that returns text. If
3416 * this config is omitted, the value of the {@link #static-title static title} property is used.
3417 */
3418 OO.ui.mixin.TitledElement = function OoUiMixinTitledElement( config ) {
3419 // Configuration initialization
3420 config = config || {};
3421
3422 // Properties
3423 this.$titled = null;
3424 this.title = null;
3425
3426 // Initialization
3427 this.setTitle( config.title !== undefined ? config.title : this.constructor.static.title );
3428 this.setTitledElement( config.$titled || this.$element );
3429 };
3430
3431 /* Setup */
3432
3433 OO.initClass( OO.ui.mixin.TitledElement );
3434
3435 /* Static Properties */
3436
3437 /**
3438 * The title text, a function that returns text, or `null` for no title. The value of the static
3439 * property is overridden if the #title config option is used.
3440 *
3441 * If the element has a default title (e.g. `<input type=file>`), `null` will allow that title to be
3442 * shown. Use empty string to suppress it.
3443 *
3444 * @static
3445 * @inheritable
3446 * @property {string|Function|null}
3447 */
3448 OO.ui.mixin.TitledElement.static.title = null;
3449
3450 /* Methods */
3451
3452 /**
3453 * Set the titled element.
3454 *
3455 * This method is used to retarget a TitledElement mixin so that its functionality applies to the
3456 * specified element.
3457 * If an element is already set, the mixin’s effect on that element is removed before the new
3458 * element is set up.
3459 *
3460 * @param {jQuery} $titled Element that should use the 'titled' functionality
3461 */
3462 OO.ui.mixin.TitledElement.prototype.setTitledElement = function ( $titled ) {
3463 if ( this.$titled ) {
3464 this.$titled.removeAttr( 'title' );
3465 }
3466
3467 this.$titled = $titled;
3468 this.updateTitle();
3469 };
3470
3471 /**
3472 * Set title.
3473 *
3474 * @param {string|Function|null} title Title text, a function that returns text, or `null`
3475 * for no title
3476 * @chainable
3477 * @return {OO.ui.Element} The element, for chaining
3478 */
3479 OO.ui.mixin.TitledElement.prototype.setTitle = function ( title ) {
3480 title = typeof title === 'function' ? OO.ui.resolveMsg( title ) : title;
3481 title = typeof title === 'string' ? title : null;
3482
3483 if ( this.title !== title ) {
3484 this.title = title;
3485 this.updateTitle();
3486 }
3487
3488 return this;
3489 };
3490
3491 /**
3492 * Update the title attribute, in case of changes to title or accessKey.
3493 *
3494 * @protected
3495 * @chainable
3496 * @return {OO.ui.Element} The element, for chaining
3497 */
3498 OO.ui.mixin.TitledElement.prototype.updateTitle = function () {
3499 var title = this.getTitle();
3500 if ( this.$titled ) {
3501 if ( title !== null ) {
3502 // Only if this is an AccessKeyedElement
3503 if ( this.formatTitleWithAccessKey ) {
3504 title = this.formatTitleWithAccessKey( title );
3505 }
3506 this.$titled.attr( 'title', title );
3507 } else {
3508 this.$titled.removeAttr( 'title' );
3509 }
3510 }
3511 return this;
3512 };
3513
3514 /**
3515 * Get title.
3516 *
3517 * @return {string} Title string
3518 */
3519 OO.ui.mixin.TitledElement.prototype.getTitle = function () {
3520 return this.title;
3521 };
3522
3523 /**
3524 * AccessKeyedElement is mixed into other classes to provide an `accesskey` HTML attribute.
3525 * Access keys allow an user to go to a specific element by using
3526 * a shortcut combination of a browser specific keys + the key
3527 * set to the field.
3528 *
3529 * @example
3530 * // AccessKeyedElement provides an `accesskey` attribute to the
3531 * // ButtonWidget class.
3532 * var button = new OO.ui.ButtonWidget( {
3533 * label: 'Button with access key',
3534 * accessKey: 'k'
3535 * } );
3536 * $( document.body ).append( button.$element );
3537 *
3538 * @abstract
3539 * @class
3540 *
3541 * @constructor
3542 * @param {Object} [config] Configuration options
3543 * @cfg {jQuery} [$accessKeyed] The element to which the `accesskey` attribute is applied.
3544 * If this config is omitted, the access key functionality is applied to $element, the
3545 * element created by the class.
3546 * @cfg {string|Function} [accessKey] The key or a function that returns the key. If
3547 * this config is omitted, no access key will be added.
3548 */
3549 OO.ui.mixin.AccessKeyedElement = function OoUiMixinAccessKeyedElement( config ) {
3550 // Configuration initialization
3551 config = config || {};
3552
3553 // Properties
3554 this.$accessKeyed = null;
3555 this.accessKey = null;
3556
3557 // Initialization
3558 this.setAccessKey( config.accessKey || null );
3559 this.setAccessKeyedElement( config.$accessKeyed || this.$element );
3560
3561 // If this is also a TitledElement and it initialized before we did, we may have
3562 // to update the title with the access key
3563 if ( this.updateTitle ) {
3564 this.updateTitle();
3565 }
3566 };
3567
3568 /* Setup */
3569
3570 OO.initClass( OO.ui.mixin.AccessKeyedElement );
3571
3572 /* Static Properties */
3573
3574 /**
3575 * The access key, a function that returns a key, or `null` for no access key.
3576 *
3577 * @static
3578 * @inheritable
3579 * @property {string|Function|null}
3580 */
3581 OO.ui.mixin.AccessKeyedElement.static.accessKey = null;
3582
3583 /* Methods */
3584
3585 /**
3586 * Set the access keyed element.
3587 *
3588 * This method is used to retarget a AccessKeyedElement mixin so that its functionality applies to
3589 * the specified element.
3590 * If an element is already set, the mixin's effect on that element is removed before the new
3591 * element is set up.
3592 *
3593 * @param {jQuery} $accessKeyed Element that should use the 'access keyed' functionality
3594 */
3595 OO.ui.mixin.AccessKeyedElement.prototype.setAccessKeyedElement = function ( $accessKeyed ) {
3596 if ( this.$accessKeyed ) {
3597 this.$accessKeyed.removeAttr( 'accesskey' );
3598 }
3599
3600 this.$accessKeyed = $accessKeyed;
3601 if ( this.accessKey ) {
3602 this.$accessKeyed.attr( 'accesskey', this.accessKey );
3603 }
3604 };
3605
3606 /**
3607 * Set access key.
3608 *
3609 * @param {string|Function|null} accessKey Key, a function that returns a key, or `null` for no
3610 * access key
3611 * @chainable
3612 * @return {OO.ui.Element} The element, for chaining
3613 */
3614 OO.ui.mixin.AccessKeyedElement.prototype.setAccessKey = function ( accessKey ) {
3615 accessKey = typeof accessKey === 'string' ? OO.ui.resolveMsg( accessKey ) : null;
3616
3617 if ( this.accessKey !== accessKey ) {
3618 if ( this.$accessKeyed ) {
3619 if ( accessKey !== null ) {
3620 this.$accessKeyed.attr( 'accesskey', accessKey );
3621 } else {
3622 this.$accessKeyed.removeAttr( 'accesskey' );
3623 }
3624 }
3625 this.accessKey = accessKey;
3626
3627 // Only if this is a TitledElement
3628 if ( this.updateTitle ) {
3629 this.updateTitle();
3630 }
3631 }
3632
3633 return this;
3634 };
3635
3636 /**
3637 * Get access key.
3638 *
3639 * @return {string} accessKey string
3640 */
3641 OO.ui.mixin.AccessKeyedElement.prototype.getAccessKey = function () {
3642 return this.accessKey;
3643 };
3644
3645 /**
3646 * Add information about the access key to the element's tooltip label.
3647 * (This is only public for hacky usage in FieldLayout.)
3648 *
3649 * @param {string} title Tooltip label for `title` attribute
3650 * @return {string}
3651 */
3652 OO.ui.mixin.AccessKeyedElement.prototype.formatTitleWithAccessKey = function ( title ) {
3653 var accessKey;
3654
3655 if ( !this.$accessKeyed ) {
3656 // Not initialized yet; the constructor will call updateTitle() which will rerun this
3657 // function.
3658 return title;
3659 }
3660 // Use jquery.accessKeyLabel if available to show modifiers, otherwise just display the
3661 // single key.
3662 if ( $.fn.updateTooltipAccessKeys && $.fn.updateTooltipAccessKeys.getAccessKeyLabel ) {
3663 accessKey = $.fn.updateTooltipAccessKeys.getAccessKeyLabel( this.$accessKeyed[ 0 ] );
3664 } else {
3665 accessKey = this.getAccessKey();
3666 }
3667 if ( accessKey ) {
3668 title += ' [' + accessKey + ']';
3669 }
3670 return title;
3671 };
3672
3673 /**
3674 * ButtonWidget is a generic widget for buttons. A wide variety of looks,
3675 * feels, and functionality can be customized via the class’s configuration options
3676 * and methods. Please see the [OOUI documentation on MediaWiki] [1] for more information
3677 * and examples.
3678 *
3679 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Buttons_and_Switches
3680 *
3681 * @example
3682 * // A button widget.
3683 * var button = new OO.ui.ButtonWidget( {
3684 * label: 'Button with Icon',
3685 * icon: 'trash',
3686 * title: 'Remove'
3687 * } );
3688 * $( document.body ).append( button.$element );
3689 *
3690 * NOTE: HTML form buttons should use the OO.ui.ButtonInputWidget class.
3691 *
3692 * @class
3693 * @extends OO.ui.Widget
3694 * @mixins OO.ui.mixin.ButtonElement
3695 * @mixins OO.ui.mixin.IconElement
3696 * @mixins OO.ui.mixin.IndicatorElement
3697 * @mixins OO.ui.mixin.LabelElement
3698 * @mixins OO.ui.mixin.TitledElement
3699 * @mixins OO.ui.mixin.FlaggedElement
3700 * @mixins OO.ui.mixin.TabIndexedElement
3701 * @mixins OO.ui.mixin.AccessKeyedElement
3702 *
3703 * @constructor
3704 * @param {Object} [config] Configuration options
3705 * @cfg {boolean} [active=false] Whether button should be shown as active
3706 * @cfg {string} [href] Hyperlink to visit when the button is clicked.
3707 * @cfg {string} [target] The frame or window in which to open the hyperlink.
3708 * @cfg {boolean} [noFollow] Search engine traversal hint (default: true)
3709 */
3710 OO.ui.ButtonWidget = function OoUiButtonWidget( config ) {
3711 // Configuration initialization
3712 config = config || {};
3713
3714 // Parent constructor
3715 OO.ui.ButtonWidget.parent.call( this, config );
3716
3717 // Mixin constructors
3718 OO.ui.mixin.ButtonElement.call( this, config );
3719 OO.ui.mixin.IconElement.call( this, config );
3720 OO.ui.mixin.IndicatorElement.call( this, config );
3721 OO.ui.mixin.LabelElement.call( this, config );
3722 OO.ui.mixin.TitledElement.call( this, $.extend( {
3723 $titled: this.$button
3724 }, config ) );
3725 OO.ui.mixin.FlaggedElement.call( this, config );
3726 OO.ui.mixin.TabIndexedElement.call( this, $.extend( {
3727 $tabIndexed: this.$button
3728 }, config ) );
3729 OO.ui.mixin.AccessKeyedElement.call( this, $.extend( {
3730 $accessKeyed: this.$button
3731 }, config ) );
3732
3733 // Properties
3734 this.href = null;
3735 this.target = null;
3736 this.noFollow = false;
3737
3738 // Events
3739 this.connect( this, {
3740 disable: 'onDisable'
3741 } );
3742
3743 // Initialization
3744 this.$button.append( this.$icon, this.$label, this.$indicator );
3745 this.$element
3746 .addClass( 'oo-ui-buttonWidget' )
3747 .append( this.$button );
3748 this.setActive( config.active );
3749 this.setHref( config.href );
3750 this.setTarget( config.target );
3751 this.setNoFollow( config.noFollow );
3752 };
3753
3754 /* Setup */
3755
3756 OO.inheritClass( OO.ui.ButtonWidget, OO.ui.Widget );
3757 OO.mixinClass( OO.ui.ButtonWidget, OO.ui.mixin.ButtonElement );
3758 OO.mixinClass( OO.ui.ButtonWidget, OO.ui.mixin.IconElement );
3759 OO.mixinClass( OO.ui.ButtonWidget, OO.ui.mixin.IndicatorElement );
3760 OO.mixinClass( OO.ui.ButtonWidget, OO.ui.mixin.LabelElement );
3761 OO.mixinClass( OO.ui.ButtonWidget, OO.ui.mixin.TitledElement );
3762 OO.mixinClass( OO.ui.ButtonWidget, OO.ui.mixin.FlaggedElement );
3763 OO.mixinClass( OO.ui.ButtonWidget, OO.ui.mixin.TabIndexedElement );
3764 OO.mixinClass( OO.ui.ButtonWidget, OO.ui.mixin.AccessKeyedElement );
3765
3766 /* Static Properties */
3767
3768 /**
3769 * @static
3770 * @inheritdoc
3771 */
3772 OO.ui.ButtonWidget.static.cancelButtonMouseDownEvents = false;
3773
3774 /**
3775 * @static
3776 * @inheritdoc
3777 */
3778 OO.ui.ButtonWidget.static.tagName = 'span';
3779
3780 /* Methods */
3781
3782 /**
3783 * Get hyperlink location.
3784 *
3785 * @return {string} Hyperlink location
3786 */
3787 OO.ui.ButtonWidget.prototype.getHref = function () {
3788 return this.href;
3789 };
3790
3791 /**
3792 * Get hyperlink target.
3793 *
3794 * @return {string} Hyperlink target
3795 */
3796 OO.ui.ButtonWidget.prototype.getTarget = function () {
3797 return this.target;
3798 };
3799
3800 /**
3801 * Get search engine traversal hint.
3802 *
3803 * @return {boolean} Whether search engines should avoid traversing this hyperlink
3804 */
3805 OO.ui.ButtonWidget.prototype.getNoFollow = function () {
3806 return this.noFollow;
3807 };
3808
3809 /**
3810 * Set hyperlink location.
3811 *
3812 * @param {string|null} href Hyperlink location, null to remove
3813 * @chainable
3814 * @return {OO.ui.Widget} The widget, for chaining
3815 */
3816 OO.ui.ButtonWidget.prototype.setHref = function ( href ) {
3817 href = typeof href === 'string' ? href : null;
3818 if ( href !== null && !OO.ui.isSafeUrl( href ) ) {
3819 href = './' + href;
3820 }
3821
3822 if ( href !== this.href ) {
3823 this.href = href;
3824 this.updateHref();
3825 }
3826
3827 return this;
3828 };
3829
3830 /**
3831 * Update the `href` attribute, in case of changes to href or
3832 * disabled state.
3833 *
3834 * @private
3835 * @chainable
3836 * @return {OO.ui.Widget} The widget, for chaining
3837 */
3838 OO.ui.ButtonWidget.prototype.updateHref = function () {
3839 if ( this.href !== null && !this.isDisabled() ) {
3840 this.$button.attr( 'href', this.href );
3841 } else {
3842 this.$button.removeAttr( 'href' );
3843 }
3844
3845 return this;
3846 };
3847
3848 /**
3849 * Handle disable events.
3850 *
3851 * @private
3852 * @param {boolean} disabled Element is disabled
3853 */
3854 OO.ui.ButtonWidget.prototype.onDisable = function () {
3855 this.updateHref();
3856 };
3857
3858 /**
3859 * Set hyperlink target.
3860 *
3861 * @param {string|null} target Hyperlink target, null to remove
3862 * @return {OO.ui.Widget} The widget, for chaining
3863 */
3864 OO.ui.ButtonWidget.prototype.setTarget = function ( target ) {
3865 target = typeof target === 'string' ? target : null;
3866
3867 if ( target !== this.target ) {
3868 this.target = target;
3869 if ( target !== null ) {
3870 this.$button.attr( 'target', target );
3871 } else {
3872 this.$button.removeAttr( 'target' );
3873 }
3874 }
3875
3876 return this;
3877 };
3878
3879 /**
3880 * Set search engine traversal hint.
3881 *
3882 * @param {boolean} noFollow True if search engines should avoid traversing this hyperlink
3883 * @return {OO.ui.Widget} The widget, for chaining
3884 */
3885 OO.ui.ButtonWidget.prototype.setNoFollow = function ( noFollow ) {
3886 noFollow = typeof noFollow === 'boolean' ? noFollow : true;
3887
3888 if ( noFollow !== this.noFollow ) {
3889 this.noFollow = noFollow;
3890 if ( noFollow ) {
3891 this.$button.attr( 'rel', 'nofollow' );
3892 } else {
3893 this.$button.removeAttr( 'rel' );
3894 }
3895 }
3896
3897 return this;
3898 };
3899
3900 // Override method visibility hints from ButtonElement
3901 /**
3902 * @method setActive
3903 * @inheritdoc
3904 */
3905 /**
3906 * @method isActive
3907 * @inheritdoc
3908 */
3909
3910 /**
3911 * A ButtonGroupWidget groups related buttons and is used together with OO.ui.ButtonWidget and
3912 * its subclasses. Each button in a group is addressed by a unique reference. Buttons can be added,
3913 * removed, and cleared from the group.
3914 *
3915 * @example
3916 * // A ButtonGroupWidget with two buttons.
3917 * var button1 = new OO.ui.PopupButtonWidget( {
3918 * label: 'Select a category',
3919 * icon: 'menu',
3920 * popup: {
3921 * $content: $( '<p>List of categories…</p>' ),
3922 * padded: true,
3923 * align: 'left'
3924 * }
3925 * } ),
3926 * button2 = new OO.ui.ButtonWidget( {
3927 * label: 'Add item'
3928 * } ),
3929 * buttonGroup = new OO.ui.ButtonGroupWidget( {
3930 * items: [ button1, button2 ]
3931 * } );
3932 * $( document.body ).append( buttonGroup.$element );
3933 *
3934 * @class
3935 * @extends OO.ui.Widget
3936 * @mixins OO.ui.mixin.GroupElement
3937 * @mixins OO.ui.mixin.TitledElement
3938 *
3939 * @constructor
3940 * @param {Object} [config] Configuration options
3941 * @cfg {OO.ui.ButtonWidget[]} [items] Buttons to add
3942 */
3943 OO.ui.ButtonGroupWidget = function OoUiButtonGroupWidget( config ) {
3944 // Configuration initialization
3945 config = config || {};
3946
3947 // Parent constructor
3948 OO.ui.ButtonGroupWidget.parent.call( this, config );
3949
3950 // Mixin constructors
3951 OO.ui.mixin.GroupElement.call( this, $.extend( {
3952 $group: this.$element
3953 }, config ) );
3954 OO.ui.mixin.TitledElement.call( this, config );
3955
3956 // Initialization
3957 this.$element.addClass( 'oo-ui-buttonGroupWidget' );
3958 if ( Array.isArray( config.items ) ) {
3959 this.addItems( config.items );
3960 }
3961 };
3962
3963 /* Setup */
3964
3965 OO.inheritClass( OO.ui.ButtonGroupWidget, OO.ui.Widget );
3966 OO.mixinClass( OO.ui.ButtonGroupWidget, OO.ui.mixin.GroupElement );
3967 OO.mixinClass( OO.ui.ButtonGroupWidget, OO.ui.mixin.TitledElement );
3968
3969 /* Static Properties */
3970
3971 /**
3972 * @static
3973 * @inheritdoc
3974 */
3975 OO.ui.ButtonGroupWidget.static.tagName = 'span';
3976
3977 /* Methods */
3978
3979 /**
3980 * Focus the widget
3981 *
3982 * @chainable
3983 * @return {OO.ui.Widget} The widget, for chaining
3984 */
3985 OO.ui.ButtonGroupWidget.prototype.focus = function () {
3986 if ( !this.isDisabled() ) {
3987 if ( this.items[ 0 ] ) {
3988 this.items[ 0 ].focus();
3989 }
3990 }
3991 return this;
3992 };
3993
3994 /**
3995 * @inheritdoc
3996 */
3997 OO.ui.ButtonGroupWidget.prototype.simulateLabelClick = function () {
3998 this.focus();
3999 };
4000
4001 /**
4002 * IconWidget is a generic widget for {@link OO.ui.mixin.IconElement icons}.
4003 * In general, IconWidgets should be used with OO.ui.LabelWidget, which creates a label that
4004 * identifies the icon’s function. See the [OOUI documentation on MediaWiki] [1]
4005 * for a list of icons included in the library.
4006 *
4007 * @example
4008 * // An IconWidget with a label via LabelWidget.
4009 * var myIcon = new OO.ui.IconWidget( {
4010 * icon: 'help',
4011 * title: 'Help'
4012 * } ),
4013 * // Create a label.
4014 * iconLabel = new OO.ui.LabelWidget( {
4015 * label: 'Help'
4016 * } );
4017 * $( document.body ).append( myIcon.$element, iconLabel.$element );
4018 *
4019 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Icons,_Indicators,_and_Labels#Icons
4020 *
4021 * @class
4022 * @extends OO.ui.Widget
4023 * @mixins OO.ui.mixin.IconElement
4024 * @mixins OO.ui.mixin.TitledElement
4025 * @mixins OO.ui.mixin.LabelElement
4026 * @mixins OO.ui.mixin.FlaggedElement
4027 *
4028 * @constructor
4029 * @param {Object} [config] Configuration options
4030 */
4031 OO.ui.IconWidget = function OoUiIconWidget( config ) {
4032 // Configuration initialization
4033 config = config || {};
4034
4035 // Parent constructor
4036 OO.ui.IconWidget.parent.call( this, config );
4037
4038 // Mixin constructors
4039 OO.ui.mixin.IconElement.call( this, $.extend( {
4040 $icon: this.$element
4041 }, config ) );
4042 OO.ui.mixin.TitledElement.call( this, $.extend( {
4043 $titled: this.$element
4044 }, config ) );
4045 OO.ui.mixin.LabelElement.call( this, $.extend( {
4046 $label: this.$element,
4047 invisibleLabel: true
4048 }, config ) );
4049 OO.ui.mixin.FlaggedElement.call( this, $.extend( {
4050 $flagged: this.$element
4051 }, config ) );
4052
4053 // Initialization
4054 this.$element.addClass( 'oo-ui-iconWidget' );
4055 // Remove class added by LabelElement initialization. It causes unexpected CSS to apply when
4056 // nested in other widgets, because this widget used to not mix in LabelElement.
4057 this.$element.removeClass( 'oo-ui-labelElement-label' );
4058 };
4059
4060 /* Setup */
4061
4062 OO.inheritClass( OO.ui.IconWidget, OO.ui.Widget );
4063 OO.mixinClass( OO.ui.IconWidget, OO.ui.mixin.IconElement );
4064 OO.mixinClass( OO.ui.IconWidget, OO.ui.mixin.TitledElement );
4065 OO.mixinClass( OO.ui.IconWidget, OO.ui.mixin.LabelElement );
4066 OO.mixinClass( OO.ui.IconWidget, OO.ui.mixin.FlaggedElement );
4067
4068 /* Static Properties */
4069
4070 /**
4071 * @static
4072 * @inheritdoc
4073 */
4074 OO.ui.IconWidget.static.tagName = 'span';
4075
4076 /**
4077 * IndicatorWidgets create indicators, which are small graphics that are generally used to draw
4078 * attention to the status of an item or to clarify the function within a control. For a list of
4079 * indicators included in the library, please see the [OOUI documentation on MediaWiki][1].
4080 *
4081 * @example
4082 * // An indicator widget.
4083 * var indicator1 = new OO.ui.IndicatorWidget( {
4084 * indicator: 'required'
4085 * } ),
4086 * // Create a fieldset layout to add a label.
4087 * fieldset = new OO.ui.FieldsetLayout();
4088 * fieldset.addItems( [
4089 * new OO.ui.FieldLayout( indicator1, {
4090 * label: 'A required indicator:'
4091 * } )
4092 * ] );
4093 * $( document.body ).append( fieldset.$element );
4094 *
4095 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Icons,_Indicators,_and_Labels#Indicators
4096 *
4097 * @class
4098 * @extends OO.ui.Widget
4099 * @mixins OO.ui.mixin.IndicatorElement
4100 * @mixins OO.ui.mixin.TitledElement
4101 * @mixins OO.ui.mixin.LabelElement
4102 *
4103 * @constructor
4104 * @param {Object} [config] Configuration options
4105 */
4106 OO.ui.IndicatorWidget = function OoUiIndicatorWidget( config ) {
4107 // Configuration initialization
4108 config = config || {};
4109
4110 // Parent constructor
4111 OO.ui.IndicatorWidget.parent.call( this, config );
4112
4113 // Mixin constructors
4114 OO.ui.mixin.IndicatorElement.call( this, $.extend( {
4115 $indicator: this.$element
4116 }, config ) );
4117 OO.ui.mixin.TitledElement.call( this, $.extend( {
4118 $titled: this.$element
4119 }, config ) );
4120 OO.ui.mixin.LabelElement.call( this, $.extend( {
4121 $label: this.$element,
4122 invisibleLabel: true
4123 }, config ) );
4124
4125 // Initialization
4126 this.$element.addClass( 'oo-ui-indicatorWidget' );
4127 // Remove class added by LabelElement initialization. It causes unexpected CSS to apply when
4128 // nested in other widgets, because this widget used to not mix in LabelElement.
4129 this.$element.removeClass( 'oo-ui-labelElement-label' );
4130 };
4131
4132 /* Setup */
4133
4134 OO.inheritClass( OO.ui.IndicatorWidget, OO.ui.Widget );
4135 OO.mixinClass( OO.ui.IndicatorWidget, OO.ui.mixin.IndicatorElement );
4136 OO.mixinClass( OO.ui.IndicatorWidget, OO.ui.mixin.TitledElement );
4137 OO.mixinClass( OO.ui.IndicatorWidget, OO.ui.mixin.LabelElement );
4138
4139 /* Static Properties */
4140
4141 /**
4142 * @static
4143 * @inheritdoc
4144 */
4145 OO.ui.IndicatorWidget.static.tagName = 'span';
4146
4147 /**
4148 * LabelWidgets help identify the function of interface elements. Each LabelWidget can
4149 * be configured with a `label` option that is set to a string, a label node, or a function:
4150 *
4151 * - String: a plaintext string
4152 * - jQuery selection: a jQuery selection, used for anything other than a plaintext label, e.g., a
4153 * label that includes a link or special styling, such as a gray color or additional
4154 * graphical elements.
4155 * - Function: a function that will produce a string in the future. Functions are used
4156 * in cases where the value of the label is not currently defined.
4157 *
4158 * In addition, the LabelWidget can be associated with an {@link OO.ui.InputWidget input widget},
4159 * which will come into focus when the label is clicked.
4160 *
4161 * @example
4162 * // Two LabelWidgets.
4163 * var label1 = new OO.ui.LabelWidget( {
4164 * label: 'plaintext label'
4165 * } ),
4166 * label2 = new OO.ui.LabelWidget( {
4167 * label: $( '<a>' ).attr( 'href', 'default.html' ).text( 'jQuery label' )
4168 * } ),
4169 * // Create a fieldset layout with fields for each example.
4170 * fieldset = new OO.ui.FieldsetLayout();
4171 * fieldset.addItems( [
4172 * new OO.ui.FieldLayout( label1 ),
4173 * new OO.ui.FieldLayout( label2 )
4174 * ] );
4175 * $( document.body ).append( fieldset.$element );
4176 *
4177 * @class
4178 * @extends OO.ui.Widget
4179 * @mixins OO.ui.mixin.LabelElement
4180 * @mixins OO.ui.mixin.TitledElement
4181 *
4182 * @constructor
4183 * @param {Object} [config] Configuration options
4184 * @cfg {OO.ui.InputWidget} [input] {@link OO.ui.InputWidget Input widget} that uses the label.
4185 * Clicking the label will focus the specified input field.
4186 */
4187 OO.ui.LabelWidget = function OoUiLabelWidget( config ) {
4188 // Configuration initialization
4189 config = config || {};
4190
4191 // Parent constructor
4192 OO.ui.LabelWidget.parent.call( this, config );
4193
4194 // Mixin constructors
4195 OO.ui.mixin.LabelElement.call( this, $.extend( {
4196 $label: this.$element
4197 }, config ) );
4198 OO.ui.mixin.TitledElement.call( this, config );
4199
4200 // Properties
4201 this.input = config.input;
4202
4203 // Initialization
4204 if ( this.input ) {
4205 if ( this.input.getInputId() ) {
4206 this.$element.attr( 'for', this.input.getInputId() );
4207 } else {
4208 this.$label.on( 'click', function () {
4209 this.input.simulateLabelClick();
4210 }.bind( this ) );
4211 }
4212 }
4213 this.$element.addClass( 'oo-ui-labelWidget' );
4214 };
4215
4216 /* Setup */
4217
4218 OO.inheritClass( OO.ui.LabelWidget, OO.ui.Widget );
4219 OO.mixinClass( OO.ui.LabelWidget, OO.ui.mixin.LabelElement );
4220 OO.mixinClass( OO.ui.LabelWidget, OO.ui.mixin.TitledElement );
4221
4222 /* Static Properties */
4223
4224 /**
4225 * @static
4226 * @inheritdoc
4227 */
4228 OO.ui.LabelWidget.static.tagName = 'label';
4229
4230 /**
4231 * PendingElement is a mixin that is used to create elements that notify users that something is
4232 * happening and that they should wait before proceeding. The pending state is visually represented
4233 * with a pending texture that appears in the head of a pending
4234 * {@link OO.ui.ProcessDialog process dialog} or in the input field of a
4235 * {@link OO.ui.TextInputWidget text input widget}.
4236 *
4237 * Currently, {@link OO.ui.ActionWidget Action widgets}, which mix in this class, can also be marked
4238 * as pending, but only when used in {@link OO.ui.MessageDialog message dialogs}. The behavior is
4239 * not currently supported for action widgets used in process dialogs.
4240 *
4241 * @example
4242 * function MessageDialog( config ) {
4243 * MessageDialog.parent.call( this, config );
4244 * }
4245 * OO.inheritClass( MessageDialog, OO.ui.MessageDialog );
4246 *
4247 * MessageDialog.static.name = 'myMessageDialog';
4248 * MessageDialog.static.actions = [
4249 * { action: 'save', label: 'Done', flags: 'primary' },
4250 * { label: 'Cancel', flags: 'safe' }
4251 * ];
4252 *
4253 * MessageDialog.prototype.initialize = function () {
4254 * MessageDialog.parent.prototype.initialize.apply( this, arguments );
4255 * this.content = new OO.ui.PanelLayout( { padded: true } );
4256 * this.content.$element.append( '<p>Click the \'Done\' action widget to see its pending ' +
4257 * 'state. Note that action widgets can be marked pending in message dialogs but not ' +
4258 * 'process dialogs.</p>' );
4259 * this.$body.append( this.content.$element );
4260 * };
4261 * MessageDialog.prototype.getBodyHeight = function () {
4262 * return 100;
4263 * }
4264 * MessageDialog.prototype.getActionProcess = function ( action ) {
4265 * var dialog = this;
4266 * if ( action === 'save' ) {
4267 * dialog.getActions().get({actions: 'save'})[0].pushPending();
4268 * return new OO.ui.Process()
4269 * .next( 1000 )
4270 * .next( function () {
4271 * dialog.getActions().get({actions: 'save'})[0].popPending();
4272 * } );
4273 * }
4274 * return MessageDialog.parent.prototype.getActionProcess.call( this, action );
4275 * };
4276 *
4277 * var windowManager = new OO.ui.WindowManager();
4278 * $( document.body ).append( windowManager.$element );
4279 *
4280 * var dialog = new MessageDialog();
4281 * windowManager.addWindows( [ dialog ] );
4282 * windowManager.openWindow( dialog );
4283 *
4284 * @abstract
4285 * @class
4286 *
4287 * @constructor
4288 * @param {Object} [config] Configuration options
4289 * @cfg {jQuery} [$pending] Element to mark as pending, defaults to this.$element
4290 */
4291 OO.ui.mixin.PendingElement = function OoUiMixinPendingElement( config ) {
4292 // Configuration initialization
4293 config = config || {};
4294
4295 // Properties
4296 this.pending = 0;
4297 this.$pending = null;
4298
4299 // Initialisation
4300 this.setPendingElement( config.$pending || this.$element );
4301 };
4302
4303 /* Setup */
4304
4305 OO.initClass( OO.ui.mixin.PendingElement );
4306
4307 /* Methods */
4308
4309 /**
4310 * Set the pending element (and clean up any existing one).
4311 *
4312 * @param {jQuery} $pending The element to set to pending.
4313 */
4314 OO.ui.mixin.PendingElement.prototype.setPendingElement = function ( $pending ) {
4315 if ( this.$pending ) {
4316 this.$pending.removeClass( 'oo-ui-pendingElement-pending' );
4317 }
4318
4319 this.$pending = $pending;
4320 if ( this.pending > 0 ) {
4321 this.$pending.addClass( 'oo-ui-pendingElement-pending' );
4322 }
4323 };
4324
4325 /**
4326 * Check if an element is pending.
4327 *
4328 * @return {boolean} Element is pending
4329 */
4330 OO.ui.mixin.PendingElement.prototype.isPending = function () {
4331 return !!this.pending;
4332 };
4333
4334 /**
4335 * Increase the pending counter. The pending state will remain active until the counter is zero
4336 * (i.e., the number of calls to #pushPending and #popPending is the same).
4337 *
4338 * @chainable
4339 * @return {OO.ui.Element} The element, for chaining
4340 */
4341 OO.ui.mixin.PendingElement.prototype.pushPending = function () {
4342 if ( this.pending === 0 ) {
4343 this.$pending.addClass( 'oo-ui-pendingElement-pending' );
4344 this.updateThemeClasses();
4345 }
4346 this.pending++;
4347
4348 return this;
4349 };
4350
4351 /**
4352 * Decrease the pending counter. The pending state will remain active until the counter is zero
4353 * (i.e., the number of calls to #pushPending and #popPending is the same).
4354 *
4355 * @chainable
4356 * @return {OO.ui.Element} The element, for chaining
4357 */
4358 OO.ui.mixin.PendingElement.prototype.popPending = function () {
4359 if ( this.pending === 1 ) {
4360 this.$pending.removeClass( 'oo-ui-pendingElement-pending' );
4361 this.updateThemeClasses();
4362 }
4363 this.pending = Math.max( 0, this.pending - 1 );
4364
4365 return this;
4366 };
4367
4368 /**
4369 * Element that will stick adjacent to a specified container, even when it is inserted elsewhere
4370 * in the document (for example, in an OO.ui.Window's $overlay).
4371 *
4372 * The elements's position is automatically calculated and maintained when window is resized or the
4373 * page is scrolled. If you reposition the container manually, you have to call #position to make
4374 * sure the element is still placed correctly.
4375 *
4376 * As positioning is only possible when both the element and the container are attached to the DOM
4377 * and visible, it's only done after you call #togglePositioning. You might want to do this inside
4378 * the #toggle method to display a floating popup, for example.
4379 *
4380 * @abstract
4381 * @class
4382 *
4383 * @constructor
4384 * @param {Object} [config] Configuration options
4385 * @cfg {jQuery} [$floatable] Node to position, assigned to #$floatable, omit to use #$element
4386 * @cfg {jQuery} [$floatableContainer] Node to position adjacent to
4387 * @cfg {string} [verticalPosition='below'] Where to position $floatable vertically:
4388 * 'below': Directly below $floatableContainer, aligning f's top edge with fC's bottom edge
4389 * 'above': Directly above $floatableContainer, aligning f's bottom edge with fC's top edge
4390 * 'top': Align the top edge with $floatableContainer's top edge
4391 * 'bottom': Align the bottom edge with $floatableContainer's bottom edge
4392 * 'center': Vertically align the center with $floatableContainer's center
4393 * @cfg {string} [horizontalPosition='start'] Where to position $floatable horizontally:
4394 * 'before': Directly before $floatableContainer, aligning f's end edge with fC's start edge
4395 * 'after': Directly after $floatableContainer, aligning f's start edge with fC's end edge
4396 * 'start': Align the start (left in LTR, right in RTL) edge with $floatableContainer's start edge
4397 * 'end': Align the end (right in LTR, left in RTL) edge with $floatableContainer's end edge
4398 * 'center': Horizontally align the center with $floatableContainer's center
4399 * @cfg {boolean} [hideWhenOutOfView=true] Whether to hide the floatable element if the container
4400 * is out of view
4401 */
4402 OO.ui.mixin.FloatableElement = function OoUiMixinFloatableElement( config ) {
4403 // Configuration initialization
4404 config = config || {};
4405
4406 // Properties
4407 this.$floatable = null;
4408 this.$floatableContainer = null;
4409 this.$floatableWindow = null;
4410 this.$floatableClosestScrollable = null;
4411 this.floatableOutOfView = false;
4412 this.onFloatableScrollHandler = this.position.bind( this );
4413 this.onFloatableWindowResizeHandler = this.position.bind( this );
4414
4415 // Initialization
4416 this.setFloatableContainer( config.$floatableContainer );
4417 this.setFloatableElement( config.$floatable || this.$element );
4418 this.setVerticalPosition( config.verticalPosition || 'below' );
4419 this.setHorizontalPosition( config.horizontalPosition || 'start' );
4420 this.hideWhenOutOfView = config.hideWhenOutOfView === undefined ?
4421 true : !!config.hideWhenOutOfView;
4422 };
4423
4424 /* Methods */
4425
4426 /**
4427 * Set floatable element.
4428 *
4429 * If an element is already set, it will be cleaned up before setting up the new element.
4430 *
4431 * @param {jQuery} $floatable Element to make floatable
4432 */
4433 OO.ui.mixin.FloatableElement.prototype.setFloatableElement = function ( $floatable ) {
4434 if ( this.$floatable ) {
4435 this.$floatable.removeClass( 'oo-ui-floatableElement-floatable' );
4436 this.$floatable.css( { left: '', top: '' } );
4437 }
4438
4439 this.$floatable = $floatable.addClass( 'oo-ui-floatableElement-floatable' );
4440 this.position();
4441 };
4442
4443 /**
4444 * Set floatable container.
4445 *
4446 * The element will be positioned relative to the specified container.
4447 *
4448 * @param {jQuery|null} $floatableContainer Container to keep visible, or null to unset
4449 */
4450 OO.ui.mixin.FloatableElement.prototype.setFloatableContainer = function ( $floatableContainer ) {
4451 this.$floatableContainer = $floatableContainer;
4452 if ( this.$floatable ) {
4453 this.position();
4454 }
4455 };
4456
4457 /**
4458 * Change how the element is positioned vertically.
4459 *
4460 * @param {string} position 'below', 'above', 'top', 'bottom' or 'center'
4461 */
4462 OO.ui.mixin.FloatableElement.prototype.setVerticalPosition = function ( position ) {
4463 if ( [ 'below', 'above', 'top', 'bottom', 'center' ].indexOf( position ) === -1 ) {
4464 throw new Error( 'Invalid value for vertical position: ' + position );
4465 }
4466 if ( this.verticalPosition !== position ) {
4467 this.verticalPosition = position;
4468 if ( this.$floatable ) {
4469 this.position();
4470 }
4471 }
4472 };
4473
4474 /**
4475 * Change how the element is positioned horizontally.
4476 *
4477 * @param {string} position 'before', 'after', 'start', 'end' or 'center'
4478 */
4479 OO.ui.mixin.FloatableElement.prototype.setHorizontalPosition = function ( position ) {
4480 if ( [ 'before', 'after', 'start', 'end', 'center' ].indexOf( position ) === -1 ) {
4481 throw new Error( 'Invalid value for horizontal position: ' + position );
4482 }
4483 if ( this.horizontalPosition !== position ) {
4484 this.horizontalPosition = position;
4485 if ( this.$floatable ) {
4486 this.position();
4487 }
4488 }
4489 };
4490
4491 /**
4492 * Toggle positioning.
4493 *
4494 * Do not turn positioning on until after the element is attached to the DOM and visible.
4495 *
4496 * @param {boolean} [positioning] Enable positioning, omit to toggle
4497 * @chainable
4498 * @return {OO.ui.Element} The element, for chaining
4499 */
4500 OO.ui.mixin.FloatableElement.prototype.togglePositioning = function ( positioning ) {
4501 var closestScrollableOfContainer;
4502
4503 if ( !this.$floatable || !this.$floatableContainer ) {
4504 return this;
4505 }
4506
4507 positioning = positioning === undefined ? !this.positioning : !!positioning;
4508
4509 if ( positioning && !this.warnedUnattached && !this.isElementAttached() ) {
4510 OO.ui.warnDeprecation( 'FloatableElement#togglePositioning: Before calling this method, the element must be attached to the DOM.' );
4511 this.warnedUnattached = true;
4512 }
4513
4514 if ( this.positioning !== positioning ) {
4515 this.positioning = positioning;
4516
4517 closestScrollableOfContainer = OO.ui.Element.static.getClosestScrollableContainer(
4518 this.$floatableContainer[ 0 ]
4519 );
4520 // If the scrollable is the root, we have to listen to scroll events
4521 // on the window because of browser inconsistencies.
4522 if ( $( closestScrollableOfContainer ).is( 'html, body' ) ) {
4523 closestScrollableOfContainer = OO.ui.Element.static.getWindow(
4524 closestScrollableOfContainer
4525 );
4526 }
4527
4528 if ( positioning ) {
4529 this.$floatableWindow = $( this.getElementWindow() );
4530 this.$floatableWindow.on( 'resize', this.onFloatableWindowResizeHandler );
4531
4532 this.$floatableClosestScrollable = $( closestScrollableOfContainer );
4533 this.$floatableClosestScrollable.on( 'scroll', this.onFloatableScrollHandler );
4534
4535 // Initial position after visible
4536 this.position();
4537 } else {
4538 if ( this.$floatableWindow ) {
4539 this.$floatableWindow.off( 'resize', this.onFloatableWindowResizeHandler );
4540 this.$floatableWindow = null;
4541 }
4542
4543 if ( this.$floatableClosestScrollable ) {
4544 this.$floatableClosestScrollable.off( 'scroll', this.onFloatableScrollHandler );
4545 this.$floatableClosestScrollable = null;
4546 }
4547
4548 this.$floatable.css( { left: '', right: '', top: '' } );
4549 }
4550 }
4551
4552 return this;
4553 };
4554
4555 /**
4556 * Check whether the bottom edge of the given element is within the viewport of the given
4557 * container.
4558 *
4559 * @private
4560 * @param {jQuery} $element
4561 * @param {jQuery} $container
4562 * @return {boolean}
4563 */
4564 OO.ui.mixin.FloatableElement.prototype.isElementInViewport = function ( $element, $container ) {
4565 var elemRect, contRect, topEdgeInBounds, bottomEdgeInBounds, leftEdgeInBounds,
4566 rightEdgeInBounds, startEdgeInBounds, endEdgeInBounds, viewportSpacing,
4567 direction = $element.css( 'direction' );
4568
4569 elemRect = $element[ 0 ].getBoundingClientRect();
4570 if ( $container[ 0 ] === window ) {
4571 viewportSpacing = OO.ui.getViewportSpacing();
4572 contRect = {
4573 top: 0,
4574 left: 0,
4575 right: document.documentElement.clientWidth,
4576 bottom: document.documentElement.clientHeight
4577 };
4578 contRect.top += viewportSpacing.top;
4579 contRect.left += viewportSpacing.left;
4580 contRect.right -= viewportSpacing.right;
4581 contRect.bottom -= viewportSpacing.bottom;
4582 } else {
4583 contRect = $container[ 0 ].getBoundingClientRect();
4584 }
4585
4586 topEdgeInBounds = elemRect.top >= contRect.top && elemRect.top <= contRect.bottom;
4587 bottomEdgeInBounds = elemRect.bottom >= contRect.top && elemRect.bottom <= contRect.bottom;
4588 leftEdgeInBounds = elemRect.left >= contRect.left && elemRect.left <= contRect.right;
4589 rightEdgeInBounds = elemRect.right >= contRect.left && elemRect.right <= contRect.right;
4590 if ( direction === 'rtl' ) {
4591 startEdgeInBounds = rightEdgeInBounds;
4592 endEdgeInBounds = leftEdgeInBounds;
4593 } else {
4594 startEdgeInBounds = leftEdgeInBounds;
4595 endEdgeInBounds = rightEdgeInBounds;
4596 }
4597
4598 if ( this.verticalPosition === 'below' && !bottomEdgeInBounds ) {
4599 return false;
4600 }
4601 if ( this.verticalPosition === 'above' && !topEdgeInBounds ) {
4602 return false;
4603 }
4604 if ( this.horizontalPosition === 'before' && !startEdgeInBounds ) {
4605 return false;
4606 }
4607 if ( this.horizontalPosition === 'after' && !endEdgeInBounds ) {
4608 return false;
4609 }
4610
4611 // The other positioning values are all about being inside the container,
4612 // so in those cases all we care about is that any part of the container is visible.
4613 return elemRect.top <= contRect.bottom && elemRect.bottom >= contRect.top &&
4614 elemRect.left <= contRect.right && elemRect.right >= contRect.left;
4615 };
4616
4617 /**
4618 * Check if the floatable is hidden to the user because it was offscreen.
4619 *
4620 * @return {boolean} Floatable is out of view
4621 */
4622 OO.ui.mixin.FloatableElement.prototype.isFloatableOutOfView = function () {
4623 return this.floatableOutOfView;
4624 };
4625
4626 /**
4627 * Position the floatable below its container.
4628 *
4629 * This should only be done when both of them are attached to the DOM and visible.
4630 *
4631 * @chainable
4632 * @return {OO.ui.Element} The element, for chaining
4633 */
4634 OO.ui.mixin.FloatableElement.prototype.position = function () {
4635 if ( !this.positioning ) {
4636 return this;
4637 }
4638
4639 if ( !(
4640 // To continue, some things need to be true:
4641 // The element must actually be in the DOM
4642 this.isElementAttached() && (
4643 // The closest scrollable is the current window
4644 this.$floatableClosestScrollable[ 0 ] === this.getElementWindow() ||
4645 // OR is an element in the element's DOM
4646 $.contains( this.getElementDocument(), this.$floatableClosestScrollable[ 0 ] )
4647 )
4648 ) ) {
4649 // Abort early if important parts of the widget are no longer attached to the DOM
4650 return this;
4651 }
4652
4653 this.floatableOutOfView = this.hideWhenOutOfView &&
4654 !this.isElementInViewport( this.$floatableContainer, this.$floatableClosestScrollable );
4655 if ( this.floatableOutOfView ) {
4656 this.$floatable.addClass( 'oo-ui-element-hidden' );
4657 return this;
4658 } else {
4659 this.$floatable.removeClass( 'oo-ui-element-hidden' );
4660 }
4661
4662 this.$floatable.css( this.computePosition() );
4663
4664 // We updated the position, so re-evaluate the clipping state.
4665 // (ClippableElement does not listen to 'scroll' events on $floatableContainer's parent, and so
4666 // will not notice the need to update itself.)
4667 // TODO: This is terrible, we shouldn't need to know about ClippableElement at all here.
4668 // Why does it not listen to the right events in the right places?
4669 if ( this.clip ) {
4670 this.clip();
4671 }
4672
4673 return this;
4674 };
4675
4676 /**
4677 * Compute how #$floatable should be positioned based on the position of #$floatableContainer
4678 * and the positioning settings. This is a helper for #position that shouldn't be called directly,
4679 * but may be overridden by subclasses if they want to change or add to the positioning logic.
4680 *
4681 * @return {Object} New position to apply with .css(). Keys are 'top', 'left', 'bottom' and 'right'.
4682 */
4683 OO.ui.mixin.FloatableElement.prototype.computePosition = function () {
4684 var isBody, scrollableX, scrollableY, containerPos,
4685 horizScrollbarHeight, vertScrollbarWidth, scrollTop, scrollLeft,
4686 newPos = { top: '', left: '', bottom: '', right: '' },
4687 direction = this.$floatableContainer.css( 'direction' ),
4688 $offsetParent = this.$floatable.offsetParent();
4689
4690 if ( $offsetParent.is( 'html' ) ) {
4691 // The innerHeight/Width and clientHeight/Width calculations don't work well on the
4692 // <html> element, but they do work on the <body>
4693 $offsetParent = $( $offsetParent[ 0 ].ownerDocument.body );
4694 }
4695 isBody = $offsetParent.is( 'body' );
4696 scrollableX = $offsetParent.css( 'overflow-x' ) === 'scroll' ||
4697 $offsetParent.css( 'overflow-x' ) === 'auto';
4698 scrollableY = $offsetParent.css( 'overflow-y' ) === 'scroll' ||
4699 $offsetParent.css( 'overflow-y' ) === 'auto';
4700
4701 vertScrollbarWidth = $offsetParent.innerWidth() - $offsetParent.prop( 'clientWidth' );
4702 horizScrollbarHeight = $offsetParent.innerHeight() - $offsetParent.prop( 'clientHeight' );
4703 // We don't need to compute and add scrollTop and scrollLeft if the scrollable container
4704 // is the body, or if it isn't scrollable
4705 scrollTop = scrollableY && !isBody ?
4706 $offsetParent.scrollTop() : 0;
4707 scrollLeft = scrollableX && !isBody ?
4708 OO.ui.Element.static.getScrollLeft( $offsetParent[ 0 ] ) : 0;
4709
4710 // Avoid passing the <body> to getRelativePosition(), because it won't return what we expect
4711 // if the <body> has a margin
4712 containerPos = isBody ?
4713 this.$floatableContainer.offset() :
4714 OO.ui.Element.static.getRelativePosition( this.$floatableContainer, $offsetParent );
4715 containerPos.bottom = containerPos.top + this.$floatableContainer.outerHeight();
4716 containerPos.right = containerPos.left + this.$floatableContainer.outerWidth();
4717 containerPos.start = direction === 'rtl' ? containerPos.right : containerPos.left;
4718 containerPos.end = direction === 'rtl' ? containerPos.left : containerPos.right;
4719
4720 if ( this.verticalPosition === 'below' ) {
4721 newPos.top = containerPos.bottom;
4722 } else if ( this.verticalPosition === 'above' ) {
4723 newPos.bottom = $offsetParent.outerHeight() - containerPos.top;
4724 } else if ( this.verticalPosition === 'top' ) {
4725 newPos.top = containerPos.top;
4726 } else if ( this.verticalPosition === 'bottom' ) {
4727 newPos.bottom = $offsetParent.outerHeight() - containerPos.bottom;
4728 } else if ( this.verticalPosition === 'center' ) {
4729 newPos.top = containerPos.top +
4730 ( this.$floatableContainer.height() - this.$floatable.height() ) / 2;
4731 }
4732
4733 if ( this.horizontalPosition === 'before' ) {
4734 newPos.end = containerPos.start;
4735 } else if ( this.horizontalPosition === 'after' ) {
4736 newPos.start = containerPos.end;
4737 } else if ( this.horizontalPosition === 'start' ) {
4738 newPos.start = containerPos.start;
4739 } else if ( this.horizontalPosition === 'end' ) {
4740 newPos.end = containerPos.end;
4741 } else if ( this.horizontalPosition === 'center' ) {
4742 newPos.left = containerPos.left +
4743 ( this.$floatableContainer.width() - this.$floatable.width() ) / 2;
4744 }
4745
4746 if ( newPos.start !== undefined ) {
4747 if ( direction === 'rtl' ) {
4748 newPos.right = ( isBody ? $( $offsetParent[ 0 ].ownerDocument.documentElement ) :
4749 $offsetParent ).outerWidth() - newPos.start;
4750 } else {
4751 newPos.left = newPos.start;
4752 }
4753 delete newPos.start;
4754 }
4755 if ( newPos.end !== undefined ) {
4756 if ( direction === 'rtl' ) {
4757 newPos.left = newPos.end;
4758 } else {
4759 newPos.right = ( isBody ? $( $offsetParent[ 0 ].ownerDocument.documentElement ) :
4760 $offsetParent ).outerWidth() - newPos.end;
4761 }
4762 delete newPos.end;
4763 }
4764
4765 // Account for scroll position
4766 if ( newPos.top !== '' ) {
4767 newPos.top += scrollTop;
4768 }
4769 if ( newPos.bottom !== '' ) {
4770 newPos.bottom -= scrollTop;
4771 }
4772 if ( newPos.left !== '' ) {
4773 newPos.left += scrollLeft;
4774 }
4775 if ( newPos.right !== '' ) {
4776 newPos.right -= scrollLeft;
4777 }
4778
4779 // Account for scrollbar gutter
4780 if ( newPos.bottom !== '' ) {
4781 newPos.bottom -= horizScrollbarHeight;
4782 }
4783 if ( direction === 'rtl' ) {
4784 if ( newPos.left !== '' ) {
4785 newPos.left -= vertScrollbarWidth;
4786 }
4787 } else {
4788 if ( newPos.right !== '' ) {
4789 newPos.right -= vertScrollbarWidth;
4790 }
4791 }
4792
4793 return newPos;
4794 };
4795
4796 /**
4797 * Element that can be automatically clipped to visible boundaries.
4798 *
4799 * Whenever the element's natural height changes, you have to call
4800 * {@link OO.ui.mixin.ClippableElement#clip} to make sure it's still
4801 * clipping correctly.
4802 *
4803 * The dimensions of #$clippableContainer will be compared to the boundaries of the
4804 * nearest scrollable container. If #$clippableContainer is too tall and/or too wide,
4805 * then #$clippable will be given a fixed reduced height and/or width and will be made
4806 * scrollable. By default, #$clippable and #$clippableContainer are the same element,
4807 * but you can build a static footer by setting #$clippableContainer to an element that contains
4808 * #$clippable and the footer.
4809 *
4810 * @abstract
4811 * @class
4812 *
4813 * @constructor
4814 * @param {Object} [config] Configuration options
4815 * @cfg {jQuery} [$clippable] Node to clip, assigned to #$clippable, omit to use #$element
4816 * @cfg {jQuery} [$clippableContainer] Node to keep visible, assigned to #$clippableContainer,
4817 * omit to use #$clippable
4818 */
4819 OO.ui.mixin.ClippableElement = function OoUiMixinClippableElement( config ) {
4820 // Configuration initialization
4821 config = config || {};
4822
4823 // Properties
4824 this.$clippable = null;
4825 this.$clippableContainer = null;
4826 this.clipping = false;
4827 this.clippedHorizontally = false;
4828 this.clippedVertically = false;
4829 this.$clippableScrollableContainer = null;
4830 this.$clippableScroller = null;
4831 this.$clippableWindow = null;
4832 this.idealWidth = null;
4833 this.idealHeight = null;
4834 this.onClippableScrollHandler = this.clip.bind( this );
4835 this.onClippableWindowResizeHandler = this.clip.bind( this );
4836
4837 // Initialization
4838 if ( config.$clippableContainer ) {
4839 this.setClippableContainer( config.$clippableContainer );
4840 }
4841 this.setClippableElement( config.$clippable || this.$element );
4842 };
4843
4844 /* Methods */
4845
4846 /**
4847 * Set clippable element.
4848 *
4849 * If an element is already set, it will be cleaned up before setting up the new element.
4850 *
4851 * @param {jQuery} $clippable Element to make clippable
4852 */
4853 OO.ui.mixin.ClippableElement.prototype.setClippableElement = function ( $clippable ) {
4854 if ( this.$clippable ) {
4855 this.$clippable.removeClass( 'oo-ui-clippableElement-clippable' );
4856 this.$clippable.css( { width: '', height: '', overflowX: '', overflowY: '' } );
4857 OO.ui.Element.static.reconsiderScrollbars( this.$clippable[ 0 ] );
4858 }
4859
4860 this.$clippable = $clippable.addClass( 'oo-ui-clippableElement-clippable' );
4861 this.clip();
4862 };
4863
4864 /**
4865 * Set clippable container.
4866 *
4867 * This is the container that will be measured when deciding whether to clip. When clipping,
4868 * #$clippable will be resized in order to keep the clippable container fully visible.
4869 *
4870 * If the clippable container is unset, #$clippable will be used.
4871 *
4872 * @param {jQuery|null} $clippableContainer Container to keep visible, or null to unset
4873 */
4874 OO.ui.mixin.ClippableElement.prototype.setClippableContainer = function ( $clippableContainer ) {
4875 this.$clippableContainer = $clippableContainer;
4876 if ( this.$clippable ) {
4877 this.clip();
4878 }
4879 };
4880
4881 /**
4882 * Toggle clipping.
4883 *
4884 * Do not turn clipping on until after the element is attached to the DOM and visible.
4885 *
4886 * @param {boolean} [clipping] Enable clipping, omit to toggle
4887 * @chainable
4888 * @return {OO.ui.Element} The element, for chaining
4889 */
4890 OO.ui.mixin.ClippableElement.prototype.toggleClipping = function ( clipping ) {
4891 clipping = clipping === undefined ? !this.clipping : !!clipping;
4892
4893 if ( clipping && !this.warnedUnattached && !this.isElementAttached() ) {
4894 OO.ui.warnDeprecation( 'ClippableElement#toggleClipping: Before calling this method, the element must be attached to the DOM.' );
4895 this.warnedUnattached = true;
4896 }
4897
4898 if ( this.clipping !== clipping ) {
4899 this.clipping = clipping;
4900 if ( clipping ) {
4901 this.$clippableScrollableContainer = $( this.getClosestScrollableElementContainer() );
4902 // If the clippable container is the root, we have to listen to scroll events and check
4903 // jQuery.scrollTop on the window because of browser inconsistencies
4904 this.$clippableScroller = this.$clippableScrollableContainer.is( 'html, body' ) ?
4905 $( OO.ui.Element.static.getWindow( this.$clippableScrollableContainer ) ) :
4906 this.$clippableScrollableContainer;
4907 this.$clippableScroller.on( 'scroll', this.onClippableScrollHandler );
4908 this.$clippableWindow = $( this.getElementWindow() )
4909 .on( 'resize', this.onClippableWindowResizeHandler );
4910 // Initial clip after visible
4911 this.clip();
4912 } else {
4913 this.$clippable.css( {
4914 width: '',
4915 height: '',
4916 maxWidth: '',
4917 maxHeight: '',
4918 overflowX: '',
4919 overflowY: ''
4920 } );
4921 OO.ui.Element.static.reconsiderScrollbars( this.$clippable[ 0 ] );
4922
4923 this.$clippableScrollableContainer = null;
4924 this.$clippableScroller.off( 'scroll', this.onClippableScrollHandler );
4925 this.$clippableScroller = null;
4926 this.$clippableWindow.off( 'resize', this.onClippableWindowResizeHandler );
4927 this.$clippableWindow = null;
4928 }
4929 }
4930
4931 return this;
4932 };
4933
4934 /**
4935 * Check if the element will be clipped to fit the visible area of the nearest scrollable container.
4936 *
4937 * @return {boolean} Element will be clipped to the visible area
4938 */
4939 OO.ui.mixin.ClippableElement.prototype.isClipping = function () {
4940 return this.clipping;
4941 };
4942
4943 /**
4944 * Check if the bottom or right of the element is being clipped by the nearest scrollable container.
4945 *
4946 * @return {boolean} Part of the element is being clipped
4947 */
4948 OO.ui.mixin.ClippableElement.prototype.isClipped = function () {
4949 return this.clippedHorizontally || this.clippedVertically;
4950 };
4951
4952 /**
4953 * Check if the right of the element is being clipped by the nearest scrollable container.
4954 *
4955 * @return {boolean} Part of the element is being clipped
4956 */
4957 OO.ui.mixin.ClippableElement.prototype.isClippedHorizontally = function () {
4958 return this.clippedHorizontally;
4959 };
4960
4961 /**
4962 * Check if the bottom of the element is being clipped by the nearest scrollable container.
4963 *
4964 * @return {boolean} Part of the element is being clipped
4965 */
4966 OO.ui.mixin.ClippableElement.prototype.isClippedVertically = function () {
4967 return this.clippedVertically;
4968 };
4969
4970 /**
4971 * Set the ideal size. These are the dimensions #$clippable will have when it's not being clipped.
4972 *
4973 * @param {number|string} [width] Width as a number of pixels or CSS string with unit suffix
4974 * @param {number|string} [height] Height as a number of pixels or CSS string with unit suffix
4975 */
4976 OO.ui.mixin.ClippableElement.prototype.setIdealSize = function ( width, height ) {
4977 this.idealWidth = width;
4978 this.idealHeight = height;
4979
4980 if ( !this.clipping ) {
4981 // Update dimensions
4982 this.$clippable.css( { width: width, height: height } );
4983 }
4984 // While clipping, idealWidth and idealHeight are not considered
4985 };
4986
4987 /**
4988 * Return the side of the clippable on which it is "anchored" (aligned to something else).
4989 * ClippableElement will clip the opposite side when reducing element's width.
4990 *
4991 * Classes that mix in ClippableElement should override this to return 'right' if their
4992 * clippable is absolutely positioned and using 'right: Npx' (and not using 'left').
4993 * If your class also mixes in FloatableElement, this is handled automatically.
4994 *
4995 * (This can't be guessed from the actual CSS because the computed values for 'left'/'right' are
4996 * always in pixels, even if they were unset or set to 'auto'.)
4997 *
4998 * When in doubt, 'left' (or 'right' in RTL) is a sane fallback.
4999 *
5000 * @return {string} 'left' or 'right'
5001 */
5002 OO.ui.mixin.ClippableElement.prototype.getHorizontalAnchorEdge = function () {
5003 if ( this.computePosition && this.positioning && this.computePosition().right !== '' ) {
5004 return 'right';
5005 }
5006 return 'left';
5007 };
5008
5009 /**
5010 * Return the side of the clippable on which it is "anchored" (aligned to something else).
5011 * ClippableElement will clip the opposite side when reducing element's width.
5012 *
5013 * Classes that mix in ClippableElement should override this to return 'bottom' if their
5014 * clippable is absolutely positioned and using 'bottom: Npx' (and not using 'top').
5015 * If your class also mixes in FloatableElement, this is handled automatically.
5016 *
5017 * (This can't be guessed from the actual CSS because the computed values for 'left'/'right' are
5018 * always in pixels, even if they were unset or set to 'auto'.)
5019 *
5020 * When in doubt, 'top' is a sane fallback.
5021 *
5022 * @return {string} 'top' or 'bottom'
5023 */
5024 OO.ui.mixin.ClippableElement.prototype.getVerticalAnchorEdge = function () {
5025 if ( this.computePosition && this.positioning && this.computePosition().bottom !== '' ) {
5026 return 'bottom';
5027 }
5028 return 'top';
5029 };
5030
5031 /**
5032 * Clip element to visible boundaries and allow scrolling when needed. You should call this method
5033 * when the element's natural height changes.
5034 *
5035 * Element will be clipped the bottom or right of the element is within 10px of the edge of, or
5036 * overlapped by, the visible area of the nearest scrollable container.
5037 *
5038 * Because calling clip() when the natural height changes isn't always possible, we also set
5039 * max-height when the element isn't being clipped. This means that if the element tries to grow
5040 * beyond the edge, something reasonable will happen before clip() is called.
5041 *
5042 * @chainable
5043 * @return {OO.ui.Element} The element, for chaining
5044 */
5045 OO.ui.mixin.ClippableElement.prototype.clip = function () {
5046 var extraHeight, extraWidth, viewportSpacing,
5047 desiredWidth, desiredHeight, allotedWidth, allotedHeight,
5048 naturalWidth, naturalHeight, clipWidth, clipHeight,
5049 $item, itemRect, $viewport, viewportRect, availableRect,
5050 direction, vertScrollbarWidth, horizScrollbarHeight,
5051 // Extra tolerance so that the sloppy code below doesn't result in results that are off
5052 // by one or two pixels. (And also so that we have space to display drop shadows.)
5053 // Chosen by fair dice roll.
5054 buffer = 7;
5055
5056 if ( !this.clipping ) {
5057 // this.$clippableScrollableContainer and this.$clippableWindow are null, so the below
5058 // will fail
5059 return this;
5060 }
5061
5062 function rectIntersection( a, b ) {
5063 var out = {};
5064 out.top = Math.max( a.top, b.top );
5065 out.left = Math.max( a.left, b.left );
5066 out.bottom = Math.min( a.bottom, b.bottom );
5067 out.right = Math.min( a.right, b.right );
5068 return out;
5069 }
5070
5071 viewportSpacing = OO.ui.getViewportSpacing();
5072
5073 if ( this.$clippableScrollableContainer.is( 'html, body' ) ) {
5074 $viewport = $( this.$clippableScrollableContainer[ 0 ].ownerDocument.body );
5075 // Dimensions of the browser window, rather than the element!
5076 viewportRect = {
5077 top: 0,
5078 left: 0,
5079 right: document.documentElement.clientWidth,
5080 bottom: document.documentElement.clientHeight
5081 };
5082 viewportRect.top += viewportSpacing.top;
5083 viewportRect.left += viewportSpacing.left;
5084 viewportRect.right -= viewportSpacing.right;
5085 viewportRect.bottom -= viewportSpacing.bottom;
5086 } else {
5087 $viewport = this.$clippableScrollableContainer;
5088 viewportRect = $viewport[ 0 ].getBoundingClientRect();
5089 // Convert into a plain object
5090 viewportRect = $.extend( {}, viewportRect );
5091 }
5092
5093 // Account for scrollbar gutter
5094 direction = $viewport.css( 'direction' );
5095 vertScrollbarWidth = $viewport.innerWidth() - $viewport.prop( 'clientWidth' );
5096 horizScrollbarHeight = $viewport.innerHeight() - $viewport.prop( 'clientHeight' );
5097 viewportRect.bottom -= horizScrollbarHeight;
5098 if ( direction === 'rtl' ) {
5099 viewportRect.left += vertScrollbarWidth;
5100 } else {
5101 viewportRect.right -= vertScrollbarWidth;
5102 }
5103
5104 // Add arbitrary tolerance
5105 viewportRect.top += buffer;
5106 viewportRect.left += buffer;
5107 viewportRect.right -= buffer;
5108 viewportRect.bottom -= buffer;
5109
5110 $item = this.$clippableContainer || this.$clippable;
5111
5112 extraHeight = $item.outerHeight() - this.$clippable.outerHeight();
5113 extraWidth = $item.outerWidth() - this.$clippable.outerWidth();
5114
5115 itemRect = $item[ 0 ].getBoundingClientRect();
5116 // Convert into a plain object
5117 itemRect = $.extend( {}, itemRect );
5118
5119 // Item might already be clipped, so we can't just use its dimensions (in case we might need to
5120 // make it larger than before). Extend the rectangle to the maximum size we are allowed to take.
5121 if ( this.getHorizontalAnchorEdge() === 'right' ) {
5122 itemRect.left = viewportRect.left;
5123 } else {
5124 itemRect.right = viewportRect.right;
5125 }
5126 if ( this.getVerticalAnchorEdge() === 'bottom' ) {
5127 itemRect.top = viewportRect.top;
5128 } else {
5129 itemRect.bottom = viewportRect.bottom;
5130 }
5131
5132 availableRect = rectIntersection( viewportRect, itemRect );
5133
5134 desiredWidth = Math.max( 0, availableRect.right - availableRect.left );
5135 desiredHeight = Math.max( 0, availableRect.bottom - availableRect.top );
5136 // It should never be desirable to exceed the dimensions of the browser viewport... right?
5137 desiredWidth = Math.min( desiredWidth,
5138 document.documentElement.clientWidth - viewportSpacing.left - viewportSpacing.right );
5139 desiredHeight = Math.min( desiredHeight,
5140 document.documentElement.clientHeight - viewportSpacing.top - viewportSpacing.right );
5141 allotedWidth = Math.ceil( desiredWidth - extraWidth );
5142 allotedHeight = Math.ceil( desiredHeight - extraHeight );
5143 naturalWidth = this.$clippable.prop( 'scrollWidth' );
5144 naturalHeight = this.$clippable.prop( 'scrollHeight' );
5145 clipWidth = allotedWidth < naturalWidth;
5146 clipHeight = allotedHeight < naturalHeight;
5147
5148 if ( clipWidth ) {
5149 // The order matters here. If overflow is not set first, Chrome displays bogus scrollbars.
5150 // See T157672.
5151 // Forcing a reflow is a smaller workaround than calling reconsiderScrollbars() for
5152 // this case.
5153 this.$clippable.css( 'overflowX', 'scroll' );
5154 // eslint-disable-next-line no-void
5155 void this.$clippable[ 0 ].offsetHeight; // Force reflow
5156 this.$clippable.css( {
5157 width: Math.max( 0, allotedWidth ),
5158 maxWidth: ''
5159 } );
5160 } else {
5161 this.$clippable.css( {
5162 overflowX: '',
5163 width: this.idealWidth || '',
5164 maxWidth: Math.max( 0, allotedWidth )
5165 } );
5166 }
5167 if ( clipHeight ) {
5168 // The order matters here. If overflow is not set first, Chrome displays bogus scrollbars.
5169 // See T157672.
5170 // Forcing a reflow is a smaller workaround than calling reconsiderScrollbars() for
5171 // this case.
5172 this.$clippable.css( 'overflowY', 'scroll' );
5173 // eslint-disable-next-line no-void
5174 void this.$clippable[ 0 ].offsetHeight; // Force reflow
5175 this.$clippable.css( {
5176 height: Math.max( 0, allotedHeight ),
5177 maxHeight: ''
5178 } );
5179 } else {
5180 this.$clippable.css( {
5181 overflowY: '',
5182 height: this.idealHeight || '',
5183 maxHeight: Math.max( 0, allotedHeight )
5184 } );
5185 }
5186
5187 // If we stopped clipping in at least one of the dimensions
5188 if ( ( this.clippedHorizontally && !clipWidth ) || ( this.clippedVertically && !clipHeight ) ) {
5189 OO.ui.Element.static.reconsiderScrollbars( this.$clippable[ 0 ] );
5190 }
5191
5192 this.clippedHorizontally = clipWidth;
5193 this.clippedVertically = clipHeight;
5194
5195 return this;
5196 };
5197
5198 /**
5199 * PopupWidget is a container for content. The popup is overlaid and positioned absolutely.
5200 * By default, each popup has an anchor that points toward its origin.
5201 * Please see the [OOUI documentation on MediaWiki.org] [1] for more information and examples.
5202 *
5203 * Unlike most widgets, PopupWidget is initially hidden and must be shown by calling #toggle.
5204 *
5205 * @example
5206 * // A PopupWidget.
5207 * var popup = new OO.ui.PopupWidget( {
5208 * $content: $( '<p>Hi there!</p>' ),
5209 * padded: true,
5210 * width: 300
5211 * } );
5212 *
5213 * $( document.body ).append( popup.$element );
5214 * // To display the popup, toggle the visibility to 'true'.
5215 * popup.toggle( true );
5216 *
5217 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Popups
5218 *
5219 * @class
5220 * @extends OO.ui.Widget
5221 * @mixins OO.ui.mixin.LabelElement
5222 * @mixins OO.ui.mixin.ClippableElement
5223 * @mixins OO.ui.mixin.FloatableElement
5224 *
5225 * @constructor
5226 * @param {Object} [config] Configuration options
5227 * @cfg {number|null} [width=320] Width of popup in pixels. Pass `null` to use automatic width.
5228 * @cfg {number|null} [height=null] Height of popup in pixels. Pass `null` to use automatic height.
5229 * @cfg {boolean} [anchor=true] Show anchor pointing to origin of popup
5230 * @cfg {string} [position='below'] Where to position the popup relative to $floatableContainer
5231 * 'above': Put popup above $floatableContainer; anchor points down to the horizontal center
5232 * of $floatableContainer
5233 * 'below': Put popup below $floatableContainer; anchor points up to the horizontal center
5234 * of $floatableContainer
5235 * 'before': Put popup to the left (LTR) / right (RTL) of $floatableContainer; anchor points
5236 * endwards (right/left) to the vertical center of $floatableContainer
5237 * 'after': Put popup to the right (LTR) / left (RTL) of $floatableContainer; anchor points
5238 * startwards (left/right) to the vertical center of $floatableContainer
5239 * @cfg {string} [align='center'] How to align the popup to $floatableContainer
5240 * 'forwards': If position is above/below, move the popup as far endwards (right in LTR, left in
5241 * RTL) as possible while still keeping the anchor within the popup; if position is before/after,
5242 * move the popup as far downwards as possible.
5243 * 'backwards': If position is above/below, move the popup as far startwards (left in LTR, right in
5244 * RTL) as possible while still keeping the anchor within the popup; if position is before/after,
5245 * move the popup as far upwards as possible.
5246 * 'center': Horizontally (if position is above/below) or vertically (before/after) align the
5247 * center of the popup with the center of $floatableContainer.
5248 * 'force-left': Alias for 'forwards' in LTR and 'backwards' in RTL
5249 * 'force-right': Alias for 'backwards' in RTL and 'forwards' in LTR
5250 * @cfg {boolean} [autoFlip=true] Whether to automatically switch the popup's position between
5251 * 'above' and 'below', or between 'before' and 'after', if there is not enough space in the
5252 * desired direction to display the popup without clipping
5253 * @cfg {jQuery} [$container] Constrain the popup to the boundaries of the specified container.
5254 * See the [OOUI docs on MediaWiki][3] for an example.
5255 * [3]: https://www.mediawiki.org/wiki/OOUI/Widgets/Popups#containerExample
5256 * @cfg {number} [containerPadding=10] Padding between the popup and its container, specified as a
5257 * number of pixels.
5258 * @cfg {jQuery} [$content] Content to append to the popup's body
5259 * @cfg {jQuery} [$footer] Content to append to the popup's footer
5260 * @cfg {boolean} [autoClose=false] Automatically close the popup when it loses focus.
5261 * @cfg {jQuery} [$autoCloseIgnore] Elements that will not close the popup when clicked.
5262 * This config option is only relevant if #autoClose is set to `true`. See the
5263 * [OOUI documentation on MediaWiki][2] for an example.
5264 * [2]: https://www.mediawiki.org/wiki/OOUI/Widgets/Popups#autocloseExample
5265 * @cfg {boolean} [head=false] Show a popup header that contains a #label (if specified) and close
5266 * button.
5267 * @cfg {boolean} [padded=false] Add padding to the popup's body
5268 */
5269 OO.ui.PopupWidget = function OoUiPopupWidget( config ) {
5270 // Configuration initialization
5271 config = config || {};
5272
5273 // Parent constructor
5274 OO.ui.PopupWidget.parent.call( this, config );
5275
5276 // Properties (must be set before ClippableElement constructor call)
5277 this.$body = $( '<div>' );
5278 this.$popup = $( '<div>' );
5279
5280 // Mixin constructors
5281 OO.ui.mixin.LabelElement.call( this, config );
5282 OO.ui.mixin.ClippableElement.call( this, $.extend( {
5283 $clippable: this.$body,
5284 $clippableContainer: this.$popup
5285 }, config ) );
5286 OO.ui.mixin.FloatableElement.call( this, config );
5287
5288 // Properties
5289 this.$anchor = $( '<div>' );
5290 // If undefined, will be computed lazily in computePosition()
5291 this.$container = config.$container;
5292 this.containerPadding = config.containerPadding !== undefined ? config.containerPadding : 10;
5293 this.autoClose = !!config.autoClose;
5294 this.transitionTimeout = null;
5295 this.anchored = false;
5296 this.onDocumentMouseDownHandler = this.onDocumentMouseDown.bind( this );
5297 this.onDocumentKeyDownHandler = this.onDocumentKeyDown.bind( this );
5298
5299 // Initialization
5300 this.setSize( config.width, config.height );
5301 this.toggleAnchor( config.anchor === undefined || config.anchor );
5302 this.setAlignment( config.align || 'center' );
5303 this.setPosition( config.position || 'below' );
5304 this.setAutoFlip( config.autoFlip === undefined || config.autoFlip );
5305 this.setAutoCloseIgnore( config.$autoCloseIgnore );
5306 this.$body.addClass( 'oo-ui-popupWidget-body' );
5307 this.$anchor.addClass( 'oo-ui-popupWidget-anchor' );
5308 this.$popup
5309 .addClass( 'oo-ui-popupWidget-popup' )
5310 .append( this.$body );
5311 this.$element
5312 .addClass( 'oo-ui-popupWidget' )
5313 .append( this.$popup, this.$anchor );
5314 // Move content, which was added to #$element by OO.ui.Widget, to the body
5315 // FIXME This is gross, we should use '$body' or something for the config
5316 if ( config.$content instanceof $ ) {
5317 this.$body.append( config.$content );
5318 }
5319
5320 if ( config.padded ) {
5321 this.$body.addClass( 'oo-ui-popupWidget-body-padded' );
5322 }
5323
5324 if ( config.head ) {
5325 this.closeButton = new OO.ui.ButtonWidget( {
5326 framed: false,
5327 icon: 'close'
5328 } );
5329 this.closeButton.connect( this, {
5330 click: 'onCloseButtonClick'
5331 } );
5332 this.$head = $( '<div>' )
5333 .addClass( 'oo-ui-popupWidget-head' )
5334 .append( this.$label, this.closeButton.$element );
5335 this.$popup.prepend( this.$head );
5336 }
5337
5338 if ( config.$footer ) {
5339 this.$footer = $( '<div>' )
5340 .addClass( 'oo-ui-popupWidget-footer' )
5341 .append( config.$footer );
5342 this.$popup.append( this.$footer );
5343 }
5344
5345 // Initially hidden - using #toggle may cause errors if subclasses override toggle with methods
5346 // that reference properties not initialized at that time of parent class construction
5347 // TODO: Find a better way to handle post-constructor setup
5348 this.visible = false;
5349 this.$element.addClass( 'oo-ui-element-hidden' );
5350 };
5351
5352 /* Setup */
5353
5354 OO.inheritClass( OO.ui.PopupWidget, OO.ui.Widget );
5355 OO.mixinClass( OO.ui.PopupWidget, OO.ui.mixin.LabelElement );
5356 OO.mixinClass( OO.ui.PopupWidget, OO.ui.mixin.ClippableElement );
5357 OO.mixinClass( OO.ui.PopupWidget, OO.ui.mixin.FloatableElement );
5358
5359 /* Events */
5360
5361 /**
5362 * @event ready
5363 *
5364 * The popup is ready: it is visible and has been positioned and clipped.
5365 */
5366
5367 /* Methods */
5368
5369 /**
5370 * Handles document mouse down events.
5371 *
5372 * @private
5373 * @param {MouseEvent} e Mouse down event
5374 */
5375 OO.ui.PopupWidget.prototype.onDocumentMouseDown = function ( e ) {
5376 if (
5377 this.isVisible() &&
5378 !OO.ui.contains( this.$element.add( this.$autoCloseIgnore ).get(), e.target, true )
5379 ) {
5380 this.toggle( false );
5381 }
5382 };
5383
5384 /**
5385 * Bind document mouse down listener.
5386 *
5387 * @private
5388 */
5389 OO.ui.PopupWidget.prototype.bindDocumentMouseDownListener = function () {
5390 // Capture clicks outside popup
5391 this.getElementDocument().addEventListener( 'mousedown', this.onDocumentMouseDownHandler, true );
5392 // We add 'click' event because iOS safari needs to respond to this event.
5393 // We can't use 'touchstart' (as is usually the equivalent to 'mousedown') because
5394 // then it will trigger when scrolling. While iOS Safari has some reported behavior
5395 // of occasionally not emitting 'click' properly, that event seems to be the standard
5396 // that it should be emitting, so we add it to this and will operate the event handler
5397 // on whichever of these events was triggered first
5398 this.getElementDocument().addEventListener( 'click', this.onDocumentMouseDownHandler, true );
5399 };
5400
5401 /**
5402 * Handles close button click events.
5403 *
5404 * @private
5405 */
5406 OO.ui.PopupWidget.prototype.onCloseButtonClick = function () {
5407 if ( this.isVisible() ) {
5408 this.toggle( false );
5409 }
5410 };
5411
5412 /**
5413 * Unbind document mouse down listener.
5414 *
5415 * @private
5416 */
5417 OO.ui.PopupWidget.prototype.unbindDocumentMouseDownListener = function () {
5418 this.getElementDocument().removeEventListener( 'mousedown', this.onDocumentMouseDownHandler, true );
5419 this.getElementDocument().removeEventListener( 'click', this.onDocumentMouseDownHandler, true );
5420 };
5421
5422 /**
5423 * Handles document key down events.
5424 *
5425 * @private
5426 * @param {KeyboardEvent} e Key down event
5427 */
5428 OO.ui.PopupWidget.prototype.onDocumentKeyDown = function ( e ) {
5429 if (
5430 e.which === OO.ui.Keys.ESCAPE &&
5431 this.isVisible()
5432 ) {
5433 this.toggle( false );
5434 e.preventDefault();
5435 e.stopPropagation();
5436 }
5437 };
5438
5439 /**
5440 * Bind document key down listener.
5441 *
5442 * @private
5443 */
5444 OO.ui.PopupWidget.prototype.bindDocumentKeyDownListener = function () {
5445 this.getElementDocument().addEventListener( 'keydown', this.onDocumentKeyDownHandler, true );
5446 };
5447
5448 /**
5449 * Unbind document key down listener.
5450 *
5451 * @private
5452 */
5453 OO.ui.PopupWidget.prototype.unbindDocumentKeyDownListener = function () {
5454 this.getElementDocument().removeEventListener( 'keydown', this.onDocumentKeyDownHandler, true );
5455 };
5456
5457 /**
5458 * Show, hide, or toggle the visibility of the anchor.
5459 *
5460 * @param {boolean} [show] Show anchor, omit to toggle
5461 */
5462 OO.ui.PopupWidget.prototype.toggleAnchor = function ( show ) {
5463 show = show === undefined ? !this.anchored : !!show;
5464
5465 if ( this.anchored !== show ) {
5466 if ( show ) {
5467 this.$element.addClass( 'oo-ui-popupWidget-anchored' );
5468 this.$element.addClass( 'oo-ui-popupWidget-anchored-' + this.anchorEdge );
5469 } else {
5470 this.$element.removeClass( 'oo-ui-popupWidget-anchored' );
5471 this.$element.removeClass( 'oo-ui-popupWidget-anchored-' + this.anchorEdge );
5472 }
5473 this.anchored = show;
5474 }
5475 };
5476
5477 /**
5478 * Change which edge the anchor appears on.
5479 *
5480 * @param {string} edge 'top', 'bottom', 'start' or 'end'
5481 */
5482 OO.ui.PopupWidget.prototype.setAnchorEdge = function ( edge ) {
5483 if ( [ 'top', 'bottom', 'start', 'end' ].indexOf( edge ) === -1 ) {
5484 throw new Error( 'Invalid value for edge: ' + edge );
5485 }
5486 if ( this.anchorEdge !== null ) {
5487 this.$element.removeClass( 'oo-ui-popupWidget-anchored-' + this.anchorEdge );
5488 }
5489 this.anchorEdge = edge;
5490 if ( this.anchored ) {
5491 this.$element.addClass( 'oo-ui-popupWidget-anchored-' + edge );
5492 }
5493 };
5494
5495 /**
5496 * Check if the anchor is visible.
5497 *
5498 * @return {boolean} Anchor is visible
5499 */
5500 OO.ui.PopupWidget.prototype.hasAnchor = function () {
5501 return this.anchored;
5502 };
5503
5504 /**
5505 * Toggle visibility of the popup. The popup is initially hidden and must be shown by calling
5506 * `.toggle( true )` after its #$element is attached to the DOM.
5507 *
5508 * Do not show the popup while it is not attached to the DOM. The calculations required to display
5509 * it in the right place and with the right dimensions only work correctly while it is attached.
5510 * Side-effects may include broken interface and exceptions being thrown. This wasn't always
5511 * strictly enforced, so currently it only generates a warning in the browser console.
5512 *
5513 * @fires ready
5514 * @inheritdoc
5515 */
5516 OO.ui.PopupWidget.prototype.toggle = function ( show ) {
5517 var change, normalHeight, oppositeHeight, normalWidth, oppositeWidth;
5518 show = show === undefined ? !this.isVisible() : !!show;
5519
5520 change = show !== this.isVisible();
5521
5522 if ( show && !this.warnedUnattached && !this.isElementAttached() ) {
5523 OO.ui.warnDeprecation( 'PopupWidget#toggle: Before calling this method, the popup must be attached to the DOM.' );
5524 this.warnedUnattached = true;
5525 }
5526 if ( show && !this.$floatableContainer && this.isElementAttached() ) {
5527 // Fall back to the parent node if the floatableContainer is not set
5528 this.setFloatableContainer( this.$element.parent() );
5529 }
5530
5531 if ( change && show && this.autoFlip ) {
5532 // Reset auto-flipping before showing the popup again. It's possible we no longer need to
5533 // flip (e.g. if the user scrolled).
5534 this.isAutoFlipped = false;
5535 }
5536
5537 // Parent method
5538 OO.ui.PopupWidget.parent.prototype.toggle.call( this, show );
5539
5540 if ( change ) {
5541 this.togglePositioning( show && !!this.$floatableContainer );
5542
5543 if ( show ) {
5544 if ( this.autoClose ) {
5545 this.bindDocumentMouseDownListener();
5546 this.bindDocumentKeyDownListener();
5547 }
5548 this.updateDimensions();
5549 this.toggleClipping( true );
5550
5551 if ( this.autoFlip ) {
5552 if ( this.popupPosition === 'above' || this.popupPosition === 'below' ) {
5553 if ( this.isClippedVertically() || this.isFloatableOutOfView() ) {
5554 // If opening the popup in the normal direction causes it to be clipped,
5555 // open in the opposite one instead
5556 normalHeight = this.$element.height();
5557 this.isAutoFlipped = !this.isAutoFlipped;
5558 this.position();
5559 if ( this.isClippedVertically() || this.isFloatableOutOfView() ) {
5560 // If that also causes it to be clipped, open in whichever direction
5561 // we have more space
5562 oppositeHeight = this.$element.height();
5563 if ( oppositeHeight < normalHeight ) {
5564 this.isAutoFlipped = !this.isAutoFlipped;
5565 this.position();
5566 }
5567 }
5568 }
5569 }
5570 if ( this.popupPosition === 'before' || this.popupPosition === 'after' ) {
5571 if ( this.isClippedHorizontally() || this.isFloatableOutOfView() ) {
5572 // If opening the popup in the normal direction causes it to be clipped,
5573 // open in the opposite one instead
5574 normalWidth = this.$element.width();
5575 this.isAutoFlipped = !this.isAutoFlipped;
5576 // Due to T180173 horizontally clipped PopupWidgets have messed up
5577 // dimensions, which causes positioning to be off. Toggle clipping back and
5578 // forth to work around.
5579 this.toggleClipping( false );
5580 this.position();
5581 this.toggleClipping( true );
5582 if ( this.isClippedHorizontally() || this.isFloatableOutOfView() ) {
5583 // If that also causes it to be clipped, open in whichever direction
5584 // we have more space
5585 oppositeWidth = this.$element.width();
5586 if ( oppositeWidth < normalWidth ) {
5587 this.isAutoFlipped = !this.isAutoFlipped;
5588 // Due to T180173, horizontally clipped PopupWidgets have messed up
5589 // dimensions, which causes positioning to be off. Toggle clipping
5590 // back and forth to work around.
5591 this.toggleClipping( false );
5592 this.position();
5593 this.toggleClipping( true );
5594 }
5595 }
5596 }
5597 }
5598 }
5599
5600 this.emit( 'ready' );
5601 } else {
5602 this.toggleClipping( false );
5603 if ( this.autoClose ) {
5604 this.unbindDocumentMouseDownListener();
5605 this.unbindDocumentKeyDownListener();
5606 }
5607 }
5608 }
5609
5610 return this;
5611 };
5612
5613 /**
5614 * Set the size of the popup.
5615 *
5616 * Changing the size may also change the popup's position depending on the alignment.
5617 *
5618 * @param {number|null} [width=320] Width in pixels. Pass `null` to use automatic width.
5619 * @param {number|null} [height=null] Height in pixels. Pass `null` to use automatic height.
5620 * @param {boolean} [transition=false] Use a smooth transition
5621 * @chainable
5622 */
5623 OO.ui.PopupWidget.prototype.setSize = function ( width, height, transition ) {
5624 this.width = width !== undefined ? width : 320;
5625 this.height = height !== undefined ? height : null;
5626 if ( this.isVisible() ) {
5627 this.updateDimensions( transition );
5628 }
5629 };
5630
5631 /**
5632 * Update the size and position.
5633 *
5634 * Only use this to keep the popup properly anchored. Use #setSize to change the size, and this will
5635 * be called automatically.
5636 *
5637 * @param {boolean} [transition=false] Use a smooth transition
5638 * @chainable
5639 */
5640 OO.ui.PopupWidget.prototype.updateDimensions = function ( transition ) {
5641 var widget = this;
5642
5643 // Prevent transition from being interrupted
5644 clearTimeout( this.transitionTimeout );
5645 if ( transition ) {
5646 // Enable transition
5647 this.$element.addClass( 'oo-ui-popupWidget-transitioning' );
5648 }
5649
5650 this.position();
5651
5652 if ( transition ) {
5653 // Prevent transitioning after transition is complete
5654 this.transitionTimeout = setTimeout( function () {
5655 widget.$element.removeClass( 'oo-ui-popupWidget-transitioning' );
5656 }, 200 );
5657 } else {
5658 // Prevent transitioning immediately
5659 this.$element.removeClass( 'oo-ui-popupWidget-transitioning' );
5660 }
5661 };
5662
5663 /**
5664 * @inheritdoc
5665 */
5666 OO.ui.PopupWidget.prototype.computePosition = function () {
5667 var direction, align, vertical, start, end, near, far, sizeProp, popupSize, anchorSize,
5668 anchorPos, anchorOffset, anchorMargin, parentPosition, positionProp, positionAdjustment,
5669 floatablePos, offsetParentPos, containerPos, popupPosition, viewportSpacing,
5670 popupPos = {},
5671 anchorCss = { left: '', right: '', top: '', bottom: '' },
5672 popupPositionOppositeMap = {
5673 above: 'below',
5674 below: 'above',
5675 before: 'after',
5676 after: 'before'
5677 },
5678 alignMap = {
5679 ltr: {
5680 'force-left': 'backwards',
5681 'force-right': 'forwards'
5682 },
5683 rtl: {
5684 'force-left': 'forwards',
5685 'force-right': 'backwards'
5686 }
5687 },
5688 anchorEdgeMap = {
5689 above: 'bottom',
5690 below: 'top',
5691 before: 'end',
5692 after: 'start'
5693 },
5694 hPosMap = {
5695 forwards: 'start',
5696 center: 'center',
5697 backwards: this.anchored ? 'before' : 'end'
5698 },
5699 vPosMap = {
5700 forwards: 'top',
5701 center: 'center',
5702 backwards: 'bottom'
5703 };
5704
5705 if ( !this.$container ) {
5706 // Lazy-initialize $container if not specified in constructor
5707 this.$container = $( this.getClosestScrollableElementContainer() );
5708 }
5709 direction = this.$container.css( 'direction' );
5710
5711 // Set height and width before we do anything else, since it might cause our measurements
5712 // to change (e.g. due to scrollbars appearing or disappearing), and it also affects centering
5713 this.$popup.css( {
5714 width: this.width !== null ? this.width : 'auto',
5715 height: this.height !== null ? this.height : 'auto'
5716 } );
5717
5718 align = alignMap[ direction ][ this.align ] || this.align;
5719 popupPosition = this.popupPosition;
5720 if ( this.isAutoFlipped ) {
5721 popupPosition = popupPositionOppositeMap[ popupPosition ];
5722 }
5723
5724 // If the popup is positioned before or after, then the anchor positioning is vertical,
5725 // otherwise horizontal
5726 vertical = popupPosition === 'before' || popupPosition === 'after';
5727 start = vertical ? 'top' : ( direction === 'rtl' ? 'right' : 'left' );
5728 end = vertical ? 'bottom' : ( direction === 'rtl' ? 'left' : 'right' );
5729 near = vertical ? 'top' : 'left';
5730 far = vertical ? 'bottom' : 'right';
5731 sizeProp = vertical ? 'Height' : 'Width';
5732 popupSize = vertical ?
5733 ( this.height || this.$popup.height() ) :
5734 ( this.width || this.$popup.width() );
5735
5736 this.setAnchorEdge( anchorEdgeMap[ popupPosition ] );
5737 this.horizontalPosition = vertical ? popupPosition : hPosMap[ align ];
5738 this.verticalPosition = vertical ? vPosMap[ align ] : popupPosition;
5739
5740 // Parent method
5741 parentPosition = OO.ui.mixin.FloatableElement.prototype.computePosition.call( this );
5742 // Find out which property FloatableElement used for positioning, and adjust that value
5743 positionProp = vertical ?
5744 ( parentPosition.top !== '' ? 'top' : 'bottom' ) :
5745 ( parentPosition.left !== '' ? 'left' : 'right' );
5746
5747 // Figure out where the near and far edges of the popup and $floatableContainer are
5748 floatablePos = this.$floatableContainer.offset();
5749 floatablePos[ far ] = floatablePos[ near ] + this.$floatableContainer[ 'outer' + sizeProp ]();
5750 // Measure where the offsetParent is and compute our position based on that and parentPosition
5751 offsetParentPos = this.$element.offsetParent()[ 0 ] === document.documentElement ?
5752 { top: 0, left: 0 } :
5753 this.$element.offsetParent().offset();
5754
5755 if ( positionProp === near ) {
5756 popupPos[ near ] = offsetParentPos[ near ] + parentPosition[ near ];
5757 popupPos[ far ] = popupPos[ near ] + popupSize;
5758 } else {
5759 popupPos[ far ] = offsetParentPos[ near ] +
5760 this.$element.offsetParent()[ 'inner' + sizeProp ]() - parentPosition[ far ];
5761 popupPos[ near ] = popupPos[ far ] - popupSize;
5762 }
5763
5764 if ( this.anchored ) {
5765 // Position the anchor (which is positioned relative to the popup) to point to
5766 // $floatableContainer
5767 anchorPos = ( floatablePos[ start ] + floatablePos[ end ] ) / 2;
5768 anchorOffset = ( start === far ? -1 : 1 ) * ( anchorPos - popupPos[ start ] );
5769
5770 // If the anchor is less than 2*anchorSize from either edge, move the popup to make more
5771 // space this.$anchor.width()/height() returns 0 because of the CSS trickery we use, so use
5772 // scrollWidth/Height
5773 anchorSize = this.$anchor[ 0 ][ 'scroll' + sizeProp ];
5774 anchorMargin = parseFloat( this.$anchor.css( 'margin-' + start ) );
5775 if ( anchorOffset + anchorMargin < 2 * anchorSize ) {
5776 // Not enough space for the anchor on the start side; pull the popup startwards
5777 positionAdjustment = ( positionProp === start ? -1 : 1 ) *
5778 ( 2 * anchorSize - ( anchorOffset + anchorMargin ) );
5779 } else if ( anchorOffset + anchorMargin > popupSize - 2 * anchorSize ) {
5780 // Not enough space for the anchor on the end side; pull the popup endwards
5781 positionAdjustment = ( positionProp === end ? -1 : 1 ) *
5782 ( anchorOffset + anchorMargin - ( popupSize - 2 * anchorSize ) );
5783 } else {
5784 positionAdjustment = 0;
5785 }
5786 } else {
5787 positionAdjustment = 0;
5788 }
5789
5790 // Check if the popup will go beyond the edge of this.$container
5791 containerPos = this.$container[ 0 ] === document.documentElement ?
5792 { top: 0, left: 0 } :
5793 this.$container.offset();
5794 containerPos[ far ] = containerPos[ near ] + this.$container[ 'inner' + sizeProp ]();
5795 if ( this.$container[ 0 ] === document.documentElement ) {
5796 viewportSpacing = OO.ui.getViewportSpacing();
5797 containerPos[ near ] += viewportSpacing[ near ];
5798 containerPos[ far ] -= viewportSpacing[ far ];
5799 }
5800 // Take into account how much the popup will move because of the adjustments we're going to make
5801 popupPos[ near ] += ( positionProp === near ? 1 : -1 ) * positionAdjustment;
5802 popupPos[ far ] += ( positionProp === near ? 1 : -1 ) * positionAdjustment;
5803 if ( containerPos[ near ] + this.containerPadding > popupPos[ near ] ) {
5804 // Popup goes beyond the near (left/top) edge, move it to the right/bottom
5805 positionAdjustment += ( positionProp === near ? 1 : -1 ) *
5806 ( containerPos[ near ] + this.containerPadding - popupPos[ near ] );
5807 } else if ( containerPos[ far ] - this.containerPadding < popupPos[ far ] ) {
5808 // Popup goes beyond the far (right/bottom) edge, move it to the left/top
5809 positionAdjustment += ( positionProp === far ? 1 : -1 ) *
5810 ( popupPos[ far ] - ( containerPos[ far ] - this.containerPadding ) );
5811 }
5812
5813 if ( this.anchored ) {
5814 // Adjust anchorOffset for positionAdjustment
5815 anchorOffset += ( positionProp === start ? -1 : 1 ) * positionAdjustment;
5816
5817 // Position the anchor
5818 anchorCss[ start ] = anchorOffset;
5819 this.$anchor.css( anchorCss );
5820 }
5821
5822 // Move the popup if needed
5823 parentPosition[ positionProp ] += positionAdjustment;
5824
5825 return parentPosition;
5826 };
5827
5828 /**
5829 * Set popup alignment
5830 *
5831 * @param {string} [align=center] Alignment of the popup, `center`, `force-left`, `force-right`,
5832 * `backwards` or `forwards`.
5833 */
5834 OO.ui.PopupWidget.prototype.setAlignment = function ( align ) {
5835 // Validate alignment
5836 if ( [ 'force-left', 'force-right', 'backwards', 'forwards', 'center' ].indexOf( align ) > -1 ) {
5837 this.align = align;
5838 } else {
5839 this.align = 'center';
5840 }
5841 this.position();
5842 };
5843
5844 /**
5845 * Get popup alignment
5846 *
5847 * @return {string} Alignment of the popup, `center`, `force-left`, `force-right`,
5848 * `backwards` or `forwards`.
5849 */
5850 OO.ui.PopupWidget.prototype.getAlignment = function () {
5851 return this.align;
5852 };
5853
5854 /**
5855 * Change the positioning of the popup.
5856 *
5857 * @param {string} position 'above', 'below', 'before' or 'after'
5858 */
5859 OO.ui.PopupWidget.prototype.setPosition = function ( position ) {
5860 if ( [ 'above', 'below', 'before', 'after' ].indexOf( position ) === -1 ) {
5861 position = 'below';
5862 }
5863 this.popupPosition = position;
5864 this.position();
5865 };
5866
5867 /**
5868 * Get popup positioning.
5869 *
5870 * @return {string} 'above', 'below', 'before' or 'after'
5871 */
5872 OO.ui.PopupWidget.prototype.getPosition = function () {
5873 return this.popupPosition;
5874 };
5875
5876 /**
5877 * Set popup auto-flipping.
5878 *
5879 * @param {boolean} autoFlip Whether to automatically switch the popup's position between
5880 * 'above' and 'below', or between 'before' and 'after', if there is not enough space in the
5881 * desired direction to display the popup without clipping
5882 */
5883 OO.ui.PopupWidget.prototype.setAutoFlip = function ( autoFlip ) {
5884 autoFlip = !!autoFlip;
5885
5886 if ( this.autoFlip !== autoFlip ) {
5887 this.autoFlip = autoFlip;
5888 }
5889 };
5890
5891 /**
5892 * Set which elements will not close the popup when clicked.
5893 *
5894 * For auto-closing popups, clicks on these elements will not cause the popup to auto-close.
5895 *
5896 * @param {jQuery} $autoCloseIgnore Elements to ignore for auto-closing
5897 */
5898 OO.ui.PopupWidget.prototype.setAutoCloseIgnore = function ( $autoCloseIgnore ) {
5899 this.$autoCloseIgnore = $autoCloseIgnore;
5900 };
5901
5902 /**
5903 * Get an ID of the body element, this can be used as the
5904 * `aria-describedby` attribute for an input field.
5905 *
5906 * @return {string} The ID of the body element
5907 */
5908 OO.ui.PopupWidget.prototype.getBodyId = function () {
5909 var id = this.$body.attr( 'id' );
5910 if ( id === undefined ) {
5911 id = OO.ui.generateElementId();
5912 this.$body.attr( 'id', id );
5913 }
5914 return id;
5915 };
5916
5917 /**
5918 * PopupElement is mixed into other classes to generate a {@link OO.ui.PopupWidget popup widget}.
5919 * A popup is a container for content. It is overlaid and positioned absolutely. By default, each
5920 * popup has an anchor, which is an arrow-like protrusion that points toward the popup’s origin.
5921 * See {@link OO.ui.PopupWidget PopupWidget} for an example.
5922 *
5923 * @abstract
5924 * @class
5925 *
5926 * @constructor
5927 * @param {Object} [config] Configuration options
5928 * @cfg {Object} [popup] Configuration to pass to popup
5929 * @cfg {boolean} [popup.autoClose=true] Popup auto-closes when it loses focus
5930 */
5931 OO.ui.mixin.PopupElement = function OoUiMixinPopupElement( config ) {
5932 // Configuration initialization
5933 config = config || {};
5934
5935 // Properties
5936 this.popup = new OO.ui.PopupWidget( $.extend(
5937 {
5938 autoClose: true,
5939 $floatableContainer: this.$element
5940 },
5941 config.popup,
5942 {
5943 $autoCloseIgnore: this.$element.add( config.popup && config.popup.$autoCloseIgnore )
5944 }
5945 ) );
5946 };
5947
5948 /* Methods */
5949
5950 /**
5951 * Get popup.
5952 *
5953 * @return {OO.ui.PopupWidget} Popup widget
5954 */
5955 OO.ui.mixin.PopupElement.prototype.getPopup = function () {
5956 return this.popup;
5957 };
5958
5959 /**
5960 * PopupButtonWidgets toggle the visibility of a contained {@link OO.ui.PopupWidget PopupWidget},
5961 * which is used to display additional information or options.
5962 *
5963 * @example
5964 * // A PopupButtonWidget.
5965 * var popupButton = new OO.ui.PopupButtonWidget( {
5966 * label: 'Popup button with options',
5967 * icon: 'menu',
5968 * popup: {
5969 * $content: $( '<p>Additional options here.</p>' ),
5970 * padded: true,
5971 * align: 'force-left'
5972 * }
5973 * } );
5974 * // Append the button to the DOM.
5975 * $( document.body ).append( popupButton.$element );
5976 *
5977 * @class
5978 * @extends OO.ui.ButtonWidget
5979 * @mixins OO.ui.mixin.PopupElement
5980 *
5981 * @constructor
5982 * @param {Object} [config] Configuration options
5983 * @cfg {jQuery} [$overlay] Render the popup into a separate layer. This configuration is useful
5984 * in cases where the expanded popup is larger than its containing `<div>`. The specified overlay
5985 * layer is usually on top of the containing `<div>` and has a larger area. By default, the popup
5986 * uses relative positioning.
5987 * See <https://www.mediawiki.org/wiki/OOUI/Concepts#Overlays>.
5988 */
5989 OO.ui.PopupButtonWidget = function OoUiPopupButtonWidget( config ) {
5990 // Configuration initialization
5991 config = config || {};
5992
5993 // Parent constructor
5994 OO.ui.PopupButtonWidget.parent.call( this, config );
5995
5996 // Mixin constructors
5997 OO.ui.mixin.PopupElement.call( this, config );
5998
5999 // Properties
6000 this.$overlay = ( config.$overlay === true ?
6001 OO.ui.getDefaultOverlay() : config.$overlay ) || this.$element;
6002
6003 // Events
6004 this.connect( this, {
6005 click: 'onAction'
6006 } );
6007
6008 // Initialization
6009 this.$element.addClass( 'oo-ui-popupButtonWidget' );
6010 this.popup.$element
6011 .addClass( 'oo-ui-popupButtonWidget-popup' )
6012 .toggleClass( 'oo-ui-popupButtonWidget-framed-popup', this.isFramed() )
6013 .toggleClass( 'oo-ui-popupButtonWidget-frameless-popup', !this.isFramed() );
6014 this.$overlay.append( this.popup.$element );
6015 };
6016
6017 /* Setup */
6018
6019 OO.inheritClass( OO.ui.PopupButtonWidget, OO.ui.ButtonWidget );
6020 OO.mixinClass( OO.ui.PopupButtonWidget, OO.ui.mixin.PopupElement );
6021
6022 /* Methods */
6023
6024 /**
6025 * Handle the button action being triggered.
6026 *
6027 * @private
6028 */
6029 OO.ui.PopupButtonWidget.prototype.onAction = function () {
6030 this.popup.toggle();
6031 };
6032
6033 /**
6034 * Mixin for OO.ui.Widget subclasses to provide OO.ui.mixin.GroupElement.
6035 *
6036 * Use together with OO.ui.mixin.ItemWidget to make disabled state inheritable.
6037 *
6038 * @private
6039 * @abstract
6040 * @class
6041 * @mixins OO.ui.mixin.GroupElement
6042 *
6043 * @constructor
6044 * @param {Object} [config] Configuration options
6045 */
6046 OO.ui.mixin.GroupWidget = function OoUiMixinGroupWidget( config ) {
6047 // Mixin constructors
6048 OO.ui.mixin.GroupElement.call( this, config );
6049 };
6050
6051 /* Setup */
6052
6053 OO.mixinClass( OO.ui.mixin.GroupWidget, OO.ui.mixin.GroupElement );
6054
6055 /* Methods */
6056
6057 /**
6058 * Set the disabled state of the widget.
6059 *
6060 * This will also update the disabled state of child widgets.
6061 *
6062 * @param {boolean} disabled Disable widget
6063 * @chainable
6064 * @return {OO.ui.Widget} The widget, for chaining
6065 */
6066 OO.ui.mixin.GroupWidget.prototype.setDisabled = function ( disabled ) {
6067 var i, len;
6068
6069 // Parent method
6070 // Note: Calling #setDisabled this way assumes this is mixed into an OO.ui.Widget
6071 OO.ui.Widget.prototype.setDisabled.call( this, disabled );
6072
6073 // During construction, #setDisabled is called before the OO.ui.mixin.GroupElement constructor
6074 if ( this.items ) {
6075 for ( i = 0, len = this.items.length; i < len; i++ ) {
6076 this.items[ i ].updateDisabled();
6077 }
6078 }
6079
6080 return this;
6081 };
6082
6083 /**
6084 * Mixin for widgets used as items in widgets that mix in OO.ui.mixin.GroupWidget.
6085 *
6086 * Item widgets have a reference to a OO.ui.mixin.GroupWidget while they are attached to the group.
6087 * This allows bidirectional communication.
6088 *
6089 * Use together with OO.ui.mixin.GroupWidget to make disabled state inheritable.
6090 *
6091 * @private
6092 * @abstract
6093 * @class
6094 *
6095 * @constructor
6096 */
6097 OO.ui.mixin.ItemWidget = function OoUiMixinItemWidget() {
6098 //
6099 };
6100
6101 /* Methods */
6102
6103 /**
6104 * Check if widget is disabled.
6105 *
6106 * Checks parent if present, making disabled state inheritable.
6107 *
6108 * @return {boolean} Widget is disabled
6109 */
6110 OO.ui.mixin.ItemWidget.prototype.isDisabled = function () {
6111 return this.disabled ||
6112 ( this.elementGroup instanceof OO.ui.Widget && this.elementGroup.isDisabled() );
6113 };
6114
6115 /**
6116 * Set group element is in.
6117 *
6118 * @param {OO.ui.mixin.GroupElement|null} group Group element, null if none
6119 * @chainable
6120 * @return {OO.ui.Widget} The widget, for chaining
6121 */
6122 OO.ui.mixin.ItemWidget.prototype.setElementGroup = function ( group ) {
6123 // Parent method
6124 // Note: Calling #setElementGroup this way assumes this is mixed into an OO.ui.Element
6125 OO.ui.Element.prototype.setElementGroup.call( this, group );
6126
6127 // Initialize item disabled states
6128 this.updateDisabled();
6129
6130 return this;
6131 };
6132
6133 /**
6134 * OptionWidgets are special elements that can be selected and configured with data. The
6135 * data is often unique for each option, but it does not have to be. OptionWidgets are used
6136 * with OO.ui.SelectWidget to create a selection of mutually exclusive options. For more information
6137 * and examples, please see the [OOUI documentation on MediaWiki][1].
6138 *
6139 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Selects_and_Options
6140 *
6141 * @class
6142 * @extends OO.ui.Widget
6143 * @mixins OO.ui.mixin.ItemWidget
6144 * @mixins OO.ui.mixin.LabelElement
6145 * @mixins OO.ui.mixin.FlaggedElement
6146 * @mixins OO.ui.mixin.AccessKeyedElement
6147 * @mixins OO.ui.mixin.TitledElement
6148 *
6149 * @constructor
6150 * @param {Object} [config] Configuration options
6151 */
6152 OO.ui.OptionWidget = function OoUiOptionWidget( config ) {
6153 // Configuration initialization
6154 config = config || {};
6155
6156 // Parent constructor
6157 OO.ui.OptionWidget.parent.call( this, config );
6158
6159 // Mixin constructors
6160 OO.ui.mixin.ItemWidget.call( this );
6161 OO.ui.mixin.LabelElement.call( this, config );
6162 OO.ui.mixin.FlaggedElement.call( this, config );
6163 OO.ui.mixin.AccessKeyedElement.call( this, config );
6164 OO.ui.mixin.TitledElement.call( this, config );
6165
6166 // Properties
6167 this.highlighted = false;
6168 this.pressed = false;
6169 this.setSelected( !!config.selected );
6170
6171 // Initialization
6172 this.$element
6173 .data( 'oo-ui-optionWidget', this )
6174 // Allow programmatic focussing (and by access key), but not tabbing
6175 .attr( 'tabindex', '-1' )
6176 .attr( 'role', 'option' )
6177 .addClass( 'oo-ui-optionWidget' )
6178 .append( this.$label );
6179 };
6180
6181 /* Setup */
6182
6183 OO.inheritClass( OO.ui.OptionWidget, OO.ui.Widget );
6184 OO.mixinClass( OO.ui.OptionWidget, OO.ui.mixin.ItemWidget );
6185 OO.mixinClass( OO.ui.OptionWidget, OO.ui.mixin.LabelElement );
6186 OO.mixinClass( OO.ui.OptionWidget, OO.ui.mixin.FlaggedElement );
6187 OO.mixinClass( OO.ui.OptionWidget, OO.ui.mixin.AccessKeyedElement );
6188 OO.mixinClass( OO.ui.OptionWidget, OO.ui.mixin.TitledElement );
6189
6190 /* Static Properties */
6191
6192 /**
6193 * Whether this option can be selected. See #setSelected.
6194 *
6195 * @static
6196 * @inheritable
6197 * @property {boolean}
6198 */
6199 OO.ui.OptionWidget.static.selectable = true;
6200
6201 /**
6202 * Whether this option can be highlighted. See #setHighlighted.
6203 *
6204 * @static
6205 * @inheritable
6206 * @property {boolean}
6207 */
6208 OO.ui.OptionWidget.static.highlightable = true;
6209
6210 /**
6211 * Whether this option can be pressed. See #setPressed.
6212 *
6213 * @static
6214 * @inheritable
6215 * @property {boolean}
6216 */
6217 OO.ui.OptionWidget.static.pressable = true;
6218
6219 /**
6220 * Whether this option will be scrolled into view when it is selected.
6221 *
6222 * @static
6223 * @inheritable
6224 * @property {boolean}
6225 */
6226 OO.ui.OptionWidget.static.scrollIntoViewOnSelect = false;
6227
6228 /* Methods */
6229
6230 /**
6231 * Check if the option can be selected.
6232 *
6233 * @return {boolean} Item is selectable
6234 */
6235 OO.ui.OptionWidget.prototype.isSelectable = function () {
6236 return this.constructor.static.selectable && !this.disabled && this.isVisible();
6237 };
6238
6239 /**
6240 * Check if the option can be highlighted. A highlight indicates that the option
6241 * may be selected when a user presses Enter key or clicks. Disabled items cannot
6242 * be highlighted.
6243 *
6244 * @return {boolean} Item is highlightable
6245 */
6246 OO.ui.OptionWidget.prototype.isHighlightable = function () {
6247 return this.constructor.static.highlightable && !this.disabled && this.isVisible();
6248 };
6249
6250 /**
6251 * Check if the option can be pressed. The pressed state occurs when a user mouses
6252 * down on an item, but has not yet let go of the mouse.
6253 *
6254 * @return {boolean} Item is pressable
6255 */
6256 OO.ui.OptionWidget.prototype.isPressable = function () {
6257 return this.constructor.static.pressable && !this.disabled && this.isVisible();
6258 };
6259
6260 /**
6261 * Check if the option is selected.
6262 *
6263 * @return {boolean} Item is selected
6264 */
6265 OO.ui.OptionWidget.prototype.isSelected = function () {
6266 return this.selected;
6267 };
6268
6269 /**
6270 * Check if the option is highlighted. A highlight indicates that the
6271 * item may be selected when a user presses Enter key or clicks.
6272 *
6273 * @return {boolean} Item is highlighted
6274 */
6275 OO.ui.OptionWidget.prototype.isHighlighted = function () {
6276 return this.highlighted;
6277 };
6278
6279 /**
6280 * Check if the option is pressed. The pressed state occurs when a user mouses
6281 * down on an item, but has not yet let go of the mouse. The item may appear
6282 * selected, but it will not be selected until the user releases the mouse.
6283 *
6284 * @return {boolean} Item is pressed
6285 */
6286 OO.ui.OptionWidget.prototype.isPressed = function () {
6287 return this.pressed;
6288 };
6289
6290 /**
6291 * Set the option’s selected state. In general, all modifications to the selection
6292 * should be handled by the SelectWidget’s
6293 * {@link OO.ui.SelectWidget#selectItem selectItem( [item] )} method instead of this method.
6294 *
6295 * @param {boolean} [state=false] Select option
6296 * @chainable
6297 * @return {OO.ui.Widget} The widget, for chaining
6298 */
6299 OO.ui.OptionWidget.prototype.setSelected = function ( state ) {
6300 if ( this.constructor.static.selectable ) {
6301 this.selected = !!state;
6302 this.$element
6303 .toggleClass( 'oo-ui-optionWidget-selected', state )
6304 .attr( 'aria-selected', state.toString() );
6305 if ( state && this.constructor.static.scrollIntoViewOnSelect ) {
6306 this.scrollElementIntoView();
6307 }
6308 this.updateThemeClasses();
6309 }
6310 return this;
6311 };
6312
6313 /**
6314 * Set the option’s highlighted state. In general, all programmatic
6315 * modifications to the highlight should be handled by the
6316 * SelectWidget’s {@link OO.ui.SelectWidget#highlightItem highlightItem( [item] )}
6317 * method instead of this method.
6318 *
6319 * @param {boolean} [state=false] Highlight option
6320 * @chainable
6321 * @return {OO.ui.Widget} The widget, for chaining
6322 */
6323 OO.ui.OptionWidget.prototype.setHighlighted = function ( state ) {
6324 if ( this.constructor.static.highlightable ) {
6325 this.highlighted = !!state;
6326 this.$element.toggleClass( 'oo-ui-optionWidget-highlighted', state );
6327 this.updateThemeClasses();
6328 }
6329 return this;
6330 };
6331
6332 /**
6333 * Set the option’s pressed state. In general, all
6334 * programmatic modifications to the pressed state should be handled by the
6335 * SelectWidget’s {@link OO.ui.SelectWidget#pressItem pressItem( [item] )}
6336 * method instead of this method.
6337 *
6338 * @param {boolean} [state=false] Press option
6339 * @chainable
6340 * @return {OO.ui.Widget} The widget, for chaining
6341 */
6342 OO.ui.OptionWidget.prototype.setPressed = function ( state ) {
6343 if ( this.constructor.static.pressable ) {
6344 this.pressed = !!state;
6345 this.$element.toggleClass( 'oo-ui-optionWidget-pressed', state );
6346 this.updateThemeClasses();
6347 }
6348 return this;
6349 };
6350
6351 /**
6352 * Get text to match search strings against.
6353 *
6354 * The default implementation returns the label text, but subclasses
6355 * can override this to provide more complex behavior.
6356 *
6357 * @return {string|boolean} String to match search string against
6358 */
6359 OO.ui.OptionWidget.prototype.getMatchText = function () {
6360 var label = this.getLabel();
6361 return typeof label === 'string' ? label : this.$label.text();
6362 };
6363
6364 /**
6365 * A SelectWidget is of a generic selection of options. The OOUI library contains several types of
6366 * select widgets, including {@link OO.ui.ButtonSelectWidget button selects},
6367 * {@link OO.ui.RadioSelectWidget radio selects}, and {@link OO.ui.MenuSelectWidget
6368 * menu selects}.
6369 *
6370 * This class should be used together with OO.ui.OptionWidget or OO.ui.DecoratedOptionWidget. For
6371 * more information, please see the [OOUI documentation on MediaWiki][1].
6372 *
6373 * @example
6374 * // A select widget with three options.
6375 * var select = new OO.ui.SelectWidget( {
6376 * items: [
6377 * new OO.ui.OptionWidget( {
6378 * data: 'a',
6379 * label: 'Option One',
6380 * } ),
6381 * new OO.ui.OptionWidget( {
6382 * data: 'b',
6383 * label: 'Option Two',
6384 * } ),
6385 * new OO.ui.OptionWidget( {
6386 * data: 'c',
6387 * label: 'Option Three',
6388 * } )
6389 * ]
6390 * } );
6391 * $( document.body ).append( select.$element );
6392 *
6393 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Selects_and_Options
6394 *
6395 * @abstract
6396 * @class
6397 * @extends OO.ui.Widget
6398 * @mixins OO.ui.mixin.GroupWidget
6399 *
6400 * @constructor
6401 * @param {Object} [config] Configuration options
6402 * @cfg {OO.ui.OptionWidget[]} [items] An array of options to add to the select.
6403 * Options are created with {@link OO.ui.OptionWidget OptionWidget} classes. See
6404 * the [OOUI documentation on MediaWiki] [2] for examples.
6405 * [2]: https://www.mediawiki.org/wiki/OOUI/Widgets/Selects_and_Options
6406 * @cfg {boolean} [multiselect] Allow for multiple selections
6407 */
6408 OO.ui.SelectWidget = function OoUiSelectWidget( config ) {
6409 // Configuration initialization
6410 config = config || {};
6411
6412 // Parent constructor
6413 OO.ui.SelectWidget.parent.call( this, config );
6414
6415 // Mixin constructors
6416 OO.ui.mixin.GroupWidget.call( this, $.extend( {
6417 $group: this.$element
6418 }, config ) );
6419
6420 // Properties
6421 this.pressed = false;
6422 this.selecting = null;
6423 this.multiselect = !!config.multiselect;
6424 this.onDocumentMouseUpHandler = this.onDocumentMouseUp.bind( this );
6425 this.onDocumentMouseMoveHandler = this.onDocumentMouseMove.bind( this );
6426 this.onDocumentKeyDownHandler = this.onDocumentKeyDown.bind( this );
6427 this.onDocumentKeyPressHandler = this.onDocumentKeyPress.bind( this );
6428 this.keyPressBuffer = '';
6429 this.keyPressBufferTimer = null;
6430 this.blockMouseOverEvents = 0;
6431
6432 // Events
6433 this.connect( this, {
6434 toggle: 'onToggle'
6435 } );
6436 this.$element.on( {
6437 focusin: this.onFocus.bind( this ),
6438 mousedown: this.onMouseDown.bind( this ),
6439 mouseover: this.onMouseOver.bind( this ),
6440 mouseleave: this.onMouseLeave.bind( this )
6441 } );
6442
6443 // Initialization
6444 this.$element
6445 // -depressed is a deprecated alias of -unpressed
6446 .addClass( 'oo-ui-selectWidget oo-ui-selectWidget-unpressed oo-ui-selectWidget-depressed' )
6447 .attr( 'role', 'listbox' );
6448 this.setFocusOwner( this.$element );
6449 if ( Array.isArray( config.items ) ) {
6450 this.addItems( config.items );
6451 }
6452 };
6453
6454 /* Setup */
6455
6456 OO.inheritClass( OO.ui.SelectWidget, OO.ui.Widget );
6457 OO.mixinClass( OO.ui.SelectWidget, OO.ui.mixin.GroupWidget );
6458
6459 /* Events */
6460
6461 /**
6462 * @event highlight
6463 *
6464 * A `highlight` event is emitted when the highlight is changed with the #highlightItem method.
6465 *
6466 * @param {OO.ui.OptionWidget|null} item Highlighted item
6467 */
6468
6469 /**
6470 * @event press
6471 *
6472 * A `press` event is emitted when the #pressItem method is used to programmatically modify the
6473 * pressed state of an option.
6474 *
6475 * @param {OO.ui.OptionWidget|null} item Pressed item
6476 */
6477
6478 /**
6479 * @event select
6480 *
6481 * A `select` event is emitted when the selection is modified programmatically with the #selectItem
6482 * method.
6483 *
6484 * @param {OO.ui.OptionWidget[]|OO.ui.OptionWidget|null} items Currently selected items
6485 */
6486
6487 /**
6488 * @event choose
6489 *
6490 * A `choose` event is emitted when an item is chosen with the #chooseItem method.
6491 *
6492 * @param {OO.ui.OptionWidget} item Chosen item
6493 * @param {boolean} selected Item is selected
6494 */
6495
6496 /**
6497 * @event add
6498 *
6499 * An `add` event is emitted when options are added to the select with the #addItems method.
6500 *
6501 * @param {OO.ui.OptionWidget[]} items Added items
6502 * @param {number} index Index of insertion point
6503 */
6504
6505 /**
6506 * @event remove
6507 *
6508 * A `remove` event is emitted when options are removed from the select with the #clearItems
6509 * or #removeItems methods.
6510 *
6511 * @param {OO.ui.OptionWidget[]} items Removed items
6512 */
6513
6514 /* Static methods */
6515
6516 /**
6517 * Normalize text for filter matching
6518 *
6519 * @param {string} text Text
6520 * @return {string} Normalized text
6521 */
6522 OO.ui.SelectWidget.static.normalizeForMatching = function ( text ) {
6523 // Replace trailing whitespace, normalize multiple spaces and make case insensitive
6524 var normalized = text.trim().replace( /\s+/, ' ' ).toLowerCase();
6525
6526 // Normalize Unicode
6527 // eslint-disable-next-line no-restricted-properties
6528 if ( normalized.normalize ) {
6529 // eslint-disable-next-line no-restricted-properties
6530 normalized = normalized.normalize();
6531 }
6532 return normalized;
6533 };
6534
6535 /* Methods */
6536
6537 /**
6538 * Handle focus events
6539 *
6540 * @private
6541 * @param {jQuery.Event} event
6542 */
6543 OO.ui.SelectWidget.prototype.onFocus = function ( event ) {
6544 var item;
6545 if ( event.target === this.$element[ 0 ] ) {
6546 // This widget was focussed, e.g. by the user tabbing to it.
6547 // The styles for focus state depend on one of the items being selected.
6548 if ( !this.findSelectedItem() ) {
6549 item = this.findFirstSelectableItem();
6550 }
6551 } else {
6552 if ( event.target.tabIndex === -1 ) {
6553 // One of the options got focussed (and the event bubbled up here).
6554 // They can't be tabbed to, but they can be activated using access keys.
6555 // OptionWidgets and focusable UI elements inside them have tabindex="-1" set.
6556 item = this.findTargetItem( event );
6557 } else {
6558 // There is something actually user-focusable in one of the labels of the options, and
6559 // the user focussed it (e.g. by tabbing to it). Do nothing (especially, don't change
6560 // the focus).
6561 return;
6562 }
6563 }
6564
6565 if ( item ) {
6566 if ( item.constructor.static.highlightable ) {
6567 this.highlightItem( item );
6568 } else {
6569 this.selectItem( item );
6570 }
6571 }
6572
6573 if ( event.target !== this.$element[ 0 ] ) {
6574 this.$focusOwner.trigger( 'focus' );
6575 }
6576 };
6577
6578 /**
6579 * Handle mouse down events.
6580 *
6581 * @private
6582 * @param {jQuery.Event} e Mouse down event
6583 * @return {undefined/boolean} False to prevent default if event is handled
6584 */
6585 OO.ui.SelectWidget.prototype.onMouseDown = function ( e ) {
6586 var item;
6587
6588 if ( !this.isDisabled() && e.which === OO.ui.MouseButtons.LEFT ) {
6589 this.togglePressed( true );
6590 item = this.findTargetItem( e );
6591 if ( item && item.isSelectable() ) {
6592 this.pressItem( item );
6593 this.selecting = item;
6594 this.getElementDocument().addEventListener( 'mouseup', this.onDocumentMouseUpHandler, true );
6595 this.getElementDocument().addEventListener( 'mousemove', this.onDocumentMouseMoveHandler, true );
6596 }
6597 }
6598 return false;
6599 };
6600
6601 /**
6602 * Handle document mouse up events.
6603 *
6604 * @private
6605 * @param {MouseEvent} e Mouse up event
6606 * @return {undefined/boolean} False to prevent default if event is handled
6607 */
6608 OO.ui.SelectWidget.prototype.onDocumentMouseUp = function ( e ) {
6609 var item;
6610
6611 this.togglePressed( false );
6612 if ( !this.selecting ) {
6613 item = this.findTargetItem( e );
6614 if ( item && item.isSelectable() ) {
6615 this.selecting = item;
6616 }
6617 }
6618 if ( !this.isDisabled() && e.which === OO.ui.MouseButtons.LEFT && this.selecting ) {
6619 this.pressItem( null );
6620 this.chooseItem( this.selecting );
6621 this.selecting = null;
6622 }
6623
6624 this.getElementDocument().removeEventListener( 'mouseup', this.onDocumentMouseUpHandler, true );
6625 this.getElementDocument().removeEventListener( 'mousemove', this.onDocumentMouseMoveHandler, true );
6626
6627 return false;
6628 };
6629
6630 /**
6631 * Handle document mouse move events.
6632 *
6633 * @private
6634 * @param {MouseEvent} e Mouse move event
6635 */
6636 OO.ui.SelectWidget.prototype.onDocumentMouseMove = function ( e ) {
6637 var item;
6638
6639 if ( !this.isDisabled() && this.pressed ) {
6640 item = this.findTargetItem( e );
6641 if ( item && item !== this.selecting && item.isSelectable() ) {
6642 this.pressItem( item );
6643 this.selecting = item;
6644 }
6645 }
6646 };
6647
6648 /**
6649 * Handle mouse over events.
6650 *
6651 * @private
6652 * @param {jQuery.Event} e Mouse over event
6653 * @return {undefined/boolean} False to prevent default if event is handled
6654 */
6655 OO.ui.SelectWidget.prototype.onMouseOver = function ( e ) {
6656 var item;
6657 if ( this.blockMouseOverEvents ) {
6658 return;
6659 }
6660 if ( !this.isDisabled() ) {
6661 item = this.findTargetItem( e );
6662 this.highlightItem( item && item.isHighlightable() ? item : null );
6663 }
6664 return false;
6665 };
6666
6667 /**
6668 * Handle mouse leave events.
6669 *
6670 * @private
6671 * @param {jQuery.Event} e Mouse over event
6672 * @return {undefined/boolean} False to prevent default if event is handled
6673 */
6674 OO.ui.SelectWidget.prototype.onMouseLeave = function () {
6675 if ( !this.isDisabled() ) {
6676 this.highlightItem( null );
6677 }
6678 return false;
6679 };
6680
6681 /**
6682 * Handle document key down events.
6683 *
6684 * @protected
6685 * @param {KeyboardEvent} e Key down event
6686 */
6687 OO.ui.SelectWidget.prototype.onDocumentKeyDown = function ( e ) {
6688 var nextItem,
6689 handled = false,
6690 currentItem = this.findHighlightedItem(),
6691 firstItem = this.getItems()[ 0 ];
6692
6693 if ( !this.isDisabled() && this.isVisible() ) {
6694 switch ( e.keyCode ) {
6695 case OO.ui.Keys.ENTER:
6696 if ( currentItem ) {
6697 // Was only highlighted, now let's select it. No-op if already selected.
6698 this.chooseItem( currentItem );
6699 handled = true;
6700 }
6701 break;
6702 case OO.ui.Keys.UP:
6703 case OO.ui.Keys.LEFT:
6704 this.clearKeyPressBuffer();
6705 nextItem = currentItem ? this.findRelativeSelectableItem( currentItem, -1 ) : firstItem;
6706 handled = true;
6707 break;
6708 case OO.ui.Keys.DOWN:
6709 case OO.ui.Keys.RIGHT:
6710 this.clearKeyPressBuffer();
6711 nextItem = currentItem ? this.findRelativeSelectableItem( currentItem, 1 ) : firstItem;
6712 handled = true;
6713 break;
6714 case OO.ui.Keys.ESCAPE:
6715 case OO.ui.Keys.TAB:
6716 if ( currentItem ) {
6717 currentItem.setHighlighted( false );
6718 }
6719 this.unbindDocumentKeyDownListener();
6720 this.unbindDocumentKeyPressListener();
6721 // Don't prevent tabbing away / defocusing
6722 handled = false;
6723 break;
6724 }
6725
6726 if ( nextItem ) {
6727 if ( nextItem.constructor.static.highlightable ) {
6728 this.highlightItem( nextItem );
6729 } else {
6730 this.chooseItem( nextItem );
6731 }
6732 this.scrollItemIntoView( nextItem );
6733 }
6734
6735 if ( handled ) {
6736 e.preventDefault();
6737 e.stopPropagation();
6738 }
6739 }
6740 };
6741
6742 /**
6743 * Bind document key down listener.
6744 *
6745 * @protected
6746 */
6747 OO.ui.SelectWidget.prototype.bindDocumentKeyDownListener = function () {
6748 this.getElementDocument().addEventListener( 'keydown', this.onDocumentKeyDownHandler, true );
6749 };
6750
6751 /**
6752 * Unbind document key down listener.
6753 *
6754 * @protected
6755 */
6756 OO.ui.SelectWidget.prototype.unbindDocumentKeyDownListener = function () {
6757 this.getElementDocument().removeEventListener( 'keydown', this.onDocumentKeyDownHandler, true );
6758 };
6759
6760 /**
6761 * Scroll item into view, preventing spurious mouse highlight actions from happening.
6762 *
6763 * @param {OO.ui.OptionWidget} item Item to scroll into view
6764 */
6765 OO.ui.SelectWidget.prototype.scrollItemIntoView = function ( item ) {
6766 var widget = this;
6767 // Chromium's Blink engine will generate spurious 'mouseover' events during programmatic
6768 // scrolling and around 100-150 ms after it is finished.
6769 this.blockMouseOverEvents++;
6770 item.scrollElementIntoView().done( function () {
6771 setTimeout( function () {
6772 widget.blockMouseOverEvents--;
6773 }, 200 );
6774 } );
6775 };
6776
6777 /**
6778 * Clear the key-press buffer
6779 *
6780 * @protected
6781 */
6782 OO.ui.SelectWidget.prototype.clearKeyPressBuffer = function () {
6783 if ( this.keyPressBufferTimer ) {
6784 clearTimeout( this.keyPressBufferTimer );
6785 this.keyPressBufferTimer = null;
6786 }
6787 this.keyPressBuffer = '';
6788 };
6789
6790 /**
6791 * Handle key press events.
6792 *
6793 * @protected
6794 * @param {KeyboardEvent} e Key press event
6795 * @return {undefined/boolean} False to prevent default if event is handled
6796 */
6797 OO.ui.SelectWidget.prototype.onDocumentKeyPress = function ( e ) {
6798 var c, filter, item;
6799
6800 if ( !e.charCode ) {
6801 if ( e.keyCode === OO.ui.Keys.BACKSPACE && this.keyPressBuffer !== '' ) {
6802 this.keyPressBuffer = this.keyPressBuffer.substr( 0, this.keyPressBuffer.length - 1 );
6803 return false;
6804 }
6805 return;
6806 }
6807 // eslint-disable-next-line no-restricted-properties
6808 if ( String.fromCodePoint ) {
6809 // eslint-disable-next-line no-restricted-properties
6810 c = String.fromCodePoint( e.charCode );
6811 } else {
6812 c = String.fromCharCode( e.charCode );
6813 }
6814
6815 if ( this.keyPressBufferTimer ) {
6816 clearTimeout( this.keyPressBufferTimer );
6817 }
6818 this.keyPressBufferTimer = setTimeout( this.clearKeyPressBuffer.bind( this ), 1500 );
6819
6820 item = this.findHighlightedItem() || this.findSelectedItem();
6821
6822 if ( this.keyPressBuffer === c ) {
6823 // Common (if weird) special case: typing "xxxx" will cycle through all
6824 // the items beginning with "x".
6825 if ( item ) {
6826 item = this.findRelativeSelectableItem( item, 1 );
6827 }
6828 } else {
6829 this.keyPressBuffer += c;
6830 }
6831
6832 filter = this.getItemMatcher( this.keyPressBuffer, false );
6833 if ( !item || !filter( item ) ) {
6834 item = this.findRelativeSelectableItem( item, 1, filter );
6835 }
6836 if ( item ) {
6837 if ( this.isVisible() && item.constructor.static.highlightable ) {
6838 this.highlightItem( item );
6839 } else {
6840 this.chooseItem( item );
6841 }
6842 this.scrollItemIntoView( item );
6843 }
6844
6845 e.preventDefault();
6846 e.stopPropagation();
6847 };
6848
6849 /**
6850 * Get a matcher for the specific string
6851 *
6852 * @protected
6853 * @param {string} query String to match against items
6854 * @param {string} [mode='prefix'] Matching mode: 'substring', 'prefix', or 'exact'
6855 * @return {Function} function ( OO.ui.OptionWidget ) => boolean
6856 */
6857 OO.ui.SelectWidget.prototype.getItemMatcher = function ( query, mode ) {
6858 var normalizeForMatching = this.constructor.static.normalizeForMatching,
6859 normalizedQuery = normalizeForMatching( query );
6860
6861 // Support deprecated exact=true argument
6862 if ( mode === true ) {
6863 mode = 'exact';
6864 }
6865
6866 return function ( item ) {
6867 var matchText = normalizeForMatching( item.getMatchText() );
6868
6869 if ( normalizedQuery === '' ) {
6870 // Empty string matches all, except if we are in 'exact'
6871 // mode, where it doesn't match at all
6872 return mode !== 'exact';
6873 }
6874
6875 switch ( mode ) {
6876 case 'exact':
6877 return matchText === normalizedQuery;
6878 case 'substring':
6879 return matchText.indexOf( normalizedQuery ) !== -1;
6880 // 'prefix'
6881 default:
6882 return matchText.indexOf( normalizedQuery ) === 0;
6883 }
6884 };
6885 };
6886
6887 /**
6888 * Bind document key press listener.
6889 *
6890 * @protected
6891 */
6892 OO.ui.SelectWidget.prototype.bindDocumentKeyPressListener = function () {
6893 this.getElementDocument().addEventListener( 'keypress', this.onDocumentKeyPressHandler, true );
6894 };
6895
6896 /**
6897 * Unbind document key down listener.
6898 *
6899 * If you override this, be sure to call this.clearKeyPressBuffer() from your
6900 * implementation.
6901 *
6902 * @protected
6903 */
6904 OO.ui.SelectWidget.prototype.unbindDocumentKeyPressListener = function () {
6905 this.getElementDocument().removeEventListener( 'keypress', this.onDocumentKeyPressHandler, true );
6906 this.clearKeyPressBuffer();
6907 };
6908
6909 /**
6910 * Visibility change handler
6911 *
6912 * @protected
6913 * @param {boolean} visible
6914 */
6915 OO.ui.SelectWidget.prototype.onToggle = function ( visible ) {
6916 if ( !visible ) {
6917 this.clearKeyPressBuffer();
6918 }
6919 };
6920
6921 /**
6922 * Get the closest item to a jQuery.Event.
6923 *
6924 * @private
6925 * @param {jQuery.Event} e
6926 * @return {OO.ui.OptionWidget|null} Outline item widget, `null` if none was found
6927 */
6928 OO.ui.SelectWidget.prototype.findTargetItem = function ( e ) {
6929 var $option = $( e.target ).closest( '.oo-ui-optionWidget' );
6930 if ( !$option.closest( '.oo-ui-selectWidget' ).is( this.$element ) ) {
6931 return null;
6932 }
6933 return $option.data( 'oo-ui-optionWidget' ) || null;
6934 };
6935
6936 /**
6937 * Find all selected items, if there are any. If the widget allows for multiselect
6938 * it will return an array of selected options. If the widget doesn't allow for
6939 * multiselect, it will return the selected option or null if no item is selected.
6940 *
6941 * @return {OO.ui.OptionWidget[]|OO.ui.OptionWidget|null} If the widget is multiselect
6942 * then return an array of selected items (or empty array),
6943 * if the widget is not multiselect, return a single selected item, or `null`
6944 * if no item is selected
6945 */
6946 OO.ui.SelectWidget.prototype.findSelectedItems = function () {
6947 var selected = this.items.filter( function ( item ) {
6948 return item.isSelected();
6949 } );
6950
6951 return this.multiselect ?
6952 selected :
6953 selected[ 0 ] || null;
6954 };
6955
6956 /**
6957 * Find selected item.
6958 *
6959 * @return {OO.ui.OptionWidget[]|OO.ui.OptionWidget|null} If the widget is multiselect
6960 * then return an array of selected items (or empty array),
6961 * if the widget is not multiselect, return a single selected item, or `null`
6962 * if no item is selected
6963 */
6964 OO.ui.SelectWidget.prototype.findSelectedItem = function () {
6965 return this.findSelectedItems();
6966 };
6967
6968 /**
6969 * Find highlighted item.
6970 *
6971 * @return {OO.ui.OptionWidget|null} Highlighted item, `null` if no item is highlighted
6972 */
6973 OO.ui.SelectWidget.prototype.findHighlightedItem = function () {
6974 var i, len;
6975
6976 for ( i = 0, len = this.items.length; i < len; i++ ) {
6977 if ( this.items[ i ].isHighlighted() ) {
6978 return this.items[ i ];
6979 }
6980 }
6981 return null;
6982 };
6983
6984 /**
6985 * Toggle pressed state.
6986 *
6987 * Press is a state that occurs when a user mouses down on an item, but
6988 * has not yet let go of the mouse. The item may appear selected, but it will not be selected
6989 * until the user releases the mouse.
6990 *
6991 * @param {boolean} pressed An option is being pressed
6992 */
6993 OO.ui.SelectWidget.prototype.togglePressed = function ( pressed ) {
6994 if ( pressed === undefined ) {
6995 pressed = !this.pressed;
6996 }
6997 if ( pressed !== this.pressed ) {
6998 this.$element
6999 .toggleClass( 'oo-ui-selectWidget-pressed', pressed )
7000 // -depressed is a deprecated alias of -unpressed
7001 .toggleClass( 'oo-ui-selectWidget-unpressed oo-ui-selectWidget-depressed', !pressed );
7002 this.pressed = pressed;
7003 }
7004 };
7005
7006 /**
7007 * Highlight an option. If the `item` param is omitted, no options will be highlighted
7008 * and any existing highlight will be removed. The highlight is mutually exclusive.
7009 *
7010 * @param {OO.ui.OptionWidget} [item] Item to highlight, omit for no highlight
7011 * @fires highlight
7012 * @chainable
7013 * @return {OO.ui.Widget} The widget, for chaining
7014 */
7015 OO.ui.SelectWidget.prototype.highlightItem = function ( item ) {
7016 var i, len, highlighted,
7017 changed = false;
7018
7019 for ( i = 0, len = this.items.length; i < len; i++ ) {
7020 highlighted = this.items[ i ] === item;
7021 if ( this.items[ i ].isHighlighted() !== highlighted ) {
7022 this.items[ i ].setHighlighted( highlighted );
7023 changed = true;
7024 }
7025 }
7026 if ( changed ) {
7027 if ( item ) {
7028 this.$focusOwner.attr( 'aria-activedescendant', item.getElementId() );
7029 } else {
7030 this.$focusOwner.removeAttr( 'aria-activedescendant' );
7031 }
7032 this.emit( 'highlight', item );
7033 }
7034
7035 return this;
7036 };
7037
7038 /**
7039 * Fetch an item by its label.
7040 *
7041 * @param {string} label Label of the item to select.
7042 * @param {boolean} [prefix=false] Allow a prefix match, if only a single item matches
7043 * @return {OO.ui.Element|null} Item with equivalent label, `null` if none exists
7044 */
7045 OO.ui.SelectWidget.prototype.getItemFromLabel = function ( label, prefix ) {
7046 var i, item, found,
7047 len = this.items.length,
7048 filter = this.getItemMatcher( label, 'exact' );
7049
7050 for ( i = 0; i < len; i++ ) {
7051 item = this.items[ i ];
7052 if ( item instanceof OO.ui.OptionWidget && item.isSelectable() && filter( item ) ) {
7053 return item;
7054 }
7055 }
7056
7057 if ( prefix ) {
7058 found = null;
7059 filter = this.getItemMatcher( label, 'prefix' );
7060 for ( i = 0; i < len; i++ ) {
7061 item = this.items[ i ];
7062 if ( item instanceof OO.ui.OptionWidget && item.isSelectable() && filter( item ) ) {
7063 if ( found ) {
7064 return null;
7065 }
7066 found = item;
7067 }
7068 }
7069 if ( found ) {
7070 return found;
7071 }
7072 }
7073
7074 return null;
7075 };
7076
7077 /**
7078 * Programmatically select an option by its label. If the item does not exist,
7079 * all options will be deselected.
7080 *
7081 * @param {string} [label] Label of the item to select.
7082 * @param {boolean} [prefix=false] Allow a prefix match, if only a single item matches
7083 * @fires select
7084 * @chainable
7085 * @return {OO.ui.Widget} The widget, for chaining
7086 */
7087 OO.ui.SelectWidget.prototype.selectItemByLabel = function ( label, prefix ) {
7088 var itemFromLabel = this.getItemFromLabel( label, !!prefix );
7089 if ( label === undefined || !itemFromLabel ) {
7090 return this.selectItem();
7091 }
7092 return this.selectItem( itemFromLabel );
7093 };
7094
7095 /**
7096 * Programmatically select an option by its data. If the `data` parameter is omitted,
7097 * or if the item does not exist, all options will be deselected.
7098 *
7099 * @param {Object|string} [data] Value of the item to select, omit to deselect all
7100 * @fires select
7101 * @chainable
7102 * @return {OO.ui.Widget} The widget, for chaining
7103 */
7104 OO.ui.SelectWidget.prototype.selectItemByData = function ( data ) {
7105 var itemFromData = this.findItemFromData( data );
7106 if ( data === undefined || !itemFromData ) {
7107 return this.selectItem();
7108 }
7109 return this.selectItem( itemFromData );
7110 };
7111
7112 /**
7113 * Programmatically unselect an option by its reference. If the widget
7114 * allows for multiple selections, there may be other items still selected;
7115 * otherwise, no items will be selected.
7116 * If no item is given, all selected items will be unselected.
7117 *
7118 * @param {OO.ui.OptionWidget} [item] Item to unselect
7119 * @fires select
7120 * @chainable
7121 * @return {OO.ui.Widget} The widget, for chaining
7122 */
7123 OO.ui.SelectWidget.prototype.unselectItem = function ( item ) {
7124 if ( item ) {
7125 item.setSelected( false );
7126 } else {
7127 this.items.forEach( function ( item ) {
7128 item.setSelected( false );
7129 } );
7130 }
7131
7132 this.emit( 'select', this.findSelectedItems() );
7133 return this;
7134 };
7135
7136 /**
7137 * Programmatically select an option by its reference. If the `item` parameter is omitted,
7138 * all options will be deselected.
7139 *
7140 * @param {OO.ui.OptionWidget} [item] Item to select, omit to deselect all
7141 * @fires select
7142 * @chainable
7143 * @return {OO.ui.Widget} The widget, for chaining
7144 */
7145 OO.ui.SelectWidget.prototype.selectItem = function ( item ) {
7146 var i, len, selected,
7147 changed = false;
7148
7149 if ( this.multiselect && item ) {
7150 // Select the item directly
7151 item.setSelected( true );
7152 } else {
7153 for ( i = 0, len = this.items.length; i < len; i++ ) {
7154 selected = this.items[ i ] === item;
7155 if ( this.items[ i ].isSelected() !== selected ) {
7156 this.items[ i ].setSelected( selected );
7157 changed = true;
7158 }
7159 }
7160 }
7161 if ( changed ) {
7162 // TODO: When should a non-highlightable element be selected?
7163 if ( item && !item.constructor.static.highlightable ) {
7164 if ( item ) {
7165 this.$focusOwner.attr( 'aria-activedescendant', item.getElementId() );
7166 } else {
7167 this.$focusOwner.removeAttr( 'aria-activedescendant' );
7168 }
7169 }
7170 this.emit( 'select', this.findSelectedItems() );
7171 }
7172
7173 return this;
7174 };
7175
7176 /**
7177 * Press an item.
7178 *
7179 * Press is a state that occurs when a user mouses down on an item, but has not
7180 * yet let go of the mouse. The item may appear selected, but it will not be selected until the user
7181 * releases the mouse.
7182 *
7183 * @param {OO.ui.OptionWidget} [item] Item to press, omit to depress all
7184 * @fires press
7185 * @chainable
7186 * @return {OO.ui.Widget} The widget, for chaining
7187 */
7188 OO.ui.SelectWidget.prototype.pressItem = function ( item ) {
7189 var i, len, pressed,
7190 changed = false;
7191
7192 for ( i = 0, len = this.items.length; i < len; i++ ) {
7193 pressed = this.items[ i ] === item;
7194 if ( this.items[ i ].isPressed() !== pressed ) {
7195 this.items[ i ].setPressed( pressed );
7196 changed = true;
7197 }
7198 }
7199 if ( changed ) {
7200 this.emit( 'press', item );
7201 }
7202
7203 return this;
7204 };
7205
7206 /**
7207 * Choose an item.
7208 *
7209 * Note that ‘choose’ should never be modified programmatically. A user can choose
7210 * an option with the keyboard or mouse and it becomes selected. To select an item programmatically,
7211 * use the #selectItem method.
7212 *
7213 * This method is identical to #selectItem, but may vary in subclasses that take additional action
7214 * when users choose an item with the keyboard or mouse.
7215 *
7216 * @param {OO.ui.OptionWidget} item Item to choose
7217 * @fires choose
7218 * @chainable
7219 * @return {OO.ui.Widget} The widget, for chaining
7220 */
7221 OO.ui.SelectWidget.prototype.chooseItem = function ( item ) {
7222 if ( item ) {
7223 if ( this.multiselect && item.isSelected() ) {
7224 this.unselectItem( item );
7225 } else {
7226 this.selectItem( item );
7227 }
7228
7229 this.emit( 'choose', item, item.isSelected() );
7230 }
7231
7232 return this;
7233 };
7234
7235 /**
7236 * Find an option by its position relative to the specified item (or to the start of the option
7237 * array, if item is `null`). The direction in which to search through the option array is specified
7238 * with a number: -1 for reverse (the default) or 1 for forward. The method will return an option,
7239 * or `null` if there are no options in the array.
7240 *
7241 * @param {OO.ui.OptionWidget|null} item Item to describe the start position, or `null` to start at
7242 * the beginning of the array.
7243 * @param {number} direction Direction to move in: -1 to move backward, 1 to move forward
7244 * @param {Function} [filter] Only consider items for which this function returns
7245 * true. Function takes an OO.ui.OptionWidget and returns a boolean.
7246 * @return {OO.ui.OptionWidget|null} Item at position, `null` if there are no items in the select
7247 */
7248 OO.ui.SelectWidget.prototype.findRelativeSelectableItem = function ( item, direction, filter ) {
7249 var currentIndex, nextIndex, i,
7250 increase = direction > 0 ? 1 : -1,
7251 len = this.items.length;
7252
7253 if ( item instanceof OO.ui.OptionWidget ) {
7254 currentIndex = this.items.indexOf( item );
7255 nextIndex = ( currentIndex + increase + len ) % len;
7256 } else {
7257 // If no item is selected and moving forward, start at the beginning.
7258 // If moving backward, start at the end.
7259 nextIndex = direction > 0 ? 0 : len - 1;
7260 }
7261
7262 for ( i = 0; i < len; i++ ) {
7263 item = this.items[ nextIndex ];
7264 if (
7265 item instanceof OO.ui.OptionWidget && item.isSelectable() &&
7266 ( !filter || filter( item ) )
7267 ) {
7268 return item;
7269 }
7270 nextIndex = ( nextIndex + increase + len ) % len;
7271 }
7272 return null;
7273 };
7274
7275 /**
7276 * Find the next selectable item or `null` if there are no selectable items.
7277 * Disabled options and menu-section markers and breaks are not selectable.
7278 *
7279 * @return {OO.ui.OptionWidget|null} Item, `null` if there aren't any selectable items
7280 */
7281 OO.ui.SelectWidget.prototype.findFirstSelectableItem = function () {
7282 return this.findRelativeSelectableItem( null, 1 );
7283 };
7284
7285 /**
7286 * Add an array of options to the select. Optionally, an index number can be used to
7287 * specify an insertion point.
7288 *
7289 * @param {OO.ui.OptionWidget[]} items Items to add
7290 * @param {number} [index] Index to insert items after
7291 * @fires add
7292 * @chainable
7293 * @return {OO.ui.Widget} The widget, for chaining
7294 */
7295 OO.ui.SelectWidget.prototype.addItems = function ( items, index ) {
7296 // Mixin method
7297 OO.ui.mixin.GroupWidget.prototype.addItems.call( this, items, index );
7298
7299 // Always provide an index, even if it was omitted
7300 this.emit( 'add', items, index === undefined ? this.items.length - items.length - 1 : index );
7301
7302 return this;
7303 };
7304
7305 /**
7306 * Remove the specified array of options from the select. Options will be detached
7307 * from the DOM, not removed, so they can be reused later. To remove all options from
7308 * the select, you may wish to use the #clearItems method instead.
7309 *
7310 * @param {OO.ui.OptionWidget[]} items Items to remove
7311 * @fires remove
7312 * @chainable
7313 * @return {OO.ui.Widget} The widget, for chaining
7314 */
7315 OO.ui.SelectWidget.prototype.removeItems = function ( items ) {
7316 var i, len, item;
7317
7318 // Deselect items being removed
7319 for ( i = 0, len = items.length; i < len; i++ ) {
7320 item = items[ i ];
7321 if ( item.isSelected() ) {
7322 this.selectItem( null );
7323 }
7324 }
7325
7326 // Mixin method
7327 OO.ui.mixin.GroupWidget.prototype.removeItems.call( this, items );
7328
7329 this.emit( 'remove', items );
7330
7331 return this;
7332 };
7333
7334 /**
7335 * Clear all options from the select. Options will be detached from the DOM, not removed,
7336 * so that they can be reused later. To remove a subset of options from the select, use
7337 * the #removeItems method.
7338 *
7339 * @fires remove
7340 * @chainable
7341 * @return {OO.ui.Widget} The widget, for chaining
7342 */
7343 OO.ui.SelectWidget.prototype.clearItems = function () {
7344 var items = this.items.slice();
7345
7346 // Mixin method
7347 OO.ui.mixin.GroupWidget.prototype.clearItems.call( this );
7348
7349 // Clear selection
7350 this.selectItem( null );
7351
7352 this.emit( 'remove', items );
7353
7354 return this;
7355 };
7356
7357 /**
7358 * Set the DOM element which has focus while the user is interacting with this SelectWidget.
7359 *
7360 * This is used to set `aria-activedescendant` and `aria-expanded` on it.
7361 *
7362 * @protected
7363 * @param {jQuery} $focusOwner
7364 */
7365 OO.ui.SelectWidget.prototype.setFocusOwner = function ( $focusOwner ) {
7366 this.$focusOwner = $focusOwner;
7367 };
7368
7369 /**
7370 * DecoratedOptionWidgets are {@link OO.ui.OptionWidget options} that can be configured
7371 * with an {@link OO.ui.mixin.IconElement icon} and/or
7372 * {@link OO.ui.mixin.IndicatorElement indicator}.
7373 * This class is used with OO.ui.SelectWidget to create a selection of mutually exclusive
7374 * options. For more information about options and selects, please see the
7375 * [OOUI documentation on MediaWiki][1].
7376 *
7377 * @example
7378 * // Decorated options in a select widget.
7379 * var select = new OO.ui.SelectWidget( {
7380 * items: [
7381 * new OO.ui.DecoratedOptionWidget( {
7382 * data: 'a',
7383 * label: 'Option with icon',
7384 * icon: 'help'
7385 * } ),
7386 * new OO.ui.DecoratedOptionWidget( {
7387 * data: 'b',
7388 * label: 'Option with indicator',
7389 * indicator: 'next'
7390 * } )
7391 * ]
7392 * } );
7393 * $( document.body ).append( select.$element );
7394 *
7395 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Selects_and_Options
7396 *
7397 * @class
7398 * @extends OO.ui.OptionWidget
7399 * @mixins OO.ui.mixin.IconElement
7400 * @mixins OO.ui.mixin.IndicatorElement
7401 *
7402 * @constructor
7403 * @param {Object} [config] Configuration options
7404 */
7405 OO.ui.DecoratedOptionWidget = function OoUiDecoratedOptionWidget( config ) {
7406 // Parent constructor
7407 OO.ui.DecoratedOptionWidget.parent.call( this, config );
7408
7409 // Mixin constructors
7410 OO.ui.mixin.IconElement.call( this, config );
7411 OO.ui.mixin.IndicatorElement.call( this, config );
7412
7413 // Initialization
7414 this.$element
7415 .addClass( 'oo-ui-decoratedOptionWidget' )
7416 .prepend( this.$icon )
7417 .append( this.$indicator );
7418 };
7419
7420 /* Setup */
7421
7422 OO.inheritClass( OO.ui.DecoratedOptionWidget, OO.ui.OptionWidget );
7423 OO.mixinClass( OO.ui.DecoratedOptionWidget, OO.ui.mixin.IconElement );
7424 OO.mixinClass( OO.ui.DecoratedOptionWidget, OO.ui.mixin.IndicatorElement );
7425
7426 /**
7427 * MenuOptionWidget is an option widget that looks like a menu item. The class is used with
7428 * OO.ui.MenuSelectWidget to create a menu of mutually exclusive options. Please see
7429 * the [OOUI documentation on MediaWiki] [1] for more information.
7430 *
7431 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Selects_and_Options#Menu_selects_and_options
7432 *
7433 * @class
7434 * @extends OO.ui.DecoratedOptionWidget
7435 *
7436 * @constructor
7437 * @param {Object} [config] Configuration options
7438 */
7439 OO.ui.MenuOptionWidget = function OoUiMenuOptionWidget( config ) {
7440 // Parent constructor
7441 OO.ui.MenuOptionWidget.parent.call( this, config );
7442
7443 // Properties
7444 this.checkIcon = new OO.ui.IconWidget( {
7445 icon: 'check',
7446 classes: [ 'oo-ui-menuOptionWidget-checkIcon' ]
7447 } );
7448
7449 // Initialization
7450 this.$element
7451 .prepend( this.checkIcon.$element )
7452 .addClass( 'oo-ui-menuOptionWidget' );
7453 };
7454
7455 /* Setup */
7456
7457 OO.inheritClass( OO.ui.MenuOptionWidget, OO.ui.DecoratedOptionWidget );
7458
7459 /* Static Properties */
7460
7461 /**
7462 * @static
7463 * @inheritdoc
7464 */
7465 OO.ui.MenuOptionWidget.static.scrollIntoViewOnSelect = true;
7466
7467 /**
7468 * MenuSectionOptionWidgets are used inside {@link OO.ui.MenuSelectWidget menu select widgets} to
7469 * group one or more related {@link OO.ui.MenuOptionWidget menu options}. MenuSectionOptionWidgets
7470 * cannot be highlighted or selected.
7471 *
7472 * @example
7473 * var dropdown = new OO.ui.DropdownWidget( {
7474 * menu: {
7475 * items: [
7476 * new OO.ui.MenuSectionOptionWidget( {
7477 * label: 'Dogs'
7478 * } ),
7479 * new OO.ui.MenuOptionWidget( {
7480 * data: 'corgi',
7481 * label: 'Welsh Corgi'
7482 * } ),
7483 * new OO.ui.MenuOptionWidget( {
7484 * data: 'poodle',
7485 * label: 'Standard Poodle'
7486 * } ),
7487 * new OO.ui.MenuSectionOptionWidget( {
7488 * label: 'Cats'
7489 * } ),
7490 * new OO.ui.MenuOptionWidget( {
7491 * data: 'lion',
7492 * label: 'Lion'
7493 * } )
7494 * ]
7495 * }
7496 * } );
7497 * $( document.body ).append( dropdown.$element );
7498 *
7499 * @class
7500 * @extends OO.ui.DecoratedOptionWidget
7501 *
7502 * @constructor
7503 * @param {Object} [config] Configuration options
7504 */
7505 OO.ui.MenuSectionOptionWidget = function OoUiMenuSectionOptionWidget( config ) {
7506 // Parent constructor
7507 OO.ui.MenuSectionOptionWidget.parent.call( this, config );
7508
7509 // Initialization
7510 this.$element
7511 .addClass( 'oo-ui-menuSectionOptionWidget' )
7512 .removeAttr( 'role aria-selected' );
7513 this.selected = false;
7514 };
7515
7516 /* Setup */
7517
7518 OO.inheritClass( OO.ui.MenuSectionOptionWidget, OO.ui.DecoratedOptionWidget );
7519
7520 /* Static Properties */
7521
7522 /**
7523 * @static
7524 * @inheritdoc
7525 */
7526 OO.ui.MenuSectionOptionWidget.static.selectable = false;
7527
7528 /**
7529 * @static
7530 * @inheritdoc
7531 */
7532 OO.ui.MenuSectionOptionWidget.static.highlightable = false;
7533
7534 /**
7535 * MenuSelectWidget is a {@link OO.ui.SelectWidget select widget} that contains options and
7536 * is used together with OO.ui.MenuOptionWidget. It is designed be used as part of another widget.
7537 * See {@link OO.ui.DropdownWidget DropdownWidget},
7538 * {@link OO.ui.ComboBoxInputWidget ComboBoxInputWidget}, and
7539 * {@link OO.ui.mixin.LookupElement LookupElement} for examples of widgets that contain menus.
7540 * MenuSelectWidgets themselves are not instantiated directly, rather subclassed
7541 * and customized to be opened, closed, and displayed as needed.
7542 *
7543 * By default, menus are clipped to the visible viewport and are not visible when a user presses the
7544 * mouse outside the menu.
7545 *
7546 * Menus also have support for keyboard interaction:
7547 *
7548 * - Enter/Return key: choose and select a menu option
7549 * - Up-arrow key: highlight the previous menu option
7550 * - Down-arrow key: highlight the next menu option
7551 * - Escape key: hide the menu
7552 *
7553 * Unlike most widgets, MenuSelectWidget is initially hidden and must be shown by calling #toggle.
7554 *
7555 * Please see the [OOUI documentation on MediaWiki][1] for more information.
7556 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Selects_and_Options
7557 *
7558 * @class
7559 * @extends OO.ui.SelectWidget
7560 * @mixins OO.ui.mixin.ClippableElement
7561 * @mixins OO.ui.mixin.FloatableElement
7562 *
7563 * @constructor
7564 * @param {Object} [config] Configuration options
7565 * @cfg {OO.ui.TextInputWidget} [input] Text input used to implement option highlighting for menu
7566 * items that match the text the user types. This config is used by
7567 * {@link OO.ui.ComboBoxInputWidget ComboBoxInputWidget} and
7568 * {@link OO.ui.mixin.LookupElement LookupElement}
7569 * @cfg {jQuery} [$input] Text input used to implement option highlighting for menu items that match
7570 * the text the user types. This config is used by
7571 * {@link OO.ui.TagMultiselectWidget TagMultiselectWidget}
7572 * @cfg {OO.ui.Widget} [widget] Widget associated with the menu's active state. If the user clicks
7573 * the mouse anywhere on the page outside of this widget, the menu is hidden. For example, if
7574 * there is a button that toggles the menu's visibility on click, the menu will be hidden then
7575 * re-shown when the user clicks that button, unless the button (or its parent widget) is passed
7576 * in here.
7577 * @cfg {boolean} [autoHide=true] Hide the menu when the mouse is pressed outside the menu.
7578 * @cfg {jQuery} [$autoCloseIgnore] If these elements are clicked, don't auto-hide the menu.
7579 * @cfg {boolean} [hideOnChoose=true] Hide the menu when the user chooses an option.
7580 * @cfg {boolean} [filterFromInput=false] Filter the displayed options from the input
7581 * @cfg {boolean} [highlightOnFilter] Highlight the first result when filtering
7582 * @cfg {string} [filterMode='prefix'] The mode by which the menu filters the results.
7583 * Options are 'exact', 'prefix' or 'substring'. See `OO.ui.SelectWidget#getItemMatcher`
7584 * @param {number|string} [width] Width of the menu as a number of pixels or CSS string with unit
7585 * suffix, used by {@link OO.ui.mixin.ClippableElement ClippableElement}
7586 */
7587 OO.ui.MenuSelectWidget = function OoUiMenuSelectWidget( config ) {
7588 // Configuration initialization
7589 config = config || {};
7590
7591 // Parent constructor
7592 OO.ui.MenuSelectWidget.parent.call( this, config );
7593
7594 // Mixin constructors
7595 OO.ui.mixin.ClippableElement.call( this, $.extend( { $clippable: this.$group }, config ) );
7596 OO.ui.mixin.FloatableElement.call( this, config );
7597
7598 // Initial vertical positions other than 'center' will result in
7599 // the menu being flipped if there is not enough space in the container.
7600 // Store the original position so we know what to reset to.
7601 this.originalVerticalPosition = this.verticalPosition;
7602
7603 // Properties
7604 this.autoHide = config.autoHide === undefined || !!config.autoHide;
7605 this.hideOnChoose = config.hideOnChoose === undefined || !!config.hideOnChoose;
7606 this.filterFromInput = !!config.filterFromInput;
7607 this.$input = config.$input ? config.$input : config.input ? config.input.$input : null;
7608 this.$widget = config.widget ? config.widget.$element : null;
7609 this.$autoCloseIgnore = config.$autoCloseIgnore || $( [] );
7610 this.onDocumentMouseDownHandler = this.onDocumentMouseDown.bind( this );
7611 this.onInputEditHandler = OO.ui.debounce( this.updateItemVisibility.bind( this ), 100 );
7612 this.highlightOnFilter = !!config.highlightOnFilter;
7613 this.lastHighlightedItem = null;
7614 this.width = config.width;
7615 this.filterMode = config.filterMode;
7616
7617 // Initialization
7618 this.$element.addClass( 'oo-ui-menuSelectWidget' );
7619 if ( config.widget ) {
7620 this.setFocusOwner( config.widget.$tabIndexed );
7621 }
7622
7623 // Initially hidden - using #toggle may cause errors if subclasses override toggle with methods
7624 // that reference properties not initialized at that time of parent class construction
7625 // TODO: Find a better way to handle post-constructor setup
7626 this.visible = false;
7627 this.$element.addClass( 'oo-ui-element-hidden' );
7628 this.$focusOwner.attr( 'aria-expanded', 'false' );
7629 };
7630
7631 /* Setup */
7632
7633 OO.inheritClass( OO.ui.MenuSelectWidget, OO.ui.SelectWidget );
7634 OO.mixinClass( OO.ui.MenuSelectWidget, OO.ui.mixin.ClippableElement );
7635 OO.mixinClass( OO.ui.MenuSelectWidget, OO.ui.mixin.FloatableElement );
7636
7637 /* Events */
7638
7639 /**
7640 * @event ready
7641 *
7642 * The menu is ready: it is visible and has been positioned and clipped.
7643 */
7644
7645 /* Static properties */
7646
7647 /**
7648 * Positions to flip to if there isn't room in the container for the
7649 * menu in a specific direction.
7650 *
7651 * @property {Object.<string,string>}
7652 */
7653 OO.ui.MenuSelectWidget.static.flippedPositions = {
7654 below: 'above',
7655 above: 'below',
7656 top: 'bottom',
7657 bottom: 'top'
7658 };
7659
7660 /* Methods */
7661
7662 /**
7663 * Handles document mouse down events.
7664 *
7665 * @protected
7666 * @param {MouseEvent} e Mouse down event
7667 */
7668 OO.ui.MenuSelectWidget.prototype.onDocumentMouseDown = function ( e ) {
7669 if (
7670 this.isVisible() &&
7671 !OO.ui.contains(
7672 this.$element.add( this.$widget ).add( this.$autoCloseIgnore ).get(),
7673 e.target,
7674 true
7675 )
7676 ) {
7677 this.toggle( false );
7678 }
7679 };
7680
7681 /**
7682 * @inheritdoc
7683 */
7684 OO.ui.MenuSelectWidget.prototype.onDocumentKeyDown = function ( e ) {
7685 var currentItem = this.findHighlightedItem() || this.findSelectedItem();
7686
7687 if ( !this.isDisabled() && this.isVisible() ) {
7688 switch ( e.keyCode ) {
7689 case OO.ui.Keys.LEFT:
7690 case OO.ui.Keys.RIGHT:
7691 // Do nothing if a text field is associated, arrow keys will be handled natively
7692 if ( !this.$input ) {
7693 OO.ui.MenuSelectWidget.parent.prototype.onDocumentKeyDown.call( this, e );
7694 }
7695 break;
7696 case OO.ui.Keys.ESCAPE:
7697 case OO.ui.Keys.TAB:
7698 if ( currentItem && !this.multiselect ) {
7699 currentItem.setHighlighted( false );
7700 }
7701 this.toggle( false );
7702 // Don't prevent tabbing away, prevent defocusing
7703 if ( e.keyCode === OO.ui.Keys.ESCAPE ) {
7704 e.preventDefault();
7705 e.stopPropagation();
7706 }
7707 break;
7708 default:
7709 OO.ui.MenuSelectWidget.parent.prototype.onDocumentKeyDown.call( this, e );
7710 return;
7711 }
7712 }
7713 };
7714
7715 /**
7716 * Update menu item visibility and clipping after input changes (if filterFromInput is enabled)
7717 * or after items were added/removed (always).
7718 *
7719 * @protected
7720 */
7721 OO.ui.MenuSelectWidget.prototype.updateItemVisibility = function () {
7722 var i, item, items, visible, section, sectionEmpty, filter, exactFilter,
7723 anyVisible = false,
7724 len = this.items.length,
7725 showAll = !this.isVisible(),
7726 exactMatch = false;
7727
7728 if ( this.$input && this.filterFromInput ) {
7729 filter = showAll ? null : this.getItemMatcher( this.$input.val(), this.filterMode );
7730 exactFilter = this.getItemMatcher( this.$input.val(), 'exact' );
7731 // Hide non-matching options, and also hide section headers if all options
7732 // in their section are hidden.
7733 for ( i = 0; i < len; i++ ) {
7734 item = this.items[ i ];
7735 if ( item instanceof OO.ui.MenuSectionOptionWidget ) {
7736 if ( section ) {
7737 // If the previous section was empty, hide its header
7738 section.toggle( showAll || !sectionEmpty );
7739 }
7740 section = item;
7741 sectionEmpty = true;
7742 } else if ( item instanceof OO.ui.OptionWidget ) {
7743 visible = showAll || filter( item );
7744 exactMatch = exactMatch || exactFilter( item );
7745 anyVisible = anyVisible || visible;
7746 sectionEmpty = sectionEmpty && !visible;
7747 item.toggle( visible );
7748 }
7749 }
7750 // Process the final section
7751 if ( section ) {
7752 section.toggle( showAll || !sectionEmpty );
7753 }
7754
7755 if ( !anyVisible ) {
7756 this.highlightItem( null );
7757 }
7758
7759 this.$element.toggleClass( 'oo-ui-menuSelectWidget-invisible', !anyVisible );
7760
7761 if (
7762 this.highlightOnFilter &&
7763 !( this.lastHighlightedItem && this.lastHighlightedItem.isVisible() )
7764 ) {
7765 // Highlight the first item on the list
7766 item = null;
7767 items = this.getItems();
7768 for ( i = 0; i < items.length; i++ ) {
7769 if ( items[ i ].isVisible() ) {
7770 item = items[ i ];
7771 break;
7772 }
7773 }
7774 this.highlightItem( item );
7775 this.lastHighlightedItem = item;
7776 }
7777
7778 }
7779
7780 // Reevaluate clipping
7781 this.clip();
7782 };
7783
7784 /**
7785 * @inheritdoc
7786 */
7787 OO.ui.MenuSelectWidget.prototype.bindDocumentKeyDownListener = function () {
7788 if ( this.$input ) {
7789 this.$input.on( 'keydown', this.onDocumentKeyDownHandler );
7790 } else {
7791 OO.ui.MenuSelectWidget.parent.prototype.bindDocumentKeyDownListener.call( this );
7792 }
7793 };
7794
7795 /**
7796 * @inheritdoc
7797 */
7798 OO.ui.MenuSelectWidget.prototype.unbindDocumentKeyDownListener = function () {
7799 if ( this.$input ) {
7800 this.$input.off( 'keydown', this.onDocumentKeyDownHandler );
7801 } else {
7802 OO.ui.MenuSelectWidget.parent.prototype.unbindDocumentKeyDownListener.call( this );
7803 }
7804 };
7805
7806 /**
7807 * @inheritdoc
7808 */
7809 OO.ui.MenuSelectWidget.prototype.bindDocumentKeyPressListener = function () {
7810 if ( this.$input ) {
7811 if ( this.filterFromInput ) {
7812 this.$input.on(
7813 'keydown mouseup cut paste change input select',
7814 this.onInputEditHandler
7815 );
7816 this.updateItemVisibility();
7817 }
7818 } else {
7819 OO.ui.MenuSelectWidget.parent.prototype.bindDocumentKeyPressListener.call( this );
7820 }
7821 };
7822
7823 /**
7824 * @inheritdoc
7825 */
7826 OO.ui.MenuSelectWidget.prototype.unbindDocumentKeyPressListener = function () {
7827 if ( this.$input ) {
7828 if ( this.filterFromInput ) {
7829 this.$input.off(
7830 'keydown mouseup cut paste change input select',
7831 this.onInputEditHandler
7832 );
7833 this.updateItemVisibility();
7834 }
7835 } else {
7836 OO.ui.MenuSelectWidget.parent.prototype.unbindDocumentKeyPressListener.call( this );
7837 }
7838 };
7839
7840 /**
7841 * Choose an item.
7842 *
7843 * When a user chooses an item, the menu is closed, unless the hideOnChoose config option is
7844 * set to false.
7845 *
7846 * Note that ‘choose’ should never be modified programmatically. A user can choose an option with
7847 * the keyboard or mouse and it becomes selected. To select an item programmatically,
7848 * use the #selectItem method.
7849 *
7850 * @param {OO.ui.OptionWidget} item Item to choose
7851 * @chainable
7852 * @return {OO.ui.Widget} The widget, for chaining
7853 */
7854 OO.ui.MenuSelectWidget.prototype.chooseItem = function ( item ) {
7855 OO.ui.MenuSelectWidget.parent.prototype.chooseItem.call( this, item );
7856 if ( this.hideOnChoose ) {
7857 this.toggle( false );
7858 }
7859 return this;
7860 };
7861
7862 /**
7863 * @inheritdoc
7864 */
7865 OO.ui.MenuSelectWidget.prototype.addItems = function ( items, index ) {
7866 // Parent method
7867 OO.ui.MenuSelectWidget.parent.prototype.addItems.call( this, items, index );
7868
7869 this.updateItemVisibility();
7870
7871 return this;
7872 };
7873
7874 /**
7875 * @inheritdoc
7876 */
7877 OO.ui.MenuSelectWidget.prototype.removeItems = function ( items ) {
7878 // Parent method
7879 OO.ui.MenuSelectWidget.parent.prototype.removeItems.call( this, items );
7880
7881 this.updateItemVisibility();
7882
7883 return this;
7884 };
7885
7886 /**
7887 * @inheritdoc
7888 */
7889 OO.ui.MenuSelectWidget.prototype.clearItems = function () {
7890 // Parent method
7891 OO.ui.MenuSelectWidget.parent.prototype.clearItems.call( this );
7892
7893 this.updateItemVisibility();
7894
7895 return this;
7896 };
7897
7898 /**
7899 * Toggle visibility of the menu. The menu is initially hidden and must be shown by calling
7900 * `.toggle( true )` after its #$element is attached to the DOM.
7901 *
7902 * Do not show the menu while it is not attached to the DOM. The calculations required to display
7903 * it in the right place and with the right dimensions only work correctly while it is attached.
7904 * Side-effects may include broken interface and exceptions being thrown. This wasn't always
7905 * strictly enforced, so currently it only generates a warning in the browser console.
7906 *
7907 * @fires ready
7908 * @inheritdoc
7909 */
7910 OO.ui.MenuSelectWidget.prototype.toggle = function ( visible ) {
7911 var change, originalHeight, flippedHeight, selectedItem;
7912
7913 visible = ( visible === undefined ? !this.visible : !!visible ) && !!this.items.length;
7914 change = visible !== this.isVisible();
7915
7916 if ( visible && !this.warnedUnattached && !this.isElementAttached() ) {
7917 OO.ui.warnDeprecation( 'MenuSelectWidget#toggle: Before calling this method, the menu must be attached to the DOM.' );
7918 this.warnedUnattached = true;
7919 }
7920
7921 if ( change && visible ) {
7922 // Reset position before showing the popup again. It's possible we no longer need to flip
7923 // (e.g. if the user scrolled).
7924 this.setVerticalPosition( this.originalVerticalPosition );
7925 }
7926
7927 // Parent method
7928 OO.ui.MenuSelectWidget.parent.prototype.toggle.call( this, visible );
7929
7930 if ( change ) {
7931 if ( visible ) {
7932
7933 if ( this.width ) {
7934 this.setIdealSize( this.width );
7935 } else if ( this.$floatableContainer ) {
7936 this.$clippable.css( 'width', 'auto' );
7937 this.setIdealSize(
7938 this.$floatableContainer[ 0 ].offsetWidth > this.$clippable[ 0 ].offsetWidth ?
7939 // Dropdown is smaller than handle so expand to width
7940 this.$floatableContainer[ 0 ].offsetWidth :
7941 // Dropdown is larger than handle so auto size
7942 'auto'
7943 );
7944 this.$clippable.css( 'width', '' );
7945 }
7946
7947 this.togglePositioning( !!this.$floatableContainer );
7948 this.toggleClipping( true );
7949
7950 this.bindDocumentKeyDownListener();
7951 this.bindDocumentKeyPressListener();
7952
7953 if (
7954 ( this.isClippedVertically() || this.isFloatableOutOfView() ) &&
7955 this.originalVerticalPosition !== 'center'
7956 ) {
7957 // If opening the menu in one direction causes it to be clipped, flip it
7958 originalHeight = this.$element.height();
7959 this.setVerticalPosition(
7960 this.constructor.static.flippedPositions[ this.originalVerticalPosition ]
7961 );
7962 if ( this.isClippedVertically() || this.isFloatableOutOfView() ) {
7963 // If flipping also causes it to be clipped, open in whichever direction
7964 // we have more space
7965 flippedHeight = this.$element.height();
7966 if ( originalHeight > flippedHeight ) {
7967 this.setVerticalPosition( this.originalVerticalPosition );
7968 }
7969 }
7970 }
7971 // Note that we do not flip the menu's opening direction if the clipping changes
7972 // later (e.g. after the user scrolls), that seems like it would be annoying
7973
7974 this.$focusOwner.attr( 'aria-expanded', 'true' );
7975
7976 selectedItem = this.findSelectedItem();
7977 if ( !this.multiselect && selectedItem ) {
7978 // TODO: Verify if this is even needed; This is already done on highlight changes
7979 // in SelectWidget#highlightItem, so we should just need to highlight the item we need to
7980 // highlight here and not bother with attr or checking selections.
7981 this.$focusOwner.attr( 'aria-activedescendant', selectedItem.getElementId() );
7982 selectedItem.scrollElementIntoView( { duration: 0 } );
7983 }
7984
7985 // Auto-hide
7986 if ( this.autoHide ) {
7987 this.getElementDocument().addEventListener( 'mousedown', this.onDocumentMouseDownHandler, true );
7988 }
7989
7990 this.emit( 'ready' );
7991 } else {
7992 this.$focusOwner.removeAttr( 'aria-activedescendant' );
7993 this.unbindDocumentKeyDownListener();
7994 this.unbindDocumentKeyPressListener();
7995 this.$focusOwner.attr( 'aria-expanded', 'false' );
7996 this.getElementDocument().removeEventListener( 'mousedown', this.onDocumentMouseDownHandler, true );
7997 this.togglePositioning( false );
7998 this.toggleClipping( false );
7999 }
8000 }
8001
8002 return this;
8003 };
8004
8005 /**
8006 * Scroll to the top of the menu
8007 */
8008 OO.ui.MenuSelectWidget.prototype.scrollToTop = function () {
8009 this.$element.scrollTop( 0 );
8010 };
8011
8012 /**
8013 * DropdownWidgets are not menus themselves, rather they contain a menu of options created with
8014 * OO.ui.MenuOptionWidget. The DropdownWidget takes care of opening and displaying the menu so that
8015 * users can interact with it.
8016 *
8017 * If you want to use this within an HTML form, such as a OO.ui.FormLayout, use
8018 * OO.ui.DropdownInputWidget instead.
8019 *
8020 * @example
8021 * // A DropdownWidget with a menu that contains three options.
8022 * var dropDown = new OO.ui.DropdownWidget( {
8023 * label: 'Dropdown menu: Select a menu option',
8024 * menu: {
8025 * items: [
8026 * new OO.ui.MenuOptionWidget( {
8027 * data: 'a',
8028 * label: 'First'
8029 * } ),
8030 * new OO.ui.MenuOptionWidget( {
8031 * data: 'b',
8032 * label: 'Second'
8033 * } ),
8034 * new OO.ui.MenuOptionWidget( {
8035 * data: 'c',
8036 * label: 'Third'
8037 * } )
8038 * ]
8039 * }
8040 * } );
8041 *
8042 * $( document.body ).append( dropDown.$element );
8043 *
8044 * dropDown.getMenu().selectItemByData( 'b' );
8045 *
8046 * dropDown.getMenu().findSelectedItem().getData(); // Returns 'b'.
8047 *
8048 * For more information, please see the [OOUI documentation on MediaWiki] [1].
8049 *
8050 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Selects_and_Options#Menu_selects_and_options
8051 *
8052 * @class
8053 * @extends OO.ui.Widget
8054 * @mixins OO.ui.mixin.IconElement
8055 * @mixins OO.ui.mixin.IndicatorElement
8056 * @mixins OO.ui.mixin.LabelElement
8057 * @mixins OO.ui.mixin.TitledElement
8058 * @mixins OO.ui.mixin.TabIndexedElement
8059 *
8060 * @constructor
8061 * @param {Object} [config] Configuration options
8062 * @cfg {Object} [menu] Configuration options to pass to
8063 * {@link OO.ui.MenuSelectWidget menu select widget}.
8064 * @cfg {jQuery} [$overlay] Render the menu into a separate layer. This configuration is useful
8065 * in cases where the expanded menu is larger than its containing `<div>`. The specified overlay
8066 * layer is usually on top of the containing `<div>` and has a larger area. By default, the menu
8067 * uses relative positioning.
8068 * See <https://www.mediawiki.org/wiki/OOUI/Concepts#Overlays>.
8069 */
8070 OO.ui.DropdownWidget = function OoUiDropdownWidget( config ) {
8071 // Configuration initialization
8072 config = $.extend( { indicator: 'down' }, config );
8073
8074 // Parent constructor
8075 OO.ui.DropdownWidget.parent.call( this, config );
8076
8077 // Properties (must be set before TabIndexedElement constructor call)
8078 this.$handle = $( '<button>' );
8079 this.$overlay = ( config.$overlay === true ?
8080 OO.ui.getDefaultOverlay() : config.$overlay ) || this.$element;
8081
8082 // Mixin constructors
8083 OO.ui.mixin.IconElement.call( this, config );
8084 OO.ui.mixin.IndicatorElement.call( this, config );
8085 OO.ui.mixin.LabelElement.call( this, config );
8086 OO.ui.mixin.TitledElement.call( this, $.extend( {
8087 $titled: this.$label
8088 }, config ) );
8089 OO.ui.mixin.TabIndexedElement.call( this, $.extend( {
8090 $tabIndexed: this.$handle
8091 }, config ) );
8092
8093 // Properties
8094 this.menu = new OO.ui.MenuSelectWidget( $.extend( {
8095 widget: this,
8096 $floatableContainer: this.$element
8097 }, config.menu ) );
8098
8099 // Events
8100 this.$handle.on( {
8101 click: this.onClick.bind( this ),
8102 keydown: this.onKeyDown.bind( this ),
8103 // Hack? Handle type-to-search when menu is not expanded and not handling its own events.
8104 keypress: this.menu.onDocumentKeyPressHandler,
8105 blur: this.menu.clearKeyPressBuffer.bind( this.menu )
8106 } );
8107 this.menu.connect( this, {
8108 select: 'onMenuSelect',
8109 toggle: 'onMenuToggle'
8110 } );
8111
8112 // Initialization
8113 this.$handle
8114 .addClass( 'oo-ui-dropdownWidget-handle' )
8115 .attr( {
8116 type: 'button',
8117 'aria-owns': this.menu.getElementId(),
8118 'aria-haspopup': 'listbox'
8119 } )
8120 .append( this.$icon, this.$label, this.$indicator );
8121 this.$element
8122 .addClass( 'oo-ui-dropdownWidget' )
8123 .append( this.$handle );
8124 this.$overlay.append( this.menu.$element );
8125 };
8126
8127 /* Setup */
8128
8129 OO.inheritClass( OO.ui.DropdownWidget, OO.ui.Widget );
8130 OO.mixinClass( OO.ui.DropdownWidget, OO.ui.mixin.IconElement );
8131 OO.mixinClass( OO.ui.DropdownWidget, OO.ui.mixin.IndicatorElement );
8132 OO.mixinClass( OO.ui.DropdownWidget, OO.ui.mixin.LabelElement );
8133 OO.mixinClass( OO.ui.DropdownWidget, OO.ui.mixin.TitledElement );
8134 OO.mixinClass( OO.ui.DropdownWidget, OO.ui.mixin.TabIndexedElement );
8135
8136 /* Methods */
8137
8138 /**
8139 * Get the menu.
8140 *
8141 * @return {OO.ui.MenuSelectWidget} Menu of widget
8142 */
8143 OO.ui.DropdownWidget.prototype.getMenu = function () {
8144 return this.menu;
8145 };
8146
8147 /**
8148 * Handles menu select events.
8149 *
8150 * @private
8151 * @param {OO.ui.MenuOptionWidget} item Selected menu item
8152 */
8153 OO.ui.DropdownWidget.prototype.onMenuSelect = function ( item ) {
8154 var selectedLabel;
8155
8156 if ( !item ) {
8157 this.setLabel( null );
8158 return;
8159 }
8160
8161 selectedLabel = item.getLabel();
8162
8163 // If the label is a DOM element, clone it, because setLabel will append() it
8164 if ( selectedLabel instanceof $ ) {
8165 selectedLabel = selectedLabel.clone();
8166 }
8167
8168 this.setLabel( selectedLabel );
8169 };
8170
8171 /**
8172 * Handle menu toggle events.
8173 *
8174 * @private
8175 * @param {boolean} isVisible Open state of the menu
8176 */
8177 OO.ui.DropdownWidget.prototype.onMenuToggle = function ( isVisible ) {
8178 this.$element.toggleClass( 'oo-ui-dropdownWidget-open', isVisible );
8179 };
8180
8181 /**
8182 * Handle mouse click events.
8183 *
8184 * @private
8185 * @param {jQuery.Event} e Mouse click event
8186 * @return {undefined/boolean} False to prevent default if event is handled
8187 */
8188 OO.ui.DropdownWidget.prototype.onClick = function ( e ) {
8189 if ( !this.isDisabled() && e.which === OO.ui.MouseButtons.LEFT ) {
8190 this.menu.toggle();
8191 }
8192 return false;
8193 };
8194
8195 /**
8196 * Handle key down events.
8197 *
8198 * @private
8199 * @param {jQuery.Event} e Key down event
8200 * @return {undefined/boolean} False to prevent default if event is handled
8201 */
8202 OO.ui.DropdownWidget.prototype.onKeyDown = function ( e ) {
8203 if (
8204 !this.isDisabled() &&
8205 (
8206 e.which === OO.ui.Keys.ENTER ||
8207 (
8208 e.which === OO.ui.Keys.SPACE &&
8209 // Avoid conflicts with type-to-search, see SelectWidget#onKeyPress.
8210 // Space only closes the menu is the user is not typing to search.
8211 this.menu.keyPressBuffer === ''
8212 ) ||
8213 (
8214 !this.menu.isVisible() &&
8215 (
8216 e.which === OO.ui.Keys.UP ||
8217 e.which === OO.ui.Keys.DOWN
8218 )
8219 )
8220 )
8221 ) {
8222 this.menu.toggle();
8223 return false;
8224 }
8225 };
8226
8227 /**
8228 * RadioOptionWidget is an option widget that looks like a radio button.
8229 * The class is used with OO.ui.RadioSelectWidget to create a selection of radio options.
8230 * Please see the [OOUI documentation on MediaWiki] [1] for more information.
8231 *
8232 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Selects_and_Options#Button_selects_and_option
8233 *
8234 * @class
8235 * @extends OO.ui.OptionWidget
8236 *
8237 * @constructor
8238 * @param {Object} [config] Configuration options
8239 */
8240 OO.ui.RadioOptionWidget = function OoUiRadioOptionWidget( config ) {
8241 // Configuration initialization
8242 config = config || {};
8243
8244 // Properties (must be done before parent constructor which calls #setDisabled)
8245 this.radio = new OO.ui.RadioInputWidget( { value: config.data, tabIndex: -1 } );
8246
8247 // Parent constructor
8248 OO.ui.RadioOptionWidget.parent.call( this, config );
8249
8250 // Initialization
8251 // Remove implicit role, we're handling it ourselves
8252 this.radio.$input.attr( 'role', 'presentation' );
8253 this.$element
8254 .addClass( 'oo-ui-radioOptionWidget' )
8255 .attr( 'role', 'radio' )
8256 .attr( 'aria-checked', 'false' )
8257 .removeAttr( 'aria-selected' )
8258 .prepend( this.radio.$element );
8259 };
8260
8261 /* Setup */
8262
8263 OO.inheritClass( OO.ui.RadioOptionWidget, OO.ui.OptionWidget );
8264
8265 /* Static Properties */
8266
8267 /**
8268 * @static
8269 * @inheritdoc
8270 */
8271 OO.ui.RadioOptionWidget.static.highlightable = false;
8272
8273 /**
8274 * @static
8275 * @inheritdoc
8276 */
8277 OO.ui.RadioOptionWidget.static.scrollIntoViewOnSelect = true;
8278
8279 /**
8280 * @static
8281 * @inheritdoc
8282 */
8283 OO.ui.RadioOptionWidget.static.pressable = false;
8284
8285 /**
8286 * @static
8287 * @inheritdoc
8288 */
8289 OO.ui.RadioOptionWidget.static.tagName = 'label';
8290
8291 /* Methods */
8292
8293 /**
8294 * @inheritdoc
8295 */
8296 OO.ui.RadioOptionWidget.prototype.setSelected = function ( state ) {
8297 OO.ui.RadioOptionWidget.parent.prototype.setSelected.call( this, state );
8298
8299 this.radio.setSelected( state );
8300 this.$element
8301 .attr( 'aria-checked', state.toString() )
8302 .removeAttr( 'aria-selected' );
8303
8304 return this;
8305 };
8306
8307 /**
8308 * @inheritdoc
8309 */
8310 OO.ui.RadioOptionWidget.prototype.setDisabled = function ( disabled ) {
8311 OO.ui.RadioOptionWidget.parent.prototype.setDisabled.call( this, disabled );
8312
8313 this.radio.setDisabled( this.isDisabled() );
8314
8315 return this;
8316 };
8317
8318 /**
8319 * RadioSelectWidget is a {@link OO.ui.SelectWidget select widget} that contains radio
8320 * options and is used together with OO.ui.RadioOptionWidget. The RadioSelectWidget provides
8321 * an interface for adding, removing and selecting options.
8322 * Please see the [OOUI documentation on MediaWiki][1] for more information.
8323 *
8324 * If you want to use this within an HTML form, such as a OO.ui.FormLayout, use
8325 * OO.ui.RadioSelectInputWidget instead.
8326 *
8327 * @example
8328 * // A RadioSelectWidget with RadioOptions.
8329 * var option1 = new OO.ui.RadioOptionWidget( {
8330 * data: 'a',
8331 * label: 'Selected radio option'
8332 * } ),
8333 * option2 = new OO.ui.RadioOptionWidget( {
8334 * data: 'b',
8335 * label: 'Unselected radio option'
8336 * } );
8337 * radioSelect = new OO.ui.RadioSelectWidget( {
8338 * items: [ option1, option2 ]
8339 * } );
8340 *
8341 * // Select 'option 1' using the RadioSelectWidget's selectItem() method.
8342 * radioSelect.selectItem( option1 );
8343 *
8344 * $( document.body ).append( radioSelect.$element );
8345 *
8346 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Selects_and_Options
8347
8348 *
8349 * @class
8350 * @extends OO.ui.SelectWidget
8351 * @mixins OO.ui.mixin.TabIndexedElement
8352 *
8353 * @constructor
8354 * @param {Object} [config] Configuration options
8355 */
8356 OO.ui.RadioSelectWidget = function OoUiRadioSelectWidget( config ) {
8357 // Parent constructor
8358 OO.ui.RadioSelectWidget.parent.call( this, config );
8359
8360 // Mixin constructors
8361 OO.ui.mixin.TabIndexedElement.call( this, config );
8362
8363 // Events
8364 this.$element.on( {
8365 focus: this.bindDocumentKeyDownListener.bind( this ),
8366 blur: this.unbindDocumentKeyDownListener.bind( this )
8367 } );
8368
8369 // Initialization
8370 this.$element
8371 .addClass( 'oo-ui-radioSelectWidget' )
8372 .attr( 'role', 'radiogroup' );
8373 };
8374
8375 /* Setup */
8376
8377 OO.inheritClass( OO.ui.RadioSelectWidget, OO.ui.SelectWidget );
8378 OO.mixinClass( OO.ui.RadioSelectWidget, OO.ui.mixin.TabIndexedElement );
8379
8380 /**
8381 * MultioptionWidgets are special elements that can be selected and configured with data. The
8382 * data is often unique for each option, but it does not have to be. MultioptionWidgets are used
8383 * with OO.ui.SelectWidget to create a selection of mutually exclusive options. For more information
8384 * and examples, please see the [OOUI documentation on MediaWiki][1].
8385 *
8386 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Selects_and_Options
8387 *
8388 * @class
8389 * @extends OO.ui.Widget
8390 * @mixins OO.ui.mixin.ItemWidget
8391 * @mixins OO.ui.mixin.LabelElement
8392 * @mixins OO.ui.mixin.TitledElement
8393 *
8394 * @constructor
8395 * @param {Object} [config] Configuration options
8396 * @cfg {boolean} [selected=false] Whether the option is initially selected
8397 */
8398 OO.ui.MultioptionWidget = function OoUiMultioptionWidget( config ) {
8399 // Configuration initialization
8400 config = config || {};
8401
8402 // Parent constructor
8403 OO.ui.MultioptionWidget.parent.call( this, config );
8404
8405 // Mixin constructors
8406 OO.ui.mixin.ItemWidget.call( this );
8407 OO.ui.mixin.LabelElement.call( this, config );
8408 OO.ui.mixin.TitledElement.call( this, config );
8409
8410 // Properties
8411 this.selected = null;
8412
8413 // Initialization
8414 this.$element
8415 .addClass( 'oo-ui-multioptionWidget' )
8416 .append( this.$label );
8417 this.setSelected( config.selected );
8418 };
8419
8420 /* Setup */
8421
8422 OO.inheritClass( OO.ui.MultioptionWidget, OO.ui.Widget );
8423 OO.mixinClass( OO.ui.MultioptionWidget, OO.ui.mixin.ItemWidget );
8424 OO.mixinClass( OO.ui.MultioptionWidget, OO.ui.mixin.LabelElement );
8425 OO.mixinClass( OO.ui.MultioptionWidget, OO.ui.mixin.TitledElement );
8426
8427 /* Events */
8428
8429 /**
8430 * @event change
8431 *
8432 * A change event is emitted when the selected state of the option changes.
8433 *
8434 * @param {boolean} selected Whether the option is now selected
8435 */
8436
8437 /* Methods */
8438
8439 /**
8440 * Check if the option is selected.
8441 *
8442 * @return {boolean} Item is selected
8443 */
8444 OO.ui.MultioptionWidget.prototype.isSelected = function () {
8445 return this.selected;
8446 };
8447
8448 /**
8449 * Set the option’s selected state. In general, all modifications to the selection
8450 * should be handled by the SelectWidget’s
8451 * {@link OO.ui.SelectWidget#selectItem selectItem( [item] )} method instead of this method.
8452 *
8453 * @param {boolean} [state=false] Select option
8454 * @chainable
8455 * @return {OO.ui.Widget} The widget, for chaining
8456 */
8457 OO.ui.MultioptionWidget.prototype.setSelected = function ( state ) {
8458 state = !!state;
8459 if ( this.selected !== state ) {
8460 this.selected = state;
8461 this.emit( 'change', state );
8462 this.$element.toggleClass( 'oo-ui-multioptionWidget-selected', state );
8463 }
8464 return this;
8465 };
8466
8467 /**
8468 * MultiselectWidget allows selecting multiple options from a list.
8469 *
8470 * For more information about menus and options, please see the [OOUI documentation
8471 * on MediaWiki][1].
8472 *
8473 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Selects_and_Options#Menu_selects_and_options
8474 *
8475 * @class
8476 * @abstract
8477 * @extends OO.ui.Widget
8478 * @mixins OO.ui.mixin.GroupWidget
8479 * @mixins OO.ui.mixin.TitledElement
8480 *
8481 * @constructor
8482 * @param {Object} [config] Configuration options
8483 * @cfg {OO.ui.MultioptionWidget[]} [items] An array of options to add to the multiselect.
8484 */
8485 OO.ui.MultiselectWidget = function OoUiMultiselectWidget( config ) {
8486 // Parent constructor
8487 OO.ui.MultiselectWidget.parent.call( this, config );
8488
8489 // Configuration initialization
8490 config = config || {};
8491
8492 // Mixin constructors
8493 OO.ui.mixin.GroupWidget.call( this, config );
8494 OO.ui.mixin.TitledElement.call( this, config );
8495
8496 // Events
8497 this.aggregate( {
8498 change: 'select'
8499 } );
8500 // This is mostly for compatibility with TagMultiselectWidget... normally, 'change' is emitted
8501 // by GroupElement only when items are added/removed
8502 this.connect( this, {
8503 select: [ 'emit', 'change' ]
8504 } );
8505
8506 // Initialization
8507 if ( config.items ) {
8508 this.addItems( config.items );
8509 }
8510 this.$group.addClass( 'oo-ui-multiselectWidget-group' );
8511 this.$element.addClass( 'oo-ui-multiselectWidget' )
8512 .append( this.$group );
8513 };
8514
8515 /* Setup */
8516
8517 OO.inheritClass( OO.ui.MultiselectWidget, OO.ui.Widget );
8518 OO.mixinClass( OO.ui.MultiselectWidget, OO.ui.mixin.GroupWidget );
8519 OO.mixinClass( OO.ui.MultiselectWidget, OO.ui.mixin.TitledElement );
8520
8521 /* Events */
8522
8523 /**
8524 * @event change
8525 *
8526 * A change event is emitted when the set of items changes, or an item is selected or deselected.
8527 */
8528
8529 /**
8530 * @event select
8531 *
8532 * A select event is emitted when an item is selected or deselected.
8533 */
8534
8535 /* Methods */
8536
8537 /**
8538 * Find options that are selected.
8539 *
8540 * @return {OO.ui.MultioptionWidget[]} Selected options
8541 */
8542 OO.ui.MultiselectWidget.prototype.findSelectedItems = function () {
8543 return this.items.filter( function ( item ) {
8544 return item.isSelected();
8545 } );
8546 };
8547
8548 /**
8549 * Find the data of options that are selected.
8550 *
8551 * @return {Object[]|string[]} Values of selected options
8552 */
8553 OO.ui.MultiselectWidget.prototype.findSelectedItemsData = function () {
8554 return this.findSelectedItems().map( function ( item ) {
8555 return item.data;
8556 } );
8557 };
8558
8559 /**
8560 * Select options by reference. Options not mentioned in the `items` array will be deselected.
8561 *
8562 * @param {OO.ui.MultioptionWidget[]} items Items to select
8563 * @chainable
8564 * @return {OO.ui.Widget} The widget, for chaining
8565 */
8566 OO.ui.MultiselectWidget.prototype.selectItems = function ( items ) {
8567 this.items.forEach( function ( item ) {
8568 var selected = items.indexOf( item ) !== -1;
8569 item.setSelected( selected );
8570 } );
8571 return this;
8572 };
8573
8574 /**
8575 * Select items by their data. Options not mentioned in the `datas` array will be deselected.
8576 *
8577 * @param {Object[]|string[]} datas Values of items to select
8578 * @chainable
8579 * @return {OO.ui.Widget} The widget, for chaining
8580 */
8581 OO.ui.MultiselectWidget.prototype.selectItemsByData = function ( datas ) {
8582 var items,
8583 widget = this;
8584 items = datas.map( function ( data ) {
8585 return widget.findItemFromData( data );
8586 } );
8587 this.selectItems( items );
8588 return this;
8589 };
8590
8591 /**
8592 * CheckboxMultioptionWidget is an option widget that looks like a checkbox.
8593 * The class is used with OO.ui.CheckboxMultiselectWidget to create a selection of checkbox options.
8594 * Please see the [OOUI documentation on MediaWiki] [1] for more information.
8595 *
8596 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Selects_and_Options#Button_selects_and_option
8597 *
8598 * @class
8599 * @extends OO.ui.MultioptionWidget
8600 *
8601 * @constructor
8602 * @param {Object} [config] Configuration options
8603 */
8604 OO.ui.CheckboxMultioptionWidget = function OoUiCheckboxMultioptionWidget( config ) {
8605 // Configuration initialization
8606 config = config || {};
8607
8608 // Properties (must be done before parent constructor which calls #setDisabled)
8609 this.checkbox = new OO.ui.CheckboxInputWidget();
8610
8611 // Parent constructor
8612 OO.ui.CheckboxMultioptionWidget.parent.call( this, config );
8613
8614 // Events
8615 this.checkbox.on( 'change', this.onCheckboxChange.bind( this ) );
8616 this.$element.on( 'keydown', this.onKeyDown.bind( this ) );
8617
8618 // Initialization
8619 this.$element
8620 .addClass( 'oo-ui-checkboxMultioptionWidget' )
8621 .prepend( this.checkbox.$element );
8622 };
8623
8624 /* Setup */
8625
8626 OO.inheritClass( OO.ui.CheckboxMultioptionWidget, OO.ui.MultioptionWidget );
8627
8628 /* Static Properties */
8629
8630 /**
8631 * @static
8632 * @inheritdoc
8633 */
8634 OO.ui.CheckboxMultioptionWidget.static.tagName = 'label';
8635
8636 /* Methods */
8637
8638 /**
8639 * Handle checkbox selected state change.
8640 *
8641 * @private
8642 */
8643 OO.ui.CheckboxMultioptionWidget.prototype.onCheckboxChange = function () {
8644 this.setSelected( this.checkbox.isSelected() );
8645 };
8646
8647 /**
8648 * @inheritdoc
8649 */
8650 OO.ui.CheckboxMultioptionWidget.prototype.setSelected = function ( state ) {
8651 OO.ui.CheckboxMultioptionWidget.parent.prototype.setSelected.call( this, state );
8652 this.checkbox.setSelected( state );
8653 return this;
8654 };
8655
8656 /**
8657 * @inheritdoc
8658 */
8659 OO.ui.CheckboxMultioptionWidget.prototype.setDisabled = function ( disabled ) {
8660 OO.ui.CheckboxMultioptionWidget.parent.prototype.setDisabled.call( this, disabled );
8661 this.checkbox.setDisabled( this.isDisabled() );
8662 return this;
8663 };
8664
8665 /**
8666 * Focus the widget.
8667 */
8668 OO.ui.CheckboxMultioptionWidget.prototype.focus = function () {
8669 this.checkbox.focus();
8670 };
8671
8672 /**
8673 * Handle key down events.
8674 *
8675 * @protected
8676 * @param {jQuery.Event} e
8677 */
8678 OO.ui.CheckboxMultioptionWidget.prototype.onKeyDown = function ( e ) {
8679 var
8680 element = this.getElementGroup(),
8681 nextItem;
8682
8683 if ( e.keyCode === OO.ui.Keys.LEFT || e.keyCode === OO.ui.Keys.UP ) {
8684 nextItem = element.getRelativeFocusableItem( this, -1 );
8685 } else if ( e.keyCode === OO.ui.Keys.RIGHT || e.keyCode === OO.ui.Keys.DOWN ) {
8686 nextItem = element.getRelativeFocusableItem( this, 1 );
8687 }
8688
8689 if ( nextItem ) {
8690 e.preventDefault();
8691 nextItem.focus();
8692 }
8693 };
8694
8695 /**
8696 * CheckboxMultiselectWidget is a {@link OO.ui.MultiselectWidget multiselect widget} that contains
8697 * checkboxes and is used together with OO.ui.CheckboxMultioptionWidget. The
8698 * CheckboxMultiselectWidget provides an interface for adding, removing and selecting options.
8699 * Please see the [OOUI documentation on MediaWiki][1] for more information.
8700 *
8701 * If you want to use this within an HTML form, such as a OO.ui.FormLayout, use
8702 * OO.ui.CheckboxMultiselectInputWidget instead.
8703 *
8704 * @example
8705 * // A CheckboxMultiselectWidget with CheckboxMultioptions.
8706 * var option1 = new OO.ui.CheckboxMultioptionWidget( {
8707 * data: 'a',
8708 * selected: true,
8709 * label: 'Selected checkbox'
8710 * } ),
8711 * option2 = new OO.ui.CheckboxMultioptionWidget( {
8712 * data: 'b',
8713 * label: 'Unselected checkbox'
8714 * } ),
8715 * multiselect = new OO.ui.CheckboxMultiselectWidget( {
8716 * items: [ option1, option2 ]
8717 * } );
8718 * $( document.body ).append( multiselect.$element );
8719 *
8720 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Selects_and_Options
8721 *
8722 * @class
8723 * @extends OO.ui.MultiselectWidget
8724 *
8725 * @constructor
8726 * @param {Object} [config] Configuration options
8727 */
8728 OO.ui.CheckboxMultiselectWidget = function OoUiCheckboxMultiselectWidget( config ) {
8729 // Parent constructor
8730 OO.ui.CheckboxMultiselectWidget.parent.call( this, config );
8731
8732 // Properties
8733 this.$lastClicked = null;
8734
8735 // Events
8736 this.$group.on( 'click', this.onClick.bind( this ) );
8737
8738 // Initialization
8739 this.$element.addClass( 'oo-ui-checkboxMultiselectWidget' );
8740 };
8741
8742 /* Setup */
8743
8744 OO.inheritClass( OO.ui.CheckboxMultiselectWidget, OO.ui.MultiselectWidget );
8745
8746 /* Methods */
8747
8748 /**
8749 * Get an option by its position relative to the specified item (or to the start of the
8750 * option array, if item is `null`). The direction in which to search through the option array
8751 * is specified with a number: -1 for reverse (the default) or 1 for forward. The method will
8752 * return an option, or `null` if there are no options in the array.
8753 *
8754 * @param {OO.ui.CheckboxMultioptionWidget|null} item Item to describe the start position, or
8755 * `null` to start at the beginning of the array.
8756 * @param {number} direction Direction to move in: -1 to move backward, 1 to move forward
8757 * @return {OO.ui.CheckboxMultioptionWidget|null} Item at position, `null` if there are no items
8758 * in the select.
8759 */
8760 OO.ui.CheckboxMultiselectWidget.prototype.getRelativeFocusableItem = function ( item, direction ) {
8761 var currentIndex, nextIndex, i,
8762 increase = direction > 0 ? 1 : -1,
8763 len = this.items.length;
8764
8765 if ( item ) {
8766 currentIndex = this.items.indexOf( item );
8767 nextIndex = ( currentIndex + increase + len ) % len;
8768 } else {
8769 // If no item is selected and moving forward, start at the beginning.
8770 // If moving backward, start at the end.
8771 nextIndex = direction > 0 ? 0 : len - 1;
8772 }
8773
8774 for ( i = 0; i < len; i++ ) {
8775 item = this.items[ nextIndex ];
8776 if ( item && !item.isDisabled() ) {
8777 return item;
8778 }
8779 nextIndex = ( nextIndex + increase + len ) % len;
8780 }
8781 return null;
8782 };
8783
8784 /**
8785 * Handle click events on checkboxes.
8786 *
8787 * @param {jQuery.Event} e
8788 */
8789 OO.ui.CheckboxMultiselectWidget.prototype.onClick = function ( e ) {
8790 var $options, lastClickedIndex, nowClickedIndex, i, direction, wasSelected, items,
8791 $lastClicked = this.$lastClicked,
8792 $nowClicked = $( e.target ).closest( '.oo-ui-checkboxMultioptionWidget' )
8793 .not( '.oo-ui-widget-disabled' );
8794
8795 // Allow selecting multiple options at once by Shift-clicking them
8796 if ( $lastClicked && $nowClicked.length && e.shiftKey ) {
8797 $options = this.$group.find( '.oo-ui-checkboxMultioptionWidget' );
8798 lastClickedIndex = $options.index( $lastClicked );
8799 nowClickedIndex = $options.index( $nowClicked );
8800 // If it's the same item, either the user is being silly, or it's a fake event generated
8801 // by the browser. In either case we don't need custom handling.
8802 if ( nowClickedIndex !== lastClickedIndex ) {
8803 items = this.items;
8804 wasSelected = items[ nowClickedIndex ].isSelected();
8805 direction = nowClickedIndex > lastClickedIndex ? 1 : -1;
8806
8807 // This depends on the DOM order of the items and the order of the .items array being
8808 // the same.
8809 for ( i = lastClickedIndex; i !== nowClickedIndex; i += direction ) {
8810 if ( !items[ i ].isDisabled() ) {
8811 items[ i ].setSelected( !wasSelected );
8812 }
8813 }
8814 // For the now-clicked element, use immediate timeout to allow the browser to do its own
8815 // handling first, then set our value. The order in which events happen is different for
8816 // clicks on the <input> and on the <label> and there are additional fake clicks fired
8817 // for non-click actions that change the checkboxes.
8818 e.preventDefault();
8819 setTimeout( function () {
8820 if ( !items[ nowClickedIndex ].isDisabled() ) {
8821 items[ nowClickedIndex ].setSelected( !wasSelected );
8822 }
8823 } );
8824 }
8825 }
8826
8827 if ( $nowClicked.length ) {
8828 this.$lastClicked = $nowClicked;
8829 }
8830 };
8831
8832 /**
8833 * Focus the widget
8834 *
8835 * @chainable
8836 * @return {OO.ui.Widget} The widget, for chaining
8837 */
8838 OO.ui.CheckboxMultiselectWidget.prototype.focus = function () {
8839 var item;
8840 if ( !this.isDisabled() ) {
8841 item = this.getRelativeFocusableItem( null, 1 );
8842 if ( item ) {
8843 item.focus();
8844 }
8845 }
8846 return this;
8847 };
8848
8849 /**
8850 * @inheritdoc
8851 */
8852 OO.ui.CheckboxMultiselectWidget.prototype.simulateLabelClick = function () {
8853 this.focus();
8854 };
8855
8856 /**
8857 * Progress bars visually display the status of an operation, such as a download,
8858 * and can be either determinate or indeterminate:
8859 *
8860 * - **determinate** process bars show the percent of an operation that is complete.
8861 *
8862 * - **indeterminate** process bars use a visual display of motion to indicate that an operation
8863 * is taking place. Because the extent of an indeterminate operation is unknown, the bar does
8864 * not use percentages.
8865 *
8866 * The value of the `progress` configuration determines whether the bar is determinate
8867 * or indeterminate.
8868 *
8869 * @example
8870 * // Examples of determinate and indeterminate progress bars.
8871 * var progressBar1 = new OO.ui.ProgressBarWidget( {
8872 * progress: 33
8873 * } );
8874 * var progressBar2 = new OO.ui.ProgressBarWidget();
8875 *
8876 * // Create a FieldsetLayout to layout progress bars.
8877 * var fieldset = new OO.ui.FieldsetLayout;
8878 * fieldset.addItems( [
8879 * new OO.ui.FieldLayout( progressBar1, {
8880 * label: 'Determinate',
8881 * align: 'top'
8882 * } ),
8883 * new OO.ui.FieldLayout( progressBar2, {
8884 * label: 'Indeterminate',
8885 * align: 'top'
8886 * } )
8887 * ] );
8888 * $( document.body ).append( fieldset.$element );
8889 *
8890 * @class
8891 * @extends OO.ui.Widget
8892 *
8893 * @constructor
8894 * @param {Object} [config] Configuration options
8895 * @cfg {number|boolean} [progress=false] The type of progress bar (determinate or indeterminate).
8896 * To create a determinate progress bar, specify a number that reflects the initial
8897 * percent complete.
8898 * By default, the progress bar is indeterminate.
8899 */
8900 OO.ui.ProgressBarWidget = function OoUiProgressBarWidget( config ) {
8901 // Configuration initialization
8902 config = config || {};
8903
8904 // Parent constructor
8905 OO.ui.ProgressBarWidget.parent.call( this, config );
8906
8907 // Properties
8908 this.$bar = $( '<div>' );
8909 this.progress = null;
8910
8911 // Initialization
8912 this.setProgress( config.progress !== undefined ? config.progress : false );
8913 this.$bar.addClass( 'oo-ui-progressBarWidget-bar' );
8914 this.$element
8915 .attr( {
8916 role: 'progressbar',
8917 'aria-valuemin': 0,
8918 'aria-valuemax': 100
8919 } )
8920 .addClass( 'oo-ui-progressBarWidget' )
8921 .append( this.$bar );
8922 };
8923
8924 /* Setup */
8925
8926 OO.inheritClass( OO.ui.ProgressBarWidget, OO.ui.Widget );
8927
8928 /* Static Properties */
8929
8930 /**
8931 * @static
8932 * @inheritdoc
8933 */
8934 OO.ui.ProgressBarWidget.static.tagName = 'div';
8935
8936 /* Methods */
8937
8938 /**
8939 * Get the percent of the progress that has been completed. Indeterminate progresses will
8940 * return `false`.
8941 *
8942 * @return {number|boolean} Progress percent
8943 */
8944 OO.ui.ProgressBarWidget.prototype.getProgress = function () {
8945 return this.progress;
8946 };
8947
8948 /**
8949 * Set the percent of the process completed or `false` for an indeterminate process.
8950 *
8951 * @param {number|boolean} progress Progress percent or `false` for indeterminate
8952 */
8953 OO.ui.ProgressBarWidget.prototype.setProgress = function ( progress ) {
8954 this.progress = progress;
8955
8956 if ( progress !== false ) {
8957 this.$bar.css( 'width', this.progress + '%' );
8958 this.$element.attr( 'aria-valuenow', this.progress );
8959 } else {
8960 this.$bar.css( 'width', '' );
8961 this.$element.removeAttr( 'aria-valuenow' );
8962 }
8963 this.$element.toggleClass( 'oo-ui-progressBarWidget-indeterminate', progress === false );
8964 };
8965
8966 /**
8967 * InputWidget is the base class for all input widgets, which
8968 * include {@link OO.ui.TextInputWidget text inputs}, {@link OO.ui.CheckboxInputWidget checkbox
8969 * inputs}, {@link OO.ui.RadioInputWidget radio inputs}, and
8970 * {@link OO.ui.ButtonInputWidget button inputs}.
8971 * See the [OOUI documentation on MediaWiki] [1] for more information and examples.
8972 *
8973 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Inputs
8974 *
8975 * @abstract
8976 * @class
8977 * @extends OO.ui.Widget
8978 * @mixins OO.ui.mixin.TabIndexedElement
8979 * @mixins OO.ui.mixin.TitledElement
8980 * @mixins OO.ui.mixin.AccessKeyedElement
8981 *
8982 * @constructor
8983 * @param {Object} [config] Configuration options
8984 * @cfg {string} [name=''] The value of the input’s HTML `name` attribute.
8985 * @cfg {string} [value=''] The value of the input.
8986 * @cfg {string} [dir] The directionality of the input (ltr/rtl).
8987 * @cfg {string} [inputId] The value of the input’s HTML `id` attribute.
8988 * @cfg {Function} [inputFilter] The name of an input filter function. Input filters modify the
8989 * value of an input before it is accepted.
8990 */
8991 OO.ui.InputWidget = function OoUiInputWidget( config ) {
8992 // Configuration initialization
8993 config = config || {};
8994
8995 // Parent constructor
8996 OO.ui.InputWidget.parent.call( this, config );
8997
8998 // Properties
8999 // See #reusePreInfuseDOM about config.$input
9000 this.$input = config.$input || this.getInputElement( config );
9001 this.value = '';
9002 this.inputFilter = config.inputFilter;
9003
9004 // Mixin constructors
9005 OO.ui.mixin.TabIndexedElement.call( this, $.extend( {
9006 $tabIndexed: this.$input
9007 }, config ) );
9008 OO.ui.mixin.TitledElement.call( this, $.extend( {
9009 $titled: this.$input
9010 }, config ) );
9011 OO.ui.mixin.AccessKeyedElement.call( this, $.extend( {
9012 $accessKeyed: this.$input
9013 }, config ) );
9014
9015 // Events
9016 this.$input.on( 'keydown mouseup cut paste change input select', this.onEdit.bind( this ) );
9017
9018 // Initialization
9019 this.$input
9020 .addClass( 'oo-ui-inputWidget-input' )
9021 .attr( 'name', config.name )
9022 .prop( 'disabled', this.isDisabled() );
9023 this.$element
9024 .addClass( 'oo-ui-inputWidget' )
9025 .append( this.$input );
9026 this.setValue( config.value );
9027 if ( config.dir ) {
9028 this.setDir( config.dir );
9029 }
9030 if ( config.inputId !== undefined ) {
9031 this.setInputId( config.inputId );
9032 }
9033 };
9034
9035 /* Setup */
9036
9037 OO.inheritClass( OO.ui.InputWidget, OO.ui.Widget );
9038 OO.mixinClass( OO.ui.InputWidget, OO.ui.mixin.TabIndexedElement );
9039 OO.mixinClass( OO.ui.InputWidget, OO.ui.mixin.TitledElement );
9040 OO.mixinClass( OO.ui.InputWidget, OO.ui.mixin.AccessKeyedElement );
9041
9042 /* Static Methods */
9043
9044 /**
9045 * @inheritdoc
9046 */
9047 OO.ui.InputWidget.static.reusePreInfuseDOM = function ( node, config ) {
9048 config = OO.ui.InputWidget.parent.static.reusePreInfuseDOM( node, config );
9049 // Reusing `$input` lets browsers preserve inputted values across page reloads, see T114134.
9050 config.$input = $( node ).find( '.oo-ui-inputWidget-input' );
9051 return config;
9052 };
9053
9054 /**
9055 * @inheritdoc
9056 */
9057 OO.ui.InputWidget.static.gatherPreInfuseState = function ( node, config ) {
9058 var state = OO.ui.InputWidget.parent.static.gatherPreInfuseState( node, config );
9059 if ( config.$input && config.$input.length ) {
9060 state.value = config.$input.val();
9061 // Might be better in TabIndexedElement, but it's awkward to do there because
9062 // mixins are awkward
9063 state.focus = config.$input.is( ':focus' );
9064 }
9065 return state;
9066 };
9067
9068 /* Events */
9069
9070 /**
9071 * @event change
9072 *
9073 * A change event is emitted when the value of the input changes.
9074 *
9075 * @param {string} value
9076 */
9077
9078 /* Methods */
9079
9080 /**
9081 * Get input element.
9082 *
9083 * Subclasses of OO.ui.InputWidget use the `config` parameter to produce different elements in
9084 * different circumstances. The element must have a `value` property (like form elements).
9085 *
9086 * @protected
9087 * @param {Object} config Configuration options
9088 * @return {jQuery} Input element
9089 */
9090 OO.ui.InputWidget.prototype.getInputElement = function () {
9091 return $( '<input>' );
9092 };
9093
9094 /**
9095 * Handle potentially value-changing events.
9096 *
9097 * @private
9098 * @param {jQuery.Event} e Key down, mouse up, cut, paste, change, input, or select event
9099 */
9100 OO.ui.InputWidget.prototype.onEdit = function () {
9101 var widget = this;
9102 if ( !this.isDisabled() ) {
9103 // Allow the stack to clear so the value will be updated
9104 setTimeout( function () {
9105 widget.setValue( widget.$input.val() );
9106 } );
9107 }
9108 };
9109
9110 /**
9111 * Get the value of the input.
9112 *
9113 * @return {string} Input value
9114 */
9115 OO.ui.InputWidget.prototype.getValue = function () {
9116 // Resynchronize our internal data with DOM data. Other scripts executing on the page can modify
9117 // it, and we won't know unless they're kind enough to trigger a 'change' event.
9118 var value = this.$input.val();
9119 if ( this.value !== value ) {
9120 this.setValue( value );
9121 }
9122 return this.value;
9123 };
9124
9125 /**
9126 * Set the directionality of the input.
9127 *
9128 * @param {string} dir Text directionality: 'ltr', 'rtl' or 'auto'
9129 * @chainable
9130 * @return {OO.ui.Widget} The widget, for chaining
9131 */
9132 OO.ui.InputWidget.prototype.setDir = function ( dir ) {
9133 this.$input.prop( 'dir', dir );
9134 return this;
9135 };
9136
9137 /**
9138 * Set the value of the input.
9139 *
9140 * @param {string} value New value
9141 * @fires change
9142 * @chainable
9143 * @return {OO.ui.Widget} The widget, for chaining
9144 */
9145 OO.ui.InputWidget.prototype.setValue = function ( value ) {
9146 value = this.cleanUpValue( value );
9147 // Update the DOM if it has changed. Note that with cleanUpValue, it
9148 // is possible for the DOM value to change without this.value changing.
9149 if ( this.$input.val() !== value ) {
9150 this.$input.val( value );
9151 }
9152 if ( this.value !== value ) {
9153 this.value = value;
9154 this.emit( 'change', this.value );
9155 }
9156 // The first time that the value is set (probably while constructing the widget),
9157 // remember it in defaultValue. This property can be later used to check whether
9158 // the value of the input has been changed since it was created.
9159 if ( this.defaultValue === undefined ) {
9160 this.defaultValue = this.value;
9161 this.$input[ 0 ].defaultValue = this.defaultValue;
9162 }
9163 return this;
9164 };
9165
9166 /**
9167 * Clean up incoming value.
9168 *
9169 * Ensures value is a string, and converts undefined and null to empty string.
9170 *
9171 * @private
9172 * @param {string} value Original value
9173 * @return {string} Cleaned up value
9174 */
9175 OO.ui.InputWidget.prototype.cleanUpValue = function ( value ) {
9176 if ( value === undefined || value === null ) {
9177 return '';
9178 } else if ( this.inputFilter ) {
9179 return this.inputFilter( String( value ) );
9180 } else {
9181 return String( value );
9182 }
9183 };
9184
9185 /**
9186 * @inheritdoc
9187 */
9188 OO.ui.InputWidget.prototype.setDisabled = function ( state ) {
9189 OO.ui.InputWidget.parent.prototype.setDisabled.call( this, state );
9190 if ( this.$input ) {
9191 this.$input.prop( 'disabled', this.isDisabled() );
9192 }
9193 return this;
9194 };
9195
9196 /**
9197 * Set the 'id' attribute of the `<input>` element.
9198 *
9199 * @param {string} id
9200 * @chainable
9201 * @return {OO.ui.Widget} The widget, for chaining
9202 */
9203 OO.ui.InputWidget.prototype.setInputId = function ( id ) {
9204 this.$input.attr( 'id', id );
9205 return this;
9206 };
9207
9208 /**
9209 * @inheritdoc
9210 */
9211 OO.ui.InputWidget.prototype.restorePreInfuseState = function ( state ) {
9212 OO.ui.InputWidget.parent.prototype.restorePreInfuseState.call( this, state );
9213 if ( state.value !== undefined && state.value !== this.getValue() ) {
9214 this.setValue( state.value );
9215 }
9216 if ( state.focus ) {
9217 this.focus();
9218 }
9219 };
9220
9221 /**
9222 * Data widget intended for creating `<input type="hidden">` inputs.
9223 *
9224 * @class
9225 * @extends OO.ui.Widget
9226 *
9227 * @constructor
9228 * @param {Object} [config] Configuration options
9229 * @cfg {string} [value=''] The value of the input.
9230 * @cfg {string} [name=''] The value of the input’s HTML `name` attribute.
9231 */
9232 OO.ui.HiddenInputWidget = function OoUiHiddenInputWidget( config ) {
9233 // Configuration initialization
9234 config = $.extend( { value: '', name: '' }, config );
9235
9236 // Parent constructor
9237 OO.ui.HiddenInputWidget.parent.call( this, config );
9238
9239 // Initialization
9240 this.$element.attr( {
9241 type: 'hidden',
9242 value: config.value,
9243 name: config.name
9244 } );
9245 this.$element.removeAttr( 'aria-disabled' );
9246 };
9247
9248 /* Setup */
9249
9250 OO.inheritClass( OO.ui.HiddenInputWidget, OO.ui.Widget );
9251
9252 /* Static Properties */
9253
9254 /**
9255 * @static
9256 * @inheritdoc
9257 */
9258 OO.ui.HiddenInputWidget.static.tagName = 'input';
9259
9260 /**
9261 * ButtonInputWidget is used to submit HTML forms and is intended to be used within
9262 * a OO.ui.FormLayout. If you do not need the button to work with HTML forms, you probably
9263 * want to use OO.ui.ButtonWidget instead. Button input widgets can be rendered as either an
9264 * HTML `<button>` (the default) or an HTML `<input>` tags. See the
9265 * [OOUI documentation on MediaWiki] [1] for more information.
9266 *
9267 * @example
9268 * // A ButtonInputWidget rendered as an HTML button, the default.
9269 * var button = new OO.ui.ButtonInputWidget( {
9270 * label: 'Input button',
9271 * icon: 'check',
9272 * value: 'check'
9273 * } );
9274 * $( document.body ).append( button.$element );
9275 *
9276 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Inputs#Button_inputs
9277 *
9278 * @class
9279 * @extends OO.ui.InputWidget
9280 * @mixins OO.ui.mixin.ButtonElement
9281 * @mixins OO.ui.mixin.IconElement
9282 * @mixins OO.ui.mixin.IndicatorElement
9283 * @mixins OO.ui.mixin.LabelElement
9284 * @mixins OO.ui.mixin.FlaggedElement
9285 *
9286 * @constructor
9287 * @param {Object} [config] Configuration options
9288 * @cfg {string} [type='button'] The value of the HTML `'type'` attribute:
9289 * 'button', 'submit' or 'reset'.
9290 * @cfg {boolean} [useInputTag=false] Use an `<input>` tag instead of a `<button>` tag, the default.
9291 * Widgets configured to be an `<input>` do not support {@link #icon icons} and
9292 * {@link #indicator indicators},
9293 * non-plaintext {@link #label labels}, or {@link #value values}. In general, useInputTag should
9294 * only be set to `true` when there’s need to support IE 6 in a form with multiple buttons.
9295 */
9296 OO.ui.ButtonInputWidget = function OoUiButtonInputWidget( config ) {
9297 // Configuration initialization
9298 config = $.extend( { type: 'button', useInputTag: false }, config );
9299
9300 // See InputWidget#reusePreInfuseDOM about config.$input
9301 if ( config.$input ) {
9302 config.$input.empty();
9303 }
9304
9305 // Properties (must be set before parent constructor, which calls #setValue)
9306 this.useInputTag = config.useInputTag;
9307
9308 // Parent constructor
9309 OO.ui.ButtonInputWidget.parent.call( this, config );
9310
9311 // Mixin constructors
9312 OO.ui.mixin.ButtonElement.call( this, $.extend( {
9313 $button: this.$input
9314 }, config ) );
9315 OO.ui.mixin.IconElement.call( this, config );
9316 OO.ui.mixin.IndicatorElement.call( this, config );
9317 OO.ui.mixin.LabelElement.call( this, config );
9318 OO.ui.mixin.FlaggedElement.call( this, config );
9319
9320 // Initialization
9321 if ( !config.useInputTag ) {
9322 this.$input.append( this.$icon, this.$label, this.$indicator );
9323 }
9324 this.$element.addClass( 'oo-ui-buttonInputWidget' );
9325 };
9326
9327 /* Setup */
9328
9329 OO.inheritClass( OO.ui.ButtonInputWidget, OO.ui.InputWidget );
9330 OO.mixinClass( OO.ui.ButtonInputWidget, OO.ui.mixin.ButtonElement );
9331 OO.mixinClass( OO.ui.ButtonInputWidget, OO.ui.mixin.IconElement );
9332 OO.mixinClass( OO.ui.ButtonInputWidget, OO.ui.mixin.IndicatorElement );
9333 OO.mixinClass( OO.ui.ButtonInputWidget, OO.ui.mixin.LabelElement );
9334 OO.mixinClass( OO.ui.ButtonInputWidget, OO.ui.mixin.FlaggedElement );
9335
9336 /* Static Properties */
9337
9338 /**
9339 * @static
9340 * @inheritdoc
9341 */
9342 OO.ui.ButtonInputWidget.static.tagName = 'span';
9343
9344 /* Methods */
9345
9346 /**
9347 * @inheritdoc
9348 * @protected
9349 */
9350 OO.ui.ButtonInputWidget.prototype.getInputElement = function ( config ) {
9351 var type;
9352 type = [ 'button', 'submit', 'reset' ].indexOf( config.type ) !== -1 ? config.type : 'button';
9353 return $( '<' + ( config.useInputTag ? 'input' : 'button' ) + ' type="' + type + '">' );
9354 };
9355
9356 /**
9357 * Set label value.
9358 *
9359 * If #useInputTag is `true`, the label is set as the `value` of the `<input>` tag.
9360 *
9361 * @param {jQuery|string|Function|null} label Label nodes, text, a function that returns nodes or
9362 * text, or `null` for no label
9363 * @chainable
9364 * @return {OO.ui.Widget} The widget, for chaining
9365 */
9366 OO.ui.ButtonInputWidget.prototype.setLabel = function ( label ) {
9367 if ( typeof label === 'function' ) {
9368 label = OO.ui.resolveMsg( label );
9369 }
9370
9371 if ( this.useInputTag ) {
9372 // Discard non-plaintext labels
9373 if ( typeof label !== 'string' ) {
9374 label = '';
9375 }
9376
9377 this.$input.val( label );
9378 }
9379
9380 return OO.ui.mixin.LabelElement.prototype.setLabel.call( this, label );
9381 };
9382
9383 /**
9384 * Set the value of the input.
9385 *
9386 * This method is disabled for button inputs configured as {@link #useInputTag <input> tags}, as
9387 * they do not support {@link #value values}.
9388 *
9389 * @param {string} value New value
9390 * @chainable
9391 * @return {OO.ui.Widget} The widget, for chaining
9392 */
9393 OO.ui.ButtonInputWidget.prototype.setValue = function ( value ) {
9394 if ( !this.useInputTag ) {
9395 OO.ui.ButtonInputWidget.parent.prototype.setValue.call( this, value );
9396 }
9397 return this;
9398 };
9399
9400 /**
9401 * @inheritdoc
9402 */
9403 OO.ui.ButtonInputWidget.prototype.getInputId = function () {
9404 // Disable generating `<label>` elements for buttons. One would very rarely need additional
9405 // label for a button, and it's already a big clickable target, and it causes
9406 // unexpected rendering.
9407 return null;
9408 };
9409
9410 /**
9411 * CheckboxInputWidgets, like HTML checkboxes, can be selected and/or configured with a value.
9412 * Note that these {@link OO.ui.InputWidget input widgets} are best laid out
9413 * in {@link OO.ui.FieldLayout field layouts} that use the {@link OO.ui.FieldLayout#align inline}
9414 * alignment. For more information, please see the [OOUI documentation on MediaWiki][1].
9415 *
9416 * This widget can be used inside an HTML form, such as a OO.ui.FormLayout.
9417 *
9418 * @example
9419 * // An example of selected, unselected, and disabled checkbox inputs.
9420 * var checkbox1 = new OO.ui.CheckboxInputWidget( {
9421 * value: 'a',
9422 * selected: true
9423 * } ),
9424 * checkbox2 = new OO.ui.CheckboxInputWidget( {
9425 * value: 'b'
9426 * } ),
9427 * checkbox3 = new OO.ui.CheckboxInputWidget( {
9428 * value:'c',
9429 * disabled: true
9430 * } ),
9431 * // Create a fieldset layout with fields for each checkbox.
9432 * fieldset = new OO.ui.FieldsetLayout( {
9433 * label: 'Checkboxes'
9434 * } );
9435 * fieldset.addItems( [
9436 * new OO.ui.FieldLayout( checkbox1, { label: 'Selected checkbox', align: 'inline' } ),
9437 * new OO.ui.FieldLayout( checkbox2, { label: 'Unselected checkbox', align: 'inline' } ),
9438 * new OO.ui.FieldLayout( checkbox3, { label: 'Disabled checkbox', align: 'inline' } ),
9439 * ] );
9440 * $( document.body ).append( fieldset.$element );
9441 *
9442 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Inputs
9443 *
9444 * @class
9445 * @extends OO.ui.InputWidget
9446 *
9447 * @constructor
9448 * @param {Object} [config] Configuration options
9449 * @cfg {boolean} [selected=false] Select the checkbox initially. By default, the checkbox is
9450 * not selected.
9451 * @cfg {boolean} [indeterminate=false] Whether the checkbox is in the indeterminate state.
9452 */
9453 OO.ui.CheckboxInputWidget = function OoUiCheckboxInputWidget( config ) {
9454 // Configuration initialization
9455 config = config || {};
9456
9457 // Parent constructor
9458 OO.ui.CheckboxInputWidget.parent.call( this, config );
9459
9460 // Properties
9461 this.checkIcon = new OO.ui.IconWidget( {
9462 icon: 'check',
9463 classes: [ 'oo-ui-checkboxInputWidget-checkIcon' ]
9464 } );
9465
9466 // Initialization
9467 this.$element
9468 .addClass( 'oo-ui-checkboxInputWidget' )
9469 // Required for pretty styling in WikimediaUI theme
9470 .append( this.checkIcon.$element );
9471 this.setSelected( config.selected !== undefined ? config.selected : false );
9472 this.setIndeterminate( config.indeterminate !== undefined ? config.indeterminate : false );
9473 };
9474
9475 /* Setup */
9476
9477 OO.inheritClass( OO.ui.CheckboxInputWidget, OO.ui.InputWidget );
9478
9479 /* Events */
9480
9481 /**
9482 * @event change
9483 *
9484 * A change event is emitted when the state of the input changes.
9485 *
9486 * @param {boolean} selected
9487 * @param {boolean} indeterminate
9488 */
9489
9490 /* Static Properties */
9491
9492 /**
9493 * @static
9494 * @inheritdoc
9495 */
9496 OO.ui.CheckboxInputWidget.static.tagName = 'span';
9497
9498 /* Static Methods */
9499
9500 /**
9501 * @inheritdoc
9502 */
9503 OO.ui.CheckboxInputWidget.static.gatherPreInfuseState = function ( node, config ) {
9504 var state = OO.ui.CheckboxInputWidget.parent.static.gatherPreInfuseState( node, config );
9505 state.checked = config.$input.prop( 'checked' );
9506 return state;
9507 };
9508
9509 /* Methods */
9510
9511 /**
9512 * @inheritdoc
9513 * @protected
9514 */
9515 OO.ui.CheckboxInputWidget.prototype.getInputElement = function () {
9516 return $( '<input>' ).attr( 'type', 'checkbox' );
9517 };
9518
9519 /**
9520 * @inheritdoc
9521 */
9522 OO.ui.CheckboxInputWidget.prototype.onEdit = function () {
9523 var widget = this;
9524 if ( !this.isDisabled() ) {
9525 // Allow the stack to clear so the value will be updated
9526 setTimeout( function () {
9527 widget.setSelected( widget.$input.prop( 'checked' ) );
9528 widget.setIndeterminate( widget.$input.prop( 'indeterminate' ) );
9529 } );
9530 }
9531 };
9532
9533 /**
9534 * Set selection state of this checkbox.
9535 *
9536 * @param {boolean} state Selected state
9537 * @param {boolean} internal Used for internal calls to suppress events
9538 * @chainable
9539 * @return {OO.ui.CheckboxInputWidget} The widget, for chaining
9540 */
9541 OO.ui.CheckboxInputWidget.prototype.setSelected = function ( state, internal ) {
9542 state = !!state;
9543 if ( this.selected !== state ) {
9544 this.selected = state;
9545 this.$input.prop( 'checked', this.selected );
9546 if ( !internal ) {
9547 this.setIndeterminate( false, true );
9548 this.emit( 'change', this.selected, this.indeterminate );
9549 }
9550 }
9551 // The first time that the selection state is set (probably while constructing the widget),
9552 // remember it in defaultSelected. This property can be later used to check whether
9553 // the selection state of the input has been changed since it was created.
9554 if ( this.defaultSelected === undefined ) {
9555 this.defaultSelected = this.selected;
9556 this.$input[ 0 ].defaultChecked = this.defaultSelected;
9557 }
9558 return this;
9559 };
9560
9561 /**
9562 * Check if this checkbox is selected.
9563 *
9564 * @return {boolean} Checkbox is selected
9565 */
9566 OO.ui.CheckboxInputWidget.prototype.isSelected = function () {
9567 // Resynchronize our internal data with DOM data. Other scripts executing on the page can modify
9568 // it, and we won't know unless they're kind enough to trigger a 'change' event.
9569 var selected = this.$input.prop( 'checked' );
9570 if ( this.selected !== selected ) {
9571 this.setSelected( selected );
9572 }
9573 return this.selected;
9574 };
9575
9576 /**
9577 * Set indeterminate state of this checkbox.
9578 *
9579 * @param {boolean} state Indeterminate state
9580 * @param {boolean} internal Used for internal calls to suppress events
9581 * @chainable
9582 * @return {OO.ui.CheckboxInputWidget} The widget, for chaining
9583 */
9584 OO.ui.CheckboxInputWidget.prototype.setIndeterminate = function ( state, internal ) {
9585 state = !!state;
9586 if ( this.indeterminate !== state ) {
9587 this.indeterminate = state;
9588 this.$input.prop( 'indeterminate', this.indeterminate );
9589 if ( !internal ) {
9590 this.setSelected( false, true );
9591 this.emit( 'change', this.selected, this.indeterminate );
9592 }
9593 }
9594 return this;
9595 };
9596
9597 /**
9598 * Check if this checkbox is selected.
9599 *
9600 * @return {boolean} Checkbox is selected
9601 */
9602 OO.ui.CheckboxInputWidget.prototype.isIndeterminate = function () {
9603 // Resynchronize our internal data with DOM data. Other scripts executing on the page can modify
9604 // it, and we won't know unless they're kind enough to trigger a 'change' event.
9605 var indeterminate = this.$input.prop( 'indeterminate' );
9606 if ( this.indeterminate !== indeterminate ) {
9607 this.setIndeterminate( indeterminate );
9608 }
9609 return this.indeterminate;
9610 };
9611
9612 /**
9613 * @inheritdoc
9614 */
9615 OO.ui.CheckboxInputWidget.prototype.simulateLabelClick = function () {
9616 if ( !this.isDisabled() ) {
9617 this.$handle.trigger( 'click' );
9618 }
9619 this.focus();
9620 };
9621
9622 /**
9623 * @inheritdoc
9624 */
9625 OO.ui.CheckboxInputWidget.prototype.restorePreInfuseState = function ( state ) {
9626 OO.ui.CheckboxInputWidget.parent.prototype.restorePreInfuseState.call( this, state );
9627 if ( state.checked !== undefined && state.checked !== this.isSelected() ) {
9628 this.setSelected( state.checked );
9629 }
9630 };
9631
9632 /**
9633 * DropdownInputWidget is a {@link OO.ui.DropdownWidget DropdownWidget} intended to be used
9634 * within an HTML form, such as a OO.ui.FormLayout. The selected value is synchronized with the
9635 * value of a hidden HTML `input` tag. Please see the [OOUI documentation on MediaWiki][1] for
9636 * more information about input widgets.
9637 *
9638 * A DropdownInputWidget always has a value (one of the options is always selected), unless there
9639 * are no options. If no `value` configuration option is provided, the first option is selected.
9640 * If you need a state representing no value (no option being selected), use a DropdownWidget.
9641 *
9642 * This and OO.ui.RadioSelectInputWidget support similar configuration options.
9643 *
9644 * @example
9645 * // A DropdownInputWidget with three options.
9646 * var dropdownInput = new OO.ui.DropdownInputWidget( {
9647 * options: [
9648 * { data: 'a', label: 'First' },
9649 * { data: 'b', label: 'Second', disabled: true },
9650 * { optgroup: 'Group label' },
9651 * { data: 'c', label: 'First sub-item)' }
9652 * ]
9653 * } );
9654 * $( document.body ).append( dropdownInput.$element );
9655 *
9656 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Inputs
9657 *
9658 * @class
9659 * @extends OO.ui.InputWidget
9660 *
9661 * @constructor
9662 * @param {Object} [config] Configuration options
9663 * @cfg {Object[]} [options=[]] Array of menu options in the format described above.
9664 * @cfg {Object} [dropdown] Configuration options for {@link OO.ui.DropdownWidget DropdownWidget}
9665 * @cfg {jQuery} [$overlay] Render the menu into a separate layer. This configuration is useful
9666 * in cases where the expanded menu is larger than its containing `<div>`. The specified overlay
9667 * layer is usually on top of the containing `<div>` and has a larger area. By default, the menu
9668 * uses relative positioning.
9669 * See <https://www.mediawiki.org/wiki/OOUI/Concepts#Overlays>.
9670 */
9671 OO.ui.DropdownInputWidget = function OoUiDropdownInputWidget( config ) {
9672 // Configuration initialization
9673 config = config || {};
9674
9675 // Properties (must be done before parent constructor which calls #setDisabled)
9676 this.dropdownWidget = new OO.ui.DropdownWidget( $.extend(
9677 {
9678 $overlay: config.$overlay
9679 },
9680 config.dropdown
9681 ) );
9682 // Set up the options before parent constructor, which uses them to validate config.value.
9683 // Use this instead of setOptions() because this.$input is not set up yet.
9684 this.setOptionsData( config.options || [] );
9685
9686 // Parent constructor
9687 OO.ui.DropdownInputWidget.parent.call( this, config );
9688
9689 // Events
9690 this.dropdownWidget.getMenu().connect( this, {
9691 select: 'onMenuSelect'
9692 } );
9693
9694 // Initialization
9695 this.$element
9696 .addClass( 'oo-ui-dropdownInputWidget' )
9697 .append( this.dropdownWidget.$element );
9698 this.setTabIndexedElement( this.dropdownWidget.$tabIndexed );
9699 this.setTitledElement( this.dropdownWidget.$handle );
9700 };
9701
9702 /* Setup */
9703
9704 OO.inheritClass( OO.ui.DropdownInputWidget, OO.ui.InputWidget );
9705
9706 /* Methods */
9707
9708 /**
9709 * @inheritdoc
9710 * @protected
9711 */
9712 OO.ui.DropdownInputWidget.prototype.getInputElement = function () {
9713 return $( '<select>' );
9714 };
9715
9716 /**
9717 * Handles menu select events.
9718 *
9719 * @private
9720 * @param {OO.ui.MenuOptionWidget|null} item Selected menu item
9721 */
9722 OO.ui.DropdownInputWidget.prototype.onMenuSelect = function ( item ) {
9723 this.setValue( item ? item.getData() : '' );
9724 };
9725
9726 /**
9727 * @inheritdoc
9728 */
9729 OO.ui.DropdownInputWidget.prototype.setValue = function ( value ) {
9730 var selected;
9731 value = this.cleanUpValue( value );
9732 // Only allow setting values that are actually present in the dropdown
9733 selected = this.dropdownWidget.getMenu().findItemFromData( value ) ||
9734 this.dropdownWidget.getMenu().findFirstSelectableItem();
9735 this.dropdownWidget.getMenu().selectItem( selected );
9736 value = selected ? selected.getData() : '';
9737 OO.ui.DropdownInputWidget.parent.prototype.setValue.call( this, value );
9738 if ( this.optionsDirty ) {
9739 // We reached this from the constructor or from #setOptions.
9740 // We have to update the <select> element.
9741 this.updateOptionsInterface();
9742 }
9743 return this;
9744 };
9745
9746 /**
9747 * @inheritdoc
9748 */
9749 OO.ui.DropdownInputWidget.prototype.setDisabled = function ( state ) {
9750 this.dropdownWidget.setDisabled( state );
9751 OO.ui.DropdownInputWidget.parent.prototype.setDisabled.call( this, state );
9752 return this;
9753 };
9754
9755 /**
9756 * Set the options available for this input.
9757 *
9758 * @param {Object[]} options Array of menu options in the format `{ data: …, label: … }`
9759 * @chainable
9760 * @return {OO.ui.Widget} The widget, for chaining
9761 */
9762 OO.ui.DropdownInputWidget.prototype.setOptions = function ( options ) {
9763 var value = this.getValue();
9764
9765 this.setOptionsData( options );
9766
9767 // Re-set the value to update the visible interface (DropdownWidget and <select>).
9768 // In case the previous value is no longer an available option, select the first valid one.
9769 this.setValue( value );
9770
9771 return this;
9772 };
9773
9774 /**
9775 * Set the internal list of options, used e.g. by setValue() to see which options are allowed.
9776 *
9777 * This method may be called before the parent constructor, so various properties may not be
9778 * initialized yet.
9779 *
9780 * @param {Object[]} options Array of menu options (see #constructor for details).
9781 * @private
9782 */
9783 OO.ui.DropdownInputWidget.prototype.setOptionsData = function ( options ) {
9784 var optionWidgets, optIndex, opt, previousOptgroup, optionWidget, optValue,
9785 widget = this;
9786
9787 this.optionsDirty = true;
9788
9789 // Go through all the supplied option configs and create either
9790 // MenuSectionOption or MenuOption widgets from each.
9791 optionWidgets = [];
9792 for ( optIndex = 0; optIndex < options.length; optIndex++ ) {
9793 opt = options[ optIndex ];
9794
9795 if ( opt.optgroup !== undefined ) {
9796 // Create a <optgroup> menu item.
9797 optionWidget = widget.createMenuSectionOptionWidget( opt.optgroup );
9798 previousOptgroup = optionWidget;
9799
9800 } else {
9801 // Create a normal <option> menu item.
9802 optValue = widget.cleanUpValue( opt.data );
9803 optionWidget = widget.createMenuOptionWidget(
9804 optValue,
9805 opt.label !== undefined ? opt.label : optValue
9806 );
9807 }
9808
9809 // Disable the menu option if it is itself disabled or if its parent optgroup is disabled.
9810 if (
9811 opt.disabled !== undefined ||
9812 previousOptgroup instanceof OO.ui.MenuSectionOptionWidget &&
9813 previousOptgroup.isDisabled()
9814 ) {
9815 optionWidget.setDisabled( true );
9816 }
9817
9818 optionWidgets.push( optionWidget );
9819 }
9820
9821 this.dropdownWidget.getMenu().clearItems().addItems( optionWidgets );
9822 };
9823
9824 /**
9825 * Create a menu option widget.
9826 *
9827 * @protected
9828 * @param {string} data Item data
9829 * @param {string} label Item label
9830 * @return {OO.ui.MenuOptionWidget} Option widget
9831 */
9832 OO.ui.DropdownInputWidget.prototype.createMenuOptionWidget = function ( data, label ) {
9833 return new OO.ui.MenuOptionWidget( {
9834 data: data,
9835 label: label
9836 } );
9837 };
9838
9839 /**
9840 * Create a menu section option widget.
9841 *
9842 * @protected
9843 * @param {string} label Section item label
9844 * @return {OO.ui.MenuSectionOptionWidget} Menu section option widget
9845 */
9846 OO.ui.DropdownInputWidget.prototype.createMenuSectionOptionWidget = function ( label ) {
9847 return new OO.ui.MenuSectionOptionWidget( {
9848 label: label
9849 } );
9850 };
9851
9852 /**
9853 * Update the user-visible interface to match the internal list of options and value.
9854 *
9855 * This method must only be called after the parent constructor.
9856 *
9857 * @private
9858 */
9859 OO.ui.DropdownInputWidget.prototype.updateOptionsInterface = function () {
9860 var
9861 $optionsContainer = this.$input,
9862 defaultValue = this.defaultValue,
9863 widget = this;
9864
9865 this.$input.empty();
9866
9867 this.dropdownWidget.getMenu().getItems().forEach( function ( optionWidget ) {
9868 var $optionNode;
9869
9870 if ( !( optionWidget instanceof OO.ui.MenuSectionOptionWidget ) ) {
9871 $optionNode = $( '<option>' )
9872 .attr( 'value', optionWidget.getData() )
9873 .text( optionWidget.getLabel() );
9874
9875 // Remember original selection state. This property can be later used to check whether
9876 // the selection state of the input has been changed since it was created.
9877 $optionNode[ 0 ].defaultSelected = ( optionWidget.getData() === defaultValue );
9878
9879 $optionsContainer.append( $optionNode );
9880 } else {
9881 $optionNode = $( '<optgroup>' )
9882 .attr( 'label', optionWidget.getLabel() );
9883 widget.$input.append( $optionNode );
9884 $optionsContainer = $optionNode;
9885 }
9886
9887 // Disable the option or optgroup if required.
9888 if ( optionWidget.isDisabled() ) {
9889 $optionNode.prop( 'disabled', true );
9890 }
9891 } );
9892
9893 this.optionsDirty = false;
9894 };
9895
9896 /**
9897 * @inheritdoc
9898 */
9899 OO.ui.DropdownInputWidget.prototype.focus = function () {
9900 this.dropdownWidget.focus();
9901 return this;
9902 };
9903
9904 /**
9905 * @inheritdoc
9906 */
9907 OO.ui.DropdownInputWidget.prototype.blur = function () {
9908 this.dropdownWidget.blur();
9909 return this;
9910 };
9911
9912 /**
9913 * RadioInputWidget creates a single radio button. Because radio buttons are usually used as a set,
9914 * in most cases you will want to use a {@link OO.ui.RadioSelectWidget radio select}
9915 * with {@link OO.ui.RadioOptionWidget radio options} instead of this class. For more information,
9916 * please see the [OOUI documentation on MediaWiki][1].
9917 *
9918 * This widget can be used inside an HTML form, such as a OO.ui.FormLayout.
9919 *
9920 * @example
9921 * // An example of selected, unselected, and disabled radio inputs
9922 * var radio1 = new OO.ui.RadioInputWidget( {
9923 * value: 'a',
9924 * selected: true
9925 * } );
9926 * var radio2 = new OO.ui.RadioInputWidget( {
9927 * value: 'b'
9928 * } );
9929 * var radio3 = new OO.ui.RadioInputWidget( {
9930 * value: 'c',
9931 * disabled: true
9932 * } );
9933 * // Create a fieldset layout with fields for each radio button.
9934 * var fieldset = new OO.ui.FieldsetLayout( {
9935 * label: 'Radio inputs'
9936 * } );
9937 * fieldset.addItems( [
9938 * new OO.ui.FieldLayout( radio1, { label: 'Selected', align: 'inline' } ),
9939 * new OO.ui.FieldLayout( radio2, { label: 'Unselected', align: 'inline' } ),
9940 * new OO.ui.FieldLayout( radio3, { label: 'Disabled', align: 'inline' } ),
9941 * ] );
9942 * $( document.body ).append( fieldset.$element );
9943 *
9944 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Inputs
9945 *
9946 * @class
9947 * @extends OO.ui.InputWidget
9948 *
9949 * @constructor
9950 * @param {Object} [config] Configuration options
9951 * @cfg {boolean} [selected=false] Select the radio button initially. By default, the radio button
9952 * is not selected.
9953 */
9954 OO.ui.RadioInputWidget = function OoUiRadioInputWidget( config ) {
9955 // Configuration initialization
9956 config = config || {};
9957
9958 // Parent constructor
9959 OO.ui.RadioInputWidget.parent.call( this, config );
9960
9961 // Initialization
9962 this.$element
9963 .addClass( 'oo-ui-radioInputWidget' )
9964 // Required for pretty styling in WikimediaUI theme
9965 .append( $( '<span>' ) );
9966 this.setSelected( config.selected !== undefined ? config.selected : false );
9967 };
9968
9969 /* Setup */
9970
9971 OO.inheritClass( OO.ui.RadioInputWidget, OO.ui.InputWidget );
9972
9973 /* Static Properties */
9974
9975 /**
9976 * @static
9977 * @inheritdoc
9978 */
9979 OO.ui.RadioInputWidget.static.tagName = 'span';
9980
9981 /* Static Methods */
9982
9983 /**
9984 * @inheritdoc
9985 */
9986 OO.ui.RadioInputWidget.static.gatherPreInfuseState = function ( node, config ) {
9987 var state = OO.ui.RadioInputWidget.parent.static.gatherPreInfuseState( node, config );
9988 state.checked = config.$input.prop( 'checked' );
9989 return state;
9990 };
9991
9992 /* Methods */
9993
9994 /**
9995 * @inheritdoc
9996 * @protected
9997 */
9998 OO.ui.RadioInputWidget.prototype.getInputElement = function () {
9999 return $( '<input>' ).attr( 'type', 'radio' );
10000 };
10001
10002 /**
10003 * @inheritdoc
10004 */
10005 OO.ui.RadioInputWidget.prototype.onEdit = function () {
10006 // RadioInputWidget doesn't track its state.
10007 };
10008
10009 /**
10010 * Set selection state of this radio button.
10011 *
10012 * @param {boolean} state `true` for selected
10013 * @chainable
10014 * @return {OO.ui.Widget} The widget, for chaining
10015 */
10016 OO.ui.RadioInputWidget.prototype.setSelected = function ( state ) {
10017 // RadioInputWidget doesn't track its state.
10018 this.$input.prop( 'checked', state );
10019 // The first time that the selection state is set (probably while constructing the widget),
10020 // remember it in defaultSelected. This property can be later used to check whether
10021 // the selection state of the input has been changed since it was created.
10022 if ( this.defaultSelected === undefined ) {
10023 this.defaultSelected = state;
10024 this.$input[ 0 ].defaultChecked = this.defaultSelected;
10025 }
10026 return this;
10027 };
10028
10029 /**
10030 * Check if this radio button is selected.
10031 *
10032 * @return {boolean} Radio is selected
10033 */
10034 OO.ui.RadioInputWidget.prototype.isSelected = function () {
10035 return this.$input.prop( 'checked' );
10036 };
10037
10038 /**
10039 * @inheritdoc
10040 */
10041 OO.ui.RadioInputWidget.prototype.simulateLabelClick = function () {
10042 if ( !this.isDisabled() ) {
10043 this.$input.trigger( 'click' );
10044 }
10045 this.focus();
10046 };
10047
10048 /**
10049 * @inheritdoc
10050 */
10051 OO.ui.RadioInputWidget.prototype.restorePreInfuseState = function ( state ) {
10052 OO.ui.RadioInputWidget.parent.prototype.restorePreInfuseState.call( this, state );
10053 if ( state.checked !== undefined && state.checked !== this.isSelected() ) {
10054 this.setSelected( state.checked );
10055 }
10056 };
10057
10058 /**
10059 * RadioSelectInputWidget is a {@link OO.ui.RadioSelectWidget RadioSelectWidget} intended to be
10060 * used within an HTML form, such as a OO.ui.FormLayout. The selected value is synchronized with
10061 * the value of a hidden HTML `input` tag. Please see the [OOUI documentation on MediaWiki][1] for
10062 * more information about input widgets.
10063 *
10064 * This and OO.ui.DropdownInputWidget support similar configuration options.
10065 *
10066 * @example
10067 * // A RadioSelectInputWidget with three options
10068 * var radioSelectInput = new OO.ui.RadioSelectInputWidget( {
10069 * options: [
10070 * { data: 'a', label: 'First' },
10071 * { data: 'b', label: 'Second'},
10072 * { data: 'c', label: 'Third' }
10073 * ]
10074 * } );
10075 * $( document.body ).append( radioSelectInput.$element );
10076 *
10077 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Inputs
10078 *
10079 * @class
10080 * @extends OO.ui.InputWidget
10081 *
10082 * @constructor
10083 * @param {Object} [config] Configuration options
10084 * @cfg {Object[]} [options=[]] Array of menu options in the format `{ data: …, label: … }`
10085 */
10086 OO.ui.RadioSelectInputWidget = function OoUiRadioSelectInputWidget( config ) {
10087 // Configuration initialization
10088 config = config || {};
10089
10090 // Properties (must be done before parent constructor which calls #setDisabled)
10091 this.radioSelectWidget = new OO.ui.RadioSelectWidget();
10092 // Set up the options before parent constructor, which uses them to validate config.value.
10093 // Use this instead of setOptions() because this.$input is not set up yet
10094 this.setOptionsData( config.options || [] );
10095
10096 // Parent constructor
10097 OO.ui.RadioSelectInputWidget.parent.call( this, config );
10098
10099 // Events
10100 this.radioSelectWidget.connect( this, {
10101 select: 'onMenuSelect'
10102 } );
10103
10104 // Initialization
10105 this.$element
10106 .addClass( 'oo-ui-radioSelectInputWidget' )
10107 .append( this.radioSelectWidget.$element );
10108 this.setTabIndexedElement( this.radioSelectWidget.$tabIndexed );
10109 };
10110
10111 /* Setup */
10112
10113 OO.inheritClass( OO.ui.RadioSelectInputWidget, OO.ui.InputWidget );
10114
10115 /* Static Methods */
10116
10117 /**
10118 * @inheritdoc
10119 */
10120 OO.ui.RadioSelectInputWidget.static.gatherPreInfuseState = function ( node, config ) {
10121 var state = OO.ui.RadioSelectInputWidget.parent.static.gatherPreInfuseState( node, config );
10122 state.value = $( node ).find( '.oo-ui-radioInputWidget .oo-ui-inputWidget-input:checked' ).val();
10123 return state;
10124 };
10125
10126 /**
10127 * @inheritdoc
10128 */
10129 OO.ui.RadioSelectInputWidget.static.reusePreInfuseDOM = function ( node, config ) {
10130 config = OO.ui.RadioSelectInputWidget.parent.static.reusePreInfuseDOM( node, config );
10131 // Cannot reuse the `<input type=radio>` set
10132 delete config.$input;
10133 return config;
10134 };
10135
10136 /* Methods */
10137
10138 /**
10139 * @inheritdoc
10140 * @protected
10141 */
10142 OO.ui.RadioSelectInputWidget.prototype.getInputElement = function () {
10143 // Use this instead of <input type="hidden">, because hidden inputs do not have separate
10144 // 'value' and 'defaultValue' properties, and InputWidget wants to handle 'defaultValue'.
10145 return $( '<input>' ).addClass( 'oo-ui-element-hidden' );
10146 };
10147
10148 /**
10149 * Handles menu select events.
10150 *
10151 * @private
10152 * @param {OO.ui.RadioOptionWidget} item Selected menu item
10153 */
10154 OO.ui.RadioSelectInputWidget.prototype.onMenuSelect = function ( item ) {
10155 this.setValue( item.getData() );
10156 };
10157
10158 /**
10159 * @inheritdoc
10160 */
10161 OO.ui.RadioSelectInputWidget.prototype.setValue = function ( value ) {
10162 var selected;
10163 value = this.cleanUpValue( value );
10164 // Only allow setting values that are actually present in the dropdown
10165 selected = this.radioSelectWidget.findItemFromData( value ) ||
10166 this.radioSelectWidget.findFirstSelectableItem();
10167 this.radioSelectWidget.selectItem( selected );
10168 value = selected ? selected.getData() : '';
10169 OO.ui.RadioSelectInputWidget.parent.prototype.setValue.call( this, value );
10170 return this;
10171 };
10172
10173 /**
10174 * @inheritdoc
10175 */
10176 OO.ui.RadioSelectInputWidget.prototype.setDisabled = function ( state ) {
10177 this.radioSelectWidget.setDisabled( state );
10178 OO.ui.RadioSelectInputWidget.parent.prototype.setDisabled.call( this, state );
10179 return this;
10180 };
10181
10182 /**
10183 * Set the options available for this input.
10184 *
10185 * @param {Object[]} options Array of menu options in the format `{ data: …, label: … }`
10186 * @chainable
10187 * @return {OO.ui.Widget} The widget, for chaining
10188 */
10189 OO.ui.RadioSelectInputWidget.prototype.setOptions = function ( options ) {
10190 var value = this.getValue();
10191
10192 this.setOptionsData( options );
10193
10194 // Re-set the value to update the visible interface (RadioSelectWidget).
10195 // In case the previous value is no longer an available option, select the first valid one.
10196 this.setValue( value );
10197
10198 return this;
10199 };
10200
10201 /**
10202 * Set the internal list of options, used e.g. by setValue() to see which options are allowed.
10203 *
10204 * This method may be called before the parent constructor, so various properties may not be
10205 * intialized yet.
10206 *
10207 * @param {Object[]} options Array of menu options in the format `{ data: …, label: … }`
10208 * @private
10209 */
10210 OO.ui.RadioSelectInputWidget.prototype.setOptionsData = function ( options ) {
10211 var widget = this;
10212
10213 this.radioSelectWidget
10214 .clearItems()
10215 .addItems( options.map( function ( opt ) {
10216 var optValue = widget.cleanUpValue( opt.data );
10217 return new OO.ui.RadioOptionWidget( {
10218 data: optValue,
10219 label: opt.label !== undefined ? opt.label : optValue
10220 } );
10221 } ) );
10222 };
10223
10224 /**
10225 * @inheritdoc
10226 */
10227 OO.ui.RadioSelectInputWidget.prototype.focus = function () {
10228 this.radioSelectWidget.focus();
10229 return this;
10230 };
10231
10232 /**
10233 * @inheritdoc
10234 */
10235 OO.ui.RadioSelectInputWidget.prototype.blur = function () {
10236 this.radioSelectWidget.blur();
10237 return this;
10238 };
10239
10240 /**
10241 * CheckboxMultiselectInputWidget is a
10242 * {@link OO.ui.CheckboxMultiselectWidget CheckboxMultiselectWidget} intended to be used within a
10243 * HTML form, such as a OO.ui.FormLayout. The selected values are synchronized with the value of
10244 * HTML `<input type=checkbox>` tags. Please see the [OOUI documentation on MediaWiki][1] for
10245 * more information about input widgets.
10246 *
10247 * @example
10248 * // A CheckboxMultiselectInputWidget with three options.
10249 * var multiselectInput = new OO.ui.CheckboxMultiselectInputWidget( {
10250 * options: [
10251 * { data: 'a', label: 'First' },
10252 * { data: 'b', label: 'Second' },
10253 * { data: 'c', label: 'Third' }
10254 * ]
10255 * } );
10256 * $( document.body ).append( multiselectInput.$element );
10257 *
10258 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Inputs
10259 *
10260 * @class
10261 * @extends OO.ui.InputWidget
10262 *
10263 * @constructor
10264 * @param {Object} [config] Configuration options
10265 * @cfg {Object[]} [options=[]] Array of menu options in the format
10266 * `{ data: …, label: …, disabled: … }`
10267 */
10268 OO.ui.CheckboxMultiselectInputWidget = function OoUiCheckboxMultiselectInputWidget( config ) {
10269 // Configuration initialization
10270 config = config || {};
10271
10272 // Properties (must be done before parent constructor which calls #setDisabled)
10273 this.checkboxMultiselectWidget = new OO.ui.CheckboxMultiselectWidget();
10274 // Must be set before the #setOptionsData call below
10275 this.inputName = config.name;
10276 // Set up the options before parent constructor, which uses them to validate config.value.
10277 // Use this instead of setOptions() because this.$input is not set up yet
10278 this.setOptionsData( config.options || [] );
10279
10280 // Parent constructor
10281 OO.ui.CheckboxMultiselectInputWidget.parent.call( this, config );
10282
10283 // Events
10284 this.checkboxMultiselectWidget.connect( this, {
10285 select: 'onCheckboxesSelect'
10286 } );
10287
10288 // Initialization
10289 this.$element
10290 .addClass( 'oo-ui-checkboxMultiselectInputWidget' )
10291 .append( this.checkboxMultiselectWidget.$element );
10292 // We don't use this.$input, but rather the CheckboxInputWidgets inside each option
10293 this.$input.detach();
10294 };
10295
10296 /* Setup */
10297
10298 OO.inheritClass( OO.ui.CheckboxMultiselectInputWidget, OO.ui.InputWidget );
10299
10300 /* Static Methods */
10301
10302 /**
10303 * @inheritdoc
10304 */
10305 OO.ui.CheckboxMultiselectInputWidget.static.gatherPreInfuseState = function ( node, config ) {
10306 var state = OO.ui.CheckboxMultiselectInputWidget.parent.static.gatherPreInfuseState(
10307 node, config
10308 );
10309 state.value = $( node ).find( '.oo-ui-checkboxInputWidget .oo-ui-inputWidget-input:checked' )
10310 .toArray().map( function ( el ) { return el.value; } );
10311 return state;
10312 };
10313
10314 /**
10315 * @inheritdoc
10316 */
10317 OO.ui.CheckboxMultiselectInputWidget.static.reusePreInfuseDOM = function ( node, config ) {
10318 config = OO.ui.CheckboxMultiselectInputWidget.parent.static.reusePreInfuseDOM( node, config );
10319 // Cannot reuse the `<input type=checkbox>` set
10320 delete config.$input;
10321 return config;
10322 };
10323
10324 /* Methods */
10325
10326 /**
10327 * @inheritdoc
10328 * @protected
10329 */
10330 OO.ui.CheckboxMultiselectInputWidget.prototype.getInputElement = function () {
10331 // Actually unused
10332 return $( '<unused>' );
10333 };
10334
10335 /**
10336 * Handles CheckboxMultiselectWidget select events.
10337 *
10338 * @private
10339 */
10340 OO.ui.CheckboxMultiselectInputWidget.prototype.onCheckboxesSelect = function () {
10341 this.setValue( this.checkboxMultiselectWidget.findSelectedItemsData() );
10342 };
10343
10344 /**
10345 * @inheritdoc
10346 */
10347 OO.ui.CheckboxMultiselectInputWidget.prototype.getValue = function () {
10348 var value = this.$element.find( '.oo-ui-checkboxInputWidget .oo-ui-inputWidget-input:checked' )
10349 .toArray().map( function ( el ) { return el.value; } );
10350 if ( this.value !== value ) {
10351 this.setValue( value );
10352 }
10353 return this.value;
10354 };
10355
10356 /**
10357 * @inheritdoc
10358 */
10359 OO.ui.CheckboxMultiselectInputWidget.prototype.setValue = function ( value ) {
10360 value = this.cleanUpValue( value );
10361 this.checkboxMultiselectWidget.selectItemsByData( value );
10362 OO.ui.CheckboxMultiselectInputWidget.parent.prototype.setValue.call( this, value );
10363 if ( this.optionsDirty ) {
10364 // We reached this from the constructor or from #setOptions.
10365 // We have to update the <select> element.
10366 this.updateOptionsInterface();
10367 }
10368 return this;
10369 };
10370
10371 /**
10372 * Clean up incoming value.
10373 *
10374 * @param {string[]} value Original value
10375 * @return {string[]} Cleaned up value
10376 */
10377 OO.ui.CheckboxMultiselectInputWidget.prototype.cleanUpValue = function ( value ) {
10378 var i, singleValue,
10379 cleanValue = [];
10380 if ( !Array.isArray( value ) ) {
10381 return cleanValue;
10382 }
10383 for ( i = 0; i < value.length; i++ ) {
10384 singleValue = OO.ui.CheckboxMultiselectInputWidget.parent.prototype.cleanUpValue
10385 .call( this, value[ i ] );
10386 // Remove options that we don't have here
10387 if ( !this.checkboxMultiselectWidget.findItemFromData( singleValue ) ) {
10388 continue;
10389 }
10390 cleanValue.push( singleValue );
10391 }
10392 return cleanValue;
10393 };
10394
10395 /**
10396 * @inheritdoc
10397 */
10398 OO.ui.CheckboxMultiselectInputWidget.prototype.setDisabled = function ( state ) {
10399 this.checkboxMultiselectWidget.setDisabled( state );
10400 OO.ui.CheckboxMultiselectInputWidget.parent.prototype.setDisabled.call( this, state );
10401 return this;
10402 };
10403
10404 /**
10405 * Set the options available for this input.
10406 *
10407 * @param {Object[]} options Array of menu options in the format
10408 * `{ data: …, label: …, disabled: … }`
10409 * @chainable
10410 * @return {OO.ui.Widget} The widget, for chaining
10411 */
10412 OO.ui.CheckboxMultiselectInputWidget.prototype.setOptions = function ( options ) {
10413 var value = this.getValue();
10414
10415 this.setOptionsData( options );
10416
10417 // Re-set the value to update the visible interface (CheckboxMultiselectWidget).
10418 // This will also get rid of any stale options that we just removed.
10419 this.setValue( value );
10420
10421 return this;
10422 };
10423
10424 /**
10425 * Set the internal list of options, used e.g. by setValue() to see which options are allowed.
10426 *
10427 * This method may be called before the parent constructor, so various properties may not be
10428 * intialized yet.
10429 *
10430 * @param {Object[]} options Array of menu options in the format
10431 * `{ data: …, label: … }`
10432 * @private
10433 */
10434 OO.ui.CheckboxMultiselectInputWidget.prototype.setOptionsData = function ( options ) {
10435 var widget = this;
10436
10437 this.optionsDirty = true;
10438
10439 this.checkboxMultiselectWidget
10440 .clearItems()
10441 .addItems( options.map( function ( opt ) {
10442 var optValue, item, optDisabled;
10443 optValue = OO.ui.CheckboxMultiselectInputWidget.parent.prototype.cleanUpValue
10444 .call( widget, opt.data );
10445 optDisabled = opt.disabled !== undefined ? opt.disabled : false;
10446 item = new OO.ui.CheckboxMultioptionWidget( {
10447 data: optValue,
10448 label: opt.label !== undefined ? opt.label : optValue,
10449 disabled: optDisabled
10450 } );
10451 // Set the 'name' and 'value' for form submission
10452 item.checkbox.$input.attr( 'name', widget.inputName );
10453 item.checkbox.setValue( optValue );
10454 return item;
10455 } ) );
10456 };
10457
10458 /**
10459 * Update the user-visible interface to match the internal list of options and value.
10460 *
10461 * This method must only be called after the parent constructor.
10462 *
10463 * @private
10464 */
10465 OO.ui.CheckboxMultiselectInputWidget.prototype.updateOptionsInterface = function () {
10466 var defaultValue = this.defaultValue;
10467
10468 this.checkboxMultiselectWidget.getItems().forEach( function ( item ) {
10469 // Remember original selection state. This property can be later used to check whether
10470 // the selection state of the input has been changed since it was created.
10471 var isDefault = defaultValue.indexOf( item.getData() ) !== -1;
10472 item.checkbox.defaultSelected = isDefault;
10473 item.checkbox.$input[ 0 ].defaultChecked = isDefault;
10474 } );
10475
10476 this.optionsDirty = false;
10477 };
10478
10479 /**
10480 * @inheritdoc
10481 */
10482 OO.ui.CheckboxMultiselectInputWidget.prototype.focus = function () {
10483 this.checkboxMultiselectWidget.focus();
10484 return this;
10485 };
10486
10487 /**
10488 * TextInputWidgets, like HTML text inputs, can be configured with options that customize the
10489 * size of the field as well as its presentation. In addition, these widgets can be configured
10490 * with {@link OO.ui.mixin.IconElement icons}, {@link OO.ui.mixin.IndicatorElement indicators}, an
10491 * optional validation-pattern (used to determine if an input value is valid or not) and an input
10492 * filter, which modifies incoming values rather than validating them.
10493 * Please see the [OOUI documentation on MediaWiki] [1] for more information and examples.
10494 *
10495 * This widget can be used inside an HTML form, such as a OO.ui.FormLayout.
10496 *
10497 * @example
10498 * // A TextInputWidget.
10499 * var textInput = new OO.ui.TextInputWidget( {
10500 * value: 'Text input'
10501 * } );
10502 * $( document.body ).append( textInput.$element );
10503 *
10504 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Inputs
10505 *
10506 * @class
10507 * @extends OO.ui.InputWidget
10508 * @mixins OO.ui.mixin.IconElement
10509 * @mixins OO.ui.mixin.IndicatorElement
10510 * @mixins OO.ui.mixin.PendingElement
10511 * @mixins OO.ui.mixin.LabelElement
10512 * @mixins OO.ui.mixin.FlaggedElement
10513 *
10514 * @constructor
10515 * @param {Object} [config] Configuration options
10516 * @cfg {string} [type='text'] The value of the HTML `type` attribute: 'text', 'password'
10517 * 'email', 'url' or 'number'.
10518 * @cfg {string} [placeholder] Placeholder text
10519 * @cfg {boolean} [autofocus=false] Use an HTML `autofocus` attribute to
10520 * instruct the browser to focus this widget.
10521 * @cfg {boolean} [readOnly=false] Prevent changes to the value of the text input.
10522 * @cfg {number} [maxLength] Maximum number of characters allowed in the input.
10523 *
10524 * For unfortunate historical reasons, this counts the number of UTF-16 code units rather than
10525 * Unicode codepoints, which means that codepoints outside the Basic Multilingual Plane (e.g.
10526 * many emojis) count as 2 characters each.
10527 * @cfg {string} [labelPosition='after'] The position of the inline label relative to that of
10528 * the value or placeholder text: `'before'` or `'after'`
10529 * @cfg {boolean} [required=false] Mark the field as required with `true`. Implies `indicator:
10530 * 'required'`. Note that `false` & setting `indicator: 'required' will result in no indicator
10531 * shown.
10532 * @cfg {boolean} [autocomplete=true] Should the browser support autocomplete for this field
10533 * @cfg {boolean} [spellcheck] Should the browser support spellcheck for this field (`undefined`
10534 * means leaving it up to the browser).
10535 * @cfg {RegExp|Function|string} [validate] Validation pattern: when string, a symbolic name of a
10536 * pattern defined by the class: 'non-empty' (the value cannot be an empty string) or 'integer'
10537 * (the value must contain only numbers); when RegExp, a regular expression that must match the
10538 * value for it to be considered valid; when Function, a function receiving the value as parameter
10539 * that must return true, or promise resolving to true, for it to be considered valid.
10540 */
10541 OO.ui.TextInputWidget = function OoUiTextInputWidget( config ) {
10542 // Configuration initialization
10543 config = $.extend( {
10544 type: 'text',
10545 labelPosition: 'after'
10546 }, config );
10547
10548 // Parent constructor
10549 OO.ui.TextInputWidget.parent.call( this, config );
10550
10551 // Mixin constructors
10552 OO.ui.mixin.IconElement.call( this, config );
10553 OO.ui.mixin.IndicatorElement.call( this, config );
10554 OO.ui.mixin.PendingElement.call( this, $.extend( { $pending: this.$input }, config ) );
10555 OO.ui.mixin.LabelElement.call( this, config );
10556 OO.ui.mixin.FlaggedElement.call( this, config );
10557
10558 // Properties
10559 this.type = this.getSaneType( config );
10560 this.readOnly = false;
10561 this.required = false;
10562 this.validate = null;
10563 this.scrollWidth = null;
10564
10565 this.setValidation( config.validate );
10566 this.setLabelPosition( config.labelPosition );
10567
10568 // Events
10569 this.$input.on( {
10570 keypress: this.onKeyPress.bind( this ),
10571 blur: this.onBlur.bind( this ),
10572 focus: this.onFocus.bind( this )
10573 } );
10574 this.$icon.on( 'mousedown', this.onIconMouseDown.bind( this ) );
10575 this.$indicator.on( 'mousedown', this.onIndicatorMouseDown.bind( this ) );
10576 this.on( 'labelChange', this.updatePosition.bind( this ) );
10577 this.on( 'change', OO.ui.debounce( this.onDebouncedChange.bind( this ), 250 ) );
10578
10579 // Initialization
10580 this.$element
10581 .addClass( 'oo-ui-textInputWidget oo-ui-textInputWidget-type-' + this.type )
10582 .append( this.$icon, this.$indicator );
10583 this.setReadOnly( !!config.readOnly );
10584 this.setRequired( !!config.required );
10585 if ( config.placeholder !== undefined ) {
10586 this.$input.attr( 'placeholder', config.placeholder );
10587 }
10588 if ( config.maxLength !== undefined ) {
10589 this.$input.attr( 'maxlength', config.maxLength );
10590 }
10591 if ( config.autofocus ) {
10592 this.$input.attr( 'autofocus', 'autofocus' );
10593 }
10594 if ( config.autocomplete === false ) {
10595 this.$input.attr( 'autocomplete', 'off' );
10596 // Turning off autocompletion also disables "form caching" when the user navigates to a
10597 // different page and then clicks "Back". Re-enable it when leaving.
10598 // Borrowed from jQuery UI.
10599 $( window ).on( {
10600 beforeunload: function () {
10601 this.$input.removeAttr( 'autocomplete' );
10602 }.bind( this ),
10603 pageshow: function () {
10604 // Browsers don't seem to actually fire this event on "Back", they instead just
10605 // reload the whole page... it shouldn't hurt, though.
10606 this.$input.attr( 'autocomplete', 'off' );
10607 }.bind( this )
10608 } );
10609 }
10610 if ( config.spellcheck !== undefined ) {
10611 this.$input.attr( 'spellcheck', config.spellcheck ? 'true' : 'false' );
10612 }
10613 if ( this.label ) {
10614 this.isWaitingToBeAttached = true;
10615 this.installParentChangeDetector();
10616 }
10617 };
10618
10619 /* Setup */
10620
10621 OO.inheritClass( OO.ui.TextInputWidget, OO.ui.InputWidget );
10622 OO.mixinClass( OO.ui.TextInputWidget, OO.ui.mixin.IconElement );
10623 OO.mixinClass( OO.ui.TextInputWidget, OO.ui.mixin.IndicatorElement );
10624 OO.mixinClass( OO.ui.TextInputWidget, OO.ui.mixin.PendingElement );
10625 OO.mixinClass( OO.ui.TextInputWidget, OO.ui.mixin.LabelElement );
10626 OO.mixinClass( OO.ui.TextInputWidget, OO.ui.mixin.FlaggedElement );
10627
10628 /* Static Properties */
10629
10630 OO.ui.TextInputWidget.static.validationPatterns = {
10631 'non-empty': /.+/,
10632 integer: /^\d+$/
10633 };
10634
10635 /* Events */
10636
10637 /**
10638 * An `enter` event is emitted when the user presses Enter key inside the text box.
10639 *
10640 * @event enter
10641 */
10642
10643 /* Methods */
10644
10645 /**
10646 * Handle icon mouse down events.
10647 *
10648 * @private
10649 * @param {jQuery.Event} e Mouse down event
10650 * @return {undefined/boolean} False to prevent default if event is handled
10651 */
10652 OO.ui.TextInputWidget.prototype.onIconMouseDown = function ( e ) {
10653 if ( e.which === OO.ui.MouseButtons.LEFT ) {
10654 this.focus();
10655 return false;
10656 }
10657 };
10658
10659 /**
10660 * Handle indicator mouse down events.
10661 *
10662 * @private
10663 * @param {jQuery.Event} e Mouse down event
10664 * @return {undefined/boolean} False to prevent default if event is handled
10665 */
10666 OO.ui.TextInputWidget.prototype.onIndicatorMouseDown = function ( e ) {
10667 if ( e.which === OO.ui.MouseButtons.LEFT ) {
10668 this.focus();
10669 return false;
10670 }
10671 };
10672
10673 /**
10674 * Handle key press events.
10675 *
10676 * @private
10677 * @param {jQuery.Event} e Key press event
10678 * @fires enter If Enter key is pressed
10679 */
10680 OO.ui.TextInputWidget.prototype.onKeyPress = function ( e ) {
10681 if ( e.which === OO.ui.Keys.ENTER ) {
10682 this.emit( 'enter', e );
10683 }
10684 };
10685
10686 /**
10687 * Handle blur events.
10688 *
10689 * @private
10690 * @param {jQuery.Event} e Blur event
10691 */
10692 OO.ui.TextInputWidget.prototype.onBlur = function () {
10693 this.setValidityFlag();
10694 };
10695
10696 /**
10697 * Handle focus events.
10698 *
10699 * @private
10700 * @param {jQuery.Event} e Focus event
10701 */
10702 OO.ui.TextInputWidget.prototype.onFocus = function () {
10703 if ( this.isWaitingToBeAttached ) {
10704 // If we've received focus, then we must be attached to the document, and if
10705 // isWaitingToBeAttached is still true, that means the handler never fired. Fire it now.
10706 this.onElementAttach();
10707 }
10708 this.setValidityFlag( true );
10709 };
10710
10711 /**
10712 * Handle element attach events.
10713 *
10714 * @private
10715 * @param {jQuery.Event} e Element attach event
10716 */
10717 OO.ui.TextInputWidget.prototype.onElementAttach = function () {
10718 this.isWaitingToBeAttached = false;
10719 // Any previously calculated size is now probably invalid if we reattached elsewhere
10720 this.valCache = null;
10721 this.positionLabel();
10722 };
10723
10724 /**
10725 * Handle debounced change events.
10726 *
10727 * @param {string} value
10728 * @private
10729 */
10730 OO.ui.TextInputWidget.prototype.onDebouncedChange = function () {
10731 this.setValidityFlag();
10732 };
10733
10734 /**
10735 * Check if the input is {@link #readOnly read-only}.
10736 *
10737 * @return {boolean}
10738 */
10739 OO.ui.TextInputWidget.prototype.isReadOnly = function () {
10740 return this.readOnly;
10741 };
10742
10743 /**
10744 * Set the {@link #readOnly read-only} state of the input.
10745 *
10746 * @param {boolean} state Make input read-only
10747 * @chainable
10748 * @return {OO.ui.Widget} The widget, for chaining
10749 */
10750 OO.ui.TextInputWidget.prototype.setReadOnly = function ( state ) {
10751 this.readOnly = !!state;
10752 this.$input.prop( 'readOnly', this.readOnly );
10753 return this;
10754 };
10755
10756 /**
10757 * Check if the input is {@link #required required}.
10758 *
10759 * @return {boolean}
10760 */
10761 OO.ui.TextInputWidget.prototype.isRequired = function () {
10762 return this.required;
10763 };
10764
10765 /**
10766 * Set the {@link #required required} state of the input.
10767 *
10768 * @param {boolean} state Make input required
10769 * @chainable
10770 * @return {OO.ui.Widget} The widget, for chaining
10771 */
10772 OO.ui.TextInputWidget.prototype.setRequired = function ( state ) {
10773 this.required = !!state;
10774 if ( this.required ) {
10775 this.$input
10776 .prop( 'required', true )
10777 .attr( 'aria-required', 'true' );
10778 if ( this.getIndicator() === null ) {
10779 this.setIndicator( 'required' );
10780 }
10781 } else {
10782 this.$input
10783 .prop( 'required', false )
10784 .removeAttr( 'aria-required' );
10785 if ( this.getIndicator() === 'required' ) {
10786 this.setIndicator( null );
10787 }
10788 }
10789 return this;
10790 };
10791
10792 /**
10793 * Support function for making #onElementAttach work across browsers.
10794 *
10795 * This whole function could be replaced with one line of code using the DOMNodeInsertedIntoDocument
10796 * event, but it's not supported by Firefox and allegedly deprecated, so we only use it as fallback.
10797 *
10798 * Due to MutationObserver performance woes, #onElementAttach is only somewhat reliably called the
10799 * first time that the element gets attached to the documented.
10800 */
10801 OO.ui.TextInputWidget.prototype.installParentChangeDetector = function () {
10802 var mutationObserver, onRemove, topmostNode, fakeParentNode,
10803 MutationObserver = window.MutationObserver ||
10804 window.WebKitMutationObserver ||
10805 window.MozMutationObserver,
10806 widget = this;
10807
10808 if ( MutationObserver ) {
10809 // The new way. If only it wasn't so ugly.
10810
10811 if ( this.isElementAttached() ) {
10812 // Widget is attached already, do nothing. This breaks the functionality of this
10813 // function when the widget is detached and reattached. Alas, doing this correctly with
10814 // MutationObserver would require observation of the whole document, which would hurt
10815 // performance of other, more important code.
10816 return;
10817 }
10818
10819 // Find topmost node in the tree
10820 topmostNode = this.$element[ 0 ];
10821 while ( topmostNode.parentNode ) {
10822 topmostNode = topmostNode.parentNode;
10823 }
10824
10825 // We have no way to detect the $element being attached somewhere without observing the
10826 // entire DOM with subtree modifications, which would hurt performance. So we cheat: we hook
10827 // to the parent node of $element, and instead detect when $element is removed from it (and
10828 // thus probably attached somewhere else). If there is no parent, we create a "fake" one. If
10829 // it doesn't get attached, we end up back here and create the parent.
10830 mutationObserver = new MutationObserver( function ( mutations ) {
10831 var i, j, removedNodes;
10832 for ( i = 0; i < mutations.length; i++ ) {
10833 removedNodes = mutations[ i ].removedNodes;
10834 for ( j = 0; j < removedNodes.length; j++ ) {
10835 if ( removedNodes[ j ] === topmostNode ) {
10836 setTimeout( onRemove, 0 );
10837 return;
10838 }
10839 }
10840 }
10841 } );
10842
10843 onRemove = function () {
10844 // If the node was attached somewhere else, report it
10845 if ( widget.isElementAttached() ) {
10846 widget.onElementAttach();
10847 }
10848 mutationObserver.disconnect();
10849 widget.installParentChangeDetector();
10850 };
10851
10852 // Create a fake parent and observe it
10853 fakeParentNode = $( '<div>' ).append( topmostNode )[ 0 ];
10854 mutationObserver.observe( fakeParentNode, { childList: true } );
10855 } else {
10856 // Using the DOMNodeInsertedIntoDocument event is much nicer and less magical, and works for
10857 // detachment and reattachment, but it's not supported by Firefox and allegedly deprecated.
10858 this.$element.on( 'DOMNodeInsertedIntoDocument', this.onElementAttach.bind( this ) );
10859 }
10860 };
10861
10862 /**
10863 * @inheritdoc
10864 * @protected
10865 */
10866 OO.ui.TextInputWidget.prototype.getInputElement = function ( config ) {
10867 if ( this.getSaneType( config ) === 'number' ) {
10868 return $( '<input>' )
10869 .attr( 'step', 'any' )
10870 .attr( 'type', 'number' );
10871 } else {
10872 return $( '<input>' ).attr( 'type', this.getSaneType( config ) );
10873 }
10874 };
10875
10876 /**
10877 * Get sanitized value for 'type' for given config.
10878 *
10879 * @param {Object} config Configuration options
10880 * @return {string|null}
10881 * @protected
10882 */
10883 OO.ui.TextInputWidget.prototype.getSaneType = function ( config ) {
10884 var allowedTypes = [
10885 'text',
10886 'password',
10887 'email',
10888 'url',
10889 'number'
10890 ];
10891 return allowedTypes.indexOf( config.type ) !== -1 ? config.type : 'text';
10892 };
10893
10894 /**
10895 * Focus the input and select a specified range within the text.
10896 *
10897 * @param {number} from Select from offset
10898 * @param {number} [to] Select to offset, defaults to from
10899 * @chainable
10900 * @return {OO.ui.Widget} The widget, for chaining
10901 */
10902 OO.ui.TextInputWidget.prototype.selectRange = function ( from, to ) {
10903 var isBackwards, start, end,
10904 input = this.$input[ 0 ];
10905
10906 to = to || from;
10907
10908 isBackwards = to < from;
10909 start = isBackwards ? to : from;
10910 end = isBackwards ? from : to;
10911
10912 this.focus();
10913
10914 try {
10915 input.setSelectionRange( start, end, isBackwards ? 'backward' : 'forward' );
10916 } catch ( e ) {
10917 // IE throws an exception if you call setSelectionRange on a unattached DOM node.
10918 // Rather than expensively check if the input is attached every time, just check
10919 // if it was the cause of an error being thrown. If not, rethrow the error.
10920 if ( this.getElementDocument().body.contains( input ) ) {
10921 throw e;
10922 }
10923 }
10924 return this;
10925 };
10926
10927 /**
10928 * Get an object describing the current selection range in a directional manner
10929 *
10930 * @return {Object} Object containing 'from' and 'to' offsets
10931 */
10932 OO.ui.TextInputWidget.prototype.getRange = function () {
10933 var input = this.$input[ 0 ],
10934 start = input.selectionStart,
10935 end = input.selectionEnd,
10936 isBackwards = input.selectionDirection === 'backward';
10937
10938 return {
10939 from: isBackwards ? end : start,
10940 to: isBackwards ? start : end
10941 };
10942 };
10943
10944 /**
10945 * Get the length of the text input value.
10946 *
10947 * This could differ from the length of #getValue if the
10948 * value gets filtered
10949 *
10950 * @return {number} Input length
10951 */
10952 OO.ui.TextInputWidget.prototype.getInputLength = function () {
10953 return this.$input[ 0 ].value.length;
10954 };
10955
10956 /**
10957 * Focus the input and select the entire text.
10958 *
10959 * @chainable
10960 * @return {OO.ui.Widget} The widget, for chaining
10961 */
10962 OO.ui.TextInputWidget.prototype.select = function () {
10963 return this.selectRange( 0, this.getInputLength() );
10964 };
10965
10966 /**
10967 * Focus the input and move the cursor to the start.
10968 *
10969 * @chainable
10970 * @return {OO.ui.Widget} The widget, for chaining
10971 */
10972 OO.ui.TextInputWidget.prototype.moveCursorToStart = function () {
10973 return this.selectRange( 0 );
10974 };
10975
10976 /**
10977 * Focus the input and move the cursor to the end.
10978 *
10979 * @chainable
10980 * @return {OO.ui.Widget} The widget, for chaining
10981 */
10982 OO.ui.TextInputWidget.prototype.moveCursorToEnd = function () {
10983 return this.selectRange( this.getInputLength() );
10984 };
10985
10986 /**
10987 * Insert new content into the input.
10988 *
10989 * @param {string} content Content to be inserted
10990 * @chainable
10991 * @return {OO.ui.Widget} The widget, for chaining
10992 */
10993 OO.ui.TextInputWidget.prototype.insertContent = function ( content ) {
10994 var start, end,
10995 range = this.getRange(),
10996 value = this.getValue();
10997
10998 start = Math.min( range.from, range.to );
10999 end = Math.max( range.from, range.to );
11000
11001 this.setValue( value.slice( 0, start ) + content + value.slice( end ) );
11002 this.selectRange( start + content.length );
11003 return this;
11004 };
11005
11006 /**
11007 * Insert new content either side of a selection.
11008 *
11009 * @param {string} pre Content to be inserted before the selection
11010 * @param {string} post Content to be inserted after the selection
11011 * @chainable
11012 * @return {OO.ui.Widget} The widget, for chaining
11013 */
11014 OO.ui.TextInputWidget.prototype.encapsulateContent = function ( pre, post ) {
11015 var start, end,
11016 range = this.getRange(),
11017 offset = pre.length;
11018
11019 start = Math.min( range.from, range.to );
11020 end = Math.max( range.from, range.to );
11021
11022 this.selectRange( start ).insertContent( pre );
11023 this.selectRange( offset + end ).insertContent( post );
11024
11025 this.selectRange( offset + start, offset + end );
11026 return this;
11027 };
11028
11029 /**
11030 * Set the validation pattern.
11031 *
11032 * The validation pattern is either a regular expression, a function, or the symbolic name of a
11033 * pattern defined by the class: 'non-empty' (the value cannot be an empty string) or 'integer' (the
11034 * value must contain only numbers).
11035 *
11036 * @param {RegExp|Function|string|null} validate Regular expression, function, or the symbolic name
11037 * of a pattern (either ‘integer’ or ‘non-empty’) defined by the class.
11038 */
11039 OO.ui.TextInputWidget.prototype.setValidation = function ( validate ) {
11040 if ( validate instanceof RegExp || validate instanceof Function ) {
11041 this.validate = validate;
11042 } else {
11043 this.validate = this.constructor.static.validationPatterns[ validate ] || /.*/;
11044 }
11045 };
11046
11047 /**
11048 * Sets the 'invalid' flag appropriately.
11049 *
11050 * @param {boolean} [isValid] Optionally override validation result
11051 */
11052 OO.ui.TextInputWidget.prototype.setValidityFlag = function ( isValid ) {
11053 var widget = this,
11054 setFlag = function ( valid ) {
11055 if ( !valid ) {
11056 widget.$input.attr( 'aria-invalid', 'true' );
11057 } else {
11058 widget.$input.removeAttr( 'aria-invalid' );
11059 }
11060 widget.setFlags( { invalid: !valid } );
11061 };
11062
11063 if ( isValid !== undefined ) {
11064 setFlag( isValid );
11065 } else {
11066 this.getValidity().then( function () {
11067 setFlag( true );
11068 }, function () {
11069 setFlag( false );
11070 } );
11071 }
11072 };
11073
11074 /**
11075 * Get the validity of current value.
11076 *
11077 * This method returns a promise that resolves if the value is valid and rejects if
11078 * it isn't. Uses the {@link #validate validation pattern} to check for validity.
11079 *
11080 * @return {jQuery.Promise} A promise that resolves if the value is valid, rejects if not.
11081 */
11082 OO.ui.TextInputWidget.prototype.getValidity = function () {
11083 var result;
11084
11085 function rejectOrResolve( valid ) {
11086 if ( valid ) {
11087 return $.Deferred().resolve().promise();
11088 } else {
11089 return $.Deferred().reject().promise();
11090 }
11091 }
11092
11093 // Check browser validity and reject if it is invalid
11094 if (
11095 this.$input[ 0 ].checkValidity !== undefined &&
11096 this.$input[ 0 ].checkValidity() === false
11097 ) {
11098 return rejectOrResolve( false );
11099 }
11100
11101 // Run our checks if the browser thinks the field is valid
11102 if ( this.validate instanceof Function ) {
11103 result = this.validate( this.getValue() );
11104 if ( result && typeof result.promise === 'function' ) {
11105 return result.promise().then( function ( valid ) {
11106 return rejectOrResolve( valid );
11107 } );
11108 } else {
11109 return rejectOrResolve( result );
11110 }
11111 } else {
11112 return rejectOrResolve( this.getValue().match( this.validate ) );
11113 }
11114 };
11115
11116 /**
11117 * Set the position of the inline label relative to that of the value: `‘before’` or `‘after’`.
11118 *
11119 * @param {string} labelPosition Label position, 'before' or 'after'
11120 * @chainable
11121 * @return {OO.ui.Widget} The widget, for chaining
11122 */
11123 OO.ui.TextInputWidget.prototype.setLabelPosition = function ( labelPosition ) {
11124 this.labelPosition = labelPosition;
11125 if ( this.label ) {
11126 // If there is no label and we only change the position, #updatePosition is a no-op,
11127 // but it takes really a lot of work to do nothing.
11128 this.updatePosition();
11129 }
11130 return this;
11131 };
11132
11133 /**
11134 * Update the position of the inline label.
11135 *
11136 * This method is called by #setLabelPosition, and can also be called on its own if
11137 * something causes the label to be mispositioned.
11138 *
11139 * @chainable
11140 * @return {OO.ui.Widget} The widget, for chaining
11141 */
11142 OO.ui.TextInputWidget.prototype.updatePosition = function () {
11143 var after = this.labelPosition === 'after';
11144
11145 this.$element
11146 .toggleClass( 'oo-ui-textInputWidget-labelPosition-after', !!this.label && after )
11147 .toggleClass( 'oo-ui-textInputWidget-labelPosition-before', !!this.label && !after );
11148
11149 this.valCache = null;
11150 this.scrollWidth = null;
11151 this.positionLabel();
11152
11153 return this;
11154 };
11155
11156 /**
11157 * Position the label by setting the correct padding on the input.
11158 *
11159 * @private
11160 * @chainable
11161 * @return {OO.ui.Widget} The widget, for chaining
11162 */
11163 OO.ui.TextInputWidget.prototype.positionLabel = function () {
11164 var after, rtl, property, newCss;
11165
11166 if ( this.isWaitingToBeAttached ) {
11167 // #onElementAttach will be called soon, which calls this method
11168 return this;
11169 }
11170
11171 newCss = {
11172 'padding-right': '',
11173 'padding-left': ''
11174 };
11175
11176 if ( this.label ) {
11177 this.$element.append( this.$label );
11178 } else {
11179 this.$label.detach();
11180 // Clear old values if present
11181 this.$input.css( newCss );
11182 return;
11183 }
11184
11185 after = this.labelPosition === 'after';
11186 rtl = this.$element.css( 'direction' ) === 'rtl';
11187 property = after === rtl ? 'padding-left' : 'padding-right';
11188
11189 newCss[ property ] = this.$label.outerWidth( true ) + ( after ? this.scrollWidth : 0 );
11190 // We have to clear the padding on the other side, in case the element direction changed
11191 this.$input.css( newCss );
11192
11193 return this;
11194 };
11195
11196 /**
11197 * SearchInputWidgets are TextInputWidgets with `type="search"` assigned and feature a
11198 * {@link OO.ui.mixin.IconElement search icon} by default.
11199 * Please see the [OOUI documentation on MediaWiki] [1] for more information and examples.
11200 *
11201 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Inputs#SearchInputWidget
11202 *
11203 * @class
11204 * @extends OO.ui.TextInputWidget
11205 *
11206 * @constructor
11207 * @param {Object} [config] Configuration options
11208 */
11209 OO.ui.SearchInputWidget = function OoUiSearchInputWidget( config ) {
11210 config = $.extend( {
11211 icon: 'search'
11212 }, config );
11213
11214 // Parent constructor
11215 OO.ui.SearchInputWidget.parent.call( this, config );
11216
11217 // Events
11218 this.connect( this, {
11219 change: 'onChange'
11220 } );
11221 this.$indicator.on( 'click', this.onIndicatorClick.bind( this ) );
11222
11223 // Initialization
11224 this.updateSearchIndicator();
11225 this.connect( this, {
11226 disable: 'onDisable'
11227 } );
11228 };
11229
11230 /* Setup */
11231
11232 OO.inheritClass( OO.ui.SearchInputWidget, OO.ui.TextInputWidget );
11233
11234 /* Methods */
11235
11236 /**
11237 * @inheritdoc
11238 * @protected
11239 */
11240 OO.ui.SearchInputWidget.prototype.getSaneType = function () {
11241 return 'search';
11242 };
11243
11244 /**
11245 * Handle click events on the indicator
11246 *
11247 * @param {jQuery.Event} e Click event
11248 * @return {boolean}
11249 */
11250 OO.ui.SearchInputWidget.prototype.onIndicatorClick = function ( e ) {
11251 if ( e.which === OO.ui.MouseButtons.LEFT ) {
11252 // Clear the text field
11253 this.setValue( '' );
11254 this.focus();
11255 return false;
11256 }
11257 };
11258
11259 /**
11260 * Update the 'clear' indicator displayed on type: 'search' text
11261 * fields, hiding it when the field is already empty or when it's not
11262 * editable.
11263 */
11264 OO.ui.SearchInputWidget.prototype.updateSearchIndicator = function () {
11265 if ( this.getValue() === '' || this.isDisabled() || this.isReadOnly() ) {
11266 this.setIndicator( null );
11267 } else {
11268 this.setIndicator( 'clear' );
11269 }
11270 };
11271
11272 /**
11273 * Handle change events.
11274 *
11275 * @private
11276 */
11277 OO.ui.SearchInputWidget.prototype.onChange = function () {
11278 this.updateSearchIndicator();
11279 };
11280
11281 /**
11282 * Handle disable events.
11283 *
11284 * @param {boolean} disabled Element is disabled
11285 * @private
11286 */
11287 OO.ui.SearchInputWidget.prototype.onDisable = function () {
11288 this.updateSearchIndicator();
11289 };
11290
11291 /**
11292 * @inheritdoc
11293 */
11294 OO.ui.SearchInputWidget.prototype.setReadOnly = function ( state ) {
11295 OO.ui.SearchInputWidget.parent.prototype.setReadOnly.call( this, state );
11296 this.updateSearchIndicator();
11297 return this;
11298 };
11299
11300 /**
11301 * MultilineTextInputWidgets, like HTML textareas, are featuring customization options to
11302 * configure number of rows visible. In addition, these widgets can be autosized to fit user
11303 * inputs and can show {@link OO.ui.mixin.IconElement icons} and
11304 * {@link OO.ui.mixin.IndicatorElement indicators}.
11305 * Please see the [OOUI documentation on MediaWiki] [1] for more information and examples.
11306 *
11307 * This widget can be used inside an HTML form, such as a OO.ui.FormLayout.
11308 *
11309 * @example
11310 * // A MultilineTextInputWidget.
11311 * var multilineTextInput = new OO.ui.MultilineTextInputWidget( {
11312 * value: 'Text input on multiple lines'
11313 * } );
11314 * $( document.body ).append( multilineTextInput.$element );
11315 *
11316 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Inputs#MultilineTextInputWidget
11317 *
11318 * @class
11319 * @extends OO.ui.TextInputWidget
11320 *
11321 * @constructor
11322 * @param {Object} [config] Configuration options
11323 * @cfg {number} [rows] Number of visible lines in textarea. If used with `autosize`,
11324 * specifies minimum number of rows to display.
11325 * @cfg {boolean} [autosize=false] Automatically resize the text input to fit its content.
11326 * Use the #maxRows config to specify a maximum number of displayed rows.
11327 * @cfg {number} [maxRows] Maximum number of rows to display when #autosize is set to true.
11328 * Defaults to the maximum of `10` and `2 * rows`, or `10` if `rows` isn't provided.
11329 */
11330 OO.ui.MultilineTextInputWidget = function OoUiMultilineTextInputWidget( config ) {
11331 config = $.extend( {
11332 type: 'text'
11333 }, config );
11334 // Parent constructor
11335 OO.ui.MultilineTextInputWidget.parent.call( this, config );
11336
11337 // Properties
11338 this.autosize = !!config.autosize;
11339 this.styleHeight = null;
11340 this.minRows = config.rows !== undefined ? config.rows : '';
11341 this.maxRows = config.maxRows || Math.max( 2 * ( this.minRows || 0 ), 10 );
11342
11343 // Clone for resizing
11344 if ( this.autosize ) {
11345 this.$clone = this.$input
11346 .clone()
11347 .removeAttr( 'id' )
11348 .removeAttr( 'name' )
11349 .insertAfter( this.$input )
11350 .attr( 'aria-hidden', 'true' )
11351 .addClass( 'oo-ui-element-hidden' );
11352 }
11353
11354 // Events
11355 this.connect( this, {
11356 change: 'onChange'
11357 } );
11358
11359 // Initialization
11360 if ( config.rows ) {
11361 this.$input.attr( 'rows', config.rows );
11362 }
11363 if ( this.autosize ) {
11364 this.$input.addClass( 'oo-ui-textInputWidget-autosized' );
11365 this.isWaitingToBeAttached = true;
11366 this.installParentChangeDetector();
11367 }
11368 };
11369
11370 /* Setup */
11371
11372 OO.inheritClass( OO.ui.MultilineTextInputWidget, OO.ui.TextInputWidget );
11373
11374 /* Static Methods */
11375
11376 /**
11377 * @inheritdoc
11378 */
11379 OO.ui.MultilineTextInputWidget.static.gatherPreInfuseState = function ( node, config ) {
11380 var state = OO.ui.MultilineTextInputWidget.parent.static.gatherPreInfuseState( node, config );
11381 state.scrollTop = config.$input.scrollTop();
11382 return state;
11383 };
11384
11385 /* Methods */
11386
11387 /**
11388 * @inheritdoc
11389 */
11390 OO.ui.MultilineTextInputWidget.prototype.onElementAttach = function () {
11391 OO.ui.MultilineTextInputWidget.parent.prototype.onElementAttach.call( this );
11392 this.adjustSize();
11393 };
11394
11395 /**
11396 * Handle change events.
11397 *
11398 * @private
11399 */
11400 OO.ui.MultilineTextInputWidget.prototype.onChange = function () {
11401 this.adjustSize();
11402 };
11403
11404 /**
11405 * @inheritdoc
11406 */
11407 OO.ui.MultilineTextInputWidget.prototype.updatePosition = function () {
11408 OO.ui.MultilineTextInputWidget.parent.prototype.updatePosition.call( this );
11409 this.adjustSize();
11410 };
11411
11412 /**
11413 * @inheritdoc
11414 *
11415 * Modify to emit 'enter' on Ctrl/Meta+Enter, instead of plain Enter
11416 */
11417 OO.ui.MultilineTextInputWidget.prototype.onKeyPress = function ( e ) {
11418 if (
11419 ( e.which === OO.ui.Keys.ENTER && ( e.ctrlKey || e.metaKey ) ) ||
11420 // Some platforms emit keycode 10 for Control+Enter keypress in a textarea
11421 e.which === 10
11422 ) {
11423 this.emit( 'enter', e );
11424 }
11425 };
11426
11427 /**
11428 * Automatically adjust the size of the text input.
11429 *
11430 * This only affects multiline inputs that are {@link #autosize autosized}.
11431 *
11432 * @chainable
11433 * @return {OO.ui.Widget} The widget, for chaining
11434 * @fires resize
11435 */
11436 OO.ui.MultilineTextInputWidget.prototype.adjustSize = function () {
11437 var scrollHeight, innerHeight, outerHeight, maxInnerHeight, measurementError,
11438 idealHeight, newHeight, scrollWidth, property;
11439
11440 if ( this.$input.val() !== this.valCache ) {
11441 if ( this.autosize ) {
11442 this.$clone
11443 .val( this.$input.val() )
11444 .attr( 'rows', this.minRows )
11445 // Set inline height property to 0 to measure scroll height
11446 .css( 'height', 0 );
11447
11448 this.$clone.removeClass( 'oo-ui-element-hidden' );
11449
11450 this.valCache = this.$input.val();
11451
11452 scrollHeight = this.$clone[ 0 ].scrollHeight;
11453
11454 // Remove inline height property to measure natural heights
11455 this.$clone.css( 'height', '' );
11456 innerHeight = this.$clone.innerHeight();
11457 outerHeight = this.$clone.outerHeight();
11458
11459 // Measure max rows height
11460 this.$clone
11461 .attr( 'rows', this.maxRows )
11462 .css( 'height', 'auto' )
11463 .val( '' );
11464 maxInnerHeight = this.$clone.innerHeight();
11465
11466 // Difference between reported innerHeight and scrollHeight with no scrollbars present.
11467 // This is sometimes non-zero on Blink-based browsers, depending on zoom level.
11468 measurementError = maxInnerHeight - this.$clone[ 0 ].scrollHeight;
11469 idealHeight = Math.min( maxInnerHeight, scrollHeight + measurementError );
11470
11471 this.$clone.addClass( 'oo-ui-element-hidden' );
11472
11473 // Only apply inline height when expansion beyond natural height is needed
11474 // Use the difference between the inner and outer height as a buffer
11475 newHeight = idealHeight > innerHeight ? idealHeight + ( outerHeight - innerHeight ) : '';
11476 if ( newHeight !== this.styleHeight ) {
11477 this.$input.css( 'height', newHeight );
11478 this.styleHeight = newHeight;
11479 this.emit( 'resize' );
11480 }
11481 }
11482 scrollWidth = this.$input[ 0 ].offsetWidth - this.$input[ 0 ].clientWidth;
11483 if ( scrollWidth !== this.scrollWidth ) {
11484 property = this.$element.css( 'direction' ) === 'rtl' ? 'left' : 'right';
11485 // Reset
11486 this.$label.css( { right: '', left: '' } );
11487 this.$indicator.css( { right: '', left: '' } );
11488
11489 if ( scrollWidth ) {
11490 this.$indicator.css( property, scrollWidth );
11491 if ( this.labelPosition === 'after' ) {
11492 this.$label.css( property, scrollWidth );
11493 }
11494 }
11495
11496 this.scrollWidth = scrollWidth;
11497 this.positionLabel();
11498 }
11499 }
11500 return this;
11501 };
11502
11503 /**
11504 * @inheritdoc
11505 * @protected
11506 */
11507 OO.ui.MultilineTextInputWidget.prototype.getInputElement = function () {
11508 return $( '<textarea>' );
11509 };
11510
11511 /**
11512 * Check if the input automatically adjusts its size.
11513 *
11514 * @return {boolean}
11515 */
11516 OO.ui.MultilineTextInputWidget.prototype.isAutosizing = function () {
11517 return !!this.autosize;
11518 };
11519
11520 /**
11521 * @inheritdoc
11522 */
11523 OO.ui.MultilineTextInputWidget.prototype.restorePreInfuseState = function ( state ) {
11524 OO.ui.MultilineTextInputWidget.parent.prototype.restorePreInfuseState.call( this, state );
11525 if ( state.scrollTop !== undefined ) {
11526 this.$input.scrollTop( state.scrollTop );
11527 }
11528 };
11529
11530 /**
11531 * ComboBoxInputWidgets combine a {@link OO.ui.TextInputWidget text input} (where a value
11532 * can be entered manually) and a {@link OO.ui.MenuSelectWidget menu of options} (from which
11533 * a value can be chosen instead). Users can choose options from the combo box in one of two ways:
11534 *
11535 * - by typing a value in the text input field. If the value exactly matches the value of a menu
11536 * option, that option will appear to be selected.
11537 * - by choosing a value from the menu. The value of the chosen option will then appear in the text
11538 * input field.
11539 *
11540 * After the user chooses an option, its `data` will be used as a new value for the widget.
11541 * A `label` also can be specified for each option: if given, it will be shown instead of the
11542 * `data` in the dropdown menu.
11543 *
11544 * This widget can be used inside an HTML form, such as a OO.ui.FormLayout.
11545 *
11546 * For more information about menus and options, please see the
11547 * [OOUI documentation on MediaWiki][1].
11548 *
11549 * @example
11550 * // A ComboBoxInputWidget.
11551 * var comboBox = new OO.ui.ComboBoxInputWidget( {
11552 * value: 'Option 1',
11553 * options: [
11554 * { data: 'Option 1' },
11555 * { data: 'Option 2' },
11556 * { data: 'Option 3' }
11557 * ]
11558 * } );
11559 * $( document.body ).append( comboBox.$element );
11560 *
11561 * @example
11562 * // Example: A ComboBoxInputWidget with additional option labels.
11563 * var comboBox = new OO.ui.ComboBoxInputWidget( {
11564 * value: 'Option 1',
11565 * options: [
11566 * {
11567 * data: 'Option 1',
11568 * label: 'Option One'
11569 * },
11570 * {
11571 * data: 'Option 2',
11572 * label: 'Option Two'
11573 * },
11574 * {
11575 * data: 'Option 3',
11576 * label: 'Option Three'
11577 * }
11578 * ]
11579 * } );
11580 * $( document.body ).append( comboBox.$element );
11581 *
11582 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Selects_and_Options#Menu_selects_and_options
11583 *
11584 * @class
11585 * @extends OO.ui.TextInputWidget
11586 *
11587 * @constructor
11588 * @param {Object} [config] Configuration options
11589 * @cfg {Object[]} [options=[]] Array of menu options in the format `{ data: …, label: … }`
11590 * @cfg {Object} [menu] Configuration options to pass to the {@link OO.ui.MenuSelectWidget menu
11591 * select widget}.
11592 * @cfg {jQuery} [$overlay] Render the menu into a separate layer. This configuration is useful
11593 * in cases where the expanded menu is larger than its containing `<div>`. The specified overlay
11594 * layer is usually on top of the containing `<div>` and has a larger area. By default, the menu
11595 * uses relative positioning.
11596 * See <https://www.mediawiki.org/wiki/OOUI/Concepts#Overlays>.
11597 */
11598 OO.ui.ComboBoxInputWidget = function OoUiComboBoxInputWidget( config ) {
11599 // Configuration initialization
11600 config = $.extend( {
11601 autocomplete: false
11602 }, config );
11603
11604 // ComboBoxInputWidget shouldn't support `multiline`
11605 config.multiline = false;
11606
11607 // See InputWidget#reusePreInfuseDOM about `config.$input`
11608 if ( config.$input ) {
11609 config.$input.removeAttr( 'list' );
11610 }
11611
11612 // Parent constructor
11613 OO.ui.ComboBoxInputWidget.parent.call( this, config );
11614
11615 // Properties
11616 this.$overlay = ( config.$overlay === true ?
11617 OO.ui.getDefaultOverlay() : config.$overlay ) || this.$element;
11618 this.dropdownButton = new OO.ui.ButtonWidget( {
11619 classes: [ 'oo-ui-comboBoxInputWidget-dropdownButton' ],
11620 label: OO.ui.msg( 'ooui-combobox-button-label' ),
11621 indicator: 'down',
11622 invisibleLabel: true,
11623 disabled: this.disabled
11624 } );
11625 this.menu = new OO.ui.MenuSelectWidget( $.extend(
11626 {
11627 widget: this,
11628 input: this,
11629 $floatableContainer: this.$element,
11630 disabled: this.isDisabled()
11631 },
11632 config.menu
11633 ) );
11634
11635 // Events
11636 this.connect( this, {
11637 change: 'onInputChange',
11638 enter: 'onInputEnter'
11639 } );
11640 this.dropdownButton.connect( this, {
11641 click: 'onDropdownButtonClick'
11642 } );
11643 this.menu.connect( this, {
11644 choose: 'onMenuChoose',
11645 add: 'onMenuItemsChange',
11646 remove: 'onMenuItemsChange',
11647 toggle: 'onMenuToggle'
11648 } );
11649
11650 // Initialization
11651 this.$input.attr( {
11652 role: 'combobox',
11653 'aria-owns': this.menu.getElementId(),
11654 'aria-autocomplete': 'list'
11655 } );
11656 this.dropdownButton.$button.attr( {
11657 'aria-controls': this.menu.getElementId()
11658 } );
11659 // Do not override options set via config.menu.items
11660 if ( config.options !== undefined ) {
11661 this.setOptions( config.options );
11662 }
11663 this.$field = $( '<div>' )
11664 .addClass( 'oo-ui-comboBoxInputWidget-field' )
11665 .append( this.$input, this.dropdownButton.$element );
11666 this.$element
11667 .addClass( 'oo-ui-comboBoxInputWidget' )
11668 .append( this.$field );
11669 this.$overlay.append( this.menu.$element );
11670 this.onMenuItemsChange();
11671 };
11672
11673 /* Setup */
11674
11675 OO.inheritClass( OO.ui.ComboBoxInputWidget, OO.ui.TextInputWidget );
11676
11677 /* Methods */
11678
11679 /**
11680 * Get the combobox's menu.
11681 *
11682 * @return {OO.ui.MenuSelectWidget} Menu widget
11683 */
11684 OO.ui.ComboBoxInputWidget.prototype.getMenu = function () {
11685 return this.menu;
11686 };
11687
11688 /**
11689 * Get the combobox's text input widget.
11690 *
11691 * @return {OO.ui.TextInputWidget} Text input widget
11692 */
11693 OO.ui.ComboBoxInputWidget.prototype.getInput = function () {
11694 return this;
11695 };
11696
11697 /**
11698 * Handle input change events.
11699 *
11700 * @private
11701 * @param {string} value New value
11702 */
11703 OO.ui.ComboBoxInputWidget.prototype.onInputChange = function ( value ) {
11704 var match = this.menu.findItemFromData( value );
11705
11706 this.menu.selectItem( match );
11707 if ( this.menu.findHighlightedItem() ) {
11708 this.menu.highlightItem( match );
11709 }
11710
11711 if ( !this.isDisabled() ) {
11712 this.menu.toggle( true );
11713 }
11714 };
11715
11716 /**
11717 * Handle input enter events.
11718 *
11719 * @private
11720 */
11721 OO.ui.ComboBoxInputWidget.prototype.onInputEnter = function () {
11722 if ( !this.isDisabled() ) {
11723 this.menu.toggle( false );
11724 }
11725 };
11726
11727 /**
11728 * Handle button click events.
11729 *
11730 * @private
11731 */
11732 OO.ui.ComboBoxInputWidget.prototype.onDropdownButtonClick = function () {
11733 this.menu.toggle();
11734 this.focus();
11735 };
11736
11737 /**
11738 * Handle menu choose events.
11739 *
11740 * @private
11741 * @param {OO.ui.OptionWidget} item Chosen item
11742 */
11743 OO.ui.ComboBoxInputWidget.prototype.onMenuChoose = function ( item ) {
11744 this.setValue( item.getData() );
11745 };
11746
11747 /**
11748 * Handle menu item change events.
11749 *
11750 * @private
11751 */
11752 OO.ui.ComboBoxInputWidget.prototype.onMenuItemsChange = function () {
11753 var match = this.menu.findItemFromData( this.getValue() );
11754 this.menu.selectItem( match );
11755 if ( this.menu.findHighlightedItem() ) {
11756 this.menu.highlightItem( match );
11757 }
11758 this.$element.toggleClass( 'oo-ui-comboBoxInputWidget-empty', this.menu.isEmpty() );
11759 };
11760
11761 /**
11762 * Handle menu toggle events.
11763 *
11764 * @private
11765 * @param {boolean} isVisible Open state of the menu
11766 */
11767 OO.ui.ComboBoxInputWidget.prototype.onMenuToggle = function ( isVisible ) {
11768 this.$element.toggleClass( 'oo-ui-comboBoxInputWidget-open', isVisible );
11769 };
11770
11771 /**
11772 * Update the disabled state of the controls
11773 *
11774 * @chainable
11775 * @protected
11776 * @return {OO.ui.ComboBoxInputWidget} The widget, for chaining
11777 */
11778 OO.ui.ComboBoxInputWidget.prototype.updateControlsDisabled = function () {
11779 var disabled = this.isDisabled() || this.isReadOnly();
11780 if ( this.dropdownButton ) {
11781 this.dropdownButton.setDisabled( disabled );
11782 }
11783 if ( this.menu ) {
11784 this.menu.setDisabled( disabled );
11785 }
11786 return this;
11787 };
11788
11789 /**
11790 * @inheritdoc
11791 */
11792 OO.ui.ComboBoxInputWidget.prototype.setDisabled = function () {
11793 // Parent method
11794 OO.ui.ComboBoxInputWidget.parent.prototype.setDisabled.apply( this, arguments );
11795 this.updateControlsDisabled();
11796 return this;
11797 };
11798
11799 /**
11800 * @inheritdoc
11801 */
11802 OO.ui.ComboBoxInputWidget.prototype.setReadOnly = function () {
11803 // Parent method
11804 OO.ui.ComboBoxInputWidget.parent.prototype.setReadOnly.apply( this, arguments );
11805 this.updateControlsDisabled();
11806 return this;
11807 };
11808
11809 /**
11810 * Set the options available for this input.
11811 *
11812 * @param {Object[]} options Array of menu options in the format `{ data: …, label: … }`
11813 * @chainable
11814 * @return {OO.ui.Widget} The widget, for chaining
11815 */
11816 OO.ui.ComboBoxInputWidget.prototype.setOptions = function ( options ) {
11817 this.getMenu()
11818 .clearItems()
11819 .addItems( options.map( function ( opt ) {
11820 return new OO.ui.MenuOptionWidget( {
11821 data: opt.data,
11822 label: opt.label !== undefined ? opt.label : opt.data
11823 } );
11824 } ) );
11825
11826 return this;
11827 };
11828
11829 /**
11830 * FieldLayouts are used with OO.ui.FieldsetLayout. Each FieldLayout requires a field-widget,
11831 * which is a widget that is specified by reference before any optional configuration settings.
11832 *
11833 * Field layouts can be configured with help text and/or labels. Labels are aligned in one of
11834 * four ways:
11835 *
11836 * - **left**: The label is placed before the field-widget and aligned with the left margin.
11837 * A left-alignment is used for forms with many fields.
11838 * - **right**: The label is placed before the field-widget and aligned to the right margin.
11839 * A right-alignment is used for long but familiar forms which users tab through,
11840 * verifying the current field with a quick glance at the label.
11841 * - **top**: The label is placed above the field-widget. A top-alignment is used for brief forms
11842 * that users fill out from top to bottom.
11843 * - **inline**: The label is placed after the field-widget and aligned to the left.
11844 * An inline-alignment is best used with checkboxes or radio buttons.
11845 *
11846 * Help text can either be:
11847 *
11848 * - accessed via a help icon that appears in the upper right corner of the rendered field layout,
11849 * or
11850 * - shown as a subtle explanation below the label.
11851 *
11852 * If the help text is brief, or is essential to always expose it, set `helpInline` to `true`.
11853 * If it is long or not essential, leave `helpInline` to its default, `false`.
11854 *
11855 * Please see the [OOUI documentation on MediaWiki] [1] for examples and more information.
11856 *
11857 * [1]: https://www.mediawiki.org/wiki/OOUI/Layouts/Fields_and_Fieldsets
11858 *
11859 * @class
11860 * @extends OO.ui.Layout
11861 * @mixins OO.ui.mixin.LabelElement
11862 * @mixins OO.ui.mixin.TitledElement
11863 *
11864 * @constructor
11865 * @param {OO.ui.Widget} fieldWidget Field widget
11866 * @param {Object} [config] Configuration options
11867 * @cfg {string} [align='left'] Alignment of the label: 'left', 'right', 'top'
11868 * or 'inline'
11869 * @cfg {Array} [errors] Error messages about the widget, which will be
11870 * displayed below the widget.
11871 * @cfg {Array} [warnings] Warning messages about the widget, which will be
11872 * displayed below the widget.
11873 * @cfg {Array} [successMessages] Success messages on user interactions with the widget,
11874 * which will be displayed below the widget.
11875 * The array may contain strings or OO.ui.HtmlSnippet instances.
11876 * @cfg {Array} [notices] Notices about the widget, which will be displayed
11877 * below the widget.
11878 * The array may contain strings or OO.ui.HtmlSnippet instances.
11879 * These are more visible than `help` messages when `helpInline` is set, and so
11880 * might be good for transient messages.
11881 * @cfg {string|OO.ui.HtmlSnippet} [help] Help text. When help text is specified
11882 * and `helpInline` is `false`, a "help" icon will appear in the upper-right
11883 * corner of the rendered field; clicking it will display the text in a popup.
11884 * If `helpInline` is `true`, then a subtle description will be shown after the
11885 * label.
11886 * @cfg {boolean} [helpInline=false] Whether or not the help should be inline,
11887 * or shown when the "help" icon is clicked.
11888 * @cfg {jQuery} [$overlay] Passed to OO.ui.PopupButtonWidget for help popup, if
11889 * `help` is given.
11890 * See <https://www.mediawiki.org/wiki/OOUI/Concepts#Overlays>.
11891 *
11892 * @throws {Error} An error is thrown if no widget is specified
11893 */
11894 OO.ui.FieldLayout = function OoUiFieldLayout( fieldWidget, config ) {
11895 // Allow passing positional parameters inside the config object
11896 if ( OO.isPlainObject( fieldWidget ) && config === undefined ) {
11897 config = fieldWidget;
11898 fieldWidget = config.fieldWidget;
11899 }
11900
11901 // Make sure we have required constructor arguments
11902 if ( fieldWidget === undefined ) {
11903 throw new Error( 'Widget not found' );
11904 }
11905
11906 // Configuration initialization
11907 config = $.extend( { align: 'left', helpInline: false }, config );
11908
11909 // Parent constructor
11910 OO.ui.FieldLayout.parent.call( this, config );
11911
11912 // Mixin constructors
11913 OO.ui.mixin.LabelElement.call( this, $.extend( {
11914 $label: $( '<label>' )
11915 }, config ) );
11916 OO.ui.mixin.TitledElement.call( this, $.extend( { $titled: this.$label }, config ) );
11917
11918 // Properties
11919 this.fieldWidget = fieldWidget;
11920 this.errors = [];
11921 this.warnings = [];
11922 this.successMessages = [];
11923 this.notices = [];
11924 this.$field = this.isFieldInline() ? $( '<span>' ) : $( '<div>' );
11925 this.$messages = $( '<ul>' );
11926 this.$header = $( '<span>' );
11927 this.$body = $( '<div>' );
11928 this.align = null;
11929 this.helpInline = config.helpInline;
11930
11931 // Events
11932 this.fieldWidget.connect( this, {
11933 disable: 'onFieldDisable'
11934 } );
11935
11936 // Initialization
11937 this.$help = config.help ?
11938 this.createHelpElement( config.help, config.$overlay ) :
11939 $( [] );
11940 if ( this.fieldWidget.getInputId() ) {
11941 this.$label.attr( 'for', this.fieldWidget.getInputId() );
11942 if ( this.helpInline ) {
11943 this.$help.attr( 'for', this.fieldWidget.getInputId() );
11944 }
11945 } else {
11946 this.$label.on( 'click', function () {
11947 this.fieldWidget.simulateLabelClick();
11948 }.bind( this ) );
11949 if ( this.helpInline ) {
11950 this.$help.on( 'click', function () {
11951 this.fieldWidget.simulateLabelClick();
11952 }.bind( this ) );
11953 }
11954 }
11955 this.$element
11956 .addClass( 'oo-ui-fieldLayout' )
11957 .toggleClass( 'oo-ui-fieldLayout-disabled', this.fieldWidget.isDisabled() )
11958 .append( this.$body );
11959 this.$body.addClass( 'oo-ui-fieldLayout-body' );
11960 this.$header.addClass( 'oo-ui-fieldLayout-header' );
11961 this.$messages.addClass( 'oo-ui-fieldLayout-messages' );
11962 this.$field
11963 .addClass( 'oo-ui-fieldLayout-field' )
11964 .append( this.fieldWidget.$element );
11965
11966 this.setErrors( config.errors || [] );
11967 this.setWarnings( config.warnings || [] );
11968 this.setSuccess( config.successMessages || [] );
11969 this.setNotices( config.notices || [] );
11970 this.setAlignment( config.align );
11971 // Call this again to take into account the widget's accessKey
11972 this.updateTitle();
11973 };
11974
11975 /* Setup */
11976
11977 OO.inheritClass( OO.ui.FieldLayout, OO.ui.Layout );
11978 OO.mixinClass( OO.ui.FieldLayout, OO.ui.mixin.LabelElement );
11979 OO.mixinClass( OO.ui.FieldLayout, OO.ui.mixin.TitledElement );
11980
11981 /* Methods */
11982
11983 /**
11984 * Handle field disable events.
11985 *
11986 * @private
11987 * @param {boolean} value Field is disabled
11988 */
11989 OO.ui.FieldLayout.prototype.onFieldDisable = function ( value ) {
11990 this.$element.toggleClass( 'oo-ui-fieldLayout-disabled', value );
11991 };
11992
11993 /**
11994 * Get the widget contained by the field.
11995 *
11996 * @return {OO.ui.Widget} Field widget
11997 */
11998 OO.ui.FieldLayout.prototype.getField = function () {
11999 return this.fieldWidget;
12000 };
12001
12002 /**
12003 * Return `true` if the given field widget can be used with `'inline'` alignment (see
12004 * #setAlignment). Return `false` if it can't or if this can't be determined.
12005 *
12006 * @return {boolean}
12007 */
12008 OO.ui.FieldLayout.prototype.isFieldInline = function () {
12009 // This is very simplistic, but should be good enough.
12010 return this.getField().$element.prop( 'tagName' ).toLowerCase() === 'span';
12011 };
12012
12013 /**
12014 * @protected
12015 * @param {string} kind 'error' or 'notice'
12016 * @param {string|OO.ui.HtmlSnippet} text
12017 * @return {jQuery}
12018 */
12019 OO.ui.FieldLayout.prototype.makeMessage = function ( kind, text ) {
12020 var $listItem, $icon, message;
12021 $listItem = $( '<li>' );
12022 if ( kind === 'error' ) {
12023 $icon = new OO.ui.IconWidget( { icon: 'alert', flags: [ 'error' ] } ).$element;
12024 $listItem.attr( 'role', 'alert' );
12025 } else if ( kind === 'warning' ) {
12026 $icon = new OO.ui.IconWidget( { icon: 'alert', flags: [ 'warning' ] } ).$element;
12027 $listItem.attr( 'role', 'alert' );
12028 } else if ( kind === 'success' ) {
12029 $icon = new OO.ui.IconWidget( { icon: 'check', flags: [ 'success' ] } ).$element;
12030 } else if ( kind === 'notice' ) {
12031 $icon = new OO.ui.IconWidget( { icon: 'notice' } ).$element;
12032 } else {
12033 $icon = '';
12034 }
12035 message = new OO.ui.LabelWidget( { label: text } );
12036 $listItem
12037 .append( $icon, message.$element )
12038 .addClass( 'oo-ui-fieldLayout-messages-' + kind );
12039 return $listItem;
12040 };
12041
12042 /**
12043 * Set the field alignment mode.
12044 *
12045 * @private
12046 * @param {string} value Alignment mode, either 'left', 'right', 'top' or 'inline'
12047 * @chainable
12048 * @return {OO.ui.BookletLayout} The layout, for chaining
12049 */
12050 OO.ui.FieldLayout.prototype.setAlignment = function ( value ) {
12051 if ( value !== this.align ) {
12052 // Default to 'left'
12053 if ( [ 'left', 'right', 'top', 'inline' ].indexOf( value ) === -1 ) {
12054 value = 'left';
12055 }
12056 // Validate
12057 if ( value === 'inline' && !this.isFieldInline() ) {
12058 value = 'top';
12059 }
12060 // Reorder elements
12061
12062 if ( this.helpInline ) {
12063 if ( value === 'top' ) {
12064 this.$header.append( this.$label );
12065 this.$body.append( this.$header, this.$field, this.$help );
12066 } else if ( value === 'inline' ) {
12067 this.$header.append( this.$label, this.$help );
12068 this.$body.append( this.$field, this.$header );
12069 } else {
12070 this.$header.append( this.$label, this.$help );
12071 this.$body.append( this.$header, this.$field );
12072 }
12073 } else {
12074 if ( value === 'top' ) {
12075 this.$header.append( this.$help, this.$label );
12076 this.$body.append( this.$header, this.$field );
12077 } else if ( value === 'inline' ) {
12078 this.$header.append( this.$help, this.$label );
12079 this.$body.append( this.$field, this.$header );
12080 } else {
12081 this.$header.append( this.$label );
12082 this.$body.append( this.$header, this.$help, this.$field );
12083 }
12084 }
12085 // Set classes. The following classes can be used here:
12086 // * oo-ui-fieldLayout-align-left
12087 // * oo-ui-fieldLayout-align-right
12088 // * oo-ui-fieldLayout-align-top
12089 // * oo-ui-fieldLayout-align-inline
12090 if ( this.align ) {
12091 this.$element.removeClass( 'oo-ui-fieldLayout-align-' + this.align );
12092 }
12093 this.$element.addClass( 'oo-ui-fieldLayout-align-' + value );
12094 this.align = value;
12095 }
12096
12097 return this;
12098 };
12099
12100 /**
12101 * Set the list of error messages.
12102 *
12103 * @param {Array} errors Error messages about the widget, which will be displayed below the widget.
12104 * The array may contain strings or OO.ui.HtmlSnippet instances.
12105 * @chainable
12106 * @return {OO.ui.BookletLayout} The layout, for chaining
12107 */
12108 OO.ui.FieldLayout.prototype.setErrors = function ( errors ) {
12109 this.errors = errors.slice();
12110 this.updateMessages();
12111 return this;
12112 };
12113
12114 /**
12115 * Set the list of warning messages.
12116 *
12117 * @param {Array} warnings Warning messages about the widget, which will be displayed below
12118 * the widget.
12119 * The array may contain strings or OO.ui.HtmlSnippet instances.
12120 * @chainable
12121 * @return {OO.ui.BookletLayout} The layout, for chaining
12122 */
12123 OO.ui.FieldLayout.prototype.setWarnings = function ( warnings ) {
12124 this.warnings = warnings.slice();
12125 this.updateMessages();
12126 return this;
12127 };
12128
12129 /**
12130 * Set the list of success messages.
12131 *
12132 * @param {Array} successMessages Success messages about the widget, which will be displayed below
12133 * the widget.
12134 * The array may contain strings or OO.ui.HtmlSnippet instances.
12135 * @chainable
12136 * @return {OO.ui.BookletLayout} The layout, for chaining
12137 */
12138 OO.ui.FieldLayout.prototype.setSuccess = function ( successMessages ) {
12139 this.successMessages = successMessages.slice();
12140 this.updateMessages();
12141 return this;
12142 };
12143
12144 /**
12145 * Set the list of notice messages.
12146 *
12147 * @param {Array} notices Notices about the widget, which will be displayed below the widget.
12148 * The array may contain strings or OO.ui.HtmlSnippet instances.
12149 * @chainable
12150 * @return {OO.ui.BookletLayout} The layout, for chaining
12151 */
12152 OO.ui.FieldLayout.prototype.setNotices = function ( notices ) {
12153 this.notices = notices.slice();
12154 this.updateMessages();
12155 return this;
12156 };
12157
12158 /**
12159 * Update the rendering of error, warning, success and notice messages.
12160 *
12161 * @private
12162 */
12163 OO.ui.FieldLayout.prototype.updateMessages = function () {
12164 var i;
12165 this.$messages.empty();
12166
12167 if (
12168 this.errors.length ||
12169 this.warnings.length ||
12170 this.successMessages.length ||
12171 this.notices.length
12172 ) {
12173 this.$body.after( this.$messages );
12174 } else {
12175 this.$messages.remove();
12176 return;
12177 }
12178
12179 for ( i = 0; i < this.errors.length; i++ ) {
12180 this.$messages.append( this.makeMessage( 'error', this.errors[ i ] ) );
12181 }
12182 for ( i = 0; i < this.warnings.length; i++ ) {
12183 this.$messages.append( this.makeMessage( 'warning', this.warnings[ i ] ) );
12184 }
12185 for ( i = 0; i < this.successMessages.length; i++ ) {
12186 this.$messages.append( this.makeMessage( 'success', this.successMessages[ i ] ) );
12187 }
12188 for ( i = 0; i < this.notices.length; i++ ) {
12189 this.$messages.append( this.makeMessage( 'notice', this.notices[ i ] ) );
12190 }
12191 };
12192
12193 /**
12194 * Include information about the widget's accessKey in our title. TitledElement calls this method.
12195 * (This is a bit of a hack.)
12196 *
12197 * @protected
12198 * @param {string} title Tooltip label for 'title' attribute
12199 * @return {string}
12200 */
12201 OO.ui.FieldLayout.prototype.formatTitleWithAccessKey = function ( title ) {
12202 if ( this.fieldWidget && this.fieldWidget.formatTitleWithAccessKey ) {
12203 return this.fieldWidget.formatTitleWithAccessKey( title );
12204 }
12205 return title;
12206 };
12207
12208 /**
12209 * Creates and returns the help element. Also sets the `aria-describedby`
12210 * attribute on the main element of the `fieldWidget`.
12211 *
12212 * @private
12213 * @param {string|OO.ui.HtmlSnippet} [help] Help text.
12214 * @param {jQuery} [$overlay] Passed to OO.ui.PopupButtonWidget for help popup.
12215 * @return {jQuery} The element that should become `this.$help`.
12216 */
12217 OO.ui.FieldLayout.prototype.createHelpElement = function ( help, $overlay ) {
12218 var helpId, helpWidget;
12219
12220 if ( this.helpInline ) {
12221 helpWidget = new OO.ui.LabelWidget( {
12222 label: help,
12223 classes: [ 'oo-ui-inline-help' ]
12224 } );
12225
12226 helpId = helpWidget.getElementId();
12227 } else {
12228 helpWidget = new OO.ui.PopupButtonWidget( {
12229 $overlay: $overlay,
12230 popup: {
12231 padded: true
12232 },
12233 classes: [ 'oo-ui-fieldLayout-help' ],
12234 framed: false,
12235 icon: 'info',
12236 label: OO.ui.msg( 'ooui-field-help' ),
12237 invisibleLabel: true
12238 } );
12239 if ( help instanceof OO.ui.HtmlSnippet ) {
12240 helpWidget.getPopup().$body.html( help.toString() );
12241 } else {
12242 helpWidget.getPopup().$body.text( help );
12243 }
12244
12245 helpId = helpWidget.getPopup().getBodyId();
12246 }
12247
12248 // Set the 'aria-describedby' attribute on the fieldWidget
12249 // Preference given to an input or a button
12250 (
12251 this.fieldWidget.$input ||
12252 this.fieldWidget.$button ||
12253 this.fieldWidget.$element
12254 ).attr( 'aria-describedby', helpId );
12255
12256 return helpWidget.$element;
12257 };
12258
12259 /**
12260 * ActionFieldLayouts are used with OO.ui.FieldsetLayout. The layout consists of a field-widget,
12261 * a button, and an optional label and/or help text. The field-widget (e.g., a
12262 * {@link OO.ui.TextInputWidget TextInputWidget}), is required and is specified before any optional
12263 * configuration settings.
12264 *
12265 * Labels can be aligned in one of four ways:
12266 *
12267 * - **left**: The label is placed before the field-widget and aligned with the left margin.
12268 * A left-alignment is used for forms with many fields.
12269 * - **right**: The label is placed before the field-widget and aligned to the right margin.
12270 * A right-alignment is used for long but familiar forms which users tab through,
12271 * verifying the current field with a quick glance at the label.
12272 * - **top**: The label is placed above the field-widget. A top-alignment is used for brief forms
12273 * that users fill out from top to bottom.
12274 * - **inline**: The label is placed after the field-widget and aligned to the left.
12275 * An inline-alignment is best used with checkboxes or radio buttons.
12276 *
12277 * Help text is accessed via a help icon that appears in the upper right corner of the rendered
12278 * field layout when help text is specified.
12279 *
12280 * @example
12281 * // Example of an ActionFieldLayout
12282 * var actionFieldLayout = new OO.ui.ActionFieldLayout(
12283 * new OO.ui.TextInputWidget( {
12284 * placeholder: 'Field widget'
12285 * } ),
12286 * new OO.ui.ButtonWidget( {
12287 * label: 'Button'
12288 * } ),
12289 * {
12290 * label: 'An ActionFieldLayout. This label is aligned top',
12291 * align: 'top',
12292 * help: 'This is help text'
12293 * }
12294 * );
12295 *
12296 * $( document.body ).append( actionFieldLayout.$element );
12297 *
12298 * @class
12299 * @extends OO.ui.FieldLayout
12300 *
12301 * @constructor
12302 * @param {OO.ui.Widget} fieldWidget Field widget
12303 * @param {OO.ui.ButtonWidget} buttonWidget Button widget
12304 * @param {Object} config
12305 */
12306 OO.ui.ActionFieldLayout = function OoUiActionFieldLayout( fieldWidget, buttonWidget, config ) {
12307 // Allow passing positional parameters inside the config object
12308 if ( OO.isPlainObject( fieldWidget ) && config === undefined ) {
12309 config = fieldWidget;
12310 fieldWidget = config.fieldWidget;
12311 buttonWidget = config.buttonWidget;
12312 }
12313
12314 // Parent constructor
12315 OO.ui.ActionFieldLayout.parent.call( this, fieldWidget, config );
12316
12317 // Properties
12318 this.buttonWidget = buttonWidget;
12319 this.$button = $( '<span>' );
12320 this.$input = this.isFieldInline() ? $( '<span>' ) : $( '<div>' );
12321
12322 // Initialization
12323 this.$element.addClass( 'oo-ui-actionFieldLayout' );
12324 this.$button
12325 .addClass( 'oo-ui-actionFieldLayout-button' )
12326 .append( this.buttonWidget.$element );
12327 this.$input
12328 .addClass( 'oo-ui-actionFieldLayout-input' )
12329 .append( this.fieldWidget.$element );
12330 this.$field.append( this.$input, this.$button );
12331 };
12332
12333 /* Setup */
12334
12335 OO.inheritClass( OO.ui.ActionFieldLayout, OO.ui.FieldLayout );
12336
12337 /**
12338 * FieldsetLayouts are composed of one or more {@link OO.ui.FieldLayout FieldLayouts},
12339 * which each contain an individual widget and, optionally, a label. Each Fieldset can be
12340 * configured with a label as well. For more information and examples,
12341 * please see the [OOUI documentation on MediaWiki][1].
12342 *
12343 * @example
12344 * // Example of a fieldset layout
12345 * var input1 = new OO.ui.TextInputWidget( {
12346 * placeholder: 'A text input field'
12347 * } );
12348 *
12349 * var input2 = new OO.ui.TextInputWidget( {
12350 * placeholder: 'A text input field'
12351 * } );
12352 *
12353 * var fieldset = new OO.ui.FieldsetLayout( {
12354 * label: 'Example of a fieldset layout'
12355 * } );
12356 *
12357 * fieldset.addItems( [
12358 * new OO.ui.FieldLayout( input1, {
12359 * label: 'Field One'
12360 * } ),
12361 * new OO.ui.FieldLayout( input2, {
12362 * label: 'Field Two'
12363 * } )
12364 * ] );
12365 * $( document.body ).append( fieldset.$element );
12366 *
12367 * [1]: https://www.mediawiki.org/wiki/OOUI/Layouts/Fields_and_Fieldsets
12368 *
12369 * @class
12370 * @extends OO.ui.Layout
12371 * @mixins OO.ui.mixin.IconElement
12372 * @mixins OO.ui.mixin.LabelElement
12373 * @mixins OO.ui.mixin.GroupElement
12374 *
12375 * @constructor
12376 * @param {Object} [config] Configuration options
12377 * @cfg {OO.ui.FieldLayout[]} [items] An array of fields to add to the fieldset.
12378 * See OO.ui.FieldLayout for more information about fields.
12379 * @cfg {string|OO.ui.HtmlSnippet} [help] Help text. When help text is specified, a "help" icon
12380 * will appear in the upper-right corner of the rendered field; clicking it will display the text
12381 * in a popup. For important messages, you are advised to use `notices`, as they are always shown.
12382 * @cfg {jQuery} [$overlay] Passed to OO.ui.PopupButtonWidget for help popup, if `help` is given.
12383 * See <https://www.mediawiki.org/wiki/OOUI/Concepts#Overlays>.
12384 */
12385 OO.ui.FieldsetLayout = function OoUiFieldsetLayout( config ) {
12386 // Configuration initialization
12387 config = config || {};
12388
12389 // Parent constructor
12390 OO.ui.FieldsetLayout.parent.call( this, config );
12391
12392 // Mixin constructors
12393 OO.ui.mixin.IconElement.call( this, config );
12394 OO.ui.mixin.LabelElement.call( this, config );
12395 OO.ui.mixin.GroupElement.call( this, config );
12396
12397 // Properties
12398 this.$header = $( '<legend>' );
12399 if ( config.help ) {
12400 this.popupButtonWidget = new OO.ui.PopupButtonWidget( {
12401 $overlay: config.$overlay,
12402 popup: {
12403 padded: true
12404 },
12405 classes: [ 'oo-ui-fieldsetLayout-help' ],
12406 framed: false,
12407 icon: 'info',
12408 label: OO.ui.msg( 'ooui-field-help' ),
12409 invisibleLabel: true
12410 } );
12411 if ( config.help instanceof OO.ui.HtmlSnippet ) {
12412 this.popupButtonWidget.getPopup().$body.html( config.help.toString() );
12413 } else {
12414 this.popupButtonWidget.getPopup().$body.text( config.help );
12415 }
12416 this.$help = this.popupButtonWidget.$element;
12417 } else {
12418 this.$help = $( [] );
12419 }
12420
12421 // Initialization
12422 this.$header
12423 .addClass( 'oo-ui-fieldsetLayout-header' )
12424 .append( this.$icon, this.$label, this.$help );
12425 this.$group.addClass( 'oo-ui-fieldsetLayout-group' );
12426 this.$element
12427 .addClass( 'oo-ui-fieldsetLayout' )
12428 .prepend( this.$header, this.$group );
12429 if ( Array.isArray( config.items ) ) {
12430 this.addItems( config.items );
12431 }
12432 };
12433
12434 /* Setup */
12435
12436 OO.inheritClass( OO.ui.FieldsetLayout, OO.ui.Layout );
12437 OO.mixinClass( OO.ui.FieldsetLayout, OO.ui.mixin.IconElement );
12438 OO.mixinClass( OO.ui.FieldsetLayout, OO.ui.mixin.LabelElement );
12439 OO.mixinClass( OO.ui.FieldsetLayout, OO.ui.mixin.GroupElement );
12440
12441 /* Static Properties */
12442
12443 /**
12444 * @static
12445 * @inheritdoc
12446 */
12447 OO.ui.FieldsetLayout.static.tagName = 'fieldset';
12448
12449 /**
12450 * FormLayouts are used to wrap {@link OO.ui.FieldsetLayout FieldsetLayouts} when you intend to use
12451 * browser-based form submission for the fields instead of handling them in JavaScript. Form layouts
12452 * can be configured with an HTML form action, an encoding type, and a method using the #action,
12453 * #enctype, and #method configs, respectively.
12454 * See the [OOUI documentation on MediaWiki] [1] for more information and examples.
12455 *
12456 * Only widgets from the {@link OO.ui.InputWidget InputWidget} family support form submission. It
12457 * includes standard form elements like {@link OO.ui.CheckboxInputWidget checkboxes}, {@link
12458 * OO.ui.RadioInputWidget radio buttons} and {@link OO.ui.TextInputWidget text fields}, as well as
12459 * some fancier controls. Some controls have both regular and InputWidget variants, for example
12460 * OO.ui.DropdownWidget and OO.ui.DropdownInputWidget – only the latter support form submission and
12461 * often have simplified APIs to match the capabilities of HTML forms.
12462 * See the [OOUI documentation on MediaWiki] [2] for more information about InputWidgets.
12463 *
12464 * [1]: https://www.mediawiki.org/wiki/OOUI/Layouts/Forms
12465 * [2]: https://www.mediawiki.org/wiki/OOUI/Widgets/Inputs
12466 *
12467 * @example
12468 * // Example of a form layout that wraps a fieldset layout.
12469 * var input1 = new OO.ui.TextInputWidget( {
12470 * placeholder: 'Username'
12471 * } ),
12472 * input2 = new OO.ui.TextInputWidget( {
12473 * placeholder: 'Password',
12474 * type: 'password'
12475 * } ),
12476 * submit = new OO.ui.ButtonInputWidget( {
12477 * label: 'Submit'
12478 * } ),
12479 * fieldset = new OO.ui.FieldsetLayout( {
12480 * label: 'A form layout'
12481 * } );
12482 *
12483 * fieldset.addItems( [
12484 * new OO.ui.FieldLayout( input1, {
12485 * label: 'Username',
12486 * align: 'top'
12487 * } ),
12488 * new OO.ui.FieldLayout( input2, {
12489 * label: 'Password',
12490 * align: 'top'
12491 * } ),
12492 * new OO.ui.FieldLayout( submit )
12493 * ] );
12494 * var form = new OO.ui.FormLayout( {
12495 * items: [ fieldset ],
12496 * action: '/api/formhandler',
12497 * method: 'get'
12498 * } )
12499 * $( document.body ).append( form.$element );
12500 *
12501 * @class
12502 * @extends OO.ui.Layout
12503 * @mixins OO.ui.mixin.GroupElement
12504 *
12505 * @constructor
12506 * @param {Object} [config] Configuration options
12507 * @cfg {string} [method] HTML form `method` attribute
12508 * @cfg {string} [action] HTML form `action` attribute
12509 * @cfg {string} [enctype] HTML form `enctype` attribute
12510 * @cfg {OO.ui.FieldsetLayout[]} [items] Fieldset layouts to add to the form layout.
12511 */
12512 OO.ui.FormLayout = function OoUiFormLayout( config ) {
12513 var action;
12514
12515 // Configuration initialization
12516 config = config || {};
12517
12518 // Parent constructor
12519 OO.ui.FormLayout.parent.call( this, config );
12520
12521 // Mixin constructors
12522 OO.ui.mixin.GroupElement.call( this, $.extend( { $group: this.$element }, config ) );
12523
12524 // Events
12525 this.$element.on( 'submit', this.onFormSubmit.bind( this ) );
12526
12527 // Make sure the action is safe
12528 action = config.action;
12529 if ( action !== undefined && !OO.ui.isSafeUrl( action ) ) {
12530 action = './' + action;
12531 }
12532
12533 // Initialization
12534 this.$element
12535 .addClass( 'oo-ui-formLayout' )
12536 .attr( {
12537 method: config.method,
12538 action: action,
12539 enctype: config.enctype
12540 } );
12541 if ( Array.isArray( config.items ) ) {
12542 this.addItems( config.items );
12543 }
12544 };
12545
12546 /* Setup */
12547
12548 OO.inheritClass( OO.ui.FormLayout, OO.ui.Layout );
12549 OO.mixinClass( OO.ui.FormLayout, OO.ui.mixin.GroupElement );
12550
12551 /* Events */
12552
12553 /**
12554 * A 'submit' event is emitted when the form is submitted.
12555 *
12556 * @event submit
12557 */
12558
12559 /* Static Properties */
12560
12561 /**
12562 * @static
12563 * @inheritdoc
12564 */
12565 OO.ui.FormLayout.static.tagName = 'form';
12566
12567 /* Methods */
12568
12569 /**
12570 * Handle form submit events.
12571 *
12572 * @private
12573 * @param {jQuery.Event} e Submit event
12574 * @fires submit
12575 * @return {OO.ui.FormLayout} The layout, for chaining
12576 */
12577 OO.ui.FormLayout.prototype.onFormSubmit = function () {
12578 if ( this.emit( 'submit' ) ) {
12579 return false;
12580 }
12581 };
12582
12583 /**
12584 * PanelLayouts expand to cover the entire area of their parent. They can be configured with
12585 * scrolling, padding, and a frame, and are often used together with
12586 * {@link OO.ui.StackLayout StackLayouts}.
12587 *
12588 * @example
12589 * // Example of a panel layout
12590 * var panel = new OO.ui.PanelLayout( {
12591 * expanded: false,
12592 * framed: true,
12593 * padded: true,
12594 * $content: $( '<p>A panel layout with padding and a frame.</p>' )
12595 * } );
12596 * $( document.body ).append( panel.$element );
12597 *
12598 * @class
12599 * @extends OO.ui.Layout
12600 *
12601 * @constructor
12602 * @param {Object} [config] Configuration options
12603 * @cfg {boolean} [scrollable=false] Allow vertical scrolling
12604 * @cfg {boolean} [padded=false] Add padding between the content and the edges of the panel.
12605 * @cfg {boolean} [expanded=true] Expand the panel to fill the entire parent element.
12606 * @cfg {boolean} [framed=false] Render the panel with a frame to visually separate it from outside
12607 * content.
12608 */
12609 OO.ui.PanelLayout = function OoUiPanelLayout( config ) {
12610 // Configuration initialization
12611 config = $.extend( {
12612 scrollable: false,
12613 padded: false,
12614 expanded: true,
12615 framed: false
12616 }, config );
12617
12618 // Parent constructor
12619 OO.ui.PanelLayout.parent.call( this, config );
12620
12621 // Initialization
12622 this.$element.addClass( 'oo-ui-panelLayout' );
12623 if ( config.scrollable ) {
12624 this.$element.addClass( 'oo-ui-panelLayout-scrollable' );
12625 }
12626 if ( config.padded ) {
12627 this.$element.addClass( 'oo-ui-panelLayout-padded' );
12628 }
12629 if ( config.expanded ) {
12630 this.$element.addClass( 'oo-ui-panelLayout-expanded' );
12631 }
12632 if ( config.framed ) {
12633 this.$element.addClass( 'oo-ui-panelLayout-framed' );
12634 }
12635 };
12636
12637 /* Setup */
12638
12639 OO.inheritClass( OO.ui.PanelLayout, OO.ui.Layout );
12640
12641 /* Static Methods */
12642
12643 /**
12644 * @inheritdoc
12645 */
12646 OO.ui.PanelLayout.static.reusePreInfuseDOM = function ( node, config ) {
12647 config = OO.ui.PanelLayout.parent.static.reusePreInfuseDOM( node, config );
12648 if ( config.preserveContent !== false ) {
12649 config.$content = $( node ).contents();
12650 }
12651 return config;
12652 };
12653
12654 /* Methods */
12655
12656 /**
12657 * Focus the panel layout
12658 *
12659 * The default implementation just focuses the first focusable element in the panel
12660 */
12661 OO.ui.PanelLayout.prototype.focus = function () {
12662 OO.ui.findFocusable( this.$element ).focus();
12663 };
12664
12665 /**
12666 * HorizontalLayout arranges its contents in a single line (using `display: inline-block` for its
12667 * items), with small margins between them. Convenient when you need to put a number of block-level
12668 * widgets on a single line next to each other.
12669 *
12670 * Note that inline elements, such as OO.ui.ButtonWidgets, do not need this wrapper.
12671 *
12672 * @example
12673 * // HorizontalLayout with a text input and a label.
12674 * var layout = new OO.ui.HorizontalLayout( {
12675 * items: [
12676 * new OO.ui.LabelWidget( { label: 'Label' } ),
12677 * new OO.ui.TextInputWidget( { value: 'Text' } )
12678 * ]
12679 * } );
12680 * $( document.body ).append( layout.$element );
12681 *
12682 * @class
12683 * @extends OO.ui.Layout
12684 * @mixins OO.ui.mixin.GroupElement
12685 *
12686 * @constructor
12687 * @param {Object} [config] Configuration options
12688 * @cfg {OO.ui.Widget[]|OO.ui.Layout[]} [items] Widgets or other layouts to add to the layout.
12689 */
12690 OO.ui.HorizontalLayout = function OoUiHorizontalLayout( config ) {
12691 // Configuration initialization
12692 config = config || {};
12693
12694 // Parent constructor
12695 OO.ui.HorizontalLayout.parent.call( this, config );
12696
12697 // Mixin constructors
12698 OO.ui.mixin.GroupElement.call( this, $.extend( { $group: this.$element }, config ) );
12699
12700 // Initialization
12701 this.$element.addClass( 'oo-ui-horizontalLayout' );
12702 if ( Array.isArray( config.items ) ) {
12703 this.addItems( config.items );
12704 }
12705 };
12706
12707 /* Setup */
12708
12709 OO.inheritClass( OO.ui.HorizontalLayout, OO.ui.Layout );
12710 OO.mixinClass( OO.ui.HorizontalLayout, OO.ui.mixin.GroupElement );
12711
12712 /**
12713 * NumberInputWidgets combine a {@link OO.ui.TextInputWidget text input} (where a value
12714 * can be entered manually) and two {@link OO.ui.ButtonWidget button widgets}
12715 * (to adjust the value in increments) to allow the user to enter a number.
12716 *
12717 * @example
12718 * // A NumberInputWidget.
12719 * var numberInput = new OO.ui.NumberInputWidget( {
12720 * label: 'NumberInputWidget',
12721 * input: { value: 5 },
12722 * min: 1,
12723 * max: 10
12724 * } );
12725 * $( document.body ).append( numberInput.$element );
12726 *
12727 * @class
12728 * @extends OO.ui.TextInputWidget
12729 *
12730 * @constructor
12731 * @param {Object} [config] Configuration options
12732 * @cfg {Object} [minusButton] Configuration options to pass to the
12733 * {@link OO.ui.ButtonWidget decrementing button widget}.
12734 * @cfg {Object} [plusButton] Configuration options to pass to the
12735 * {@link OO.ui.ButtonWidget incrementing button widget}.
12736 * @cfg {number} [min=-Infinity] Minimum allowed value
12737 * @cfg {number} [max=Infinity] Maximum allowed value
12738 * @cfg {number|null} [step] If specified, the field only accepts values that are multiples of this.
12739 * @cfg {number} [buttonStep=step||1] Delta when using the buttons or Up/Down arrow keys.
12740 * Defaults to `step` if specified, otherwise `1`.
12741 * @cfg {number} [pageStep=10*buttonStep] Delta when using the Page-up/Page-down keys.
12742 * Defaults to 10 times `buttonStep`.
12743 * @cfg {boolean} [showButtons=true] Whether to show the plus and minus buttons.
12744 */
12745 OO.ui.NumberInputWidget = function OoUiNumberInputWidget( config ) {
12746 var $field = $( '<div>' ).addClass( 'oo-ui-numberInputWidget-field' );
12747
12748 // Configuration initialization
12749 config = $.extend( {
12750 min: -Infinity,
12751 max: Infinity,
12752 showButtons: true
12753 }, config );
12754
12755 // For backward compatibility
12756 $.extend( config, config.input );
12757 this.input = this;
12758
12759 // Parent constructor
12760 OO.ui.NumberInputWidget.parent.call( this, $.extend( config, {
12761 type: 'number'
12762 } ) );
12763
12764 if ( config.showButtons ) {
12765 this.minusButton = new OO.ui.ButtonWidget( $.extend(
12766 {
12767 disabled: this.isDisabled(),
12768 tabIndex: -1,
12769 classes: [ 'oo-ui-numberInputWidget-minusButton' ],
12770 icon: 'subtract'
12771 },
12772 config.minusButton
12773 ) );
12774 this.minusButton.$element.attr( 'aria-hidden', 'true' );
12775 this.plusButton = new OO.ui.ButtonWidget( $.extend(
12776 {
12777 disabled: this.isDisabled(),
12778 tabIndex: -1,
12779 classes: [ 'oo-ui-numberInputWidget-plusButton' ],
12780 icon: 'add'
12781 },
12782 config.plusButton
12783 ) );
12784 this.plusButton.$element.attr( 'aria-hidden', 'true' );
12785 }
12786
12787 // Events
12788 this.$input.on( {
12789 keydown: this.onKeyDown.bind( this ),
12790 'wheel mousewheel DOMMouseScroll': this.onWheel.bind( this )
12791 } );
12792 if ( config.showButtons ) {
12793 this.plusButton.connect( this, {
12794 click: [ 'onButtonClick', +1 ]
12795 } );
12796 this.minusButton.connect( this, {
12797 click: [ 'onButtonClick', -1 ]
12798 } );
12799 }
12800
12801 // Build the field
12802 $field.append( this.$input );
12803 if ( config.showButtons ) {
12804 $field
12805 .prepend( this.minusButton.$element )
12806 .append( this.plusButton.$element );
12807 }
12808
12809 // Initialization
12810 if ( config.allowInteger || config.isInteger ) {
12811 // Backward compatibility
12812 config.step = 1;
12813 }
12814 this.setRange( config.min, config.max );
12815 this.setStep( config.buttonStep, config.pageStep, config.step );
12816 // Set the validation method after we set step and range
12817 // so that it doesn't immediately call setValidityFlag
12818 this.setValidation( this.validateNumber.bind( this ) );
12819
12820 this.$element
12821 .addClass( 'oo-ui-numberInputWidget' )
12822 .toggleClass( 'oo-ui-numberInputWidget-buttoned', config.showButtons )
12823 .append( $field );
12824 };
12825
12826 /* Setup */
12827
12828 OO.inheritClass( OO.ui.NumberInputWidget, OO.ui.TextInputWidget );
12829
12830 /* Methods */
12831
12832 // Backward compatibility
12833 OO.ui.NumberInputWidget.prototype.setAllowInteger = function ( flag ) {
12834 this.setStep( flag ? 1 : null );
12835 };
12836 // Backward compatibility
12837 OO.ui.NumberInputWidget.prototype.setIsInteger = OO.ui.NumberInputWidget.prototype.setAllowInteger;
12838
12839 // Backward compatibility
12840 OO.ui.NumberInputWidget.prototype.getAllowInteger = function () {
12841 return this.step === 1;
12842 };
12843 // Backward compatibility
12844 OO.ui.NumberInputWidget.prototype.getIsInteger = OO.ui.NumberInputWidget.prototype.getAllowInteger;
12845
12846 /**
12847 * Set the range of allowed values
12848 *
12849 * @param {number} min Minimum allowed value
12850 * @param {number} max Maximum allowed value
12851 */
12852 OO.ui.NumberInputWidget.prototype.setRange = function ( min, max ) {
12853 if ( min > max ) {
12854 throw new Error( 'Minimum (' + min + ') must not be greater than maximum (' + max + ')' );
12855 }
12856 this.min = min;
12857 this.max = max;
12858 this.$input.attr( 'min', this.min );
12859 this.$input.attr( 'max', this.max );
12860 this.setValidityFlag();
12861 };
12862
12863 /**
12864 * Get the current range
12865 *
12866 * @return {number[]} Minimum and maximum values
12867 */
12868 OO.ui.NumberInputWidget.prototype.getRange = function () {
12869 return [ this.min, this.max ];
12870 };
12871
12872 /**
12873 * Set the stepping deltas
12874 *
12875 * @param {number} [buttonStep=step||1] Delta when using the buttons or up/down arrow keys.
12876 * Defaults to `step` if specified, otherwise `1`.
12877 * @param {number} [pageStep=10*buttonStep] Delta when using the page-up/page-down keys.
12878 * Defaults to 10 times `buttonStep`.
12879 * @param {number|null} [step] If specified, the field only accepts values that are multiples
12880 * of this.
12881 */
12882 OO.ui.NumberInputWidget.prototype.setStep = function ( buttonStep, pageStep, step ) {
12883 if ( buttonStep === undefined ) {
12884 buttonStep = step || 1;
12885 }
12886 if ( pageStep === undefined ) {
12887 pageStep = 10 * buttonStep;
12888 }
12889 if ( step !== null && step <= 0 ) {
12890 throw new Error( 'Step value, if given, must be positive' );
12891 }
12892 if ( buttonStep <= 0 ) {
12893 throw new Error( 'Button step value must be positive' );
12894 }
12895 if ( pageStep <= 0 ) {
12896 throw new Error( 'Page step value must be positive' );
12897 }
12898 this.step = step;
12899 this.buttonStep = buttonStep;
12900 this.pageStep = pageStep;
12901 this.$input.attr( 'step', this.step || 'any' );
12902 this.setValidityFlag();
12903 };
12904
12905 /**
12906 * @inheritdoc
12907 */
12908 OO.ui.NumberInputWidget.prototype.setValue = function ( value ) {
12909 if ( value === '' ) {
12910 // Some browsers allow a value in the input even if there isn't one reported by $input.val()
12911 // so here we make sure an 'empty' value is actually displayed as such.
12912 this.$input.val( '' );
12913 }
12914 return OO.ui.NumberInputWidget.parent.prototype.setValue.call( this, value );
12915 };
12916
12917 /**
12918 * Get the current stepping values
12919 *
12920 * @return {number[]} Button step, page step, and validity step
12921 */
12922 OO.ui.NumberInputWidget.prototype.getStep = function () {
12923 return [ this.buttonStep, this.pageStep, this.step ];
12924 };
12925
12926 /**
12927 * Get the current value of the widget as a number
12928 *
12929 * @return {number} May be NaN, or an invalid number
12930 */
12931 OO.ui.NumberInputWidget.prototype.getNumericValue = function () {
12932 return +this.getValue();
12933 };
12934
12935 /**
12936 * Adjust the value of the widget
12937 *
12938 * @param {number} delta Adjustment amount
12939 */
12940 OO.ui.NumberInputWidget.prototype.adjustValue = function ( delta ) {
12941 var n, v = this.getNumericValue();
12942
12943 delta = +delta;
12944 if ( isNaN( delta ) || !isFinite( delta ) ) {
12945 throw new Error( 'Delta must be a finite number' );
12946 }
12947
12948 if ( isNaN( v ) ) {
12949 n = 0;
12950 } else {
12951 n = v + delta;
12952 n = Math.max( Math.min( n, this.max ), this.min );
12953 if ( this.step ) {
12954 n = Math.round( n / this.step ) * this.step;
12955 }
12956 }
12957
12958 if ( n !== v ) {
12959 this.setValue( n );
12960 }
12961 };
12962 /**
12963 * Validate input
12964 *
12965 * @private
12966 * @param {string} value Field value
12967 * @return {boolean}
12968 */
12969 OO.ui.NumberInputWidget.prototype.validateNumber = function ( value ) {
12970 var n = +value;
12971 if ( value === '' ) {
12972 return !this.isRequired();
12973 }
12974
12975 if ( isNaN( n ) || !isFinite( n ) ) {
12976 return false;
12977 }
12978
12979 if ( this.step && Math.floor( n / this.step ) !== n / this.step ) {
12980 return false;
12981 }
12982
12983 if ( n < this.min || n > this.max ) {
12984 return false;
12985 }
12986
12987 return true;
12988 };
12989
12990 /**
12991 * Handle mouse click events.
12992 *
12993 * @private
12994 * @param {number} dir +1 or -1
12995 */
12996 OO.ui.NumberInputWidget.prototype.onButtonClick = function ( dir ) {
12997 this.adjustValue( dir * this.buttonStep );
12998 };
12999
13000 /**
13001 * Handle mouse wheel events.
13002 *
13003 * @private
13004 * @param {jQuery.Event} event
13005 * @return {undefined/boolean} False to prevent default if event is handled
13006 */
13007 OO.ui.NumberInputWidget.prototype.onWheel = function ( event ) {
13008 var delta = 0;
13009
13010 if ( !this.isDisabled() && this.$input.is( ':focus' ) ) {
13011 // Standard 'wheel' event
13012 if ( event.originalEvent.deltaMode !== undefined ) {
13013 this.sawWheelEvent = true;
13014 }
13015 if ( event.originalEvent.deltaY ) {
13016 delta = -event.originalEvent.deltaY;
13017 } else if ( event.originalEvent.deltaX ) {
13018 delta = event.originalEvent.deltaX;
13019 }
13020
13021 // Non-standard events
13022 if ( !this.sawWheelEvent ) {
13023 if ( event.originalEvent.wheelDeltaX ) {
13024 delta = -event.originalEvent.wheelDeltaX;
13025 } else if ( event.originalEvent.wheelDeltaY ) {
13026 delta = event.originalEvent.wheelDeltaY;
13027 } else if ( event.originalEvent.wheelDelta ) {
13028 delta = event.originalEvent.wheelDelta;
13029 } else if ( event.originalEvent.detail ) {
13030 delta = -event.originalEvent.detail;
13031 }
13032 }
13033
13034 if ( delta ) {
13035 delta = delta < 0 ? -1 : 1;
13036 this.adjustValue( delta * this.buttonStep );
13037 }
13038
13039 return false;
13040 }
13041 };
13042
13043 /**
13044 * Handle key down events.
13045 *
13046 * @private
13047 * @param {jQuery.Event} e Key down event
13048 * @return {undefined/boolean} False to prevent default if event is handled
13049 */
13050 OO.ui.NumberInputWidget.prototype.onKeyDown = function ( e ) {
13051 if ( !this.isDisabled() ) {
13052 switch ( e.which ) {
13053 case OO.ui.Keys.UP:
13054 this.adjustValue( this.buttonStep );
13055 return false;
13056 case OO.ui.Keys.DOWN:
13057 this.adjustValue( -this.buttonStep );
13058 return false;
13059 case OO.ui.Keys.PAGEUP:
13060 this.adjustValue( this.pageStep );
13061 return false;
13062 case OO.ui.Keys.PAGEDOWN:
13063 this.adjustValue( -this.pageStep );
13064 return false;
13065 }
13066 }
13067 };
13068
13069 /**
13070 * @inheritdoc
13071 */
13072 OO.ui.NumberInputWidget.prototype.setDisabled = function ( disabled ) {
13073 // Parent method
13074 OO.ui.NumberInputWidget.parent.prototype.setDisabled.call( this, disabled );
13075
13076 if ( this.minusButton ) {
13077 this.minusButton.setDisabled( this.isDisabled() );
13078 }
13079 if ( this.plusButton ) {
13080 this.plusButton.setDisabled( this.isDisabled() );
13081 }
13082
13083 return this;
13084 };
13085
13086 /**
13087 * SelectFileInputWidgets allow for selecting files, using <input type="file">. These
13088 * widgets can be configured with {@link OO.ui.mixin.IconElement icons}, {@link
13089 * OO.ui.mixin.IndicatorElement indicators} and {@link OO.ui.mixin.TitledElement titles}.
13090 * Please see the [OOUI documentation on MediaWiki] [1] for more information and examples.
13091 *
13092 * SelectFileInputWidgets must be used in HTML forms, as getValue only returns the filename.
13093 *
13094 * @example
13095 * // A file select input widget.
13096 * var selectFile = new OO.ui.SelectFileInputWidget();
13097 * $( document.body ).append( selectFile.$element );
13098 *
13099 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets
13100 *
13101 * @class
13102 * @extends OO.ui.InputWidget
13103 *
13104 * @constructor
13105 * @param {Object} [config] Configuration options
13106 * @cfg {string[]|null} [accept=null] MIME types to accept. null accepts all types.
13107 * @cfg {boolean} [multiple=false] Allow multiple files to be selected.
13108 * @cfg {string} [placeholder] Text to display when no file is selected.
13109 * @cfg {Object} [button] Config to pass to select file button.
13110 * @cfg {string} [icon] Icon to show next to file info
13111 */
13112 OO.ui.SelectFileInputWidget = function OoUiSelectFileInputWidget( config ) {
13113 var widget = this;
13114
13115 config = config || {};
13116
13117 // Construct buttons before parent method is called (calling setDisabled)
13118 this.selectButton = new OO.ui.ButtonWidget( $.extend( {
13119 $element: $( '<label>' ),
13120 classes: [ 'oo-ui-selectFileInputWidget-selectButton' ],
13121 label: OO.ui.msg( 'ooui-selectfile-button-select' )
13122 }, config.button ) );
13123
13124 // Configuration initialization
13125 config = $.extend( {
13126 accept: null,
13127 placeholder: OO.ui.msg( 'ooui-selectfile-placeholder' ),
13128 $tabIndexed: this.selectButton.$tabIndexed
13129 }, config );
13130
13131 this.info = new OO.ui.SearchInputWidget( {
13132 classes: [ 'oo-ui-selectFileInputWidget-info' ],
13133 placeholder: config.placeholder,
13134 // Pass an empty collection so that .focus() always does nothing
13135 $tabIndexed: $( [] )
13136 } ).setIcon( config.icon );
13137 // Set tabindex manually on $input as $tabIndexed has been overridden
13138 this.info.$input.attr( 'tabindex', -1 );
13139
13140 // Parent constructor
13141 OO.ui.SelectFileInputWidget.parent.call( this, config );
13142
13143 // Properties
13144 this.currentFiles = this.filterFiles( this.$input[ 0 ].files || [] );
13145 if ( Array.isArray( config.accept ) ) {
13146 this.accept = config.accept;
13147 } else {
13148 this.accept = null;
13149 }
13150 this.multiple = !!config.multiple;
13151
13152 // Events
13153 this.info.connect( this, { change: 'onInfoChange' } );
13154 this.selectButton.$button.on( {
13155 keypress: this.onKeyPress.bind( this )
13156 } );
13157 this.$input.on( {
13158 change: this.onFileSelected.bind( this ),
13159 // Support: IE11
13160 // In IE 11, focussing a file input (by clicking on it) displays a text cursor and scrolls
13161 // the cursor into view (in this case, it scrolls the button, which has 'overflow: hidden').
13162 // Since this messes with our custom styling (the file input has large dimensions and this
13163 // causes the label to scroll out of view), scroll the button back to top. (T192131)
13164 focus: function () {
13165 widget.$input.parent().prop( 'scrollTop', 0 );
13166 }
13167 } );
13168 this.connect( this, { change: 'updateUI' } );
13169
13170 this.fieldLayout = new OO.ui.ActionFieldLayout( this.info, this.selectButton, { align: 'top' } );
13171
13172 this.$input
13173 .attr( {
13174 type: 'file',
13175 // this.selectButton is tabindexed
13176 tabindex: -1,
13177 // Infused input may have previously by
13178 // TabIndexed, so remove aria-disabled attr.
13179 'aria-disabled': null
13180 } );
13181
13182 if ( this.accept ) {
13183 this.$input.attr( 'accept', this.accept.join( ', ' ) );
13184 }
13185 if ( this.multiple ) {
13186 this.$input.attr( 'multiple', '' );
13187 }
13188 this.selectButton.$button.append( this.$input );
13189
13190 this.$element
13191 .addClass( 'oo-ui-selectFileInputWidget' )
13192 .append( this.fieldLayout.$element );
13193
13194 this.updateUI();
13195 };
13196
13197 /* Setup */
13198
13199 OO.inheritClass( OO.ui.SelectFileInputWidget, OO.ui.InputWidget );
13200
13201 /* Static properties */
13202
13203 // Set empty title so that browser default tooltips like "No file chosen" don't appear.
13204 // On SelectFileWidget this tooltip will often be incorrect, so create a consistent
13205 // experience on SelectFileInputWidget.
13206 OO.ui.SelectFileInputWidget.static.title = '';
13207
13208 /* Methods */
13209
13210 /**
13211 * Get the filename of the currently selected file.
13212 *
13213 * @return {string} Filename
13214 */
13215 OO.ui.SelectFileInputWidget.prototype.getFilename = function () {
13216 if ( this.currentFiles.length ) {
13217 return this.currentFiles.map( function ( file ) {
13218 return file.name;
13219 } ).join( ', ' );
13220 } else {
13221 // Try to strip leading fakepath.
13222 return this.getValue().split( '\\' ).pop();
13223 }
13224 };
13225
13226 /**
13227 * @inheritdoc
13228 */
13229 OO.ui.SelectFileInputWidget.prototype.setValue = function ( value ) {
13230 if ( value === undefined ) {
13231 // Called during init, don't replace value if just infusing.
13232 return;
13233 }
13234 if ( value ) {
13235 // We need to update this.value, but without trying to modify
13236 // the DOM value, which would throw an exception.
13237 if ( this.value !== value ) {
13238 this.value = value;
13239 this.emit( 'change', this.value );
13240 }
13241 } else {
13242 this.currentFiles = [];
13243 // Parent method
13244 OO.ui.SelectFileInputWidget.super.prototype.setValue.call( this, '' );
13245 }
13246 };
13247
13248 /**
13249 * Handle file selection from the input.
13250 *
13251 * @protected
13252 * @param {jQuery.Event} e
13253 */
13254 OO.ui.SelectFileInputWidget.prototype.onFileSelected = function ( e ) {
13255 this.currentFiles = this.filterFiles( e.target.files || [] );
13256 };
13257
13258 /**
13259 * Update the user interface when a file is selected or unselected.
13260 *
13261 * @protected
13262 */
13263 OO.ui.SelectFileInputWidget.prototype.updateUI = function () {
13264 this.info.setValue( this.getFilename() );
13265 };
13266
13267 /**
13268 * Determine if we should accept this file.
13269 *
13270 * @private
13271 * @param {FileList|File[]} files Files to filter
13272 * @return {File[]} Filter files
13273 */
13274 OO.ui.SelectFileInputWidget.prototype.filterFiles = function ( files ) {
13275 var accept = this.accept;
13276
13277 function mimeAllowed( file ) {
13278 var i, mimeTest,
13279 mimeType = file.type;
13280
13281 if ( !accept || !mimeType ) {
13282 return true;
13283 }
13284
13285 for ( i = 0; i < accept.length; i++ ) {
13286 mimeTest = accept[ i ];
13287 if ( mimeTest === mimeType ) {
13288 return true;
13289 } else if ( mimeTest.substr( -2 ) === '/*' ) {
13290 mimeTest = mimeTest.substr( 0, mimeTest.length - 1 );
13291 if ( mimeType.substr( 0, mimeTest.length ) === mimeTest ) {
13292 return true;
13293 }
13294 }
13295 }
13296 return false;
13297 }
13298
13299 return Array.prototype.filter.call( files, mimeAllowed );
13300 };
13301
13302 /**
13303 * Handle info input change events
13304 *
13305 * The info widget can only be changed by the user
13306 * with the clear button.
13307 *
13308 * @private
13309 * @param {string} value
13310 */
13311 OO.ui.SelectFileInputWidget.prototype.onInfoChange = function ( value ) {
13312 if ( value === '' ) {
13313 this.setValue( null );
13314 }
13315 };
13316
13317 /**
13318 * Handle key press events.
13319 *
13320 * @private
13321 * @param {jQuery.Event} e Key press event
13322 * @return {undefined/boolean} False to prevent default if event is handled
13323 */
13324 OO.ui.SelectFileInputWidget.prototype.onKeyPress = function ( e ) {
13325 if ( !this.isDisabled() && this.$input &&
13326 ( e.which === OO.ui.Keys.SPACE || e.which === OO.ui.Keys.ENTER )
13327 ) {
13328 // Emit a click to open the file selector.
13329 this.$input.trigger( 'click' );
13330 // Taking focus from the selectButton means keyUp isn't fired, so fire it manually.
13331 this.selectButton.onDocumentKeyUp( e );
13332 return false;
13333 }
13334 };
13335
13336 /**
13337 * @inheritdoc
13338 */
13339 OO.ui.SelectFileInputWidget.prototype.setDisabled = function ( disabled ) {
13340 // Parent method
13341 OO.ui.SelectFileInputWidget.parent.prototype.setDisabled.call( this, disabled );
13342
13343 this.selectButton.setDisabled( disabled );
13344 this.info.setDisabled( disabled );
13345
13346 return this;
13347 };
13348
13349 }( OO ) );
13350
13351 //# sourceMappingURL=oojs-ui-core.js.map.json