Update OOUI to v0.45.0
[mediawiki.git] / resources / lib / ooui / oojs-ui-widgets.js
blobe355ba42e52cc01d3280a56e6b7e5b98a2d7c367
1 /*!
2  * OOUI v0.45.0
3  * https://www.mediawiki.org/wiki/OOUI
4  *
5  * Copyright 2011–2022 OOUI Team and other contributors.
6  * Released under the MIT license
7  * http://oojs.mit-license.org
8  *
9  * Date: 2022-09-28T14:26:11Z
10  */
11 ( function ( OO ) {
13 'use strict';
15 /**
16  * DraggableElement is a mixin class used to create elements that can be clicked
17  * and dragged by a mouse to a new position within a group. This class must be used
18  * in conjunction with OO.ui.mixin.DraggableGroupElement, which provides a container for
19  * the draggable elements.
20  *
21  * @abstract
22  * @class
23  *
24  * @constructor
25  * @param {Object} [config] Configuration options
26  * @cfg {jQuery} [$handle] The part of the element which can be used for dragging, defaults to
27  *  the whole element
28  * @cfg {boolean} [draggable=true] The items are draggable. This can change with #toggleDraggable
29  *  but the draggable state should be called from the DraggableGroupElement, which updates
30  *  the whole group
31  */
32 OO.ui.mixin.DraggableElement = function OoUiMixinDraggableElement( config ) {
33         config = config || {};
35         // Properties
36         this.index = null;
37         this.$handle = config.$handle || this.$element;
38         this.wasHandleUsed = null;
40         // Initialize and events
41         this.$element
42                 .addClass( 'oo-ui-draggableElement' )
43                 .on( {
44                         mousedown: this.onDragMouseDown.bind( this ),
45                         dragstart: this.onDragStart.bind( this ),
46                         dragover: this.onDragOver.bind( this ),
47                         dragend: this.onDragEnd.bind( this ),
48                         drop: this.onDrop.bind( this )
49                 } );
50         this.$handle.addClass( 'oo-ui-draggableElement-handle' );
51         this.toggleDraggable( config.draggable === undefined ? true : !!config.draggable );
54 OO.initClass( OO.ui.mixin.DraggableElement );
56 /* Events */
58 /**
59  * @event dragstart
60  *
61  * A dragstart event is emitted when the user clicks and begins dragging an item.
62  * @param {OO.ui.mixin.DraggableElement} item The item the user has clicked and is dragging with
63  *  the mouse.
64  */
66 /**
67  * @event dragend
68  * A dragend event is emitted when the user drags an item and releases the mouse,
69  * thus terminating the drag operation.
70  */
72 /**
73  * @event drop
74  * A drop event is emitted when the user drags an item and then releases the mouse button
75  * over a valid target.
76  */
78 /* Static Properties */
80 /**
81  * @inheritdoc OO.ui.mixin.ButtonElement
82  */
83 OO.ui.mixin.DraggableElement.static.cancelButtonMouseDownEvents = false;
85 /* Methods */
87 /**
88  * Change the draggable state of this widget.
89  * This allows users to temporarily halt the dragging operations.
90  *
91  * @param {boolean} isDraggable Widget supports draggable operations
92  * @fires draggable
93  */
94 OO.ui.mixin.DraggableElement.prototype.toggleDraggable = function ( isDraggable ) {
95         isDraggable = isDraggable !== undefined ? !!isDraggable : !this.draggable;
97         if ( this.draggable !== isDraggable ) {
98                 this.draggable = isDraggable;
100                 this.$handle.toggleClass( 'oo-ui-draggableElement-undraggable', !this.draggable );
102                 // We make the entire element draggable, not just the handle, so that
103                 // the whole element appears to move. wasHandleUsed prevents drags from
104                 // starting outside the handle
105                 this.$element.prop( 'draggable', this.draggable );
106         }
110  * Check the draggable state of this widget.
112  * @return {boolean} Widget supports draggable operations
113  */
114 OO.ui.mixin.DraggableElement.prototype.isDraggable = function () {
115         return this.draggable;
119  * Respond to mousedown event.
121  * @private
122  * @param {jQuery.Event} e Drag event
123  */
124 OO.ui.mixin.DraggableElement.prototype.onDragMouseDown = function ( e ) {
125         if ( !this.isDraggable() ) {
126                 return;
127         }
129         this.wasHandleUsed =
130                 // Optimization: if the handle is the whole element this is always true
131                 this.$handle[ 0 ] === this.$element[ 0 ] ||
132                 // Check the mousedown occurred inside the handle
133                 OO.ui.contains( this.$handle[ 0 ], e.target, true );
137  * Respond to dragstart event.
139  * @private
140  * @param {jQuery.Event} e Drag event
141  * @return {boolean} False if the event is cancelled
142  * @fires dragstart
143  */
144 OO.ui.mixin.DraggableElement.prototype.onDragStart = function ( e ) {
145         var element = this;
147         if ( !this.wasHandleUsed || !this.isDraggable() ) {
148                 return false;
149         }
151         var dataTransfer = e.originalEvent.dataTransfer;
152         // Define drop effect
153         dataTransfer.dropEffect = 'none';
154         dataTransfer.effectAllowed = 'move';
155         // Support: Firefox
156         // We must set up a dataTransfer data property or Firefox seems to
157         // ignore the fact the element is draggable.
158         try {
159                 dataTransfer.setData( 'application-x/OOUI-draggable', this.getIndex() );
160         } catch ( err ) {
161                 // The above is only for Firefox. Move on if it fails.
162         }
163         // Briefly add a 'clone' class to style the browser's native drag image
164         this.$element.addClass( 'oo-ui-draggableElement-clone' );
165         // Add placeholder class after the browser has rendered the clone
166         setTimeout( function () {
167                 element.$element
168                         .removeClass( 'oo-ui-draggableElement-clone' )
169                         .addClass( 'oo-ui-draggableElement-placeholder' );
170         } );
171         // Emit event
172         this.emit( 'dragstart', this );
173         return true;
177  * Respond to dragend event.
179  * @private
180  * @fires dragend
181  */
182 OO.ui.mixin.DraggableElement.prototype.onDragEnd = function () {
183         this.$element.removeClass( 'oo-ui-draggableElement-placeholder' );
184         this.emit( 'dragend' );
188  * Handle drop event.
190  * @private
191  * @param {jQuery.Event} e Drop event
192  * @fires drop
193  */
194 OO.ui.mixin.DraggableElement.prototype.onDrop = function ( e ) {
195         e.preventDefault();
196         this.emit( 'drop', e );
200  * In order for drag/drop to work, the dragover event must
201  * return false and stop propogation.
203  * @param {jQuery.Event} e Drag event
204  * @private
205  */
206 OO.ui.mixin.DraggableElement.prototype.onDragOver = function ( e ) {
207         e.preventDefault();
211  * Set item index.
212  * Store it in the DOM so we can access from the widget drag event.
214  * @private
215  * @param {number} index Item index
216  */
217 OO.ui.mixin.DraggableElement.prototype.setIndex = function ( index ) {
218         if ( this.index !== index ) {
219                 this.index = index;
220                 this.$element.data( 'index', index );
221         }
225  * Get item index.
227  * @private
228  * @return {number} Item index
229  */
230 OO.ui.mixin.DraggableElement.prototype.getIndex = function () {
231         return this.index;
235  * DraggableGroupElement is a mixin class used to create a group element to
236  * contain draggable elements, which are items that can be clicked and dragged by a mouse.
237  * The class is used with OO.ui.mixin.DraggableElement.
239  * @abstract
240  * @class
241  * @mixins OO.ui.mixin.GroupElement
243  * @constructor
244  * @param {Object} [config] Configuration options
245  * @cfg {OO.ui.mixin.DraggableElement[]} items
246  * @cfg {string} [orientation='vertical'] Item orientation: 'horizontal' or 'vertical'.
247  *  The orientation should match the layout of the items. Items displayed in a single row
248  *  or in several rows should use horizontal orientation. The vertical orientation should only be
249  *  used when the items are displayed in a single column.
250  * @cfg {boolean} [draggable=true] The items are draggable. This can change with #toggleDraggable
251  */
252 OO.ui.mixin.DraggableGroupElement = function OoUiMixinDraggableGroupElement( config ) {
253         // Configuration initialization
254         config = config || {};
256         // Parent constructor
257         OO.ui.mixin.GroupElement.call( this, config );
259         // Properties
260         this.orientation = config.orientation || 'vertical';
261         this.dragItem = null;
262         this.itemKeys = {};
263         this.dir = null;
264         this.itemsOrder = null;
265         this.draggable = config.draggable === undefined ? true : !!config.draggable;
267         // Events
268         this.aggregate( {
269                 dragstart: 'itemDragStart',
270                 dragend: 'itemDragEnd',
271                 drop: 'itemDrop'
272         } );
273         this.connect( this, {
274                 itemDragStart: 'onItemDragStart',
275                 itemDrop: 'onItemDropOrDragEnd',
276                 itemDragEnd: 'onItemDropOrDragEnd'
277         } );
279         // Initialize
280         this.addItems( config.items || [] );
281         this.$element
282                 .addClass( 'oo-ui-draggableGroupElement' )
283                 .toggleClass( 'oo-ui-draggableGroupElement-horizontal', this.orientation === 'horizontal' );
286 /* Setup */
287 OO.mixinClass( OO.ui.mixin.DraggableGroupElement, OO.ui.mixin.GroupElement );
289 /* Events */
292  * An item has been dragged to a new position, but not yet dropped.
294  * @event drag
295  * @param {OO.ui.mixin.DraggableElement} item Dragged item
296  * @param {number} [newIndex] New index for the item
297  */
300  * An item has been dropped at a new position.
302  * @event reorder
303  * @param {OO.ui.mixin.DraggableElement} item Reordered item
304  * @param {number} [newIndex] New index for the item
305  */
308  * Draggable state of this widget has changed.
310  * @event draggable
311  * @param {boolean} [draggable] Widget is draggable
312  */
314 /* Methods */
317  * Change the draggable state of this widget.
318  * This allows users to temporarily halt the dragging operations.
320  * @param {boolean} isDraggable Widget supports draggable operations
321  * @fires draggable
322  */
323 OO.ui.mixin.DraggableGroupElement.prototype.toggleDraggable = function ( isDraggable ) {
324         isDraggable = isDraggable !== undefined ? !!isDraggable : !this.draggable;
326         if ( this.draggable !== isDraggable ) {
327                 this.draggable = isDraggable;
329                 // Tell the items their draggable state changed
330                 this.getItems().forEach( function ( item ) {
331                         item.toggleDraggable( this.draggable );
332                 }.bind( this ) );
334                 // Emit event
335                 this.emit( 'draggable', this.draggable );
336         }
340  * Check the draggable state of this widget
342  * @return {boolean} Widget supports draggable operations
343  */
344 OO.ui.mixin.DraggableGroupElement.prototype.isDraggable = function () {
345         return this.draggable;
349  * Respond to item drag start event
351  * @private
352  * @param {OO.ui.mixin.DraggableElement} item Dragged item
353  */
354 OO.ui.mixin.DraggableGroupElement.prototype.onItemDragStart = function ( item ) {
355         if ( !this.isDraggable() ) {
356                 return;
357         }
358         // Make a shallow copy of this.items so we can re-order it during previews
359         // without affecting the original array.
360         this.itemsOrder = this.items.slice();
361         this.updateIndexes();
362         if ( this.orientation === 'horizontal' ) {
363                 // Calculate and cache directionality on drag start - it's a little
364                 // expensive and it shouldn't change while dragging.
365                 this.dir = this.$element.css( 'direction' );
366         }
367         this.setDragItem( item );
371  * Update the index properties of the items
372  */
373 OO.ui.mixin.DraggableGroupElement.prototype.updateIndexes = function () {
374         // Map the index of each object
375         for ( var i = 0, len = this.itemsOrder.length; i < len; i++ ) {
376                 this.itemsOrder[ i ].setIndex( i );
377         }
381  * Handle drop or dragend event and switch the order of the items accordingly
383  * @private
384  * @param {OO.ui.mixin.DraggableElement} item Dropped item
385  * @return {OO.ui.Element} The element, for chaining
386  */
387 OO.ui.mixin.DraggableGroupElement.prototype.onItemDropOrDragEnd = function () {
388         var item = this.getDragItem();
390         // TODO: Figure out a way to configure a list of legally droppable
391         // elements even if they are not yet in the list
392         if ( item ) {
393                 var originalIndex = this.items.indexOf( item );
394                 // If the item has moved forward, add one to the index to account for the left shift
395                 var targetIndex = item.getIndex() + ( item.getIndex() > originalIndex ? 1 : 0 );
396                 if ( targetIndex !== originalIndex ) {
397                         this.reorder( this.getDragItem(), targetIndex );
398                         this.emit( 'reorder', this.getDragItem(), targetIndex );
399                 }
400                 this.updateIndexes();
401         }
402         this.unsetDragItem();
403         // Return false to prevent propogation
404         return false;
408  * Respond to dragover event
410  * @private
411  * @param {jQuery.Event} e Dragover event
412  * @fires reorder
413  */
414 OO.ui.mixin.DraggableGroupElement.prototype.onDragOver = function ( e ) {
415         var item = this.getDragItem(),
416                 dragItemIndex = item.getIndex();
418         // Get the OptionWidget item we are dragging over
419         var overIndex = $( e.target ).closest( '.oo-ui-draggableElement' ).data( 'index' );
421         if ( overIndex !== undefined && overIndex !== dragItemIndex ) {
422                 var targetIndex = overIndex + ( overIndex > dragItemIndex ? 1 : 0 );
424                 if ( targetIndex > 0 ) {
425                         this.$group.children().eq( targetIndex - 1 ).after( item.$element );
426                 } else {
427                         this.$group.prepend( item.$element );
428                 }
429                 // Move item in itemsOrder array
430                 this.itemsOrder.splice( overIndex, 0,
431                         this.itemsOrder.splice( dragItemIndex, 1 )[ 0 ]
432                 );
433                 this.updateIndexes();
434                 this.emit( 'drag', item, targetIndex );
435         }
436         // Prevent default
437         e.preventDefault();
441  * Reorder the items in the group
443  * @param {OO.ui.mixin.DraggableElement} item Reordered item
444  * @param {number} newIndex New index
445  */
446 OO.ui.mixin.DraggableGroupElement.prototype.reorder = function ( item, newIndex ) {
447         this.addItems( [ item ], newIndex );
451  * Set a dragged item
453  * @param {OO.ui.mixin.DraggableElement} item Dragged item
454  */
455 OO.ui.mixin.DraggableGroupElement.prototype.setDragItem = function ( item ) {
456         if ( this.dragItem !== item ) {
457                 this.dragItem = item;
458                 this.$element.on( 'dragover', this.onDragOver.bind( this ) );
459                 this.$element.addClass( 'oo-ui-draggableGroupElement-dragging' );
460         }
464  * Unset the current dragged item
465  */
466 OO.ui.mixin.DraggableGroupElement.prototype.unsetDragItem = function () {
467         if ( this.dragItem ) {
468                 this.dragItem = null;
469                 this.$element.off( 'dragover' );
470                 this.$element.removeClass( 'oo-ui-draggableGroupElement-dragging' );
471         }
475  * Get the item that is currently being dragged.
477  * @return {OO.ui.mixin.DraggableElement|null} The currently dragged item, or `null` if no item is
478  *  being dragged
479  */
480 OO.ui.mixin.DraggableGroupElement.prototype.getDragItem = function () {
481         return this.dragItem;
485  * RequestManager is a mixin that manages the lifecycle of a promise-backed request for a widget,
486  * such as the {@link OO.ui.mixin.LookupElement}.
488  * @class
489  * @abstract
491  * @constructor
492  * @param {Object} [config] Configuration options
493  * @cfg {boolean} [showPendingRequest=true] Show pending state while request data is being fetched.
494  *  Requires widget to have also mixed in {@link OO.ui.mixin.PendingElement}.
495  */
496 OO.ui.mixin.RequestManager = function OoUiMixinRequestManager( config ) {
497         this.requestCache = {};
498         this.requestQuery = null;
499         this.requestRequest = null;
500         this.showPendingRequest = !!this.pushPending && config.showPendingRequest !== false;
503 /* Setup */
505 OO.initClass( OO.ui.mixin.RequestManager );
508  * Get request results for the current query.
510  * @return {jQuery.Promise} Promise object which will be passed response data as the first argument
511  *  of the done event. If the request was aborted to make way for a subsequent request, this
512  *  promise may not be rejected, depending on what jQuery feels like doing.
513  */
514 OO.ui.mixin.RequestManager.prototype.getRequestData = function () {
515         var widget = this,
516                 value = this.getRequestQuery(),
517                 deferred = $.Deferred();
519         this.abortRequest();
520         if ( Object.prototype.hasOwnProperty.call( this.requestCache, value ) ) {
521                 deferred.resolve( this.requestCache[ value ] );
522         } else {
523                 if ( this.showPendingRequest ) {
524                         this.pushPending();
525                 }
526                 this.requestQuery = value;
527                 var ourRequest = this.requestRequest = this.getRequest();
528                 ourRequest
529                         .always( function () {
530                                 // We need to pop pending even if this is an old request, otherwise
531                                 // the widget will remain pending forever.
532                                 // TODO: this assumes that an aborted request will fail or succeed soon after
533                                 // being aborted, or at least eventually. It would be nice if we could popPending()
534                                 // at abort time, but only if we knew that we hadn't already called popPending()
535                                 // for that request.
536                                 if ( widget.showPendingRequest ) {
537                                         widget.popPending();
538                                 }
539                         } )
540                         .done( function ( response ) {
541                                 // If this is an old request (and aborting it somehow caused it to still succeed),
542                                 // ignore its success completely
543                                 if ( ourRequest === widget.requestRequest ) {
544                                         widget.requestQuery = null;
545                                         widget.requestRequest = null;
546                                         widget.requestCache[ value ] =
547                                                 widget.getRequestCacheDataFromResponse( response );
548                                         deferred.resolve( widget.requestCache[ value ] );
549                                 }
550                         } )
551                         .fail( function () {
552                                 // If this is an old request (or a request failing because it's being aborted),
553                                 // ignore its failure completely
554                                 if ( ourRequest === widget.requestRequest ) {
555                                         widget.requestQuery = null;
556                                         widget.requestRequest = null;
557                                         deferred.reject();
558                                 }
559                         } );
560         }
561         return deferred.promise();
565  * Abort the currently pending request, if any.
567  * @private
568  */
569 OO.ui.mixin.RequestManager.prototype.abortRequest = function () {
570         var oldRequest = this.requestRequest;
571         if ( oldRequest ) {
572                 // First unset this.requestRequest to the fail handler will notice
573                 // that the request is no longer current
574                 this.requestRequest = null;
575                 this.requestQuery = null;
576                 oldRequest.abort();
577         }
581  * Get the query to be made.
583  * @protected
584  * @method
585  * @abstract
586  * @return {string} query to be used
587  */
588 OO.ui.mixin.RequestManager.prototype.getRequestQuery = null;
591  * Get a new request object of the current query value.
593  * @protected
594  * @method
595  * @abstract
596  * @return {jQuery.Promise} jQuery AJAX object, or promise object with an .abort() method
597  */
598 OO.ui.mixin.RequestManager.prototype.getRequest = null;
601  * Pre-process data returned by the request from #getRequest.
603  * The return value of this function will be cached, and any further queries for the given value
604  * will use the cache rather than doing API requests.
606  * @protected
607  * @method
608  * @abstract
609  * @param {Mixed} response Response from server
610  * @return {Mixed} Cached result data
611  */
612 OO.ui.mixin.RequestManager.prototype.getRequestCacheDataFromResponse = null;
615  * LookupElement is a mixin that creates a {@link OO.ui.MenuSelectWidget menu} of suggested
616  * values for a {@link OO.ui.TextInputWidget text input widget}. Suggested values are based on
617  * the characters the user types into the text input field and, in general, the menu is only
618  * displayed when the user types. If a suggested value is chosen from the lookup menu, that value
619  * becomes the value of the input field.
621  * Note that a new menu of suggested items is displayed when a value is chosen from the
622  * lookup menu. If this is not the desired behavior, disable lookup menus with the
623  * #setLookupsDisabled method, then set the value, then re-enable lookups.
625  * See the [OOUI demos][1] for an example.
627  * [1]: https://doc.wikimedia.org/oojs-ui/master/demos/#LookupElement-try-inputting-an-integer
629  * @class
630  * @abstract
631  * @mixins OO.ui.mixin.RequestManager
633  * @constructor
634  * @param {Object} [config] Configuration options
635  * @cfg {jQuery} [$overlay] Overlay for the lookup menu; defaults to relative positioning.
636  *  See <https://www.mediawiki.org/wiki/OOUI/Concepts#Overlays>.
637  * @cfg {jQuery} [$container=this.$element] The container element. The lookup menu is rendered
638  *  beneath the specified element.
639  * @cfg {Object} [menu] Configuration options to pass to
640  *  {@link OO.ui.MenuSelectWidget menu select widget}
641  * @cfg {boolean} [allowSuggestionsWhenEmpty=false] Request and display a lookup menu when the
642  *  text input is empty.
643  *  By default, the lookup menu is not generated and displayed until the user begins to type.
644  * @cfg {boolean} [highlightFirst=true] Whether the first lookup result should be highlighted
645  *  (so, that the user can take it over into the input with simply pressing return) automatically
646  *  or not.
647  * @cfg {boolean} [showSuggestionsOnFocus=true] Show suggestions when focusing the input. If this
648  *  is set to false, suggestions will still be shown on a mousedown triggered focus. This matches
649  *  browser autocomplete behavior.
650  */
651 OO.ui.mixin.LookupElement = function OoUiMixinLookupElement( config ) {
652         // Configuration initialization
653         config = $.extend( { highlightFirst: true }, config );
655         // Mixin constructors
656         OO.ui.mixin.RequestManager.call( this, config );
658         // Properties
659         this.$overlay = ( config.$overlay === true ?
660                 OO.ui.getDefaultOverlay() : config.$overlay ) || this.$element;
661         this.lookupMenu = new OO.ui.MenuSelectWidget( $.extend( {
662                 widget: this,
663                 input: this,
664                 $floatableContainer: config.$container || this.$element
665         }, config.menu ) );
667         this.allowSuggestionsWhenEmpty = config.allowSuggestionsWhenEmpty || false;
669         this.lookupsDisabled = false;
670         this.lookupInputFocused = false;
671         this.lookupHighlightFirstItem = config.highlightFirst;
672         this.showSuggestionsOnFocus = config.showSuggestionsOnFocus !== false;
674         // Events
675         this.$input.on( {
676                 focus: this.onLookupInputFocus.bind( this ),
677                 blur: this.onLookupInputBlur.bind( this ),
678                 mousedown: this.onLookupInputMouseDown.bind( this )
679         } );
680         this.connect( this, {
681                 change: 'onLookupInputChange'
682         } );
683         this.lookupMenu.connect( this, {
684                 toggle: 'onLookupMenuToggle',
685                 choose: 'onLookupMenuChoose'
686         } );
688         // Initialization
689         this.$input.attr( {
690                 role: 'combobox',
691                 'aria-owns': this.lookupMenu.getElementId(),
692                 'aria-autocomplete': 'list'
693         } );
694         this.$element.addClass( 'oo-ui-lookupElement' );
695         this.lookupMenu.$element.addClass( 'oo-ui-lookupElement-menu' );
696         this.$overlay.append( this.lookupMenu.$element );
699 /* Setup */
701 OO.mixinClass( OO.ui.mixin.LookupElement, OO.ui.mixin.RequestManager );
703 /* Methods */
706  * Handle input focus event.
708  * @protected
709  * @param {jQuery.Event} e Input focus event
710  */
711 OO.ui.mixin.LookupElement.prototype.onLookupInputFocus = function () {
712         this.lookupInputFocused = true;
713         if ( this.showSuggestionsOnFocus ) {
714                 this.populateLookupMenu();
715         }
719  * Handle input blur event.
721  * @protected
722  * @param {jQuery.Event} e Input blur event
723  */
724 OO.ui.mixin.LookupElement.prototype.onLookupInputBlur = function () {
725         this.closeLookupMenu();
726         this.lookupInputFocused = false;
730  * Handle input mouse down event.
732  * @protected
733  * @param {jQuery.Event} e Input mouse down event
734  */
735 OO.ui.mixin.LookupElement.prototype.onLookupInputMouseDown = function () {
736         if (
737                 !this.lookupMenu.isVisible() &&
738                 (
739                         // Open the menu if the input was already focused.
740                         // This way we allow the user to open the menu again after closing it with Escape (esc)
741                         // by clicking in the input.
742                         this.lookupInputFocused ||
743                         // If showSuggestionsOnFocus is disabled, still open the menu on mousedown.
744                         !this.showSuggestionsOnFocus
745                 )
746         ) {
747                 this.populateLookupMenu();
748         }
752  * Handle input change event.
754  * @protected
755  * @param {string} value New input value
756  */
757 OO.ui.mixin.LookupElement.prototype.onLookupInputChange = function () {
758         if ( this.lookupInputFocused ) {
759                 this.populateLookupMenu();
760         }
764  * Handle the lookup menu being shown/hidden.
766  * @protected
767  * @param {boolean} visible Whether the lookup menu is now visible.
768  */
769 OO.ui.mixin.LookupElement.prototype.onLookupMenuToggle = function ( visible ) {
770         if ( !visible ) {
771                 // When the menu is hidden, abort any active request and clear the menu.
772                 // This has to be done here in addition to closeLookupMenu(), because
773                 // MenuSelectWidget will close itself when the user presses Escape (esc).
774                 this.abortLookupRequest();
775                 this.lookupMenu.clearItems();
776         }
780  * Handle menu item 'choose' event, updating the text input value to the value of the clicked item.
782  * @protected
783  * @param {OO.ui.MenuOptionWidget} item Selected item
784  */
785 OO.ui.mixin.LookupElement.prototype.onLookupMenuChoose = function ( item ) {
786         this.setValue( item.getData() );
790  * Get lookup menu.
792  * @private
793  * @return {OO.ui.MenuSelectWidget}
794  */
795 OO.ui.mixin.LookupElement.prototype.getLookupMenu = function () {
796         return this.lookupMenu;
800  * Disable or re-enable lookups.
802  * When lookups are disabled, calls to #populateLookupMenu will be ignored.
804  * @param {boolean} disabled Disable lookups
805  */
806 OO.ui.mixin.LookupElement.prototype.setLookupsDisabled = function ( disabled ) {
807         this.lookupsDisabled = !!disabled;
811  * Open the menu. If there are no entries in the menu, this does nothing.
813  * @private
814  * @chainable
815  * @return {OO.ui.Element} The element, for chaining
816  */
817 OO.ui.mixin.LookupElement.prototype.openLookupMenu = function () {
818         if ( !this.lookupMenu.isEmpty() ) {
819                 this.lookupMenu.toggle( true );
820         }
821         return this;
825  * Close the menu, empty it, and abort any pending request.
827  * @private
828  * @chainable
829  * @return {OO.ui.Element} The element, for chaining
830  */
831 OO.ui.mixin.LookupElement.prototype.closeLookupMenu = function () {
832         this.lookupMenu.toggle( false );
833         this.abortLookupRequest();
834         this.lookupMenu.clearItems();
835         return this;
839  * Request menu items based on the input's current value, and when they arrive,
840  * populate the menu with these items and show the menu.
842  * If lookups have been disabled with #setLookupsDisabled, this function does nothing.
844  * @private
845  * @chainable
846  * @return {OO.ui.Element} The element, for chaining
847  */
848 OO.ui.mixin.LookupElement.prototype.populateLookupMenu = function () {
849         var widget = this,
850                 value = this.getValue();
852         if ( this.lookupsDisabled || this.isReadOnly() ) {
853                 return;
854         }
856         // If the input is empty, clear the menu, unless suggestions when empty are allowed.
857         if ( !this.allowSuggestionsWhenEmpty && value === '' ) {
858                 this.closeLookupMenu();
859         // Skip population if there is already a request pending for the current value
860         } else if ( value !== this.lookupQuery ) {
861                 this.getLookupMenuItems()
862                         .done( function ( items ) {
863                                 widget.lookupMenu.clearItems();
864                                 if ( items.length ) {
865                                         widget.lookupMenu
866                                                 .addItems( items )
867                                                 .toggle( true );
868                                         widget.initializeLookupMenuSelection();
869                                 } else {
870                                         widget.lookupMenu.toggle( false );
871                                 }
872                         } )
873                         .fail( function () {
874                                 widget.lookupMenu.clearItems();
875                                 widget.lookupMenu.toggle( false );
876                         } );
877         }
879         return this;
883  * Highlight the first selectable item in the menu, if configured.
885  * @private
886  * @chainable
887  */
888 OO.ui.mixin.LookupElement.prototype.initializeLookupMenuSelection = function () {
889         if ( this.lookupHighlightFirstItem && !this.lookupMenu.findSelectedItem() ) {
890                 this.lookupMenu.highlightItem( this.lookupMenu.findFirstSelectableItem() );
891         }
895  * Get lookup menu items for the current query.
897  * @private
898  * @return {jQuery.Promise} Promise object which will be passed menu items as the first argument of
899  *   the done event. If the request was aborted to make way for a subsequent request, this promise
900  *   will not be rejected: it will remain pending forever.
901  */
902 OO.ui.mixin.LookupElement.prototype.getLookupMenuItems = function () {
903         return this.getRequestData().then( function ( data ) {
904                 return this.getLookupMenuOptionsFromData( data );
905         }.bind( this ) );
909  * Abort the currently pending lookup request, if any.
911  * @private
912  */
913 OO.ui.mixin.LookupElement.prototype.abortLookupRequest = function () {
914         this.abortRequest();
918  * Get a new request object of the current lookup query value.
920  * @protected
921  * @method
922  * @abstract
923  * @return {jQuery.Promise} jQuery AJAX object, or promise object with an .abort() method
924  */
925 OO.ui.mixin.LookupElement.prototype.getLookupRequest = null;
928  * Pre-process data returned by the request from #getLookupRequest.
930  * The return value of this function will be cached, and any further queries for the given value
931  * will use the cache rather than doing API requests.
933  * @protected
934  * @method
935  * @abstract
936  * @param {Mixed} response Response from server
937  * @return {Mixed} Cached result data
938  */
939 OO.ui.mixin.LookupElement.prototype.getLookupCacheDataFromResponse = null;
942  * Get a list of menu option widgets from the (possibly cached) data returned by
943  * #getLookupCacheDataFromResponse.
945  * @protected
946  * @method
947  * @abstract
948  * @param {Mixed} data Cached result data, usually an array
949  * @return {OO.ui.MenuOptionWidget[]} Menu items
950  */
951 OO.ui.mixin.LookupElement.prototype.getLookupMenuOptionsFromData = null;
954  * Set the read-only state of the widget.
956  * This will also disable/enable the lookups functionality.
958  * @param {boolean} readOnly Make input read-only
959  * @chainable
960  * @return {OO.ui.Element} The element, for chaining
961  */
962 OO.ui.mixin.LookupElement.prototype.setReadOnly = function ( readOnly ) {
963         // Parent method
964         // Note: Calling #setReadOnly this way assumes this is mixed into an OO.ui.TextInputWidget
965         OO.ui.TextInputWidget.prototype.setReadOnly.call( this, readOnly );
967         // During construction, #setReadOnly is called before the OO.ui.mixin.LookupElement constructor.
968         if ( this.isReadOnly() && this.lookupMenu ) {
969                 this.closeLookupMenu();
970         }
972         return this;
976  * @inheritdoc OO.ui.mixin.RequestManager
977  */
978 OO.ui.mixin.LookupElement.prototype.getRequestQuery = function () {
979         return this.getValue();
983  * @inheritdoc OO.ui.mixin.RequestManager
984  */
985 OO.ui.mixin.LookupElement.prototype.getRequest = function () {
986         return this.getLookupRequest();
990  * @inheritdoc OO.ui.mixin.RequestManager
991  */
992 OO.ui.mixin.LookupElement.prototype.getRequestCacheDataFromResponse = function ( response ) {
993         return this.getLookupCacheDataFromResponse( response );
997  * TabPanelLayouts are used within {@link OO.ui.IndexLayout index layouts} to create tab panels that
998  * users can select and display from the index's optional {@link OO.ui.TabSelectWidget tab}
999  * navigation. TabPanels are usually not instantiated directly, rather extended to include the
1000  * required content and functionality.
1002  * Each tab panel must have a unique symbolic name, which is passed to the constructor. In addition,
1003  * the tab panel's tab item is customized (with a label) using the #setupTabItem method. See
1004  * {@link OO.ui.IndexLayout IndexLayout} for an example.
1006  * @class
1007  * @extends OO.ui.PanelLayout
1009  * @constructor
1010  * @param {string} name Unique symbolic name of tab panel
1011  * @param {Object} [config] Configuration options
1012  * @cfg {jQuery|string|Function|OO.ui.HtmlSnippet} [label] Label for tab panel's tab
1013  * @cfg {Object} [tabItemConfig] Additional tab item config
1014  */
1015 OO.ui.TabPanelLayout = function OoUiTabPanelLayout( name, config ) {
1016         // Allow passing positional parameters inside the config object
1017         if ( OO.isPlainObject( name ) && config === undefined ) {
1018                 config = name;
1019                 name = config.name;
1020         }
1022         // Configuration initialization
1023         config = $.extend( { scrollable: true }, config );
1025         // Parent constructor
1026         OO.ui.TabPanelLayout.super.call( this, config );
1028         // Properties
1029         this.name = name;
1030         this.label = config.label;
1031         this.tabItemConfig = config.tabItemConfig || {};
1032         this.tabItem = null;
1033         this.active = false;
1035         // Initialization
1036         this.$element
1037                 .addClass( 'oo-ui-tabPanelLayout' )
1038                 .attr( 'role', 'tabpanel' );
1041 /* Setup */
1043 OO.inheritClass( OO.ui.TabPanelLayout, OO.ui.PanelLayout );
1045 /* Events */
1048  * An 'active' event is emitted when the tab panel becomes active. Tab panels become active when
1049  * they are shown in a index layout that is configured to display only one tab panel at a time.
1051  * @event active
1052  * @param {boolean} active Tab panel is active
1053  */
1055 /* Methods */
1058  * Get the symbolic name of the tab panel.
1060  * @return {string} Symbolic name of tab panel
1061  */
1062 OO.ui.TabPanelLayout.prototype.getName = function () {
1063         return this.name;
1067  * Check if tab panel is active.
1069  * Tab panels become active when they are shown in a {@link OO.ui.IndexLayout index layout} that is
1070  * configured to display only one tab panel at a time. Additional CSS is applied to the tab panel's
1071  * tab item to reflect the active state.
1073  * @return {boolean} Tab panel is active
1074  */
1075 OO.ui.TabPanelLayout.prototype.isActive = function () {
1076         return this.active;
1080  * Get tab item.
1082  * The tab item allows users to access the tab panel from the index's tab
1083  * navigation. The tab item itself can be customized (with a label, level, etc.) using the
1084  * #setupTabItem method.
1086  * @return {OO.ui.TabOptionWidget|null} Tab option widget
1087  */
1088 OO.ui.TabPanelLayout.prototype.getTabItem = function () {
1089         return this.tabItem;
1093  * Get config for creating a tab item.
1095  * @return {Object} Tab option config
1096  */
1097 OO.ui.TabPanelLayout.prototype.getTabItemConfig = function () {
1098         return this.tabItemConfig;
1102  * Set or unset the tab item.
1104  * Specify a {@link OO.ui.TabOptionWidget tab option} to set it,
1105  * or `null` to clear the tab item. To customize the tab item itself (e.g., to set a label or tab
1106  * level), use #setupTabItem instead of this method.
1108  * @param {OO.ui.TabOptionWidget|null} tabItem Tab option widget, null to clear
1109  * @chainable
1110  * @return {OO.ui.TabPanelLayout} The layout, for chaining
1111  */
1112 OO.ui.TabPanelLayout.prototype.setTabItem = function ( tabItem ) {
1113         this.tabItem = tabItem || null;
1114         if ( tabItem ) {
1115                 this.setupTabItem();
1116         }
1117         return this;
1121  * Set up the tab item.
1123  * Use this method to customize the tab item (e.g., to add a label or tab level). To set or unset
1124  * the tab item itself (with a {@link OO.ui.TabOptionWidget tab option} or `null`), use
1125  * the #setTabItem method instead.
1127  * @param {OO.ui.TabOptionWidget} tabItem Tab option widget to set up
1128  * @chainable
1129  * @return {OO.ui.TabPanelLayout} The layout, for chaining
1130  */
1131 OO.ui.TabPanelLayout.prototype.setupTabItem = function () {
1132         this.$element.attr( 'aria-labelledby', this.tabItem.getElementId() );
1134         this.tabItem.$element.attr( 'aria-controls', this.getElementId() );
1136         if ( this.label ) {
1137                 this.tabItem.setLabel( this.label );
1138         }
1139         return this;
1143  * Set the tab panel to its 'active' state.
1145  * Tab panels become active when they are shown in a index layout that is configured to display only
1146  * one tab panel at a time. Additional CSS is applied to the tab item to reflect the tab panel's
1147  * active state. Outside of the index context, setting the active state on a tab panel does nothing.
1149  * @param {boolean} active Tab panel is active
1150  * @fires active
1151  */
1152 OO.ui.TabPanelLayout.prototype.setActive = function ( active ) {
1153         active = !!active;
1155         if ( active !== this.active ) {
1156                 this.active = active;
1157                 this.$element.toggleClass( 'oo-ui-tabPanelLayout-active', this.active );
1158                 this.emit( 'active', this.active );
1159         }
1163  * PageLayouts are used within {@link OO.ui.BookletLayout booklet layouts} to create pages that
1164  * users can select and display from the booklet's optional
1165  * {@link OO.ui.OutlineSelectWidget outline} navigation. Pages are usually not instantiated
1166  * directly, rather extended to include the required content and functionality.
1168  * Each page must have a unique symbolic name, which is passed to the constructor. In addition, the
1169  * page's outline item is customized (with a label, outline level, etc.) using
1170  * the #setupOutlineItem method. See {@link OO.ui.BookletLayout BookletLayout} for an example.
1172  * @class
1173  * @extends OO.ui.PanelLayout
1175  * @constructor
1176  * @param {string} name Unique symbolic name of page
1177  * @param {Object} [config] Configuration options
1178  */
1179 OO.ui.PageLayout = function OoUiPageLayout( name, config ) {
1180         // Allow passing positional parameters inside the config object
1181         if ( OO.isPlainObject( name ) && config === undefined ) {
1182                 config = name;
1183                 name = config.name;
1184         }
1186         // Configuration initialization
1187         config = $.extend( { scrollable: true }, config );
1189         // Parent constructor
1190         OO.ui.PageLayout.super.call( this, config );
1192         // Properties
1193         this.name = name;
1194         this.outlineItem = null;
1195         this.active = false;
1197         // Initialization
1198         this.$element.addClass( 'oo-ui-pageLayout' );
1201 /* Setup */
1203 OO.inheritClass( OO.ui.PageLayout, OO.ui.PanelLayout );
1205 /* Events */
1208  * An 'active' event is emitted when the page becomes active. Pages become active when they are
1209  * shown in a booklet layout that is configured to display only one page at a time.
1211  * @event active
1212  * @param {boolean} active Page is active
1213  */
1215 /* Methods */
1218  * Get the symbolic name of the page.
1220  * @return {string} Symbolic name of page
1221  */
1222 OO.ui.PageLayout.prototype.getName = function () {
1223         return this.name;
1227  * Check if page is active.
1229  * Pages become active when they are shown in a {@link OO.ui.BookletLayout booklet layout} that is
1230  * configured to display only one page at a time. Additional CSS is applied to the page's outline
1231  * item to reflect the active state.
1233  * @return {boolean} Page is active
1234  */
1235 OO.ui.PageLayout.prototype.isActive = function () {
1236         return this.active;
1240  * Get outline item.
1242  * The outline item allows users to access the page from the booklet's outline
1243  * navigation. The outline item itself can be customized (with a label, level, etc.) using the
1244  * #setupOutlineItem method.
1246  * @return {OO.ui.OutlineOptionWidget|null} Outline option widget
1247  */
1248 OO.ui.PageLayout.prototype.getOutlineItem = function () {
1249         return this.outlineItem;
1253  * Set or unset the outline item.
1255  * Specify an {@link OO.ui.OutlineOptionWidget outline option} to set it,
1256  * or `null` to clear the outline item. To customize the outline item itself (e.g., to set a label
1257  * or outline level), use #setupOutlineItem instead of this method.
1259  * @param {OO.ui.OutlineOptionWidget|null} outlineItem Outline option widget, null to clear
1260  * @chainable
1261  * @return {OO.ui.PageLayout} The layout, for chaining
1262  */
1263 OO.ui.PageLayout.prototype.setOutlineItem = function ( outlineItem ) {
1264         this.outlineItem = outlineItem || null;
1265         if ( outlineItem ) {
1266                 this.setupOutlineItem();
1267         }
1268         return this;
1272  * Set up the outline item.
1274  * Override this method to customize the outline item (e.g., to add a label or outline level). To
1275  * set or unset the outline item itself (with an {@link OO.ui.OutlineOptionWidget outline option} or
1276  * `null`), use the #setOutlineItem method instead.
1278  * @protected
1279  */
1280 OO.ui.PageLayout.prototype.setupOutlineItem = function () {
1284  * Set the page to its 'active' state.
1286  * Pages become active when they are shown in a booklet layout that is configured to display only
1287  * one page at a time. Additional CSS is applied to the outline item to reflect the page's active
1288  * state. Outside of the booklet context, setting the active state on a page does nothing.
1290  * @param {boolean} active Page is active
1291  * @fires active
1292  */
1293 OO.ui.PageLayout.prototype.setActive = function ( active ) {
1294         active = !!active;
1296         if ( active !== this.active ) {
1297                 this.active = active;
1298                 this.$element.toggleClass( 'oo-ui-pageLayout-active', active );
1299                 this.emit( 'active', this.active );
1300         }
1304  * StackLayouts contain a series of {@link OO.ui.PanelLayout panel layouts}. By default, only one
1305  * panel is displayed at a time, though the stack layout can also be configured to show all
1306  * contained panels, one after another, by setting the #continuous option to 'true'.
1308  *     @example
1309  *     // A stack layout with two panels, configured to be displayed continuously
1310  *     var myStack = new OO.ui.StackLayout( {
1311  *         items: [
1312  *             new OO.ui.PanelLayout( {
1313  *                 $content: $( '<p>Panel One</p>' ),
1314  *                 padded: true,
1315  *                 framed: true
1316  *             } ),
1317  *             new OO.ui.PanelLayout( {
1318  *                 $content: $( '<p>Panel Two</p>' ),
1319  *                 padded: true,
1320  *                 framed: true
1321  *             } )
1322  *         ],
1323  *         continuous: true
1324  *     } );
1325  *     $( document.body ).append( myStack.$element );
1327  * @class
1328  * @extends OO.ui.PanelLayout
1329  * @mixins OO.ui.mixin.GroupElement
1331  * @constructor
1332  * @param {Object} [config] Configuration options
1333  * @cfg {boolean} [continuous=false] Show all panels, one after another. By default, only one panel
1334  *  is displayed at a time.
1335  * @cfg {OO.ui.Layout[]} [items] Panel layouts to add to the stack layout.
1336  */
1337 OO.ui.StackLayout = function OoUiStackLayout( config ) {
1338         // Configuration initialization
1339         // Make the layout scrollable in continuous mode, otherwise each
1340         // panel is responsible for its own scrolling.
1341         config = $.extend( { scrollable: !!( config && config.continuous ) }, config );
1343         // Parent constructor
1344         OO.ui.StackLayout.super.call( this, config );
1346         // Mixin constructors
1347         OO.ui.mixin.GroupElement.call( this, $.extend( { $group: this.$element }, config ) );
1349         // Properties
1350         this.currentItem = null;
1351         this.continuous = !!config.continuous;
1353         // Initialization
1354         this.$element.addClass( 'oo-ui-stackLayout' );
1355         if ( this.continuous ) {
1356                 this.$element.addClass( 'oo-ui-stackLayout-continuous' );
1357         }
1358         this.addItems( config.items || [] );
1361 /* Setup */
1363 OO.inheritClass( OO.ui.StackLayout, OO.ui.PanelLayout );
1364 OO.mixinClass( OO.ui.StackLayout, OO.ui.mixin.GroupElement );
1366 /* Events */
1369  * A 'set' event is emitted when panels are {@link #addItems added}, {@link #removeItems removed},
1370  * {@link #clearItems cleared} or {@link #setItem displayed}.
1372  * @event set
1373  * @param {OO.ui.Layout|null} item Current panel or `null` if no panel is shown
1374  */
1376 /* Methods */
1379  * Get the current panel.
1381  * @return {OO.ui.Layout|null}
1382  */
1383 OO.ui.StackLayout.prototype.getCurrentItem = function () {
1384         return this.currentItem;
1388  * Unset the current item.
1390  * @private
1391  * @param {OO.ui.StackLayout} layout
1392  * @fires set
1393  */
1394 OO.ui.StackLayout.prototype.unsetCurrentItem = function () {
1395         var prevItem = this.currentItem;
1396         if ( prevItem === null ) {
1397                 return;
1398         }
1400         this.currentItem = null;
1401         this.emit( 'set', null );
1405  * Add panel layouts to the stack layout.
1407  * Panels will be added to the end of the stack layout array unless the optional index parameter
1408  * specifies a different insertion point. Adding a panel that is already in the stack will move it
1409  * to the end of the array or the point specified by the index.
1411  * @param {OO.ui.Layout[]} [items] Panels to add
1412  * @param {number} [index] Index of the insertion point
1413  * @chainable
1414  * @return {OO.ui.StackLayout} The layout, for chaining
1415  */
1416 OO.ui.StackLayout.prototype.addItems = function ( items, index ) {
1417         if ( !items || !items.length ) {
1418                 return this;
1419         }
1421         // Update the visibility
1422         this.updateHiddenState( items, this.currentItem );
1424         // Mixin method
1425         OO.ui.mixin.GroupElement.prototype.addItems.call( this, items, index );
1427         if ( !this.currentItem ) {
1428                 this.setItem( items[ 0 ] );
1429         }
1431         return this;
1435  * Remove the specified panels from the stack layout.
1437  * Removed panels are detached from the DOM, not removed, so that they may be reused. To remove all
1438  * panels, you may wish to use the #clearItems method instead.
1440  * @param {OO.ui.Layout[]} itemsToRemove Panels to remove
1441  * @chainable
1442  * @return {OO.ui.StackLayout} The layout, for chaining
1443  * @fires set
1444  */
1445 OO.ui.StackLayout.prototype.removeItems = function ( itemsToRemove ) {
1446         var isCurrentItemRemoved = itemsToRemove.indexOf( this.currentItem ) !== -1;
1448         var nextItem;
1449         if ( isCurrentItemRemoved ) {
1450                 var i = this.items.indexOf( this.currentItem );
1451                 do {
1452                         nextItem = this.items[ ++i ];
1453                 } while ( nextItem && itemsToRemove.indexOf( nextItem ) !== -1 );
1454         }
1456         // Mixin method
1457         OO.ui.mixin.GroupElement.prototype.removeItems.call( this, itemsToRemove );
1459         if ( isCurrentItemRemoved ) {
1460                 if ( this.items.length ) {
1461                         this.setItem( nextItem || this.items[ this.items.length - 1 ] );
1462                 } else {
1463                         this.unsetCurrentItem();
1464                 }
1465         }
1467         return this;
1471  * Clear all panels from the stack layout.
1473  * Cleared panels are detached from the DOM, not removed, so that they may be reused. To remove only
1474  * a subset of panels, use the #removeItems method.
1476  * @chainable
1477  * @return {OO.ui.StackLayout} The layout, for chaining
1478  * @fires set
1479  */
1480 OO.ui.StackLayout.prototype.clearItems = function () {
1481         this.unsetCurrentItem();
1482         OO.ui.mixin.GroupElement.prototype.clearItems.call( this );
1484         return this;
1488  * Show the specified panel.
1490  * If another panel is currently displayed, it will be hidden.
1492  * @param {OO.ui.Layout} item Panel to show
1493  * @chainable
1494  * @return {OO.ui.StackLayout} The layout, for chaining
1495  * @fires set
1496  */
1497 OO.ui.StackLayout.prototype.setItem = function ( item ) {
1498         if ( item !== this.currentItem ) {
1499                 this.updateHiddenState( this.items, item );
1501                 if ( this.items.indexOf( item ) !== -1 ) {
1502                         this.currentItem = item;
1503                         this.emit( 'set', item );
1504                 } else {
1505                         this.unsetCurrentItem();
1506                 }
1507         }
1509         return this;
1513  * Reset the scroll offset of all panels, or the container if continuous
1515  * @inheritdoc
1516  */
1517 OO.ui.StackLayout.prototype.resetScroll = function () {
1518         if ( this.continuous ) {
1519                 // Parent method
1520                 return OO.ui.StackLayout.super.prototype.resetScroll.call( this );
1521         }
1522         // Reset each panel
1523         this.getItems().forEach( function ( panel ) {
1524                 // eslint-disable-next-line no-jquery/no-class-state
1525                 var hidden = panel.$element.hasClass( 'oo-ui-element-hidden' );
1526                 // Scroll can only be reset when panel is visible
1527                 panel.$element.removeClass( 'oo-ui-element-hidden' );
1528                 panel.resetScroll();
1529                 if ( hidden ) {
1530                         panel.$element.addClass( 'oo-ui-element-hidden' );
1531                 }
1532         } );
1534         return this;
1538  * Update the visibility of all items in case of non-continuous view.
1540  * Ensure all items are hidden except for the selected one.
1541  * This method does nothing when the stack is continuous.
1543  * @private
1544  * @param {OO.ui.Layout[]} items Item list iterate over
1545  * @param {OO.ui.Layout} [selectedItem] Selected item to show
1546  */
1547 OO.ui.StackLayout.prototype.updateHiddenState = function ( items, selectedItem ) {
1548         if ( !this.continuous ) {
1549                 for ( var i = 0, len = items.length; i < len; i++ ) {
1550                         if ( !selectedItem || selectedItem !== items[ i ] ) {
1551                                 items[ i ].toggle( false );
1552                                 items[ i ].$element.attr( 'aria-hidden', 'true' );
1553                         }
1554                 }
1555                 if ( selectedItem ) {
1556                         selectedItem.toggle( true );
1557                         selectedItem.$element.removeAttr( 'aria-hidden' );
1558                 }
1559         }
1563  * MenuLayouts combine a menu and a content {@link OO.ui.PanelLayout panel}. The menu is positioned
1564  * relative to the content (after, before, top, or bottom) and its size is customized with the
1565  * #menuSize config. The content area will fill all remaining space.
1567  *     @example
1568  *     var menuLayout,
1569  *         menuPanel = new OO.ui.PanelLayout( {
1570  *             padded: true,
1571  *             expanded: true,
1572  *             scrollable: true
1573  *         } ),
1574  *         contentPanel = new OO.ui.PanelLayout( {
1575  *             padded: true,
1576  *             expanded: true,
1577  *             scrollable: true
1578  *         } ),
1579  *         select = new OO.ui.SelectWidget( {
1580  *             items: [
1581  *                 new OO.ui.OptionWidget( {
1582  *                     data: 'before',
1583  *                     label: 'Before'
1584  *                 } ),
1585  *                 new OO.ui.OptionWidget( {
1586  *                     data: 'after',
1587  *                     label: 'After'
1588  *                 } ),
1589  *                 new OO.ui.OptionWidget( {
1590  *                     data: 'top',
1591  *                     label: 'Top'
1592  *                 } ),
1593  *                 new OO.ui.OptionWidget( {
1594  *                     data: 'bottom',
1595  *                     label: 'Bottom'
1596  *                 } )
1597  *              ]
1598  *         } ).on( 'select', function ( item ) {
1599  *            menuLayout.setMenuPosition( item.getData() );
1600  *         } );
1602  *     menuLayout = new OO.ui.MenuLayout( {
1603  *         position: 'top',
1604  *         menuPanel: menuPanel,
1605  *         contentPanel: contentPanel
1606  *     } );
1607  *     menuLayout.$menu.append(
1608  *         menuPanel.$element.append( '<b>Menu panel</b>', select.$element )
1609  *     );
1610  *     menuLayout.$content.append(
1611  *         contentPanel.$element.append(
1612  *             '<b>Content panel</b>',
1613  *             '<p>Note that the menu is positioned relative to the content panel: ' +
1614  *             'top, bottom, after, before.</p>'
1615  *          )
1616  *     );
1617  *     $( document.body ).append( menuLayout.$element );
1619  * If menu size needs to be overridden, it can be accomplished using CSS similar to the snippet
1620  * below. MenuLayout's CSS will override the appropriate values with 'auto' or '0' to display the
1621  * menu correctly. If `menuPosition` is known beforehand, CSS rules corresponding to other positions
1622  * may be omitted.
1624  *     .oo-ui-menuLayout-menu {
1625  *         width: 200px;
1626  *         height: 200px;
1627  *     }
1629  *     .oo-ui-menuLayout-content {
1630  *         top: 200px;
1631  *         left: 200px;
1632  *         right: 200px;
1633  *         bottom: 200px;
1634  *     }
1636  * @class
1637  * @extends OO.ui.Layout
1639  * @constructor
1640  * @param {Object} [config] Configuration options
1641  * @cfg {OO.ui.PanelLayout} [menuPanel] Menu panel
1642  * @cfg {OO.ui.PanelLayout} [contentPanel] Content panel
1643  * @cfg {boolean} [expanded=true] Expand the layout to fill the entire parent element.
1644  * @cfg {boolean} [showMenu=true] Show menu
1645  * @cfg {string} [menuPosition='before'] Position of menu: `top`, `after`, `bottom` or `before`
1646  */
1647 OO.ui.MenuLayout = function OoUiMenuLayout( config ) {
1648         // Configuration initialization
1649         config = $.extend( {
1650                 expanded: true,
1651                 showMenu: true,
1652                 menuPosition: 'before'
1653         }, config );
1655         // Parent constructor
1656         OO.ui.MenuLayout.super.call( this, config );
1658         this.menuPanel = null;
1659         this.contentPanel = null;
1660         this.expanded = !!config.expanded;
1661         /**
1662          * Menu DOM node
1663          *
1664          * @property {jQuery}
1665          */
1666         this.$menu = $( '<div>' );
1667         /**
1668          * Content DOM node
1669          *
1670          * @property {jQuery}
1671          */
1672         this.$content = $( '<div>' );
1674         // Initialization
1675         this.$menu.addClass( 'oo-ui-menuLayout-menu' );
1676         this.$content.addClass( 'oo-ui-menuLayout-content' );
1677         this.$element.addClass( 'oo-ui-menuLayout' );
1678         if ( config.expanded ) {
1679                 this.$element.addClass( 'oo-ui-menuLayout-expanded' );
1680         } else {
1681                 this.$element.addClass( 'oo-ui-menuLayout-static' );
1682         }
1683         if ( config.menuPanel ) {
1684                 this.setMenuPanel( config.menuPanel );
1685         }
1686         if ( config.contentPanel ) {
1687                 this.setContentPanel( config.contentPanel );
1688         }
1689         this.setMenuPosition( config.menuPosition );
1690         this.toggleMenu( config.showMenu );
1693 /* Setup */
1695 OO.inheritClass( OO.ui.MenuLayout, OO.ui.Layout );
1697 /* Methods */
1700  * Toggle menu.
1702  * @param {boolean} showMenu Show menu, omit to toggle
1703  * @chainable
1704  * @return {OO.ui.MenuLayout} The layout, for chaining
1705  */
1706 OO.ui.MenuLayout.prototype.toggleMenu = function ( showMenu ) {
1707         showMenu = showMenu === undefined ? !this.showMenu : !!showMenu;
1709         if ( this.showMenu !== showMenu ) {
1710                 this.showMenu = showMenu;
1711                 this.$element
1712                         .toggleClass( 'oo-ui-menuLayout-showMenu', this.showMenu )
1713                         .toggleClass( 'oo-ui-menuLayout-hideMenu', !this.showMenu );
1714                 this.$menu.attr( 'aria-hidden', this.showMenu ? 'false' : 'true' );
1715         }
1717         return this;
1721  * Check if menu is visible
1723  * @return {boolean} Menu is visible
1724  */
1725 OO.ui.MenuLayout.prototype.isMenuVisible = function () {
1726         return this.showMenu;
1730  * Set menu position.
1732  * @param {string} position Position of menu, either `top`, `after`, `bottom` or `before`
1733  * @chainable
1734  * @return {OO.ui.MenuLayout} The layout, for chaining
1735  */
1736 OO.ui.MenuLayout.prototype.setMenuPosition = function ( position ) {
1737         if ( [ 'top', 'bottom', 'before', 'after' ].indexOf( position ) === -1 ) {
1738                 position = 'before';
1739         }
1741         this.$element.removeClass( 'oo-ui-menuLayout-' + this.menuPosition );
1742         this.menuPosition = position;
1743         if ( this.menuPosition === 'top' || this.menuPosition === 'before' ) {
1744                 this.$element.append( this.$menu, this.$content );
1745         } else {
1746                 this.$element.append( this.$content, this.$menu );
1747         }
1748         this.$element.addClass( 'oo-ui-menuLayout-' + position );
1750         return this;
1754  * Get menu position.
1756  * @return {string} Menu position
1757  */
1758 OO.ui.MenuLayout.prototype.getMenuPosition = function () {
1759         return this.menuPosition;
1763  * Set the menu panel.
1765  * @param {OO.ui.PanelLayout} menuPanel Menu panel
1766  */
1767 OO.ui.MenuLayout.prototype.setMenuPanel = function ( menuPanel ) {
1768         this.menuPanel = menuPanel;
1769         this.$menu.append( this.menuPanel.$element );
1773  * Set the content panel.
1775  * @param {OO.ui.PanelLayout} contentPanel Content panel
1776  */
1777 OO.ui.MenuLayout.prototype.setContentPanel = function ( contentPanel ) {
1778         this.contentPanel = contentPanel;
1779         this.$content.append( this.contentPanel.$element );
1783  * Clear the menu panel.
1784  */
1785 OO.ui.MenuLayout.prototype.clearMenuPanel = function () {
1786         this.menuPanel = null;
1787         this.$menu.empty();
1791  * Clear the content panel.
1792  */
1793 OO.ui.MenuLayout.prototype.clearContentPanel = function () {
1794         this.contentPanel = null;
1795         this.$content.empty();
1799  * Reset the scroll offset of all panels and the tab select widget
1801  * @inheritdoc
1802  */
1803 OO.ui.MenuLayout.prototype.resetScroll = function () {
1804         if ( this.menuPanel ) {
1805                 this.menuPanel.resetScroll();
1806         }
1807         if ( this.contentPanel ) {
1808                 this.contentPanel.resetScroll();
1809         }
1811         return this;
1815  * BookletLayouts contain {@link OO.ui.PageLayout page layouts} as well as
1816  * an {@link OO.ui.OutlineSelectWidget outline} that allows users to easily navigate
1817  * through the pages and select which one to display. By default, only one page is
1818  * displayed at a time and the outline is hidden. When a user navigates to a new page,
1819  * the booklet layout automatically focuses on the first focusable element, unless the
1820  * default setting is changed. Optionally, booklets can be configured to show
1821  * {@link OO.ui.OutlineControlsWidget controls} for adding, moving, and removing items.
1823  *     @example
1824  *     // Example of a BookletLayout that contains two PageLayouts.
1826  *     function PageOneLayout( name, config ) {
1827  *         PageOneLayout.super.call( this, name, config );
1828  *         this.$element.append( '<p>First page</p><p>(This booklet has an outline, displayed on ' +
1829  *         'the left)</p>' );
1830  *     }
1831  *     OO.inheritClass( PageOneLayout, OO.ui.PageLayout );
1832  *     PageOneLayout.prototype.setupOutlineItem = function () {
1833  *         this.outlineItem.setLabel( 'Page One' );
1834  *     };
1836  *     function PageTwoLayout( name, config ) {
1837  *         PageTwoLayout.super.call( this, name, config );
1838  *         this.$element.append( '<p>Second page</p>' );
1839  *     }
1840  *     OO.inheritClass( PageTwoLayout, OO.ui.PageLayout );
1841  *     PageTwoLayout.prototype.setupOutlineItem = function () {
1842  *         this.outlineItem.setLabel( 'Page Two' );
1843  *     };
1845  *     var page1 = new PageOneLayout( 'one' ),
1846  *         page2 = new PageTwoLayout( 'two' );
1848  *     var booklet = new OO.ui.BookletLayout( {
1849  *         outlined: true
1850  *     } );
1852  *     booklet.addPages( [ page1, page2 ] );
1853  *     $( document.body ).append( booklet.$element );
1855  * @class
1856  * @extends OO.ui.MenuLayout
1858  * @constructor
1859  * @param {Object} [config] Configuration options
1860  * @cfg {boolean} [continuous=false] Show all pages, one after another
1861  * @cfg {boolean} [autoFocus=true] Focus on the first focusable element when a new page is
1862  *  displayed. Disabled on mobile.
1863  * @cfg {boolean} [outlined=false] Show the outline. The outline is used to navigate through the
1864  *  pages of the booklet.
1865  * @cfg {boolean} [editable=false] Show controls for adding, removing and reordering pages.
1866  */
1867 OO.ui.BookletLayout = function OoUiBookletLayout( config ) {
1868         // Configuration initialization
1869         config = config || {};
1871         // Parent constructor
1872         OO.ui.BookletLayout.super.call( this, config );
1874         // Properties
1875         this.currentPageName = null;
1876         this.pages = {};
1877         this.ignoreFocus = false;
1878         this.stackLayout = new OO.ui.StackLayout( {
1879                 continuous: !!config.continuous,
1880                 expanded: this.expanded
1881         } );
1882         this.setContentPanel( this.stackLayout );
1883         this.autoFocus = config.autoFocus === undefined || !!config.autoFocus;
1884         this.outlineVisible = false;
1885         this.outlined = !!config.outlined;
1886         if ( this.outlined ) {
1887                 this.editable = !!config.editable;
1888                 this.outlineControlsWidget = null;
1889                 this.outlineSelectWidget = new OO.ui.OutlineSelectWidget();
1890                 this.outlinePanel = new OO.ui.PanelLayout( {
1891                         expanded: this.expanded,
1892                         scrollable: true
1893                 } );
1894                 this.setMenuPanel( this.outlinePanel );
1895                 this.outlineVisible = true;
1896                 if ( this.editable ) {
1897                         this.outlineControlsWidget = new OO.ui.OutlineControlsWidget(
1898                                 this.outlineSelectWidget
1899                         );
1900                 }
1901         }
1902         this.toggleMenu( this.outlined );
1904         // Events
1905         this.stackLayout.connect( this, {
1906                 set: 'onStackLayoutSet'
1907         } );
1908         if ( this.outlined ) {
1909                 this.outlineSelectWidget.connect( this, {
1910                         select: 'onOutlineSelectWidgetSelect'
1911                 } );
1912         }
1913         if ( this.autoFocus ) {
1914                 // Event 'focus' does not bubble, but 'focusin' does
1915                 this.stackLayout.$element.on( 'focusin', this.onStackLayoutFocus.bind( this ) );
1916         }
1918         // Initialization
1919         this.$element.addClass( 'oo-ui-bookletLayout' );
1920         this.stackLayout.$element.addClass( 'oo-ui-bookletLayout-stackLayout' );
1921         if ( this.outlined ) {
1922                 this.outlinePanel.$element
1923                         .addClass( 'oo-ui-bookletLayout-outlinePanel' )
1924                         .append( this.outlineSelectWidget.$element );
1925                 if ( this.editable ) {
1926                         this.outlinePanel.$element
1927                                 .addClass( 'oo-ui-bookletLayout-outlinePanel-editable' )
1928                                 .append( this.outlineControlsWidget.$element );
1929                 }
1930         }
1933 /* Setup */
1935 OO.inheritClass( OO.ui.BookletLayout, OO.ui.MenuLayout );
1937 /* Events */
1940  * A 'set' event is emitted when a page is {@link #setPage set} to be displayed by the
1941  * booklet layout.
1943  * @event set
1944  * @param {OO.ui.PageLayout} page Current page
1945  */
1948  * An 'add' event is emitted when pages are {@link #addPages added} to the booklet layout.
1950  * @event add
1951  * @param {OO.ui.PageLayout[]} page Added pages
1952  * @param {number} index Index pages were added at
1953  */
1956  * A 'remove' event is emitted when pages are {@link #clearPages cleared} or
1957  * {@link #removePages removed} from the booklet.
1959  * @event remove
1960  * @param {OO.ui.PageLayout[]} pages Removed pages
1961  */
1963 /* Methods */
1966  * Handle stack layout focus.
1968  * @private
1969  * @param {jQuery.Event} e Focusin event
1970  */
1971 OO.ui.BookletLayout.prototype.onStackLayoutFocus = function ( e ) {
1972         // Find the page that an element was focused within
1973         var $target = $( e.target ).closest( '.oo-ui-pageLayout' );
1974         for ( var name in this.pages ) {
1975                 // Check for page match, exclude current page to find only page changes
1976                 if ( this.pages[ name ].$element[ 0 ] === $target[ 0 ] && name !== this.currentPageName ) {
1977                         this.setPage( name );
1978                         break;
1979                 }
1980         }
1984  * Handle stack layout set events.
1986  * @private
1987  * @param {OO.ui.PanelLayout|null} page The page panel that is now the current panel
1988  */
1989 OO.ui.BookletLayout.prototype.onStackLayoutSet = function ( page ) {
1990         var layout = this;
1991         // If everything is unselected, do nothing
1992         if ( !page ) {
1993                 return;
1994         }
1995         var promise;
1996         // For continuous BookletLayouts, scroll the selected page into view first
1997         if ( this.stackLayout.continuous && !this.scrolling ) {
1998                 promise = page.scrollElementIntoView();
1999         } else {
2000                 promise = $.Deferred().resolve();
2001         }
2002         // Focus the first element on the newly selected panel.
2003         // Don't focus if the page was set by scrolling.
2004         if ( this.autoFocus && !OO.ui.isMobile() && !this.scrolling ) {
2005                 promise.done( function () {
2006                         layout.focus();
2007                 } );
2008         }
2012  * Focus the first input in the current page.
2014  * If no page is selected, the first selectable page will be selected.
2015  * If the focus is already in an element on the current page, nothing will happen.
2017  * @param {number} [itemIndex] A specific item to focus on
2018  */
2019 OO.ui.BookletLayout.prototype.focus = function ( itemIndex ) {
2020         var items = this.stackLayout.getItems();
2022         var page;
2023         if ( itemIndex !== undefined && items[ itemIndex ] ) {
2024                 page = items[ itemIndex ];
2025         } else {
2026                 page = this.stackLayout.getCurrentItem();
2027         }
2029         if ( !page && this.outlined ) {
2030                 this.selectFirstSelectablePage();
2031                 page = this.stackLayout.getCurrentItem();
2032         }
2033         if ( !page ) {
2034                 return;
2035         }
2036         // Only change the focus if is not already in the current page
2037         if ( !OO.ui.contains( page.$element[ 0 ], this.getElementDocument().activeElement, true ) ) {
2038                 page.focus();
2039         }
2043  * Find the first focusable input in the booklet layout and focus
2044  * on it.
2045  */
2046 OO.ui.BookletLayout.prototype.focusFirstFocusable = function () {
2047         OO.ui.findFocusable( this.stackLayout.$element ).focus();
2051  * Handle outline widget select events.
2053  * @private
2054  * @param {OO.ui.OptionWidget|null} item Selected item
2055  */
2056 OO.ui.BookletLayout.prototype.onOutlineSelectWidgetSelect = function ( item ) {
2057         if ( item ) {
2058                 this.setPage( item.getData() );
2059         }
2063  * Check if booklet has an outline.
2065  * @return {boolean} Booklet has an outline
2066  */
2067 OO.ui.BookletLayout.prototype.isOutlined = function () {
2068         return this.outlined;
2072  * Check if booklet has editing controls.
2074  * @return {boolean} Booklet is editable
2075  */
2076 OO.ui.BookletLayout.prototype.isEditable = function () {
2077         return this.editable;
2081  * Check if booklet has a visible outline.
2083  * @return {boolean} Outline is visible
2084  */
2085 OO.ui.BookletLayout.prototype.isOutlineVisible = function () {
2086         return this.outlined && this.outlineVisible;
2090  * Hide or show the outline.
2092  * @param {boolean} [show] Show outline, omit to invert current state
2093  * @chainable
2094  * @return {OO.ui.BookletLayout} The layout, for chaining
2095  */
2096 OO.ui.BookletLayout.prototype.toggleOutline = function ( show ) {
2097         var booklet = this;
2099         if ( this.outlined ) {
2100                 show = show === undefined ? !this.outlineVisible : !!show;
2101                 this.outlineVisible = show;
2102                 this.toggleMenu( show );
2103                 if ( show && this.editable ) {
2104                         // HACK: Kill dumb scrollbars when the sidebar stops animating, see T161798.
2105                         // Only necessary when outline controls are present, delay matches transition on
2106                         // `.oo-ui-menuLayout-menu`.
2107                         setTimeout( function () {
2108                                 OO.ui.Element.static.reconsiderScrollbars( booklet.outlinePanel.$element[ 0 ] );
2109                         }, OO.ui.theme.getDialogTransitionDuration() );
2110                 }
2111         }
2113         return this;
2117  * Find the page closest to the specified page.
2119  * @param {OO.ui.PageLayout} page Page to use as a reference point
2120  * @return {OO.ui.PageLayout|null} Page closest to the specified page
2121  */
2122 OO.ui.BookletLayout.prototype.findClosestPage = function ( page ) {
2123         var pages = this.stackLayout.getItems(),
2124                 index = pages.indexOf( page );
2126         if ( index === -1 ) {
2127                 return null;
2128         }
2130         var next = pages[ index + 1 ];
2131         var prev = pages[ index - 1 ];
2132         // Prefer adjacent pages at the same level
2133         if ( this.outlined ) {
2134                 var level = this.outlineSelectWidget.findItemFromData( page.getName() ).getLevel();
2135                 if (
2136                         prev &&
2137                         level === this.outlineSelectWidget.findItemFromData( prev.getName() ).getLevel()
2138                 ) {
2139                         return prev;
2140                 }
2141                 if (
2142                         next &&
2143                         level === this.outlineSelectWidget.findItemFromData( next.getName() ).getLevel()
2144                 ) {
2145                         return next;
2146                 }
2147         }
2148         return prev || next || null;
2152  * Get the outline widget.
2154  * If the booklet is not outlined, the method will return `null`.
2156  * @return {OO.ui.OutlineSelectWidget|null} Outline widget, or null if the booklet is not outlined
2157  */
2158 OO.ui.BookletLayout.prototype.getOutline = function () {
2159         return this.outlineSelectWidget;
2163  * Get the outline controls widget.
2165  * If the outline is not editable, the method will return `null`.
2167  * @return {OO.ui.OutlineControlsWidget|null} The outline controls widget.
2168  */
2169 OO.ui.BookletLayout.prototype.getOutlineControls = function () {
2170         return this.outlineControlsWidget;
2174  * Get a page by its symbolic name.
2176  * @param {string} name Symbolic name of page
2177  * @return {OO.ui.PageLayout|undefined} Page, if found
2178  */
2179 OO.ui.BookletLayout.prototype.getPage = function ( name ) {
2180         return this.pages[ name ];
2184  * Get the current page.
2186  * @return {OO.ui.PageLayout|undefined} Current page, if found
2187  */
2188 OO.ui.BookletLayout.prototype.getCurrentPage = function () {
2189         var name = this.getCurrentPageName();
2190         return name ? this.getPage( name ) : undefined;
2194  * Get the symbolic name of the current page.
2196  * @return {string|null} Symbolic name of the current page
2197  */
2198 OO.ui.BookletLayout.prototype.getCurrentPageName = function () {
2199         return this.currentPageName;
2203  * Add pages to the booklet layout
2205  * When pages are added with the same names as existing pages, the existing pages will be
2206  * automatically removed before the new pages are added.
2208  * @param {OO.ui.PageLayout[]} pages Pages to add
2209  * @param {number} index Index of the insertion point
2210  * @fires add
2211  * @chainable
2212  * @return {OO.ui.BookletLayout} The layout, for chaining
2213  */
2214 OO.ui.BookletLayout.prototype.addPages = function ( pages, index ) {
2215         var stackLayoutPages = this.stackLayout.getItems(),
2216                 remove = [],
2217                 items = [];
2219         var i, len;
2220         var page, name;
2221         // Remove pages with same names
2222         for ( i = 0, len = pages.length; i < len; i++ ) {
2223                 page = pages[ i ];
2224                 name = page.getName();
2226                 if ( Object.prototype.hasOwnProperty.call( this.pages, name ) ) {
2227                         // Correct the insertion index
2228                         var currentIndex = stackLayoutPages.indexOf( this.pages[ name ] );
2229                         if ( currentIndex !== -1 && currentIndex + 1 < index ) {
2230                                 index--;
2231                         }
2232                         remove.push( this.pages[ name ] );
2233                 }
2234         }
2235         if ( remove.length ) {
2236                 this.removePages( remove );
2237         }
2239         // Add new pages
2240         for ( i = 0, len = pages.length; i < len; i++ ) {
2241                 page = pages[ i ];
2242                 name = page.getName();
2243                 this.pages[ page.getName() ] = page;
2244                 if ( this.outlined ) {
2245                         var item = new OO.ui.OutlineOptionWidget( { data: name } );
2246                         page.setOutlineItem( item );
2247                         items.push( item );
2248                 }
2249         }
2251         if ( this.outlined ) {
2252                 this.outlineSelectWidget.addItems( items, index );
2253                 // It's impossible to lose a selection here. Selecting something else is business logic.
2254         }
2255         this.stackLayout.addItems( pages, index );
2256         this.emit( 'add', pages, index );
2258         return this;
2262  * Remove the specified pages from the booklet layout.
2264  * To remove all pages from the booklet, you may wish to use the #clearPages method instead.
2266  * @param {OO.ui.PageLayout[]} pages An array of pages to remove
2267  * @fires remove
2268  * @chainable
2269  * @return {OO.ui.BookletLayout} The layout, for chaining
2270  */
2271 OO.ui.BookletLayout.prototype.removePages = function ( pages ) {
2272         var itemsToRemove = [];
2274         for ( var i = 0, len = pages.length; i < len; i++ ) {
2275                 var page = pages[ i ];
2276                 var name = page.getName();
2277                 delete this.pages[ name ];
2278                 if ( this.outlined ) {
2279                         itemsToRemove.push( this.outlineSelectWidget.findItemFromData( name ) );
2280                         page.setOutlineItem( null );
2281                 }
2282                 // If the current page is removed, clear currentPageName
2283                 if ( this.currentPageName === name ) {
2284                         this.currentPageName = null;
2285                 }
2286         }
2287         if ( itemsToRemove.length ) {
2288                 this.outlineSelectWidget.removeItems( itemsToRemove );
2289                 // We might loose the selection here, but what to select instead is business logic.
2290         }
2291         this.stackLayout.removeItems( pages );
2292         this.emit( 'remove', pages );
2294         return this;
2298  * Clear all pages from the booklet layout.
2300  * To remove only a subset of pages from the booklet, use the #removePages method.
2302  * @fires remove
2303  * @chainable
2304  * @return {OO.ui.BookletLayout} The layout, for chaining
2305  */
2306 OO.ui.BookletLayout.prototype.clearPages = function () {
2307         var pages = this.stackLayout.getItems();
2309         this.pages = {};
2310         this.currentPageName = null;
2311         if ( this.outlined ) {
2312                 this.outlineSelectWidget.clearItems();
2313                 for ( var i = 0, len = pages.length; i < len; i++ ) {
2314                         pages[ i ].setOutlineItem( null );
2315                 }
2316         }
2317         this.stackLayout.clearItems();
2319         this.emit( 'remove', pages );
2321         return this;
2325  * Set the current page by symbolic name.
2327  * @fires set
2328  * @param {string} name Symbolic name of page
2329  */
2330 OO.ui.BookletLayout.prototype.setPage = function ( name ) {
2331         var page = this.pages[ name ];
2332         if ( !page || name === this.currentPageName ) {
2333                 return;
2334         }
2336         var previousPage = this.currentPageName ? this.pages[ this.currentPageName ] : null;
2337         this.currentPageName = name;
2339         if ( this.outlined ) {
2340                 var selectedItem = this.outlineSelectWidget.findSelectedItem();
2341                 if ( !selectedItem || selectedItem.getData() !== name ) {
2342                         // Warning! This triggers a "select" event and the .onOutlineSelectWidgetSelect()
2343                         // handler, which calls .setPage() a second time. Make sure .currentPageName is set to
2344                         // break this loop.
2345                         this.outlineSelectWidget.selectItemByData( name );
2346                 }
2347         }
2349         var $focused;
2350         if ( previousPage ) {
2351                 previousPage.setActive( false );
2352                 // Blur anything focused if the next page doesn't have anything focusable.
2353                 // This is not needed if the next page has something focusable (because once it is
2354                 // focused this blur happens automatically). If the layout is non-continuous, this
2355                 // check is meaningless because the next page is not visible yet and thus can't
2356                 // hold focus.
2357                 if ( this.autoFocus &&
2358                         !OO.ui.isMobile() &&
2359                         this.stackLayout.continuous &&
2360                         OO.ui.findFocusable( page.$element ).length !== 0
2361                 ) {
2362                         $focused = previousPage.$element.find( ':focus' );
2363                         if ( $focused.length ) {
2364                                 $focused[ 0 ].blur();
2365                         }
2366                 }
2367         }
2368         page.setActive( true );
2369         this.stackLayout.setItem( page );
2370         if ( !this.stackLayout.continuous && previousPage ) {
2371                 // This should not be necessary, since any inputs on the previous page should have
2372                 // been blurred when it was hidden, but browsers are not very consistent about
2373                 // this.
2374                 $focused = previousPage.$element.find( ':focus' );
2375                 if ( $focused.length ) {
2376                         $focused[ 0 ].blur();
2377                 }
2378         }
2379         this.emit( 'set', page );
2383  * For outlined-continuous booklets, also reset the outlineSelectWidget to the first item.
2385  * @inheritdoc
2386  */
2387 OO.ui.BookletLayout.prototype.resetScroll = function () {
2388         // Parent method
2389         OO.ui.BookletLayout.super.prototype.resetScroll.call( this );
2391         if (
2392                 this.outlined &&
2393                 this.stackLayout.continuous &&
2394                 this.outlineSelectWidget.findFirstSelectableItem()
2395         ) {
2396                 this.scrolling = true;
2397                 this.outlineSelectWidget.selectItem( this.outlineSelectWidget.findFirstSelectableItem() );
2398                 this.scrolling = false;
2399         }
2400         return this;
2404  * Select the first selectable page.
2406  * @chainable
2407  * @return {OO.ui.BookletLayout} The layout, for chaining
2408  */
2409 OO.ui.BookletLayout.prototype.selectFirstSelectablePage = function () {
2410         if ( !this.outlineSelectWidget.findSelectedItem() ) {
2411                 this.outlineSelectWidget.selectItem( this.outlineSelectWidget.findFirstSelectableItem() );
2412         }
2414         return this;
2418  * IndexLayouts contain {@link OO.ui.TabPanelLayout tab panel layouts} as well as
2419  * {@link OO.ui.TabSelectWidget tabs} that allow users to easily navigate through the tab panels
2420  * and select which one to display. By default, only one tab panel is displayed at a time. When a
2421  * user navigates to a new tab panel, the index layout automatically focuses on the first focusable
2422  * element, unless the default setting is changed.
2424  * TODO: This class is similar to BookletLayout, we may want to refactor to reduce duplication
2426  *     @example
2427  *     // Example of a IndexLayout that contains two TabPanelLayouts.
2429  *     function TabPanelOneLayout( name, config ) {
2430  *         TabPanelOneLayout.super.call( this, name, config );
2431  *         this.$element.append( '<p>First tab panel</p>' );
2432  *     }
2433  *     OO.inheritClass( TabPanelOneLayout, OO.ui.TabPanelLayout );
2434  *     TabPanelOneLayout.prototype.setupTabItem = function () {
2435  *         this.tabItem.setLabel( 'Tab panel one' );
2436  *     };
2438  *     var tabPanel1 = new TabPanelOneLayout( 'one' ),
2439  *         tabPanel2 = new OO.ui.TabPanelLayout( 'two', { label: 'Tab panel two' } );
2441  *     tabPanel2.$element.append( '<p>Second tab panel</p>' );
2443  *     var index = new OO.ui.IndexLayout();
2445  *     index.addTabPanels( [ tabPanel1, tabPanel2 ] );
2446  *     $( document.body ).append( index.$element );
2448  * @class
2449  * @extends OO.ui.MenuLayout
2451  * @constructor
2452  * @param {Object} [config] Configuration options
2453  * @cfg {OO.ui.StackLayout} [contentPanel] Content stack (see MenuLayout)
2454  * @cfg {boolean} [continuous=false] Show all tab panels, one after another
2455  * @cfg {boolean} [autoFocus=true] Focus on the first focusable element when a new tab panel is
2456  *  displayed. Disabled on mobile.
2457  * @cfg {boolean} [framed=true] Render the tabs with frames
2458  */
2459 OO.ui.IndexLayout = function OoUiIndexLayout( config ) {
2460         // Configuration initialization
2461         config = $.extend( {}, config, { menuPosition: 'top' } );
2463         // Parent constructor
2464         OO.ui.IndexLayout.super.call( this, config );
2466         // Properties
2467         this.currentTabPanelName = null;
2468         // Allow infused widgets to pass existing tabPanels
2469         this.tabPanels = config.tabPanels || {};
2471         this.ignoreFocus = false;
2472         this.stackLayout = this.contentPanel || new OO.ui.StackLayout( {
2473                 continuous: !!config.continuous,
2474                 expanded: this.expanded
2475         } );
2476         this.setContentPanel( this.stackLayout );
2477         this.autoFocus = config.autoFocus === undefined || !!config.autoFocus;
2479         // Allow infused widgets to pass an existing tabSelectWidget
2480         this.tabSelectWidget = config.tabSelectWidget || new OO.ui.TabSelectWidget( {
2481                 framed: config.framed === undefined || config.framed
2482         } );
2483         this.tabPanel = this.menuPanel || new OO.ui.PanelLayout( {
2484                 expanded: this.expanded
2485         } );
2486         this.setMenuPanel( this.tabPanel );
2488         this.toggleMenu( true );
2490         // Events
2491         this.stackLayout.connect( this, {
2492                 set: 'onStackLayoutSet'
2493         } );
2494         this.tabSelectWidget.connect( this, {
2495                 select: 'onTabSelectWidgetSelect'
2496         } );
2497         if ( this.autoFocus ) {
2498                 // Event 'focus' does not bubble, but 'focusin' does.
2499                 this.stackLayout.$element.on( 'focusin', this.onStackLayoutFocus.bind( this ) );
2500         }
2502         // Initialization
2503         this.$element.addClass( 'oo-ui-indexLayout' );
2504         this.stackLayout.$element.addClass( 'oo-ui-indexLayout-stackLayout' );
2505         this.tabPanel.$element
2506                 .addClass( 'oo-ui-indexLayout-tabPanel' )
2507                 .append( this.tabSelectWidget.$element );
2509         this.selectFirstSelectableTabPanel();
2512 /* Setup */
2514 OO.inheritClass( OO.ui.IndexLayout, OO.ui.MenuLayout );
2516 /* Events */
2519  * A 'set' event is emitted when a tab panel is {@link #setTabPanel set} to be displayed by the
2520  * index layout.
2522  * @event set
2523  * @param {OO.ui.TabPanelLayout} tabPanel Current tab panel
2524  */
2527  * An 'add' event is emitted when tab panels are {@link #addTabPanels added} to the index layout.
2529  * @event add
2530  * @param {OO.ui.TabPanelLayout[]} tabPanel Added tab panels
2531  * @param {number} index Index tab panels were added at
2532  */
2535  * A 'remove' event is emitted when tab panels are {@link #clearTabPanels cleared} or
2536  * {@link #removeTabPanels removed} from the index.
2538  * @event remove
2539  * @param {OO.ui.TabPanelLayout[]} tabPanel Removed tab panels
2540  */
2542 /* Methods */
2545  * Handle stack layout focus.
2547  * @private
2548  * @param {jQuery.Event} e Focusing event
2549  */
2550 OO.ui.IndexLayout.prototype.onStackLayoutFocus = function ( e ) {
2551         // Find the tab panel that an element was focused within
2552         var $target = $( e.target ).closest( '.oo-ui-tabPanelLayout' );
2553         for ( var name in this.tabPanels ) {
2554                 // Check for tab panel match, exclude current tab panel to find only tab panel changes
2555                 if ( this.tabPanels[ name ].$element[ 0 ] === $target[ 0 ] &&
2556                                 name !== this.currentTabPanelName ) {
2557                         this.setTabPanel( name );
2558                         break;
2559                 }
2560         }
2564  * Handle stack layout set events.
2566  * @private
2567  * @param {OO.ui.PanelLayout|null} tabPanel The tab panel that is now the current panel
2568  */
2569 OO.ui.IndexLayout.prototype.onStackLayoutSet = function ( tabPanel ) {
2570         // If everything is unselected, do nothing
2571         if ( !tabPanel ) {
2572                 return;
2573         }
2574         // Focus the first element on the newly selected panel
2575         if ( this.autoFocus && !OO.ui.isMobile() ) {
2576                 this.focus();
2577         }
2581  * Focus the first input in the current tab panel.
2583  * If no tab panel is selected, the first selectable tab panel will be selected.
2584  * If the focus is already in an element on the current tab panel, nothing will happen.
2586  * @param {number} [itemIndex] A specific item to focus on
2587  */
2588 OO.ui.IndexLayout.prototype.focus = function ( itemIndex ) {
2589         var items = this.stackLayout.getItems();
2591         var tabPanel;
2592         if ( itemIndex !== undefined && items[ itemIndex ] ) {
2593                 tabPanel = items[ itemIndex ];
2594         } else {
2595                 tabPanel = this.stackLayout.getCurrentItem();
2596         }
2598         if ( !tabPanel ) {
2599                 this.selectFirstSelectableTabPanel();
2600                 tabPanel = this.stackLayout.getCurrentItem();
2601         }
2602         if ( !tabPanel ) {
2603                 return;
2604         }
2605         // Only change the focus if is not already in the current page
2606         if ( !OO.ui.contains(
2607                 tabPanel.$element[ 0 ],
2608                 this.getElementDocument().activeElement,
2609                 true
2610         ) ) {
2611                 tabPanel.focus();
2612         }
2616  * Find the first focusable input in the index layout and focus
2617  * on it.
2618  */
2619 OO.ui.IndexLayout.prototype.focusFirstFocusable = function () {
2620         OO.ui.findFocusable( this.stackLayout.$element ).focus();
2624  * Handle tab widget select events.
2626  * @private
2627  * @param {OO.ui.OptionWidget|null} item Selected item
2628  */
2629 OO.ui.IndexLayout.prototype.onTabSelectWidgetSelect = function ( item ) {
2630         if ( item ) {
2631                 this.setTabPanel( item.getData() );
2632         }
2636  * Get the tab panel closest to the specified tab panel.
2638  * @param {OO.ui.TabPanelLayout} tabPanel Tab panel to use as a reference point
2639  * @return {OO.ui.TabPanelLayout|null} Tab panel closest to the specified
2640  */
2641 OO.ui.IndexLayout.prototype.getClosestTabPanel = function ( tabPanel ) {
2642         var tabPanels = this.stackLayout.getItems(),
2643                 index = tabPanels.indexOf( tabPanel );
2645         if ( index === -1 ) {
2646                 return null;
2647         }
2649         var next = tabPanels[ index + 1 ];
2650         var prev = tabPanels[ index - 1 ];
2651         // Prefer adjacent tab panels at the same level
2652         var level = this.tabSelectWidget.findItemFromData( tabPanel.getName() ).getLevel();
2653         if (
2654                 prev &&
2655                 level === this.tabSelectWidget.findItemFromData( prev.getName() ).getLevel()
2656         ) {
2657                 return prev;
2658         }
2659         if (
2660                 next &&
2661                 level === this.tabSelectWidget.findItemFromData( next.getName() ).getLevel()
2662         ) {
2663                 return next;
2664         }
2665         return prev || next || null;
2669  * Get the tabs widget.
2671  * @return {OO.ui.TabSelectWidget} Tabs widget
2672  */
2673 OO.ui.IndexLayout.prototype.getTabs = function () {
2674         return this.tabSelectWidget;
2678  * Get a tab panel by its symbolic name.
2680  * @param {string} name Symbolic name of tab panel
2681  * @return {OO.ui.TabPanelLayout|undefined} Tab panel, if found
2682  */
2683 OO.ui.IndexLayout.prototype.getTabPanel = function ( name ) {
2684         return this.tabPanels[ name ];
2688  * Get the current tab panel.
2690  * @return {OO.ui.TabPanelLayout|undefined} Current tab panel, if found
2691  */
2692 OO.ui.IndexLayout.prototype.getCurrentTabPanel = function () {
2693         var name = this.getCurrentTabPanelName();
2694         return name ? this.getTabPanel( name ) : undefined;
2698  * Get the symbolic name of the current tab panel.
2700  * @return {string|null} Symbolic name of the current tab panel
2701  */
2702 OO.ui.IndexLayout.prototype.getCurrentTabPanelName = function () {
2703         return this.currentTabPanelName;
2707  * Add tab panels to the index layout.
2709  * When tab panels are added with the same names as existing tab panels, the existing tab panels
2710  * will be automatically removed before the new tab panels are added.
2712  * @param {OO.ui.TabPanelLayout[]} tabPanels Tab panels to add
2713  * @param {number} index Index of the insertion point
2714  * @fires add
2715  * @chainable
2716  * @return {OO.ui.IndexLayout} The layout, for chaining
2717  */
2718 OO.ui.IndexLayout.prototype.addTabPanels = function ( tabPanels, index ) {
2719         var i, len, name, tabPanel, tabItem, currentIndex,
2720                 stackLayoutTabPanels = this.stackLayout.getItems(),
2721                 remove = [],
2722                 tabItems = [];
2724         // Remove tab panels with same names
2725         for ( i = 0, len = tabPanels.length; i < len; i++ ) {
2726                 tabPanel = tabPanels[ i ];
2727                 name = tabPanel.getName();
2729                 if ( Object.prototype.hasOwnProperty.call( this.tabPanels, name ) ) {
2730                         // Correct the insertion index
2731                         currentIndex = stackLayoutTabPanels.indexOf( this.tabPanels[ name ] );
2732                         if ( currentIndex !== -1 && currentIndex + 1 < index ) {
2733                                 index--;
2734                         }
2735                         remove.push( this.tabPanels[ name ] );
2736                 }
2737         }
2738         if ( remove.length ) {
2739                 this.removeTabPanels( remove );
2740         }
2742         // Add new tab panels
2743         for ( i = 0, len = tabPanels.length; i < len; i++ ) {
2744                 tabPanel = tabPanels[ i ];
2745                 name = tabPanel.getName();
2746                 this.tabPanels[ name ] = tabPanel;
2747                 tabItem = new OO.ui.TabOptionWidget(
2748                         $.extend( { data: name }, tabPanel.getTabItemConfig() )
2749                 );
2750                 tabPanel.setTabItem( tabItem );
2751                 tabItems.push( tabItem );
2752         }
2754         if ( tabItems.length ) {
2755                 this.tabSelectWidget.addItems( tabItems, index );
2756                 this.selectFirstSelectableTabPanel();
2757         }
2758         this.stackLayout.addItems( tabPanels, index );
2759         this.emit( 'add', tabPanels, index );
2761         return this;
2765  * Remove the specified tab panels from the index layout.
2767  * To remove all tab panels from the index, you may wish to use the #clearTabPanels method instead.
2769  * @param {OO.ui.TabPanelLayout[]} tabPanels An array of tab panels to remove
2770  * @fires remove
2771  * @chainable
2772  * @return {OO.ui.IndexLayout} The layout, for chaining
2773  */
2774 OO.ui.IndexLayout.prototype.removeTabPanels = function ( tabPanels ) {
2775         var i, len, name, tabPanel,
2776                 items = [];
2778         for ( i = 0, len = tabPanels.length; i < len; i++ ) {
2779                 tabPanel = tabPanels[ i ];
2780                 name = tabPanel.getName();
2781                 delete this.tabPanels[ name ];
2782                 items.push( this.tabSelectWidget.findItemFromData( name ) );
2783                 tabPanel.setTabItem( null );
2784         }
2785         if ( items.length ) {
2786                 this.tabSelectWidget.removeItems( items );
2787                 this.selectFirstSelectableTabPanel();
2788         }
2789         this.stackLayout.removeItems( tabPanels );
2790         this.emit( 'remove', tabPanels );
2792         return this;
2796  * Clear all tab panels from the index layout.
2798  * To remove only a subset of tab panels from the index, use the #removeTabPanels method.
2800  * @fires remove
2801  * @chainable
2802  * @return {OO.ui.IndexLayout} The layout, for chaining
2803  */
2804 OO.ui.IndexLayout.prototype.clearTabPanels = function () {
2805         var i, len,
2806                 tabPanels = this.stackLayout.getItems();
2808         this.tabPanels = {};
2809         this.currentTabPanelName = null;
2810         this.tabSelectWidget.clearItems();
2811         for ( i = 0, len = tabPanels.length; i < len; i++ ) {
2812                 tabPanels[ i ].setTabItem( null );
2813         }
2814         this.stackLayout.clearItems();
2816         this.emit( 'remove', tabPanels );
2818         return this;
2822  * Set the current tab panel by symbolic name.
2824  * @fires set
2825  * @param {string} name Symbolic name of tab panel
2826  */
2827 OO.ui.IndexLayout.prototype.setTabPanel = function ( name ) {
2828         var selectedItem,
2829                 $focused,
2830                 previousTabPanel,
2831                 tabPanel;
2833         if ( name !== this.currentTabPanelName ) {
2834                 tabPanel = this.getTabPanel( name );
2835                 previousTabPanel = this.getCurrentTabPanel();
2836                 selectedItem = this.tabSelectWidget.findSelectedItem();
2837                 if ( !selectedItem || selectedItem.getData() !== name ) {
2838                         this.tabSelectWidget.selectItemByData( name );
2839                 }
2840                 if ( tabPanel ) {
2841                         if ( previousTabPanel ) {
2842                                 previousTabPanel.setActive( false );
2843                                 // Blur anything focused if the next tab panel doesn't have anything focusable.
2844                                 // This is not needed if the next tab panel has something focusable (because once
2845                                 // it is focused this blur happens automatically). If the layout is non-continuous,
2846                                 // this check is meaningless because the next tab panel is not visible yet and thus
2847                                 // can't hold focus.
2848                                 if (
2849                                         this.autoFocus &&
2850                                         !OO.ui.isMobile() &&
2851                                         this.stackLayout.continuous &&
2852                                         OO.ui.findFocusable( tabPanel.$element ).length !== 0
2853                                 ) {
2854                                         $focused = previousTabPanel.$element.find( ':focus' );
2855                                         if ( $focused.length ) {
2856                                                 $focused[ 0 ].blur();
2857                                         }
2858                                 }
2859                         }
2860                         this.currentTabPanelName = name;
2861                         tabPanel.setActive( true );
2862                         this.stackLayout.setItem( tabPanel );
2863                         if ( !this.stackLayout.continuous && previousTabPanel ) {
2864                                 // This should not be necessary, since any inputs on the previous tab panel should
2865                                 // have been blurred when it was hidden, but browsers are not very consistent about
2866                                 // this.
2867                                 $focused = previousTabPanel.$element.find( ':focus' );
2868                                 if ( $focused.length ) {
2869                                         $focused[ 0 ].blur();
2870                                 }
2871                         }
2872                         this.emit( 'set', tabPanel );
2873                 }
2874         }
2878  * Select the first selectable tab panel.
2880  * @chainable
2881  * @return {OO.ui.IndexLayout} The layout, for chaining
2882  */
2883 OO.ui.IndexLayout.prototype.selectFirstSelectableTabPanel = function () {
2884         if ( !this.tabSelectWidget.findSelectedItem() ) {
2885                 this.tabSelectWidget.selectItem( this.tabSelectWidget.findFirstSelectableItem() );
2886         }
2888         return this;
2892  * ToggleWidget implements basic behavior of widgets with an on/off state.
2893  * Please see OO.ui.ToggleButtonWidget and OO.ui.ToggleSwitchWidget for examples.
2895  * @abstract
2896  * @class
2897  * @extends OO.ui.Widget
2898  * @mixins OO.ui.mixin.TitledElement
2900  * @constructor
2901  * @param {Object} [config] Configuration options
2902  * @cfg {boolean} [value=false] The toggle’s initial on/off state.
2903  *  By default, the toggle is in the 'off' state.
2904  */
2905 OO.ui.ToggleWidget = function OoUiToggleWidget( config ) {
2906         // Configuration initialization
2907         config = config || {};
2909         // Parent constructor
2910         OO.ui.ToggleWidget.super.call( this, config );
2912         // Mixin constructor
2913         OO.ui.mixin.TitledElement.call( this, config );
2915         // Properties
2916         this.value = null;
2918         // Initialization
2919         this.$element.addClass( 'oo-ui-toggleWidget' );
2920         this.setValue( !!config.value );
2923 /* Setup */
2925 OO.inheritClass( OO.ui.ToggleWidget, OO.ui.Widget );
2926 OO.mixinClass( OO.ui.ToggleWidget, OO.ui.mixin.TitledElement );
2928 /* Events */
2931  * @event change
2933  * A change event is emitted when the on/off state of the toggle changes.
2935  * @param {boolean} value Value representing the new state of the toggle
2936  */
2938 /* Methods */
2941  * Get the value representing the toggle’s state.
2943  * @return {boolean} The on/off state of the toggle
2944  */
2945 OO.ui.ToggleWidget.prototype.getValue = function () {
2946         return this.value;
2950  * Set the state of the toggle: `true` for 'on', `false` for 'off'.
2952  * @param {boolean} value The state of the toggle
2953  * @fires change
2954  * @chainable
2955  * @return {OO.ui.Widget} The widget, for chaining
2956  */
2957 OO.ui.ToggleWidget.prototype.setValue = function ( value ) {
2958         value = !!value;
2959         if ( this.value !== value ) {
2960                 this.value = value;
2961                 this.emit( 'change', value );
2962                 this.$element.toggleClass( 'oo-ui-toggleWidget-on', value );
2963                 this.$element.toggleClass( 'oo-ui-toggleWidget-off', !value );
2964         }
2965         return this;
2969  * ToggleButtons are buttons that have a state (‘on’ or ‘off’) that is represented by a
2970  * Boolean value. Like other {@link OO.ui.ButtonWidget buttons}, toggle buttons can be
2971  * configured with {@link OO.ui.mixin.IconElement icons},
2972  * {@link OO.ui.mixin.IndicatorElement indicators},
2973  * {@link OO.ui.mixin.TitledElement titles}, {@link OO.ui.mixin.FlaggedElement styling flags},
2974  * and {@link OO.ui.mixin.LabelElement labels}. Please see
2975  * the [OOUI documentation][1] on MediaWiki for more information.
2977  *     @example
2978  *     // Toggle buttons in the 'off' and 'on' state.
2979  *     var toggleButton1 = new OO.ui.ToggleButtonWidget( {
2980  *             label: 'Toggle Button off'
2981  *         } ),
2982  *         toggleButton2 = new OO.ui.ToggleButtonWidget( {
2983  *             label: 'Toggle Button on',
2984  *             value: true
2985  *         } );
2986  *     // Append the buttons to the DOM.
2987  *     $( document.body ).append( toggleButton1.$element, toggleButton2.$element );
2989  * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Buttons_and_Switches#Toggle_buttons
2991  * @class
2992  * @extends OO.ui.ToggleWidget
2993  * @mixins OO.ui.mixin.ButtonElement
2994  * @mixins OO.ui.mixin.IconElement
2995  * @mixins OO.ui.mixin.IndicatorElement
2996  * @mixins OO.ui.mixin.LabelElement
2997  * @mixins OO.ui.mixin.FlaggedElement
2998  * @mixins OO.ui.mixin.TabIndexedElement
3000  * @constructor
3001  * @param {Object} [config] Configuration options
3002  * @cfg {boolean} [value=false] The toggle button’s initial on/off
3003  *  state. By default, the button is in the 'off' state.
3004  */
3005 OO.ui.ToggleButtonWidget = function OoUiToggleButtonWidget( config ) {
3006         // Configuration initialization
3007         config = config || {};
3009         // Parent constructor
3010         OO.ui.ToggleButtonWidget.super.call( this, config );
3012         // Mixin constructors
3013         OO.ui.mixin.ButtonElement.call( this, $.extend( {
3014                 active: this.active
3015         }, config ) );
3016         OO.ui.mixin.IconElement.call( this, config );
3017         OO.ui.mixin.IndicatorElement.call( this, config );
3018         OO.ui.mixin.LabelElement.call( this, config );
3019         OO.ui.mixin.FlaggedElement.call( this, config );
3020         OO.ui.mixin.TabIndexedElement.call( this, $.extend( {
3021                 $tabIndexed: this.$button
3022         }, config ) );
3024         // Events
3025         this.connect( this, {
3026                 click: 'onAction'
3027         } );
3029         // Initialization
3030         this.$button.append( this.$icon, this.$label, this.$indicator );
3031         this.$element
3032                 .addClass( 'oo-ui-toggleButtonWidget' )
3033                 .append( this.$button );
3034         this.setTitledElement( this.$button );
3037 /* Setup */
3039 OO.inheritClass( OO.ui.ToggleButtonWidget, OO.ui.ToggleWidget );
3040 OO.mixinClass( OO.ui.ToggleButtonWidget, OO.ui.mixin.ButtonElement );
3041 OO.mixinClass( OO.ui.ToggleButtonWidget, OO.ui.mixin.IconElement );
3042 OO.mixinClass( OO.ui.ToggleButtonWidget, OO.ui.mixin.IndicatorElement );
3043 OO.mixinClass( OO.ui.ToggleButtonWidget, OO.ui.mixin.LabelElement );
3044 OO.mixinClass( OO.ui.ToggleButtonWidget, OO.ui.mixin.FlaggedElement );
3045 OO.mixinClass( OO.ui.ToggleButtonWidget, OO.ui.mixin.TabIndexedElement );
3047 /* Static Properties */
3050  * @static
3051  * @inheritdoc
3052  */
3053 OO.ui.ToggleButtonWidget.static.tagName = 'span';
3055 /* Methods */
3058  * Handle the button action being triggered.
3060  * @private
3061  */
3062 OO.ui.ToggleButtonWidget.prototype.onAction = function () {
3063         this.setValue( !this.value );
3067  * @inheritdoc
3068  */
3069 OO.ui.ToggleButtonWidget.prototype.setValue = function ( value ) {
3070         value = !!value;
3071         if ( value !== this.value ) {
3072                 // Might be called from parent constructor before ButtonElement constructor
3073                 if ( this.$button ) {
3074                         this.$button.attr( 'aria-pressed', value.toString() );
3075                 }
3076                 this.setActive( value );
3077         }
3079         // Parent method
3080         OO.ui.ToggleButtonWidget.super.prototype.setValue.call( this, value );
3082         return this;
3086  * @inheritdoc
3087  */
3088 OO.ui.ToggleButtonWidget.prototype.setButtonElement = function ( $button ) {
3089         if ( this.$button ) {
3090                 this.$button.removeAttr( 'aria-pressed' );
3091         }
3092         OO.ui.mixin.ButtonElement.prototype.setButtonElement.call( this, $button );
3093         this.$button.attr( 'aria-pressed', this.value.toString() );
3097  * ToggleSwitches are switches that slide on and off. Their state is represented by a Boolean
3098  * value (`true` for ‘on’, and `false` otherwise, the default). The ‘off’ state is represented
3099  * visually by a slider in the leftmost position.
3101  *     @example
3102  *     // Toggle switches in the 'off' and 'on' position.
3103  *     var toggleSwitch1 = new OO.ui.ToggleSwitchWidget(),
3104  *         toggleSwitch2 = new OO.ui.ToggleSwitchWidget( {
3105  *             value: true
3106  *         } );
3107  *         // Create a FieldsetLayout to layout and label switches.
3108  *         fieldset = new OO.ui.FieldsetLayout( {
3109  *             label: 'Toggle switches'
3110  *         } );
3111  *     fieldset.addItems( [
3112  *         new OO.ui.FieldLayout( toggleSwitch1, {
3113  *             label: 'Off',
3114  *             align: 'top'
3115  *         } ),
3116  *         new OO.ui.FieldLayout( toggleSwitch2, {
3117  *             label: 'On',
3118  *             align: 'top'
3119  *         } )
3120  *     ] );
3121  *     $( document.body ).append( fieldset.$element );
3123  * @class
3124  * @extends OO.ui.ToggleWidget
3125  * @mixins OO.ui.mixin.TabIndexedElement
3127  * @constructor
3128  * @param {Object} [config] Configuration options
3129  * @cfg {boolean} [value=false] The toggle switch’s initial on/off state.
3130  *  By default, the toggle switch is in the 'off' position.
3131  */
3132 OO.ui.ToggleSwitchWidget = function OoUiToggleSwitchWidget( config ) {
3133         // Parent constructor
3134         OO.ui.ToggleSwitchWidget.super.call( this, config );
3136         // Mixin constructors
3137         OO.ui.mixin.TabIndexedElement.call( this, config );
3139         // Properties
3140         this.dragging = false;
3141         this.dragStart = null;
3142         this.sliding = false;
3143         this.$glow = $( '<span>' );
3144         this.$grip = $( '<span>' );
3146         // Events
3147         this.$element.on( {
3148                 click: this.onClick.bind( this ),
3149                 keypress: this.onKeyPress.bind( this )
3150         } );
3152         // Initialization
3153         this.$glow.addClass( 'oo-ui-toggleSwitchWidget-glow' );
3154         this.$grip.addClass( 'oo-ui-toggleSwitchWidget-grip' );
3155         this.$element
3156                 .addClass( 'oo-ui-toggleSwitchWidget' )
3157                 .attr( 'role', 'switch' )
3158                 .append( this.$glow, this.$grip );
3161 /* Setup */
3163 OO.inheritClass( OO.ui.ToggleSwitchWidget, OO.ui.ToggleWidget );
3164 OO.mixinClass( OO.ui.ToggleSwitchWidget, OO.ui.mixin.TabIndexedElement );
3166 /* Methods */
3169  * Handle mouse click events.
3171  * @private
3172  * @param {jQuery.Event} e Mouse click event
3173  * @return {undefined|boolean} False to prevent default if event is handled
3174  */
3175 OO.ui.ToggleSwitchWidget.prototype.onClick = function ( e ) {
3176         if ( !this.isDisabled() && e.which === OO.ui.MouseButtons.LEFT ) {
3177                 this.setValue( !this.value );
3178         }
3179         return false;
3183  * Handle key press events.
3185  * @private
3186  * @param {jQuery.Event} e Key press event
3187  * @return {undefined|boolean} False to prevent default if event is handled
3188  */
3189 OO.ui.ToggleSwitchWidget.prototype.onKeyPress = function ( e ) {
3190         if ( !this.isDisabled() && ( e.which === OO.ui.Keys.SPACE || e.which === OO.ui.Keys.ENTER ) ) {
3191                 this.setValue( !this.value );
3192                 return false;
3193         }
3197  * @inheritdoc
3198  */
3199 OO.ui.ToggleSwitchWidget.prototype.setValue = function ( value ) {
3200         OO.ui.ToggleSwitchWidget.super.prototype.setValue.call( this, value );
3201         this.$element.attr( 'aria-checked', this.value.toString() );
3202         return this;
3206  * @inheritdoc
3207  */
3208 OO.ui.ToggleSwitchWidget.prototype.simulateLabelClick = function () {
3209         if ( !this.isDisabled() ) {
3210                 this.setValue( !this.value );
3211         }
3212         this.focus();
3216  * OutlineControlsWidget is a set of controls for an
3217  * {@link OO.ui.OutlineSelectWidget outline select widget}.
3218  * Controls include moving items up and down, removing items, and adding different kinds of items.
3220  * **Currently, this class is only used by {@link OO.ui.BookletLayout booklet layouts}.**
3222  * @class
3223  * @extends OO.ui.Widget
3224  * @mixins OO.ui.mixin.GroupElement
3226  * @constructor
3227  * @param {OO.ui.OutlineSelectWidget} outline Outline to control
3228  * @param {Object} [config] Configuration options
3229  * @cfg {Object} [abilities] List of abilties
3230  * @cfg {boolean} [abilities.move=true] Allow moving movable items
3231  * @cfg {boolean} [abilities.remove=true] Allow removing removable items
3232  */
3233 OO.ui.OutlineControlsWidget = function OoUiOutlineControlsWidget( outline, config ) {
3234         // Allow passing positional parameters inside the config object
3235         if ( OO.isPlainObject( outline ) && config === undefined ) {
3236                 config = outline;
3237                 outline = config.outline;
3238         }
3240         // Configuration initialization
3241         config = config || {};
3243         // Parent constructor
3244         OO.ui.OutlineControlsWidget.super.call( this, config );
3246         // Mixin constructors
3247         OO.ui.mixin.GroupElement.call( this, config );
3249         // Properties
3250         this.outline = outline;
3251         this.$movers = $( '<div>' );
3252         this.upButton = new OO.ui.ButtonWidget( {
3253                 framed: false,
3254                 icon: 'upTriangle',
3255                 title: OO.ui.msg( 'ooui-outline-control-move-up' )
3256         } );
3257         this.downButton = new OO.ui.ButtonWidget( {
3258                 framed: false,
3259                 icon: 'downTriangle',
3260                 title: OO.ui.msg( 'ooui-outline-control-move-down' )
3261         } );
3262         this.removeButton = new OO.ui.ButtonWidget( {
3263                 framed: false,
3264                 icon: 'trash',
3265                 title: OO.ui.msg( 'ooui-outline-control-remove' )
3266         } );
3267         this.abilities = { move: true, remove: true };
3269         // Events
3270         outline.connect( this, {
3271                 select: 'onOutlineChange',
3272                 add: 'onOutlineChange',
3273                 remove: 'onOutlineChange'
3274         } );
3275         this.upButton.connect( this, {
3276                 click: [ 'emit', 'move', -1 ]
3277         } );
3278         this.downButton.connect( this, {
3279                 click: [ 'emit', 'move', 1 ]
3280         } );
3281         this.removeButton.connect( this, {
3282                 click: [ 'emit', 'remove' ]
3283         } );
3285         // Initialization
3286         this.$element.addClass( 'oo-ui-outlineControlsWidget' );
3287         this.$group.addClass( 'oo-ui-outlineControlsWidget-items' );
3288         this.$movers
3289                 .addClass( 'oo-ui-outlineControlsWidget-movers' )
3290                 .append( this.upButton.$element, this.downButton.$element, this.removeButton.$element );
3291         this.$element.append( this.$icon, this.$group, this.$movers );
3292         this.setAbilities( config.abilities || {} );
3295 /* Setup */
3297 OO.inheritClass( OO.ui.OutlineControlsWidget, OO.ui.Widget );
3298 OO.mixinClass( OO.ui.OutlineControlsWidget, OO.ui.mixin.GroupElement );
3300 /* Events */
3303  * @event move
3304  * @param {number} places Number of places to move
3305  */
3308  * @event remove
3309  */
3311 /* Methods */
3314  * Set abilities.
3316  * @param {Object} abilities List of abilties
3317  * @param {boolean} [abilities.move] Allow moving movable items
3318  * @param {boolean} [abilities.remove] Allow removing removable items
3319  */
3320 OO.ui.OutlineControlsWidget.prototype.setAbilities = function ( abilities ) {
3321         for ( var ability in this.abilities ) {
3322                 if ( abilities[ ability ] !== undefined ) {
3323                         this.abilities[ ability ] = !!abilities[ ability ];
3324                 }
3325         }
3327         this.onOutlineChange();
3331  * Handle outline change events.
3333  * @private
3334  */
3335 OO.ui.OutlineControlsWidget.prototype.onOutlineChange = function () {
3336         var items = this.outline.getItems(),
3337                 selectedItem = this.outline.findSelectedItem(),
3338                 movable = this.abilities.move && selectedItem && selectedItem.isMovable(),
3339                 removable = this.abilities.remove && selectedItem && selectedItem.isRemovable();
3341         var firstMovable, lastMovable;
3342         if ( movable ) {
3343                 var i = -1;
3344                 var len = items.length;
3345                 while ( ++i < len ) {
3346                         if ( items[ i ].isMovable() ) {
3347                                 firstMovable = items[ i ];
3348                                 break;
3349                         }
3350                 }
3351                 i = len;
3352                 while ( i-- ) {
3353                         if ( items[ i ].isMovable() ) {
3354                                 lastMovable = items[ i ];
3355                                 break;
3356                         }
3357                 }
3358         }
3359         this.upButton.setDisabled( !movable || selectedItem === firstMovable );
3360         this.downButton.setDisabled( !movable || selectedItem === lastMovable );
3361         this.removeButton.setDisabled( !removable );
3365  * OutlineOptionWidget is an item in an {@link OO.ui.OutlineSelectWidget OutlineSelectWidget}.
3367  * Currently, this class is only used by {@link OO.ui.BookletLayout booklet layouts}, which contain
3368  * {@link OO.ui.PageLayout page layouts}. See {@link OO.ui.BookletLayout BookletLayout}
3369  * for an example.
3371  * @class
3372  * @extends OO.ui.DecoratedOptionWidget
3374  * @constructor
3375  * @param {Object} [config] Configuration options
3376  * @cfg {number} [level=0] Indentation level
3377  * @cfg {boolean} [movable=false] Allow modification from
3378  *  {@link OO.ui.OutlineControlsWidget outline controls}.
3379  * @cfg {boolean} [removable=false]
3380  */
3381 OO.ui.OutlineOptionWidget = function OoUiOutlineOptionWidget( config ) {
3382         // Configuration initialization
3383         config = config || {};
3385         // Parent constructor
3386         OO.ui.OutlineOptionWidget.super.call( this, config );
3388         // Properties
3389         this.movable = !!config.movable;
3390         this.removable = !!config.removable;
3392         // Initialization
3393         this.$element.addClass( 'oo-ui-outlineOptionWidget' );
3394         this.setLevel( config.level );
3397 /* Setup */
3399 OO.inheritClass( OO.ui.OutlineOptionWidget, OO.ui.DecoratedOptionWidget );
3401 /* Static Properties */
3404  * @static
3405  * @inheritdoc
3406  */
3407 OO.ui.OutlineOptionWidget.static.highlightable = true;
3410  * @static
3411  * @inheritdoc
3412  */
3413 OO.ui.OutlineOptionWidget.static.scrollIntoViewOnSelect = true;
3416  * @static
3417  * @inheritable
3418  * @property {string}
3419  */
3420 OO.ui.OutlineOptionWidget.static.levelClass = 'oo-ui-outlineOptionWidget-level-';
3423  * @static
3424  * @inheritable
3425  * @property {number}
3426  */
3427 OO.ui.OutlineOptionWidget.static.levels = 3;
3429 /* Methods */
3432  * Check if item is movable.
3434  * Movability is used by {@link OO.ui.OutlineControlsWidget outline controls}.
3436  * @return {boolean} Item is movable
3437  */
3438 OO.ui.OutlineOptionWidget.prototype.isMovable = function () {
3439         return this.movable;
3443  * Check if item is removable.
3445  * Removability is used by {@link OO.ui.OutlineControlsWidget outline controls}.
3447  * @return {boolean} Item is removable
3448  */
3449 OO.ui.OutlineOptionWidget.prototype.isRemovable = function () {
3450         return this.removable;
3454  * Get indentation level.
3456  * @return {number} Indentation level
3457  */
3458 OO.ui.OutlineOptionWidget.prototype.getLevel = function () {
3459         return this.level;
3463  * Set movability.
3465  * Movability is used by {@link OO.ui.OutlineControlsWidget outline controls}.
3467  * @param {boolean} movable Item is movable
3468  * @chainable
3469  * @return {OO.ui.Widget} The widget, for chaining
3470  */
3471 OO.ui.OutlineOptionWidget.prototype.setMovable = function ( movable ) {
3472         this.movable = !!movable;
3473         this.updateThemeClasses();
3474         return this;
3478  * Set removability.
3480  * Removability is used by {@link OO.ui.OutlineControlsWidget outline controls}.
3482  * @param {boolean} removable Item is removable
3483  * @chainable
3484  * @return {OO.ui.Widget} The widget, for chaining
3485  */
3486 OO.ui.OutlineOptionWidget.prototype.setRemovable = function ( removable ) {
3487         this.removable = !!removable;
3488         this.updateThemeClasses();
3489         return this;
3493  * Set indentation level.
3495  * @param {number} [level=0] Indentation level, in the range of [0,#maxLevel]
3496  * @chainable
3497  * @return {OO.ui.Widget} The widget, for chaining
3498  */
3499 OO.ui.OutlineOptionWidget.prototype.setLevel = function ( level ) {
3500         if ( this.level === level ) {
3501                 return this;
3502         }
3504         var levels = this.constructor.static.levels,
3505                 levelClass = this.constructor.static.levelClass;
3507         if ( this.level !== undefined ) {
3508                 this.$element.removeClass( levelClass + this.level );
3509         }
3510         this.level = level > 0 ? Math.min( level, levels - 1 ) : 0;
3511         this.$element.addClass( levelClass + this.level );
3512         this.updateThemeClasses();
3514         return this;
3518  * OutlineSelectWidget is a structured list that contains
3519  * {@link OO.ui.OutlineOptionWidget outline options}
3520  * A set of controls can be provided with an {@link OO.ui.OutlineControlsWidget outline controls}
3521  * widget.
3523  * **Currently, this class is only used by {@link OO.ui.BookletLayout booklet layouts}.**
3525  * @class
3526  * @extends OO.ui.SelectWidget
3527  * @mixins OO.ui.mixin.TabIndexedElement
3529  * @constructor
3530  * @param {Object} [config] Configuration options
3531  */
3532 OO.ui.OutlineSelectWidget = function OoUiOutlineSelectWidget( config ) {
3533         // Parent constructor
3534         OO.ui.OutlineSelectWidget.super.call( this, config );
3536         // Mixin constructors
3537         OO.ui.mixin.TabIndexedElement.call( this, config );
3539         // Events
3540         this.$element.on( {
3541                 focus: this.bindDocumentKeyDownListener.bind( this ),
3542                 blur: this.unbindDocumentKeyDownListener.bind( this )
3543         } );
3545         // Initialization
3546         this.$element.addClass( 'oo-ui-outlineSelectWidget' );
3549 /* Setup */
3551 OO.inheritClass( OO.ui.OutlineSelectWidget, OO.ui.SelectWidget );
3552 OO.mixinClass( OO.ui.OutlineSelectWidget, OO.ui.mixin.TabIndexedElement );
3555  * ButtonOptionWidget is a special type of {@link OO.ui.mixin.ButtonElement button element} that
3556  * can be selected and configured with data. The class is
3557  * used with OO.ui.ButtonSelectWidget to create a selection of button options. Please see the
3558  * [OOUI documentation on MediaWiki] [1] for more information.
3560  * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Selects_and_Options#Button_selects_and_options
3562  * @class
3563  * @extends OO.ui.OptionWidget
3564  * @mixins OO.ui.mixin.ButtonElement
3565  * @mixins OO.ui.mixin.IconElement
3566  * @mixins OO.ui.mixin.IndicatorElement
3568  * @constructor
3569  * @param {Object} [config] Configuration options
3570  */
3571 OO.ui.ButtonOptionWidget = function OoUiButtonOptionWidget( config ) {
3572         // Configuration initialization
3573         config = config || {};
3575         // Parent constructor
3576         OO.ui.ButtonOptionWidget.super.call( this, config );
3578         // Mixin constructors
3579         OO.ui.mixin.ButtonElement.call( this, config );
3580         OO.ui.mixin.IconElement.call( this, config );
3581         OO.ui.mixin.IndicatorElement.call( this, config );
3583         // Initialization
3584         this.$element.addClass( 'oo-ui-buttonOptionWidget' );
3585         this.$button.append( this.$icon, this.$label, this.$indicator );
3586         this.$element.append( this.$button );
3587         this.setTitledElement( this.$button );
3590 /* Setup */
3592 OO.inheritClass( OO.ui.ButtonOptionWidget, OO.ui.OptionWidget );
3593 OO.mixinClass( OO.ui.ButtonOptionWidget, OO.ui.mixin.ButtonElement );
3594 OO.mixinClass( OO.ui.ButtonOptionWidget, OO.ui.mixin.IconElement );
3595 OO.mixinClass( OO.ui.ButtonOptionWidget, OO.ui.mixin.IndicatorElement );
3597 /* Static Properties */
3600  * Allow button mouse down events to pass through so they can be handled by the parent select widget
3602  * @static
3603  * @inheritdoc
3604  */
3605 OO.ui.ButtonOptionWidget.static.cancelButtonMouseDownEvents = false;
3608  * @static
3609  * @inheritdoc
3610  */
3611 OO.ui.ButtonOptionWidget.static.highlightable = false;
3613 /* Methods */
3616  * @inheritdoc
3617  */
3618 OO.ui.ButtonOptionWidget.prototype.setSelected = function ( state ) {
3619         OO.ui.ButtonOptionWidget.super.prototype.setSelected.call( this, state );
3621         if ( this.constructor.static.selectable ) {
3622                 this.setActive( state );
3623         }
3625         return this;
3629  * ButtonSelectWidget is a {@link OO.ui.SelectWidget select widget} that contains
3630  * button options and is used together with
3631  * OO.ui.ButtonOptionWidget. The ButtonSelectWidget provides an interface for
3632  * highlighting, choosing, and selecting mutually exclusive options. Please see
3633  * the [OOUI documentation on MediaWiki] [1] for more information.
3635  *     @example
3636  *     // A ButtonSelectWidget that contains three ButtonOptionWidgets.
3637  *     var option1 = new OO.ui.ButtonOptionWidget( {
3638  *             data: 1,
3639  *             label: 'Option 1',
3640  *             title: 'Button option 1'
3641  *         } ),
3642  *         option2 = new OO.ui.ButtonOptionWidget( {
3643  *             data: 2,
3644  *             label: 'Option 2',
3645  *             title: 'Button option 2'
3646  *         } ),
3647  *         option3 = new OO.ui.ButtonOptionWidget( {
3648  *             data: 3,
3649  *             label: 'Option 3',
3650  *             title: 'Button option 3'
3651  *         } ),
3652  *         buttonSelect = new OO.ui.ButtonSelectWidget( {
3653  *             items: [ option1, option2, option3 ]
3654  *         } );
3655  *     $( document.body ).append( buttonSelect.$element );
3657  * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Selects_and_Options
3659  * @class
3660  * @extends OO.ui.SelectWidget
3661  * @mixins OO.ui.mixin.TabIndexedElement
3663  * @constructor
3664  * @param {Object} [config] Configuration options
3665  */
3666 OO.ui.ButtonSelectWidget = function OoUiButtonSelectWidget( config ) {
3667         // Parent constructor
3668         OO.ui.ButtonSelectWidget.super.call( this, config );
3670         // Mixin constructors
3671         OO.ui.mixin.TabIndexedElement.call( this, config );
3673         // Events
3674         this.$element.on( {
3675                 focus: this.bindDocumentKeyDownListener.bind( this ),
3676                 blur: this.unbindDocumentKeyDownListener.bind( this )
3677         } );
3679         // Initialization
3680         this.$element.addClass( 'oo-ui-buttonSelectWidget' );
3683 /* Setup */
3685 OO.inheritClass( OO.ui.ButtonSelectWidget, OO.ui.SelectWidget );
3686 OO.mixinClass( OO.ui.ButtonSelectWidget, OO.ui.mixin.TabIndexedElement );
3689  * TabOptionWidget is an item in a {@link OO.ui.TabSelectWidget TabSelectWidget}.
3691  * Currently, this class is only used by {@link OO.ui.IndexLayout index layouts}, which contain
3692  * {@link OO.ui.TabPanelLayout tab panel layouts}. See {@link OO.ui.IndexLayout IndexLayout}
3693  * for an example.
3695  * @class
3696  * @extends OO.ui.OptionWidget
3698  * @constructor
3699  * @param {Object} [config] Configuration options
3700  * @cfg {string} [href] Hyperlink to add to TabOption. Mostly used in OOUI PHP.
3701  */
3702 OO.ui.TabOptionWidget = function OoUiTabOptionWidget( config ) {
3703         // Configuration initialization
3704         config = config || {};
3706         if ( config.href ) {
3707                 config = $.extend( {
3708                         $label: $( '<a>' ).attr( 'href', config.href )
3709                 }, config );
3710         }
3712         // Parent constructor
3713         OO.ui.TabOptionWidget.super.call( this, config );
3715         // Initialization
3716         this.$element
3717                 .addClass( 'oo-ui-tabOptionWidget' )
3718                 .attr( 'role', 'tab' );
3721 /* Setup */
3723 OO.inheritClass( OO.ui.TabOptionWidget, OO.ui.OptionWidget );
3725 /* Static Properties */
3728  * @static
3729  * @inheritdoc
3730  */
3731 OO.ui.TabOptionWidget.static.highlightable = false;
3734  * @static
3735  * @inheritdoc
3736  */
3737 OO.ui.TabOptionWidget.static.scrollIntoViewOnSelect = true;
3740  * Center tab horizontally after selecting on mobile
3742  * @param {Object} [config] Configuration options
3743  * @return {jQuery.Promise} Promise which resolves when the scroll is complete
3744  */
3745 OO.ui.TabOptionWidget.prototype.scrollElementIntoView = function ( config ) {
3746         if ( !OO.ui.isMobile() || !this.getElementGroup() ) {
3747                 // Parent method
3748                 return OO.ui.TabOptionWidget.super.prototype.scrollElementIntoView.call( this );
3749         } else {
3750                 var padding = Math.max( (
3751                         this.getElementGroup().$element[ 0 ].clientWidth - this.$element[ 0 ].clientWidth
3752                 ) / 2, 0 );
3753                 // Parent method
3754                 return OO.ui.TabOptionWidget.super.prototype.scrollElementIntoView.call( this, $.extend(
3755                         {
3756                                 padding: {
3757                                         left: padding,
3758                                         right: padding
3759                                 }
3760                         },
3761                         config
3762                 ) );
3763         }
3767  * TabSelectWidget is a list that contains {@link OO.ui.TabOptionWidget tab options}
3769  * **Currently, this class is only used by {@link OO.ui.IndexLayout index layouts}.**
3771  * @class
3772  * @extends OO.ui.SelectWidget
3773  * @mixins OO.ui.mixin.TabIndexedElement
3775  * @constructor
3776  * @param {Object} [config] Configuration options
3777  * @cfg {boolean} [framed=true] Use framed tabs
3778  */
3779 OO.ui.TabSelectWidget = function OoUiTabSelectWidget( config ) {
3780         // Parent constructor
3781         OO.ui.TabSelectWidget.super.call( this, config );
3783         // Mixin constructors
3784         OO.ui.mixin.TabIndexedElement.call( this, config );
3786         // Events
3787         this.$element.on( {
3788                 focus: this.bindDocumentKeyDownListener.bind( this ),
3789                 blur: this.unbindDocumentKeyDownListener.bind( this )
3790         } );
3792         // Initialization
3793         this.$element
3794                 .addClass( 'oo-ui-tabSelectWidget' )
3795                 .attr( 'role', 'tablist' );
3797         this.toggleFramed( config.framed === undefined || config.framed );
3799         if ( OO.ui.isMobile() ) {
3800                 this.$element.addClass( 'oo-ui-tabSelectWidget-mobile' );
3801         }
3804 /* Setup */
3806 OO.inheritClass( OO.ui.TabSelectWidget, OO.ui.SelectWidget );
3807 OO.mixinClass( OO.ui.TabSelectWidget, OO.ui.mixin.TabIndexedElement );
3809 /* Methods */
3812  * Check if tabs are framed.
3814  * @return {boolean} Tabs are framed
3815  */
3816 OO.ui.TabSelectWidget.prototype.isFramed = function () {
3817         return this.framed;
3821  * Render the tabs with or without frames.
3823  * @param {boolean} [framed] Make tabs framed, omit to toggle
3824  * @chainable
3825  * @return {OO.ui.Element} The element, for chaining
3826  */
3827 OO.ui.TabSelectWidget.prototype.toggleFramed = function ( framed ) {
3828         framed = framed === undefined ? !this.framed : !!framed;
3829         if ( framed !== this.framed ) {
3830                 this.framed = framed;
3831                 this.$element
3832                         .toggleClass( 'oo-ui-tabSelectWidget-frameless', !framed )
3833                         .toggleClass( 'oo-ui-tabSelectWidget-framed', framed );
3834         }
3836         return this;
3840  * ButtonMenuSelectWidgets launch a menu of options created with OO.ui.MenuOptionWidget.
3841  * The ButtonMenuSelectWidget takes care of opening and displaying the menu so that
3842  * users can interact with it.
3844  *     @example
3845  *     // A ButtonMenuSelectWidget with a menu that contains three options.
3846  *     var buttonMenu = new OO.ui.ButtonMenuSelectWidget( {
3847  *         icon: 'menu',
3848  *         menu: {
3849  *             items: [
3850  *                 new OO.ui.MenuOptionWidget( {
3851  *                     data: 'a',
3852  *                     label: 'First'
3853  *                 } ),
3854  *                 new OO.ui.MenuOptionWidget( {
3855  *                     data: 'b',
3856  *                     label: 'Second'
3857  *                 } ),
3858  *                 new OO.ui.MenuOptionWidget( {
3859  *                     data: 'c',
3860  *                     label: 'Third'
3861  *                 } )
3862  *             ]
3863  *         }
3864  *     } );
3866  *     $( document.body ).append( buttonMenu.$element );
3868  *     // When using the `clearOnSelect` option, listen to the `choose` event
3869  *     // to avoid getting the null select event.
3870  *     buttonMenu.getMenu().on( 'choose', function ( menuOption ) {
3871  *         console.log( menuOption.getData() );
3872  *     } );
3874  * @class
3875  * @extends OO.ui.ButtonWidget
3877  * @constructor
3878  * @param {Object} [config] Configuration options
3879  * @cfg {boolean} [clearOnSelect=true] Clear selection immediately after making it
3880  * @cfg {Object} [menuClass=OO.ui.MenuSelectWidget] Class for the menu widget. This
3881  *  must be a subclass of {@link OO.ui.MenuSelectWidget menu select widget}.
3882  * @cfg {Object} [menu] Configuration options to pass to
3883  *  {@link OO.ui.MenuSelectWidget menu select widget}.
3884  * @cfg {jQuery|boolean} [$overlay] Render the menu into a separate layer. This configuration is
3885  *  useful in cases where the expanded menu is larger than its containing `<div>`. The specified
3886  *  overlay layer is usually on top of the containing `<div>` and has a larger area. By default,
3887  *  the menu uses relative positioning. Pass 'true' to use the default overlay.
3888  *  See <https://www.mediawiki.org/wiki/OOUI/Concepts#Overlays>.
3889  */
3890 OO.ui.ButtonMenuSelectWidget = function OoUiButtonMenuSelectWidget( config ) {
3891         // Configuration initialization
3892         config = config || {};
3894         // Parent constructor
3895         OO.ui.ButtonMenuSelectWidget.super.call( this, config );
3897         this.$overlay = ( config.$overlay === true ?
3898                 OO.ui.getDefaultOverlay() : config.$overlay ) || this.$element;
3900         var MenuClass = config.menuClass || OO.ui.MenuSelectWidget;
3902         // Properties
3903         this.clearOnSelect = config.clearOnSelect !== false;
3904         this.menu = new MenuClass( $.extend( {
3905                 widget: this,
3906                 $floatableContainer: this.$element
3907         }, config.menu ) );
3909         // Events
3910         this.connect( this, {
3911                 click: 'onButtonMenuClick'
3912         } );
3913         this.getMenu().connect( this, {
3914                 select: 'onMenuSelect',
3915                 toggle: 'onMenuToggle'
3916         } );
3918         // Initialization
3919         this.$button
3920                 .attr( {
3921                         'aria-expanded': 'false',
3922                         'aria-haspopup': 'true',
3923                         'aria-owns': this.menu.getElementId()
3924                 } );
3925         this.$element.addClass( 'oo-ui-buttonMenuSelectWidget' );
3926         this.$overlay.append( this.menu.$element );
3929 /* Setup */
3931 OO.inheritClass( OO.ui.ButtonMenuSelectWidget, OO.ui.ButtonWidget );
3933 /* Methods */
3936  * Get the menu.
3938  * @return {OO.ui.MenuSelectWidget} Menu of widget
3939  */
3940 OO.ui.ButtonMenuSelectWidget.prototype.getMenu = function () {
3941         return this.menu;
3945  * Handle menu select events.
3947  * @private
3948  * @param {OO.ui.MenuOptionWidget} item Selected menu item
3949  */
3950 OO.ui.ButtonMenuSelectWidget.prototype.onMenuSelect = function ( item ) {
3951         if ( this.clearOnSelect && item ) {
3952                 // This will cause an additional 'select' event to fire, so
3953                 // users should probably listen to the 'choose' event.
3954                 this.getMenu().selectItem();
3955         }
3959  * Handle menu toggle events.
3961  * @private
3962  * @param {boolean} isVisible Open state of the menu
3963  */
3964 OO.ui.ButtonMenuSelectWidget.prototype.onMenuToggle = function ( isVisible ) {
3965         this.$element.toggleClass( 'oo-ui-buttonElement-pressed', isVisible );
3969  * Handle mouse click events.
3971  * @private
3972  */
3973 OO.ui.ButtonMenuSelectWidget.prototype.onButtonMenuClick = function () {
3974         this.menu.toggle();
3978  * TagItemWidgets are used within a {@link OO.ui.TagMultiselectWidget
3979  * TagMultiselectWidget} to display the selected items.
3981  * @class
3982  * @extends OO.ui.Widget
3983  * @mixins OO.ui.mixin.ItemWidget
3984  * @mixins OO.ui.mixin.LabelElement
3985  * @mixins OO.ui.mixin.FlaggedElement
3986  * @mixins OO.ui.mixin.TabIndexedElement
3987  * @mixins OO.ui.mixin.DraggableElement
3989  * @constructor
3990  * @param {Object} [config] Configuration object
3991  * @cfg {boolean} [valid=true] Item is valid
3992  * @cfg {boolean} [fixed=false] Item is fixed. This means the item is
3993  *  always included in the values and cannot be removed.
3994  */
3995 OO.ui.TagItemWidget = function OoUiTagItemWidget( config ) {
3996         config = config || {};
3998         // Parent constructor
3999         OO.ui.TagItemWidget.super.call( this, config );
4001         // Mixin constructors
4002         OO.ui.mixin.ItemWidget.call( this );
4003         OO.ui.mixin.LabelElement.call( this, config );
4004         OO.ui.mixin.FlaggedElement.call( this, config );
4005         OO.ui.mixin.TabIndexedElement.call( this, config );
4006         OO.ui.mixin.DraggableElement.call( this, config );
4008         this.valid = config.valid === undefined ? true : !!config.valid;
4009         this.fixed = !!config.fixed;
4011         this.closeButton = new OO.ui.ButtonWidget( {
4012                 framed: false,
4013                 icon: 'close',
4014                 tabIndex: -1,
4015                 title: OO.ui.msg( 'ooui-item-remove' )
4016         } );
4017         this.closeButton.setDisabled( this.isDisabled() );
4019         // Events
4020         this.closeButton.connect( this, {
4021                 click: 'remove'
4022         } );
4023         this.$element
4024                 .on( 'click', this.select.bind( this ) )
4025                 .on( 'keydown', this.onKeyDown.bind( this ) )
4026                 // Prevent propagation of mousedown; the tag item "lives" in the
4027                 // clickable area of the TagMultiselectWidget, which listens to
4028                 // mousedown to open the menu or popup. We want to prevent that
4029                 // for clicks specifically on the tag itself, so the actions taken
4030                 // are more deliberate. When the tag is clicked, it will emit the
4031                 // selection event (similar to how #OO.ui.MultioptionWidget emits 'change')
4032                 // and can be handled separately.
4033                 .on( 'mousedown', function ( e ) { e.stopPropagation(); } );
4035         // Initialization
4036         this.$element
4037                 .addClass( 'oo-ui-tagItemWidget' )
4038                 .append( this.$label, this.closeButton.$element );
4041 /* Initialization */
4043 OO.inheritClass( OO.ui.TagItemWidget, OO.ui.Widget );
4044 OO.mixinClass( OO.ui.TagItemWidget, OO.ui.mixin.ItemWidget );
4045 OO.mixinClass( OO.ui.TagItemWidget, OO.ui.mixin.LabelElement );
4046 OO.mixinClass( OO.ui.TagItemWidget, OO.ui.mixin.FlaggedElement );
4047 OO.mixinClass( OO.ui.TagItemWidget, OO.ui.mixin.TabIndexedElement );
4048 OO.mixinClass( OO.ui.TagItemWidget, OO.ui.mixin.DraggableElement );
4050 /* Events */
4053  * @event remove
4055  * A remove action was performed on the item
4056  */
4059  * @event navigate
4060  * @param {string} direction Direction of the movement, forward or backwards
4062  * A navigate action was performed on the item
4063  */
4066  * @event select
4068  * The tag widget was selected. This can occur when the widget
4069  * is either clicked or enter was pressed on it.
4070  */
4073  * @event valid
4074  * @param {boolean} isValid Item is valid
4076  * Item validity has changed
4077  */
4080  * @event fixed
4081  * @param {boolean} isFixed Item is fixed
4083  * Item fixed state has changed
4084  */
4086 /* Methods */
4089  * Set this item as fixed, meaning it cannot be removed
4091  * @param {boolean} [state] Item is fixed, omit to toggle
4092  * @fires fixed
4093  * @return {OO.ui.Widget} The widget, for chaining
4094  */
4095 OO.ui.TagItemWidget.prototype.setFixed = function ( state ) {
4096         state = state === undefined ? !this.fixed : !!state;
4098         if ( this.fixed !== state ) {
4099                 this.fixed = state;
4100                 if ( this.closeButton ) {
4101                         this.closeButton.toggle( !this.fixed );
4102                 }
4104                 if ( !this.fixed && this.elementGroup && !this.elementGroup.isDraggable() ) {
4105                         // Only enable the state of the item if the
4106                         // entire group is draggable
4107                         this.toggleDraggable( !this.fixed );
4108                 }
4109                 this.$element.toggleClass( 'oo-ui-tagItemWidget-fixed', this.fixed );
4111                 this.emit( 'fixed', this.isFixed() );
4112         }
4113         return this;
4117  * Check whether the item is fixed
4119  * @return {boolean}
4120  */
4121 OO.ui.TagItemWidget.prototype.isFixed = function () {
4122         return this.fixed;
4126  * Handle removal of the item
4128  * This is mainly for extensibility concerns, so other children
4129  * of this class can change the behavior if they need to. This
4130  * is called by both clicking the 'remove' button but also
4131  * on keypress, which is harder to override if needed.
4133  * @fires remove
4134  */
4135 OO.ui.TagItemWidget.prototype.remove = function () {
4136         if ( !this.isDisabled() && !this.isFixed() ) {
4137                 this.emit( 'remove' );
4138         }
4142  * Handle a keydown event on the widget
4144  * @fires navigate
4145  * @fires remove
4146  * @param {jQuery.Event} e Key down event
4147  * @return {boolean|undefined} false to stop the operation
4148  */
4149 OO.ui.TagItemWidget.prototype.onKeyDown = function ( e ) {
4150         if (
4151                 !this.isDisabled() &&
4152                 !this.isFixed() &&
4153                 ( e.keyCode === OO.ui.Keys.BACKSPACE || e.keyCode === OO.ui.Keys.DELETE )
4154         ) {
4155                 this.remove();
4156                 return false;
4157         } else if ( e.keyCode === OO.ui.Keys.ENTER ) {
4158                 this.select();
4159                 return false;
4160         } else if (
4161                 e.keyCode === OO.ui.Keys.LEFT ||
4162                 e.keyCode === OO.ui.Keys.RIGHT
4163         ) {
4164                 var movement;
4166                 if ( OO.ui.Element.static.getDir( this.$element ) === 'rtl' ) {
4167                         movement = {
4168                                 left: 'forwards',
4169                                 right: 'backwards'
4170                         };
4171                 } else {
4172                         movement = {
4173                                 left: 'backwards',
4174                                 right: 'forwards'
4175                         };
4176                 }
4178                 this.emit(
4179                         'navigate',
4180                         e.keyCode === OO.ui.Keys.LEFT ?
4181                                 movement.left : movement.right
4182                 );
4183                 return false;
4184         }
4188  * Select this item
4190  * @fires select
4191  */
4192 OO.ui.TagItemWidget.prototype.select = function () {
4193         if ( !this.isDisabled() ) {
4194                 this.emit( 'select' );
4195         }
4199  * Set the valid state of this item
4201  * @param {boolean} [valid] Item is valid, omit to toggle
4202  * @fires valid
4203  */
4204 OO.ui.TagItemWidget.prototype.toggleValid = function ( valid ) {
4205         valid = valid === undefined ? !this.valid : !!valid;
4207         if ( this.valid !== valid ) {
4208                 this.valid = valid;
4210                 this.setFlags( { invalid: !this.valid } );
4212                 this.emit( 'valid', this.valid );
4213         }
4217  * Check whether the item is valid
4219  * @return {boolean} Item is valid
4220  */
4221 OO.ui.TagItemWidget.prototype.isValid = function () {
4222         return this.valid;
4226  * A basic tag multiselect widget, similar in concept to
4227  * {@link OO.ui.ComboBoxInputWidget combo box widget} that allows the user to add multiple values
4228  * that are displayed in a tag area.
4230  * This widget is a base widget; see {@link OO.ui.MenuTagMultiselectWidget MenuTagMultiselectWidget}
4231  * and {@link OO.ui.PopupTagMultiselectWidget PopupTagMultiselectWidget} for the implementations
4232  * that use a menu and a popup respectively.
4234  *     @example
4235  *     // A TagMultiselectWidget.
4236  *     var widget = new OO.ui.TagMultiselectWidget( {
4237  *         inputPosition: 'outline',
4238  *         allowedValues: [ 'Option 1', 'Option 2', 'Option 3' ],
4239  *         selected: [ 'Option 1' ]
4240  *     } );
4241  *     $( document.body ).append( widget.$element );
4243  * @class
4244  * @extends OO.ui.Widget
4245  * @mixins OO.ui.mixin.GroupWidget
4246  * @mixins OO.ui.mixin.DraggableGroupElement
4247  * @mixins OO.ui.mixin.IndicatorElement
4248  * @mixins OO.ui.mixin.IconElement
4249  * @mixins OO.ui.mixin.TabIndexedElement
4250  * @mixins OO.ui.mixin.FlaggedElement
4251  * @mixins OO.ui.mixin.TitledElement
4253  * @constructor
4254  * @param {Object} config Configuration object
4255  * @cfg {Object} [input] Configuration options for the input widget
4256  * @cfg {OO.ui.InputWidget} [inputWidget] An optional input widget. If given, it will
4257  *  replace the input widget used in the TagMultiselectWidget. If not given,
4258  *  TagMultiselectWidget creates its own.
4259  * @cfg {boolean} [inputPosition='inline'] Position of the input. Options are:
4260  *  - inline: The input is invisible, but exists inside the tag list, so
4261  *    the user types into the tag groups to add tags.
4262  *  - outline: The input is underneath the tag area.
4263  *  - none: No input supplied
4264  * @cfg {boolean} [allowEditTags=true] Allow editing of the tags by clicking them
4265  * @cfg {boolean} [allowArbitrary=false] Allow data items to be added even if
4266  *  not present in the menu.
4267  * @cfg {Mixed[]} [allowedValues] An array representing the allowed items
4268  *  by their datas.
4269  * @cfg {boolean} [allowDuplicates=false] Allow duplicate items to be added
4270  * @cfg {boolean} [allowDisplayInvalidTags=false] Allow the display of
4271  *  invalid tags. These tags will display with an invalid state, and
4272  *  the widget as a whole will have an invalid state if any invalid tags
4273  *  are present.
4274  * @cfg {number} [tagLimit] An optional limit on the number of selected options.
4275  *  If 'tagLimit' is set and is reached, the input is disabled, not allowing any
4276  *  additions. If 'tagLimit' is unset or is 0, an unlimited number of items can be
4277  *  added.
4278  * @cfg {boolean} [allowReordering=true] Allow reordering of the items
4279  * @cfg {Object[]|string[]} [selected] A set of selected tags. If given,
4280  *  these will appear in the tag list on initialization, as long as they
4281  *  pass the validity tests.
4282  */
4283 OO.ui.TagMultiselectWidget = function OoUiTagMultiselectWidget( config ) {
4284         var rAF = window.requestAnimationFrame || setTimeout,
4285                 widget = this,
4286                 $tabFocus = $( '<span>' ).addClass( 'oo-ui-tagMultiselectWidget-focusTrap' );
4288         config = config || {};
4290         // Parent constructor
4291         OO.ui.TagMultiselectWidget.super.call( this, config );
4293         // Mixin constructors
4294         OO.ui.mixin.GroupWidget.call( this, config );
4295         OO.ui.mixin.IndicatorElement.call( this, config );
4296         OO.ui.mixin.IconElement.call( this, config );
4297         OO.ui.mixin.TabIndexedElement.call( this, config );
4298         OO.ui.mixin.FlaggedElement.call( this, config );
4299         OO.ui.mixin.DraggableGroupElement.call( this, config );
4300         OO.ui.mixin.TitledElement.call( this, config );
4302         this.toggleDraggable(
4303                 config.allowReordering === undefined ?
4304                         true : !!config.allowReordering
4305         );
4307         this.inputPosition =
4308                 this.constructor.static.allowedInputPositions.indexOf( config.inputPosition ) > -1 ?
4309                         config.inputPosition : 'inline';
4310         this.allowEditTags = config.allowEditTags === undefined ? true : !!config.allowEditTags;
4311         this.allowArbitrary = !!config.allowArbitrary;
4312         this.allowDuplicates = !!config.allowDuplicates;
4313         this.allowedValues = config.allowedValues || [];
4314         this.allowDisplayInvalidTags = config.allowDisplayInvalidTags;
4315         this.hasInput = this.inputPosition !== 'none';
4316         this.tagLimit = config.tagLimit;
4317         this.height = null;
4318         this.valid = true;
4320         this.$content = $( '<div>' ).addClass( 'oo-ui-tagMultiselectWidget-content' );
4321         this.$handle = $( '<div>' )
4322                 .addClass( 'oo-ui-tagMultiselectWidget-handle' )
4323                 .append(
4324                         this.$indicator,
4325                         this.$icon,
4326                         this.$content
4327                                 .append(
4328                                         this.$group.addClass( 'oo-ui-tagMultiselectWidget-group' )
4329                                 )
4330                 );
4332         // Events
4333         this.aggregate( {
4334                 remove: 'itemRemove',
4335                 navigate: 'itemNavigate',
4336                 select: 'itemSelect',
4337                 fixed: 'itemFixed'
4338         } );
4339         this.connect( this, {
4340                 itemRemove: 'onTagRemove',
4341                 itemSelect: 'onTagSelect',
4342                 itemFixed: 'onTagFixed',
4343                 itemNavigate: 'onTagNavigate',
4344                 change: 'onChangeTags'
4345         } );
4346         this.$handle.on( {
4347                 mousedown: this.onMouseDown.bind( this )
4348         } );
4350         // Initialize
4351         this.$element
4352                 .addClass( 'oo-ui-tagMultiselectWidget' )
4353                 .append( this.$handle );
4355         if ( this.hasInput ) {
4356                 if ( config.inputWidget ) {
4357                         this.input = config.inputWidget;
4358                 } else {
4359                         this.input = new OO.ui.TextInputWidget( $.extend( {
4360                                 placeholder: config.placeholder,
4361                                 classes: [ 'oo-ui-tagMultiselectWidget-input' ]
4362                         }, config.input ) );
4363                 }
4364                 this.input.setDisabled( this.isDisabled() );
4366                 var inputEvents = {
4367                         focus: this.onInputFocus.bind( this ),
4368                         blur: this.onInputBlur.bind( this ),
4369                         'propertychange change click mouseup keydown keyup input cut paste select focus':
4370                                 OO.ui.debounce( this.updateInputSize.bind( this ) ),
4371                         keydown: this.onInputKeyDown.bind( this ),
4372                         keypress: this.onInputKeyPress.bind( this )
4373                 };
4375                 this.input.$input.on( inputEvents );
4376                 this.inputPlaceholder = this.input.$input.attr( 'placeholder' );
4378                 if ( this.inputPosition === 'outline' ) {
4379                         // Override max-height for the input widget
4380                         // in the case the widget is outline so it can
4381                         // stretch all the way if the widget is wide
4382                         this.input.$element.css( 'max-width', 'inherit' );
4383                         this.$element
4384                                 .addClass( 'oo-ui-tagMultiselectWidget-outlined' )
4385                                 .append( this.input.$element );
4386                 } else {
4387                         this.$element.addClass( 'oo-ui-tagMultiselectWidget-inlined' );
4388                         // HACK: When the widget is using 'inline' input, the
4389                         // behavior needs to only use the $input itself
4390                         // so we style and size it accordingly (otherwise
4391                         // the styling and sizing can get very convoluted
4392                         // when the wrapping divs and other elements)
4393                         // We are taking advantage of still being able to
4394                         // call the widget itself for operations like
4395                         // .getValue() and setDisabled() and .focus() but
4396                         // having only the $input attached to the DOM
4397                         this.$group.append( this.input.$input );
4398                 }
4399         } else {
4400                 this.$content.append( $tabFocus );
4401         }
4403         this.setTabIndexedElement(
4404                 this.hasInput ?
4405                         this.input.$input :
4406                         $tabFocus
4407         );
4409         if ( config.selected ) {
4410                 this.setValue( config.selected );
4411         }
4413         // HACK: Input size needs to be calculated after everything
4414         // else is rendered
4415         rAF( function () {
4416                 if ( widget.hasInput ) {
4417                         widget.updateInputSize();
4418                 }
4419         } );
4422 /* Initialization */
4424 OO.inheritClass( OO.ui.TagMultiselectWidget, OO.ui.Widget );
4425 OO.mixinClass( OO.ui.TagMultiselectWidget, OO.ui.mixin.GroupWidget );
4426 OO.mixinClass( OO.ui.TagMultiselectWidget, OO.ui.mixin.DraggableGroupElement );
4427 OO.mixinClass( OO.ui.TagMultiselectWidget, OO.ui.mixin.IndicatorElement );
4428 OO.mixinClass( OO.ui.TagMultiselectWidget, OO.ui.mixin.IconElement );
4429 OO.mixinClass( OO.ui.TagMultiselectWidget, OO.ui.mixin.TabIndexedElement );
4430 OO.mixinClass( OO.ui.TagMultiselectWidget, OO.ui.mixin.FlaggedElement );
4431 OO.mixinClass( OO.ui.TagMultiselectWidget, OO.ui.mixin.TitledElement );
4433 /* Static properties */
4436  * Allowed input positions.
4437  * - inline: The input is inside the tag list
4438  * - outline: The input is under the tag list
4439  * - none: There is no input
4441  * @property {Array}
4442  */
4443 OO.ui.TagMultiselectWidget.static.allowedInputPositions = [ 'inline', 'outline', 'none' ];
4445 /* Methods */
4448  * Handle mouse down events.
4450  * @private
4451  * @param {jQuery.Event} e Mouse down event
4452  * @return {boolean} False to prevent defaults
4453  */
4454 OO.ui.TagMultiselectWidget.prototype.onMouseDown = function ( e ) {
4455         if (
4456                 !this.isDisabled() &&
4457                 ( !this.hasInput || e.target !== this.input.$input[ 0 ] ) &&
4458                 e.which === OO.ui.MouseButtons.LEFT
4459         ) {
4460                 this.focus();
4461                 return false;
4462         }
4466  * Handle key press events.
4468  * @private
4469  * @param {jQuery.Event} e Key press event
4470  * @return {boolean} Whether to prevent defaults
4471  */
4472 OO.ui.TagMultiselectWidget.prototype.onInputKeyPress = function ( e ) {
4473         var withMetaKey = e.metaKey || e.ctrlKey;
4475         if ( !this.isDisabled() ) {
4476                 var stopOrContinue;
4477                 if ( e.which === OO.ui.Keys.ENTER ) {
4478                         stopOrContinue = this.doInputEnter( e, withMetaKey );
4479                 }
4481                 this.updateInputSize();
4482                 return stopOrContinue;
4483         }
4487  * Handle key down events.
4489  * @private
4490  * @param {jQuery.Event} e Key down event
4491  * @return {boolean}
4492  */
4493 OO.ui.TagMultiselectWidget.prototype.onInputKeyDown = function ( e ) {
4494         var widget = this,
4495                 withMetaKey = e.metaKey || e.ctrlKey;
4497         function isMovementInsideInput( dir ) {
4498                 var inputRange = widget.input.getRange(),
4499                         inputValue = widget.hasInput && widget.input.getValue();
4501                 if ( dir === 'forwards' && inputRange.to > inputValue.length - 1 ) {
4502                         return false;
4503                 }
4505                 if ( dir === 'backwards' && inputRange.from <= 0 ) {
4506                         return false;
4507                 }
4509                 return true;
4510         }
4512         if ( !this.isDisabled() ) {
4513                 // 'keypress' event is not triggered for Backspace key
4514                 if ( e.keyCode === OO.ui.Keys.BACKSPACE ) {
4515                         return this.doInputBackspace( e, withMetaKey );
4516                 } else if ( e.keyCode === OO.ui.Keys.ESCAPE ) {
4517                         return this.doInputEscape( e );
4518                 } else if (
4519                         e.keyCode === OO.ui.Keys.LEFT ||
4520                         e.keyCode === OO.ui.Keys.RIGHT
4521                 ) {
4522                         var movement;
4523                         if ( OO.ui.Element.static.getDir( this.$element ) === 'rtl' ) {
4524                                 movement = {
4525                                         left: 'forwards',
4526                                         right: 'backwards'
4527                                 };
4528                         } else {
4529                                 movement = {
4530                                         left: 'backwards',
4531                                         right: 'forwards'
4532                                 };
4533                         }
4534                         var direction = e.keyCode === OO.ui.Keys.LEFT ?
4535                                 movement.left : movement.right;
4537                         if ( !this.hasInput || !isMovementInsideInput( direction ) ) {
4538                                 return this.doInputArrow( e, direction, withMetaKey );
4539                         }
4540                 }
4541         }
4545  * Respond to input focus event
4546  */
4547 OO.ui.TagMultiselectWidget.prototype.onInputFocus = function () {
4548         this.$element.addClass( 'oo-ui-tagMultiselectWidget-focus' );
4549         // Reset validity
4550         this.toggleValid( true );
4554  * Respond to input blur event
4555  */
4556 OO.ui.TagMultiselectWidget.prototype.onInputBlur = function () {
4557         // Skip of blur was triggered by DOM re-ordering in onChangeTags
4558         if ( this.changing ) {
4559                 return;
4560         }
4562         this.$element.removeClass( 'oo-ui-tagMultiselectWidget-focus' );
4564         // Set the widget as invalid if there's text in the input
4565         this.addTagFromInput();
4566         this.toggleValid( this.checkValidity() && ( !this.hasInput || !this.input.getValue() ) );
4570  * Perform an action after the Enter key on the input
4572  * @param {jQuery.Event} e Event data
4573  * @param {boolean} [withMetaKey] Whether this key was pressed with
4574  * a meta key like Control
4575  * @return {boolean} Whether to prevent defaults
4576  */
4577 OO.ui.TagMultiselectWidget.prototype.doInputEnter = function () {
4578         this.addTagFromInput();
4579         return false;
4583  * Perform an action responding to the Backspace key on the input
4585  * @param {jQuery.Event} e Event data
4586  * @param {boolean} [withMetaKey] Whether this key was pressed with
4587  * a meta key like Control
4588  * @return {boolean} Whether to prevent defaults
4589  */
4590 OO.ui.TagMultiselectWidget.prototype.doInputBackspace = function ( e, withMetaKey ) {
4591         if (
4592                 this.inputPosition === 'inline' &&
4593                 this.input.getValue() === '' &&
4594                 !this.isEmpty()
4595         ) {
4596                 // Delete the last item
4597                 var items = this.getItems();
4598                 var item = items[ items.length - 1 ];
4600                 if ( !item.isDisabled() && !item.isFixed() ) {
4601                         this.removeItems( [ item ] );
4602                         // If Ctrl/Cmd was pressed, delete item entirely.
4603                         // Otherwise put it into the text field for editing.
4604                         if ( !withMetaKey ) {
4605                                 var itemLabel;
4606                                 if ( typeof item.getLabel() === 'string' ) {
4607                                         itemLabel = item.getLabel();
4608                                 } else if ( item.getLabel() instanceof $ ) {
4609                                         itemLabel = item.getLabel().text();
4610                                 }
4611                                 this.input.setValue( itemLabel );
4612                         }
4613                 }
4615                 return false;
4616         }
4620  * Perform an action after the Escape key on the input
4622  * @param {jQuery.Event} e Event data
4623  */
4624 OO.ui.TagMultiselectWidget.prototype.doInputEscape = function () {
4625         this.clearInput();
4629  * Perform an action after the Left/Right arrow key on the input, select the previous
4630  * item from the input.
4631  * See #getPreviousItem
4633  * @param {jQuery.Event} e Event data
4634  * @param {string} direction Direction of the movement; forwards or backwards
4635  * @param {boolean} [withMetaKey] Whether this key was pressed with
4636  *  a meta key like Control
4637  */
4638 OO.ui.TagMultiselectWidget.prototype.doInputArrow = function ( e, direction ) {
4639         if (
4640                 this.inputPosition === 'inline' &&
4641                 !this.isEmpty() &&
4642                 direction === 'backwards'
4643         ) {
4644                 // Get previous item
4645                 this.getPreviousItem().focus();
4646         }
4650  * Respond to item select event
4652  * @param {OO.ui.TagItemWidget} item Selected item
4653  */
4654 OO.ui.TagMultiselectWidget.prototype.onTagSelect = function ( item ) {
4655         if ( this.hasInput && this.allowEditTags && !item.isFixed() ) {
4656                 if ( this.input.getValue() ) {
4657                         this.addTagFromInput();
4658                 }
4659                 // 1. Get the label of the tag into the input
4660                 this.input.setValue( item.getLabel() );
4661                 // 2. Remove the tag
4662                 this.removeItems( [ item ] );
4663                 // 3. Focus the input
4664                 this.focus();
4665         }
4669  * Respond to item fixed state change
4671  * @param {OO.ui.TagItemWidget} item Selected item
4672  */
4673 OO.ui.TagMultiselectWidget.prototype.onTagFixed = function ( item ) {
4674         var items = this.getItems();
4676         // Move item to the end of the static items
4677         var i;
4678         for ( i = 0; i < items.length; i++ ) {
4679                 if ( items[ i ] !== item && !items[ i ].isFixed() ) {
4680                         break;
4681                 }
4682         }
4683         this.addItems( item, i );
4686  * Respond to change event, where items were added, removed, or cleared.
4687  */
4688 OO.ui.TagMultiselectWidget.prototype.onChangeTags = function () {
4689         var isUnderLimit = this.isUnderLimit();
4691         this.changing = true;
4693         // Reset validity
4694         this.toggleValid( this.checkValidity() );
4696         if ( this.hasInput ) {
4697                 this.updateInputSize();
4698                 if ( !isUnderLimit ) {
4699                         // Clear the input
4700                         this.input.setValue( '' );
4701                 }
4702                 if ( this.inputPosition === 'outline' ) {
4703                         // Show/clear the placeholder and enable/disable the input
4704                         // based on whether we are/aren't under the specified limit
4705                         this.input.$input.attr( 'placeholder', isUnderLimit ? this.inputPlaceholder : '' );
4706                         this.input.setDisabled( !isUnderLimit );
4707                 } else {
4708                         var hadFocus = document.activeElement === this.input.$input[ 0 ];
4709                         // Move input to the end of the group
4710                         this.$group.append( this.input.$input );
4711                         // Show/hide the input
4712                         this.input.$input.toggleClass( 'oo-ui-element-hidden', !isUnderLimit );
4713                         if ( hadFocus && isUnderLimit ) {
4714                                 this.input.focus();
4715                         }
4716                 }
4717         }
4718         this.updateIfHeightChanged();
4720         this.changing = false;
4724  * @inheritdoc
4725  */
4726 OO.ui.TagMultiselectWidget.prototype.setDisabled = function ( isDisabled ) {
4727         // Parent method
4728         OO.ui.TagMultiselectWidget.super.prototype.setDisabled.call( this, isDisabled );
4730         if ( this.hasInput && this.input ) {
4731                 if ( !isDisabled ) {
4732                         this.updateInputSize();
4733                 }
4734                 this.input.setDisabled( !!isDisabled || !this.isUnderLimit() );
4735         }
4737         if ( this.items ) {
4738                 this.getItems().forEach( function ( item ) {
4739                         item.setDisabled( !!isDisabled );
4740                 } );
4741         }
4745  * Respond to tag remove event
4747  * @param {OO.ui.TagItemWidget} item Removed tag
4748  */
4749 OO.ui.TagMultiselectWidget.prototype.onTagRemove = function ( item ) {
4750         this.removeTagByData( item.getData() );
4754  * Respond to navigate event on the tag
4756  * @param {OO.ui.TagItemWidget} item Removed tag
4757  * @param {string} direction Direction of movement; 'forwards' or 'backwards'
4758  */
4759 OO.ui.TagMultiselectWidget.prototype.onTagNavigate = function ( item, direction ) {
4760         var firstItem = this.getItems()[ 0 ];
4762         if ( direction === 'forwards' ) {
4763                 this.getNextItem( item ).focus();
4764         } else if ( !this.inputPosition === 'inline' || item !== firstItem ) {
4765                 // If the widget has an inline input, we want to stop at the starting edge
4766                 // of the tags
4767                 this.getPreviousItem( item ).focus();
4768         }
4772  * Get data and label for a new tag from the input value
4774  * @return {Object} The data and label for a tag
4775  */
4776 OO.ui.TagMultiselectWidget.prototype.getTagInfoFromInput = function () {
4777         var val = this.input.getValue();
4778         return { data: val, label: val };
4782  * Add tag from input value
4783  */
4784 OO.ui.TagMultiselectWidget.prototype.addTagFromInput = function () {
4785         var tagInfo = this.getTagInfoFromInput();
4787         if ( !tagInfo.data ) {
4788                 return;
4789         }
4791         if ( this.addTag( tagInfo.data, tagInfo.label ) ) {
4792                 this.clearInput();
4793         }
4797  * Clear the input
4798  */
4799 OO.ui.TagMultiselectWidget.prototype.clearInput = function () {
4800         this.input.setValue( '' );
4804  * Check whether the given value is a duplicate of an existing
4805  * tag already in the list.
4807  * @param {Mixed} data Requested value
4808  * @return {boolean} Value is duplicate
4809  */
4810 OO.ui.TagMultiselectWidget.prototype.isDuplicateData = function ( data ) {
4811         return !!this.findItemFromData( data );
4815  * Check whether a given value is allowed to be added
4817  * @param {Mixed} data Requested value
4818  * @return {boolean} Value is allowed
4819  */
4820 OO.ui.TagMultiselectWidget.prototype.isAllowedData = function ( data ) {
4821         if (
4822                 !this.allowDuplicates &&
4823                 this.isDuplicateData( data )
4824         ) {
4825                 return false;
4826         }
4828         if ( this.allowArbitrary ) {
4829                 return true;
4830         }
4832         // Check with allowed values
4833         if (
4834                 this.getAllowedValues().some( function ( value ) {
4835                         return data === value;
4836                 } )
4837         ) {
4838                 return true;
4839         }
4841         return false;
4845  * Get the allowed values list
4847  * @return {Mixed[]} Allowed data values
4848  */
4849 OO.ui.TagMultiselectWidget.prototype.getAllowedValues = function () {
4850         return this.allowedValues;
4854  * Add a value to the allowed values list
4856  * @param {Mixed} value Allowed data value
4857  */
4858 OO.ui.TagMultiselectWidget.prototype.addAllowedValue = function ( value ) {
4859         if ( this.allowedValues.indexOf( value ) === -1 ) {
4860                 this.allowedValues.push( value );
4861         }
4865  * Get the datas of the currently selected items
4867  * @return {Mixed[]} Datas of currently selected items
4868  */
4869 OO.ui.TagMultiselectWidget.prototype.getValue = function () {
4870         return this.getItems()
4871                 .filter( function ( item ) {
4872                         return item.isValid();
4873                 } )
4874                 .map( function ( item ) {
4875                         return item.getData();
4876                 } );
4880  * Set the value of this widget by datas.
4882  * @param {string|string[]|Object|Object[]} valueObject An object representing the data
4883  *  and label of the value. If the widget allows arbitrary values,
4884  *  the items will be added as-is. Otherwise, the data value will
4885  *  be checked against allowedValues.
4886  *  This object must contain at least a data key. Example:
4887  *  { data: 'foo', label: 'Foo item' }
4888  *  For multiple items, use an array of objects. For example:
4889  *  [
4890  *     { data: 'foo', label: 'Foo item' },
4891  *     { data: 'bar', label: 'Bar item' }
4892  *  ]
4893  *  Value can also be added with plaintext array, for example:
4894  *  [ 'foo', 'bar', 'bla' ] or a single string, like 'foo'
4895  */
4896 OO.ui.TagMultiselectWidget.prototype.setValue = function ( valueObject ) {
4897         valueObject = Array.isArray( valueObject ) ? valueObject : [ valueObject ];
4899         this.clearItems();
4900         valueObject.forEach( function ( obj ) {
4901                 if ( typeof obj === 'object' ) {
4902                         this.addTag( obj.data, obj.label );
4903                 } else {
4904                         this.addTag( String( obj ) );
4905                 }
4906         }.bind( this ) );
4910  * Add tag to the display area.
4912  * Performs a validation check on the tag to be added.
4914  * @param {Mixed} data Tag data
4915  * @param {string|jQuery} [label=data] Tag label. If no label is provided, the
4916  *  stringified version of the data will be used instead.
4917  * @return {boolean} Item was added successfully
4918  */
4919 OO.ui.TagMultiselectWidget.prototype.addTag = function ( data, label ) {
4920         var isValid = this.isAllowedData( data );
4922         if ( this.isUnderLimit() && ( isValid || this.allowDisplayInvalidTags ) ) {
4923                 var newItemWidget = this.createTagItemWidget( data, label );
4924                 newItemWidget.toggleValid( isValid );
4925                 this.addItems( [ newItemWidget ] );
4926                 return true;
4927         }
4929         return false;
4933  * Check whether the number of current tags is within the limit.
4935  * @return {boolean} True if current tag count is within the limit or
4936  *  if 'tagLimit' is not set
4937  */
4938 OO.ui.TagMultiselectWidget.prototype.isUnderLimit = function () {
4939         return !this.tagLimit ||
4940                 this.getItemCount() < this.tagLimit;
4944  * Remove tag by its data property.
4946  * @param {string|Object} data Tag data
4947  */
4948 OO.ui.TagMultiselectWidget.prototype.removeTagByData = function ( data ) {
4949         var item = this.findItemFromData( data );
4951         this.removeItems( [ item ] );
4955  * Construct a OO.ui.TagItemWidget (or a subclass thereof) from given label and data.
4957  * @protected
4958  * @param {Mixed} data Item data
4959  * @param {string|jQuery} [label=data] The label text or JQuery collection.
4960  * @return {OO.ui.TagItemWidget}
4961  */
4962 OO.ui.TagMultiselectWidget.prototype.createTagItemWidget = function ( data, label ) {
4963         return new OO.ui.TagItemWidget( { data: data, label: label || data } );
4967  * Given an item, returns the item after it. If the item is already the
4968  * last item, return `this.input`. If no item is passed, returns the
4969  * very first item.
4971  * @protected
4972  * @param {OO.ui.TagItemWidget} [item] Tag item
4973  * @return {OO.ui.Widget} The next widget available.
4974  */
4975 OO.ui.TagMultiselectWidget.prototype.getNextItem = function ( item ) {
4976         var itemIndex = this.items.indexOf( item );
4978         if ( item === undefined || itemIndex === -1 ) {
4979                 return this.items[ 0 ];
4980         }
4982         if ( itemIndex === this.items.length - 1 ) { // Last item
4983                 if ( this.hasInput ) {
4984                         return this.input;
4985                 } else {
4986                         // Return first item
4987                         return this.items[ 0 ];
4988                 }
4989         } else {
4990                 return this.items[ itemIndex + 1 ];
4991         }
4995  * Given an item, returns the item before it. If the item is already the
4996  * first item, return `this.input`. If no item is passed, returns the
4997  * very last item.
4999  * @protected
5000  * @param {OO.ui.TagItemWidget} [item] Tag item
5001  * @return {OO.ui.Widget} The previous widget available.
5002  */
5003 OO.ui.TagMultiselectWidget.prototype.getPreviousItem = function ( item ) {
5004         var itemIndex = this.items.indexOf( item );
5006         if ( item === undefined || itemIndex === -1 ) {
5007                 return this.items[ this.items.length - 1 ];
5008         }
5010         if ( itemIndex === 0 ) {
5011                 if ( this.hasInput ) {
5012                         return this.input;
5013                 } else {
5014                         // Return the last item
5015                         return this.items[ this.items.length - 1 ];
5016                 }
5017         } else {
5018                 return this.items[ itemIndex - 1 ];
5019         }
5023  * Update the dimensions of the text input field to encompass all available area.
5024  * This is especially relevant for when the input is at the edge of a line
5025  * and should get smaller. The usual operation (as an inline-block with min-width)
5026  * does not work in that case, pushing the input downwards to the next line.
5028  * @private
5029  */
5030 OO.ui.TagMultiselectWidget.prototype.updateInputSize = function () {
5031         if ( this.inputPosition === 'inline' && !this.isDisabled() ) {
5032                 if ( this.input.$input[ 0 ].scrollWidth === 0 ) {
5033                         // Input appears to be attached but not visible.
5034                         // Don't attempt to adjust its size, because our measurements
5035                         // are going to fail anyway.
5036                         return;
5037                 }
5038                 this.input.$input.css( 'width', '1em' );
5039                 var $lastItem = this.$group.children().last();
5040                 var direction = OO.ui.Element.static.getDir( this.$handle );
5042                 // Get the width of the input with the placeholder text as
5043                 // the value and save it so that we don't keep recalculating
5044                 var placeholder = this.input.$input.attr( 'placeholder' );
5045                 if (
5046                         this.contentWidthWithPlaceholder === undefined &&
5047                         this.input.getValue() === '' &&
5048                         placeholder !== undefined
5049                 ) {
5050                         // Set the value directly to avoid any side effects of setValue
5051                         this.input.$input.val( placeholder );
5052                         this.contentWidthWithPlaceholder = this.input.$input[ 0 ].scrollWidth;
5053                         this.input.$input.val( '' );
5055                 }
5057                 // Always keep the input wide enough for the placeholder text
5058                 var contentWidth = Math.max(
5059                         this.input.$input[ 0 ].scrollWidth,
5060                         // undefined arguments in Math.max lead to NaN
5061                         ( this.contentWidthWithPlaceholder === undefined ) ?
5062                                 0 : this.contentWidthWithPlaceholder
5063                 );
5064                 var currentWidth = this.input.$input.width();
5066                 if ( contentWidth < currentWidth ) {
5067                         this.updateIfHeightChanged();
5068                         // All is fine, don't perform expensive calculations
5069                         return;
5070                 }
5072                 var bestWidth;
5073                 if ( $lastItem.length === 0 ) {
5074                         bestWidth = this.$content.innerWidth();
5075                 } else {
5076                         bestWidth = direction === 'ltr' ?
5077                                 this.$content.innerWidth() - $lastItem.position().left - $lastItem.outerWidth() :
5078                                 $lastItem.position().left;
5079                 }
5081                 // Some safety margin because I *really* don't feel like finding out where the
5082                 // few pixels this is off by are coming from.
5083                 bestWidth -= 13;
5084                 if ( contentWidth > bestWidth ) {
5085                         // This will result in the input getting shifted to the next line
5086                         bestWidth = this.$content.innerWidth() - 13;
5087                 }
5088                 this.input.$input.width( Math.floor( bestWidth ) );
5089                 this.updateIfHeightChanged();
5090         } else {
5091                 this.updateIfHeightChanged();
5092         }
5096  * Determine if widget height changed, and if so,
5097  * emit the resize event. This is useful for when there are either
5098  * menus or popups attached to the bottom of the widget, to allow
5099  * them to change their positioning in case the widget moved down
5100  * or up.
5102  * @private
5103  */
5104 OO.ui.TagMultiselectWidget.prototype.updateIfHeightChanged = function () {
5105         var height = this.$element.height();
5106         if ( height !== this.height ) {
5107                 this.height = height;
5108                 this.emit( 'resize' );
5109         }
5113  * Check whether all items in the widget are valid
5115  * @return {boolean} Widget is valid
5116  */
5117 OO.ui.TagMultiselectWidget.prototype.checkValidity = function () {
5118         return this.getItems().every( function ( item ) {
5119                 return item.isValid();
5120         } );
5124  * Set the valid state of this item
5126  * @param {boolean} [valid] Item is valid, omit to toggle
5127  * @fires valid
5128  */
5129 OO.ui.TagMultiselectWidget.prototype.toggleValid = function ( valid ) {
5130         valid = valid === undefined ? !this.valid : !!valid;
5132         if ( this.valid !== valid ) {
5133                 this.valid = valid;
5135                 this.setFlags( { invalid: !this.valid } );
5137                 this.emit( 'valid', this.valid );
5138         }
5142  * Get the current valid state of the widget
5144  * @return {boolean} Widget is valid
5145  */
5146 OO.ui.TagMultiselectWidget.prototype.isValid = function () {
5147         return this.valid;
5151  * PopupTagMultiselectWidget is a {@link OO.ui.TagMultiselectWidget OO.ui.TagMultiselectWidget}
5152  * intended to use a popup. The popup can be configured to have a default input to insert values
5153  * into the widget.
5155  *     @example
5156  *     // A PopupTagMultiselectWidget.
5157  *     var widget = new OO.ui.PopupTagMultiselectWidget();
5158  *     $( document.body ).append( widget.$element );
5160  *     // Example: A PopupTagMultiselectWidget with an external popup.
5161  *     var popupInput = new OO.ui.TextInputWidget(),
5162  *         widget = new OO.ui.PopupTagMultiselectWidget( {
5163  *            popupInput: popupInput,
5164  *            popup: {
5165  *               $content: popupInput.$element
5166  *            }
5167  *         } );
5168  *     $( document.body ).append( widget.$element );
5170  * @class
5171  * @extends OO.ui.TagMultiselectWidget
5172  * @mixins OO.ui.mixin.PopupElement
5174  * @param {Object} config Configuration object
5175  * @cfg {jQuery} [$overlay] An overlay for the popup.
5176  *  See <https://www.mediawiki.org/wiki/OOUI/Concepts#Overlays>.
5177  * @cfg {Object} [popup] Configuration options for the popup
5178  * @cfg {OO.ui.InputWidget} [popupInput] An input widget inside the popup that will be
5179  *  focused when the popup is opened and will be used as replacement for the
5180  *  general input in the widget.
5181  * @deprecated
5182  */
5183 OO.ui.PopupTagMultiselectWidget = function OoUiPopupTagMultiselectWidget( config ) {
5184         var defaultConfig = { popup: {} };
5186         config = config || {};
5188         // Parent constructor
5189         OO.ui.PopupTagMultiselectWidget.super.call( this, $.extend( {
5190                 inputPosition: 'none'
5191         }, config ) );
5193         this.$overlay = ( config.$overlay === true ?
5194                 OO.ui.getDefaultOverlay() : config.$overlay ) || this.$element;
5196         if ( !config.popup ) {
5197                 // For the default base implementation, we give a popup
5198                 // with an input widget inside it. For any other use cases
5199                 // the popup needs to be populated externally and the
5200                 // event handled to add tags separately and manually
5201                 var defaultInput = new OO.ui.TextInputWidget();
5203                 defaultConfig.popupInput = defaultInput;
5204                 defaultConfig.popup.$content = defaultInput.$element;
5205                 defaultConfig.popup.padded = true;
5207                 this.$element.addClass( 'oo-ui-popupTagMultiselectWidget-defaultPopup' );
5208         }
5210         // Add overlay, and add that to the autoCloseIgnore
5211         defaultConfig.popup.$overlay = this.$overlay;
5212         defaultConfig.popup.$autoCloseIgnore = this.hasInput ?
5213                 this.input.$element.add( this.$overlay ) : this.$overlay;
5215         // Allow extending any of the above
5216         config = $.extend( defaultConfig, config );
5218         // Mixin constructors
5219         OO.ui.mixin.PopupElement.call( this, config );
5221         if ( this.hasInput ) {
5222                 this.input.$input.on( 'focus', this.popup.toggle.bind( this.popup, true ) );
5223         }
5225         // Configuration options
5226         this.popupInput = config.popupInput;
5227         if ( this.popupInput ) {
5228                 this.popupInput.connect( this, {
5229                         enter: 'onPopupInputEnter'
5230                 } );
5231         }
5233         // Events
5234         this.on( 'resize', this.popup.updateDimensions.bind( this.popup ) );
5235         this.popup.connect( this, {
5236                 toggle: 'onPopupToggle'
5237         } );
5238         this.$tabIndexed.on( 'focus', this.onFocus.bind( this ) );
5240         // Initialize
5241         this.$element
5242                 .append( this.popup.$element )
5243                 .addClass( 'oo-ui-popupTagMultiselectWidget' );
5245         // Deprecation warning
5246         OO.ui.warnDeprecation( 'PopupTagMultiselectWidget: Deprecated widget. Use MenuTagMultiselectWidget instead. See T208821.' );
5249 /* Initialization */
5251 OO.inheritClass( OO.ui.PopupTagMultiselectWidget, OO.ui.TagMultiselectWidget );
5252 OO.mixinClass( OO.ui.PopupTagMultiselectWidget, OO.ui.mixin.PopupElement );
5254 /* Methods */
5257  * Focus event handler.
5259  * @private
5260  */
5261 OO.ui.PopupTagMultiselectWidget.prototype.onFocus = function () {
5262         this.popup.toggle( true );
5266  * Respond to popup toggle event
5268  * @param {boolean} isVisible Popup is visible
5269  */
5270 OO.ui.PopupTagMultiselectWidget.prototype.onPopupToggle = function ( isVisible ) {
5271         if ( isVisible && this.popupInput ) {
5272                 this.popupInput.focus();
5273         }
5277  * Respond to popup input enter event
5278  */
5279 OO.ui.PopupTagMultiselectWidget.prototype.onPopupInputEnter = function () {
5280         if ( this.popupInput ) {
5281                 this.addTagByPopupValue( this.popupInput.getValue() );
5282                 this.popupInput.setValue( '' );
5283         }
5287  * @inheritdoc
5288  */
5289 OO.ui.PopupTagMultiselectWidget.prototype.onTagSelect = function ( item ) {
5290         if ( this.popupInput && this.allowEditTags ) {
5291                 this.popupInput.setValue( item.getData() );
5292                 this.removeItems( [ item ] );
5294                 this.popup.toggle( true );
5295                 this.popupInput.focus();
5296         } else {
5297                 // Parent
5298                 OO.ui.PopupTagMultiselectWidget.super.prototype.onTagSelect.call( this, item );
5299         }
5303  * Add a tag by the popup value.
5304  * Whatever is responsible for setting the value in the popup should call
5305  * this method to add a tag, or use the regular methods like #addTag or
5306  * #setValue directly.
5308  * @param {string} data The value of item
5309  * @param {string} [label] The label of the tag. If not given, the data is used.
5310  */
5311 OO.ui.PopupTagMultiselectWidget.prototype.addTagByPopupValue = function ( data, label ) {
5312         this.addTag( data, label );
5316  * MenuTagMultiselectWidget is a {@link OO.ui.TagMultiselectWidget OO.ui.TagMultiselectWidget}
5317  * intended to use a menu of selectable options.
5319  *     @example
5320  *     // A basic MenuTagMultiselectWidget.
5321  *     var widget = new OO.ui.MenuTagMultiselectWidget( {
5322  *         inputPosition: 'outline',
5323  *         options: [
5324  *            { data: 'option1', label: 'Option 1', icon: 'tag' },
5325  *            { data: 'option2', label: 'Option 2' },
5326  *            { data: 'option3', label: 'Option 3' },
5327  *         ],
5328  *         selected: [ 'option1', 'option2' ]
5329  *     } );
5330  *     $( document.body ).append( widget.$element );
5332  * @class
5333  * @extends OO.ui.TagMultiselectWidget
5335  * @constructor
5336  * @param {Object} [config] Configuration object
5337  * @cfg {boolean} [clearInputOnChoose=true] Clear the text input value when a menu option is chosen
5338  * @cfg {Object} [menu] Configuration object for the menu widget
5339  * @cfg {jQuery} [$overlay] An overlay for the menu.
5340  *  See <https://www.mediawiki.org/wiki/OOUI/Concepts#Overlays>.
5341  * @cfg {Object[]} [options=[]] Array of menu options in the format `{ data: …, label: … }`
5342  */
5343 OO.ui.MenuTagMultiselectWidget = function OoUiMenuTagMultiselectWidget( config ) {
5344         var $autoCloseIgnore = $( [] );
5346         config = config || {};
5348         // Ensure that any pre-selected items exist as menu options,
5349         // so that they can be added as tags from #setValue
5350         var options = config.options || [];
5351         var selected = config.selected || [];
5352         options = options.concat(
5353                 selected.map( function ( option ) {
5354                         if ( typeof option === 'string' ) {
5355                                 return {
5356                                         data: option,
5357                                         label: option
5358                                 };
5359                         }
5360                         return option;
5361                 } )
5362         );
5364         var configCopy = OO.copy( config );
5365         configCopy.options = options;
5366         configCopy.selected = selected;
5368         // Parent constructor
5369         OO.ui.MenuTagMultiselectWidget.super.call( this, configCopy );
5371         $autoCloseIgnore = $autoCloseIgnore.add( this.$group );
5372         if ( this.hasInput ) {
5373                 $autoCloseIgnore = $autoCloseIgnore.add( this.input.$element );
5374         }
5376         this.$overlay = ( config.$overlay === true ?
5377                 OO.ui.getDefaultOverlay() : config.$overlay ) || this.$element;
5378         this.clearInputOnChoose = config.clearInputOnChoose === undefined ||
5379                 !!config.clearInputOnChoose;
5380         this.menu = this.createMenuWidget( $.extend( {
5381                 widget: this,
5382                 hideOnChoose: false,
5383                 input: this.hasInput ? this.input : null,
5384                 $input: this.hasInput ? this.input.$input : null,
5385                 filterFromInput: !!this.hasInput,
5386                 highlightOnFilter: !this.allowArbitrary,
5387                 multiselect: true,
5388                 $autoCloseIgnore: $autoCloseIgnore,
5389                 $floatableContainer: this.hasInput && this.inputPosition === 'outline' ?
5390                         this.input.$element : this.$element,
5391                 $overlay: this.$overlay,
5392                 disabled: this.isDisabled()
5393         }, config.menu ) );
5394         this.addOptions( options );
5396         // Events
5397         this.menu.connect( this, {
5398                 choose: 'onMenuChoose',
5399                 toggle: 'onMenuToggle'
5400         } );
5401         if ( this.hasInput ) {
5402                 this.input.connect( this, {
5403                         change: 'onInputChange'
5404                 } );
5405         }
5406         this.connect( this, {
5407                 resize: 'onResize'
5408         } );
5410         // Initialization
5411         this.$overlay.append( this.menu.$element );
5412         this.$element.addClass( 'oo-ui-menuTagMultiselectWidget' );
5413         // Remove MenuSelectWidget's generic focus owner ARIA attribute
5414         // TODO: Should this widget have a `role` that is compatible with this attribute?
5415         this.menu.$focusOwner.removeAttr( 'aria-expanded' );
5416         // TagMultiselectWidget already does this, but it doesn't work right because this.menu is
5417         // not yet set up while the parent constructor runs, and #getAllowedValues rejects everything.
5418         if ( selected.length > 0 ) {
5419                 this.setValue( selected );
5420         }
5423 /* Initialization */
5425 OO.inheritClass( OO.ui.MenuTagMultiselectWidget, OO.ui.TagMultiselectWidget );
5427 /* Methods */
5430  * Respond to resize event
5431  */
5432 OO.ui.MenuTagMultiselectWidget.prototype.onResize = function () {
5433         // Reposition the menu
5434         this.menu.position();
5438  * @inheritdoc
5439  */
5440 OO.ui.MenuTagMultiselectWidget.prototype.onInputFocus = function () {
5441         // Parent method
5442         OO.ui.MenuTagMultiselectWidget.super.prototype.onInputFocus.call( this );
5444         this.menu.toggle( true );
5448  * Respond to input change event
5449  */
5450 OO.ui.MenuTagMultiselectWidget.prototype.onInputChange = function () {
5451         this.menu.toggle( true );
5455  * Respond to menu choose event, which is intentional by the user.
5457  * @param {OO.ui.OptionWidget} menuItem Selected menu items
5458  * @param {boolean} selected Item is selected
5459  */
5460 OO.ui.MenuTagMultiselectWidget.prototype.onMenuChoose = function ( menuItem, selected ) {
5461         if ( selected && !this.findItemFromData( menuItem.getData() ) ) {
5462                 // The menu item is selected, add it to the tags
5463                 this.addTag( menuItem.getData(), menuItem.getLabel() );
5464         } else {
5465                 // The menu item was unselected, remove the tag
5466                 this.removeTagByData( menuItem.getData() );
5467         }
5469         if ( this.hasInput && this.clearInputOnChoose ) {
5470                 this.input.setValue( '' );
5471         }
5475  * Respond to menu toggle event. Reset item highlights on hide.
5477  * @param {boolean} isVisible The menu is visible
5478  */
5479 OO.ui.MenuTagMultiselectWidget.prototype.onMenuToggle = function ( isVisible ) {
5480         if ( !isVisible ) {
5481                 this.menu.highlightItem( null );
5482                 this.menu.scrollToTop();
5483         }
5484         setTimeout( function () {
5485                 // Remove MenuSelectWidget's generic focus owner ARIA attribute
5486                 // TODO: Should this widget have a `role` that is compatible with this attribute?
5487                 this.menu.$focusOwner.removeAttr( 'aria-expanded' );
5488         }.bind( this ) );
5492  * @inheritdoc
5493  */
5494 OO.ui.MenuTagMultiselectWidget.prototype.onTagSelect = function ( tagItem ) {
5495         var menuItem = this.menu.findItemFromData( tagItem.getData() );
5496         if ( !this.allowArbitrary ) {
5497                 // Override the base behavior from TagMultiselectWidget; the base behavior
5498                 // in TagMultiselectWidget is to remove the tag to edit it in the input,
5499                 // but in our case, we want to utilize the menu selection behavior, and
5500                 // definitely not remove the item.
5502                 // If there is an input that is used for filtering, erase the value so we don't filter
5503                 if ( this.hasInput && this.menu.filterFromInput ) {
5504                         this.input.setValue( '' );
5505                 }
5507                 this.focus();
5509                 // Highlight the menu item
5510                 this.menu.highlightItem( menuItem );
5511                 this.menu.scrollItemIntoView( menuItem );
5513         } else {
5514                 // Use the default
5515                 OO.ui.MenuTagMultiselectWidget.super.prototype.onTagSelect.call( this, tagItem );
5516         }
5520  * @inheritdoc
5521  */
5522 OO.ui.MenuTagMultiselectWidget.prototype.removeItems = function ( items ) {
5523         var widget = this;
5525         // Parent
5526         OO.ui.MenuTagMultiselectWidget.super.prototype.removeItems.call( this, items );
5528         items.forEach( function ( tagItem ) {
5529                 var menuItem = widget.menu.findItemFromData( tagItem.getData() );
5530                 if ( menuItem ) {
5531                         // Synchronize the menu selection - unselect the removed tag
5532                         widget.menu.unselectItem( menuItem );
5533                 }
5534         } );
5538  * @inheritdoc
5539  */
5540 OO.ui.MenuTagMultiselectWidget.prototype.setValue = function ( valueObject ) {
5541         valueObject = Array.isArray( valueObject ) ? valueObject : [ valueObject ];
5543         // We override this method from the parent, to make sure we are adding proper
5544         // menu items, and are accounting for cases where we have this widget with
5545         // a menu but also 'allowArbitrary'
5546         if ( !this.menu ) {
5547                 return;
5548         }
5550         this.clearItems();
5551         valueObject.forEach( function ( obj ) {
5552                 var data, label;
5554                 if ( typeof obj === 'string' ) {
5555                         data = label = obj;
5556                 } else {
5557                         data = obj.data;
5558                         label = obj.label;
5559                 }
5561                 // Check if the item is in the menu
5562                 var menuItem = this.menu.getItemFromLabel( label ) || this.menu.findItemFromData( data );
5563                 if ( menuItem ) {
5564                         // Menu item found, add the menu item
5565                         this.addTag( menuItem.getData(), menuItem.getLabel() );
5566                         // Make sure that item is also selected
5567                         this.menu.selectItem( menuItem );
5568                 } else if ( this.allowArbitrary ) {
5569                         // If the item isn't in the menu, only add it if we
5570                         // allow for arbitrary values
5571                         this.addTag( data, label );
5572                 }
5573         }.bind( this ) );
5577  * @inheritdoc
5578  */
5579 OO.ui.MenuTagMultiselectWidget.prototype.setDisabled = function ( isDisabled ) {
5580         // Parent method
5581         OO.ui.MenuTagMultiselectWidget.super.prototype.setDisabled.call( this, isDisabled );
5583         if ( this.menu ) {
5584                 // Protect against calling setDisabled() before the menu was initialized
5585                 this.menu.setDisabled( isDisabled );
5586         }
5590  * Highlight the first selectable item in the menu, if configured.
5592  * @private
5593  * @chainable
5594  */
5595 OO.ui.MenuTagMultiselectWidget.prototype.initializeMenuSelection = function () {
5596         this.menu.highlightItem(
5597                 this.allowArbitrary ?
5598                         null :
5599                         this.menu.findFirstSelectableItem()
5600         );
5602         var highlightedItem = this.menu.findHighlightedItem();
5603         // Scroll to the highlighted item, if it exists
5604         if ( highlightedItem ) {
5605                 this.menu.scrollItemIntoView( highlightedItem );
5606         }
5610  * @inheritdoc
5611  */
5612 OO.ui.MenuTagMultiselectWidget.prototype.getTagInfoFromInput = function () {
5613         var val = this.input.getValue(),
5614                 // Look for a highlighted item first
5615                 // Then look for the element that fits the data
5616                 item = this.menu.findHighlightedItem() || this.menu.findItemFromData( val ),
5617                 data = item ? item.getData() : val,
5618                 label = item ? item.getLabel() : val;
5620         return { data: data, label: label };
5624  * Create the menu for this widget. This is in a separate method so that
5625  * child classes can override this without polluting the constructor with
5626  * unnecessary extra objects that will be overidden.
5628  * @param {Object} menuConfig Configuration options
5629  * @return {OO.ui.MenuSelectWidget} Menu widget
5630  */
5631 OO.ui.MenuTagMultiselectWidget.prototype.createMenuWidget = function ( menuConfig ) {
5632         return new OO.ui.MenuSelectWidget( menuConfig );
5636  * Add options to the menu, ensuring that they are unique by data.
5638  * @param {Object[]} menuOptions Object defining options
5639  */
5640 OO.ui.MenuTagMultiselectWidget.prototype.addOptions = function ( menuOptions ) {
5641         var widget = this,
5642                 optionsData = [],
5643                 items = [];
5645         menuOptions.forEach( function ( obj ) {
5646                 if ( optionsData.indexOf( obj.data ) === -1 ) {
5647                         optionsData.push( obj.data );
5648                         items.push(
5649                                 widget.createMenuOptionWidget( obj.data, obj.label, obj.icon )
5650                         );
5651                 }
5652         } );
5654         this.menu.addItems( items );
5658  * Create a menu option widget.
5660  * @param {string} data Item data
5661  * @param {string} [label=data] Item label
5662  * @param {string} [icon] Symbolic icon name
5663  * @return {OO.ui.OptionWidget} Option widget
5664  */
5665 OO.ui.MenuTagMultiselectWidget.prototype.createMenuOptionWidget = function ( data, label, icon ) {
5666         return new OO.ui.MenuOptionWidget( {
5667                 data: data,
5668                 label: label || data,
5669                 icon: icon
5670         } );
5674  * Get the menu
5676  * @return {OO.ui.MenuSelectWidget} Menu
5677  */
5678 OO.ui.MenuTagMultiselectWidget.prototype.getMenu = function () {
5679         return this.menu;
5683  * Get the allowed values list
5685  * @return {string[]} Allowed data values
5686  */
5687 OO.ui.MenuTagMultiselectWidget.prototype.getAllowedValues = function () {
5688         var menuDatas = [];
5689         if ( this.menu ) {
5690                 // If the parent constructor is calling us, we're not ready yet, this.menu is not set up.
5691                 menuDatas = this.menu.getItems().map( function ( menuItem ) {
5692                         return menuItem.getData();
5693                 } );
5694         }
5695         return this.allowedValues.concat( menuDatas );
5699  * SelectFileWidgets allow for selecting files, using the HTML5 File API. These
5700  * widgets can be configured with {@link OO.ui.mixin.IconElement icons}, {@link
5701  * OO.ui.mixin.IndicatorElement indicators} and {@link OO.ui.mixin.TitledElement titles}.
5702  * Please see the [OOUI documentation on MediaWiki] [1] for more information and examples.
5704  * Although SelectFileWidget inherits from SelectFileInputWidget, it does not
5705  * behave as an InputWidget, and can't be used in HTML forms.
5707  *     @example
5708  *     // A file select widget.
5709  *     var selectFile = new OO.ui.SelectFileWidget();
5710  *     $( document.body ).append( selectFile.$element );
5712  * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets
5714  * @class
5715  * @extends OO.ui.SelectFileInputWidget
5716  * @mixins OO.ui.mixin.PendingElement
5718  * @constructor
5719  * @param {Object} [config] Configuration options
5720  * @cfg {string} [notsupported] Text to display when file support is missing in the browser.
5721  * @cfg {boolean} [droppable=true] Whether to accept files by drag and drop.
5722  * @cfg {boolean} [buttonOnly=false] Show only the select file button, no info field. Requires
5723  *  showDropTarget to be false.
5724  * @cfg {boolean} [showDropTarget=false] Whether to show a drop target. Requires droppable to be
5725  *  true.
5726  * @cfg {number} [thumbnailSizeLimit=20] File size limit in MiB above which to not try and show a
5727  *  preview (for performance).
5728  */
5729 OO.ui.SelectFileWidget = function OoUiSelectFileWidget( config ) {
5730         var isSupported = this.constructor.static.isSupported();
5732         // Configuration initialization
5733         config = $.extend( {
5734                 notsupported: OO.ui.msg( 'ooui-selectfile-not-supported' ),
5735                 droppable: true,
5736                 buttonOnly: false,
5737                 showDropTarget: false,
5738                 thumbnailSizeLimit: 20
5739         }, config );
5741         if ( !isSupported ) {
5742                 config.disabled = true;
5743         }
5745         // Parent constructor
5746         OO.ui.SelectFileWidget.super.call( this, config );
5748         // Mixin constructors
5749         OO.ui.mixin.PendingElement.call( this );
5751         if ( !isSupported ) {
5752                 this.info.setValue( config.notsupported );
5753         }
5755         // Properties
5756         var droppable = config.droppable && isSupported;
5757         this.showDropTarget = droppable && config.showDropTarget;
5758         this.thumbnailSizeLimit = config.thumbnailSizeLimit;
5760         // Initialization
5761         if ( this.showDropTarget ) {
5762                 this.selectButton.setIcon( 'upload' );
5763                 this.$element
5764                         .addClass( 'oo-ui-selectFileWidget-dropTarget' )
5765                         .on( {
5766                                 click: this.onDropTargetClick.bind( this )
5767                         } )
5768                         .append(
5769                                 this.info.$element,
5770                                 this.selectButton.$element,
5771                                 $( '<span>' )
5772                                         .addClass( 'oo-ui-selectFileWidget-dropLabel' )
5773                                         .text( OO.ui.msg(
5774                                                 this.multiple ?
5775                                                         'ooui-selectfile-dragdrop-placeholder-multiple' :
5776                                                         'ooui-selectfile-dragdrop-placeholder'
5777                                         ) )
5778                         );
5779                 if ( !this.multiple ) {
5780                         this.$thumbnail = $( '<div>' ).addClass( 'oo-ui-selectFileWidget-thumbnail' );
5781                         this.setPendingElement( this.$thumbnail );
5782                         this.$element
5783                                 .addClass( 'oo-ui-selectFileWidget-withThumbnail' )
5784                                 .prepend( this.$thumbnail );
5785                 }
5786                 this.fieldLayout.$element.remove();
5787         } else if ( config.buttonOnly ) {
5788                 // Copy over any classes that may have been added already.
5789                 // Ensure no events are bound to this.$element before here.
5790                 this.selectButton.$element
5791                         .addClass( this.$element.attr( 'class' ) )
5792                         .addClass( 'oo-ui-selectFileWidget-buttonOnly' );
5793                 // Set this.$element to just be the button
5794                 this.$element = this.selectButton.$element;
5795         }
5797         // Events
5798         if ( droppable ) {
5799                 var dragHandler = this.onDragEnterOrOver.bind( this );
5800                 this.$element.on( {
5801                         dragenter: dragHandler,
5802                         dragover: dragHandler,
5803                         dragleave: this.onDragLeave.bind( this ),
5804                         drop: this.onDrop.bind( this )
5805                 } );
5806         }
5808         this.$input
5809                 .on( 'click', function ( e ) {
5810                         // Prevents dropTarget to get clicked which calls
5811                         // a click on this input
5812                         e.stopPropagation();
5813                 } );
5815         this.$element.addClass( 'oo-ui-selectFileWidget' );
5817         this.updateUI();
5820 /* Setup */
5822 OO.inheritClass( OO.ui.SelectFileWidget, OO.ui.SelectFileInputWidget );
5823 OO.mixinClass( OO.ui.SelectFileWidget, OO.ui.mixin.PendingElement );
5825 /* Static Properties */
5828  * Check if this widget is supported
5830  * @static
5831  * @return {boolean}
5832  */
5833 OO.ui.SelectFileWidget.static.isSupported = function () {
5834         if ( OO.ui.SelectFileWidget.static.isSupportedCache === null ) {
5835                 var $input = $( '<input>' ).attr( 'type', 'file' );
5836                 OO.ui.SelectFileWidget.static.isSupportedCache = $input[ 0 ].files !== undefined;
5837         }
5838         return OO.ui.SelectFileWidget.static.isSupportedCache;
5841 OO.ui.SelectFileWidget.static.isSupportedCache = null;
5843 /* Events */
5846  * @event change
5848  * A change event is emitted when the on/off state of the toggle changes.
5850  * @param {File|null} value New value
5851  */
5853 /* Methods */
5856  * Get the current value of the field
5858  * For single file widgets returns a File or null.
5859  * For multiple file widgets returns a list of Files.
5861  * @return {File|File[]|null}
5862  */
5863 OO.ui.SelectFileWidget.prototype.getValue = function () {
5864         return this.multiple ? this.currentFiles : this.currentFiles[ 0 ];
5868  * Set the current value of the field
5870  * @param {File[]|null} files Files to select
5871  */
5872 OO.ui.SelectFileWidget.prototype.setValue = function ( files ) {
5873         if ( files && !this.multiple ) {
5874                 files = files.slice( 0, 1 );
5875         }
5877         function comparableFile( file ) {
5878                 // Use extend to convert to plain objects so they can be compared.
5879                 return $.extend( {}, file );
5880         }
5882         if ( !OO.compare(
5883                 files && files.map( comparableFile ),
5884                 this.currentFiles && this.currentFiles.map( comparableFile )
5885         ) ) {
5886                 this.currentFiles = files || [];
5887                 this.emit( 'change', this.currentFiles );
5888         }
5892  * @inheritdoc
5893  */
5894 OO.ui.SelectFileWidget.prototype.getFilename = function () {
5895         return this.currentFiles.map( function ( file ) {
5896                 return file.name;
5897         } ).join( ', ' );
5901  * Disable InputWidget#onEdit listener, onFileSelected is used instead.
5903  * @inheritdoc
5904  */
5905 OO.ui.SelectFileWidget.prototype.onEdit = function () {};
5908  * @inheritdoc
5909  */
5910 OO.ui.SelectFileWidget.prototype.updateUI = function () {
5911         // Too early, or not supported
5912         if ( !this.selectButton || !this.constructor.static.isSupported() ) {
5913                 return;
5914         }
5916         // Parent method
5917         OO.ui.SelectFileWidget.super.prototype.updateUI.call( this );
5919         if ( this.currentFiles.length ) {
5920                 this.$element.removeClass( 'oo-ui-selectFileInputWidget-empty' );
5922                 if ( this.showDropTarget ) {
5923                         if ( !this.multiple ) {
5924                                 this.pushPending();
5925                                 this.loadAndGetImageUrl( this.currentFiles[ 0 ] ).done( function ( url ) {
5926                                         this.$thumbnail.css( 'background-image', 'url( ' + url + ' )' );
5927                                 }.bind( this ) ).fail( function () {
5928                                         this.$thumbnail.append(
5929                                                 new OO.ui.IconWidget( {
5930                                                         icon: 'attachment',
5931                                                         classes: [ 'oo-ui-selectFileWidget-noThumbnail-icon' ]
5932                                                 } ).$element
5933                                         );
5934                                 }.bind( this ) ).always( function () {
5935                                         this.popPending();
5936                                 }.bind( this ) );
5937                         }
5938                         this.$element.off( 'click' );
5939                 }
5940         } else {
5941                 if ( this.showDropTarget ) {
5942                         this.$element.off( 'click' );
5943                         this.$element.on( {
5944                                 click: this.onDropTargetClick.bind( this )
5945                         } );
5946                         if ( !this.multiple ) {
5947                                 this.$thumbnail
5948                                         .empty()
5949                                         .css( 'background-image', '' );
5950                         }
5951                 }
5952                 this.$element.addClass( 'oo-ui-selectFileInputWidget-empty' );
5953         }
5957  * If the selected file is an image, get its URL and load it.
5959  * @param {File} file File
5960  * @return {jQuery.Promise} Promise resolves with the image URL after it has loaded
5961  */
5962 OO.ui.SelectFileWidget.prototype.loadAndGetImageUrl = function ( file ) {
5963         var deferred = $.Deferred(),
5964                 reader = new FileReader();
5966         if (
5967                 ( OO.getProp( file, 'type' ) || '' ).indexOf( 'image/' ) === 0 &&
5968                 file.size < this.thumbnailSizeLimit * 1024 * 1024
5969         ) {
5970                 reader.onload = function ( event ) {
5971                         var img = document.createElement( 'img' );
5972                         img.addEventListener( 'load', function () {
5973                                 if (
5974                                         img.naturalWidth === 0 ||
5975                                         img.naturalHeight === 0 ||
5976                                         img.complete === false
5977                                 ) {
5978                                         deferred.reject();
5979                                 } else {
5980                                         deferred.resolve( event.target.result );
5981                                 }
5982                         } );
5983                         img.src = event.target.result;
5984                 };
5985                 reader.readAsDataURL( file );
5986         } else {
5987                 deferred.reject();
5988         }
5990         return deferred.promise();
5994  * @inheritdoc
5995  */
5996 OO.ui.SelectFileWidget.prototype.onFileSelected = function ( e ) {
5997         if ( this.inputClearing ) {
5998                 return;
5999         }
6001         var files = this.filterFiles( e.target.files || [] );
6003         // After a file is selected clear the native widget to avoid confusion
6004         this.inputClearing = true;
6005         this.$input[ 0 ].value = '';
6006         this.inputClearing = false;
6008         this.setValue( files );
6012  * Handle drop target click events.
6014  * @private
6015  * @param {jQuery.Event} e Key press event
6016  * @return {undefined|boolean} False to prevent default if event is handled
6017  */
6018 OO.ui.SelectFileWidget.prototype.onDropTargetClick = function () {
6019         if ( !this.isDisabled() && this.$input ) {
6020                 this.$input.trigger( 'click' );
6021                 return false;
6022         }
6026  * Handle drag enter and over events
6028  * @private
6029  * @param {jQuery.Event} e Drag event
6030  * @return {undefined|boolean} False to prevent default if event is handled
6031  */
6032 OO.ui.SelectFileWidget.prototype.onDragEnterOrOver = function ( e ) {
6033         var hasDroppableFile = false,
6034                 dt = e.originalEvent.dataTransfer;
6036         e.preventDefault();
6037         e.stopPropagation();
6039         if ( this.isDisabled() ) {
6040                 this.$element.removeClass( 'oo-ui-selectFileWidget-canDrop' );
6041                 dt.dropEffect = 'none';
6042                 return false;
6043         }
6045         // DataTransferItem and File both have a type property, but in Chrome files
6046         // have no information at this point.
6047         var itemsOrFiles = dt.items || dt.files;
6048         if ( itemsOrFiles && itemsOrFiles.length ) {
6049                 if ( this.filterFiles( itemsOrFiles ).length ) {
6050                         hasDroppableFile = true;
6051                 }
6052         // dt.types is Array-like, but not an Array
6053         } else if ( Array.prototype.indexOf.call( OO.getProp( dt, 'types' ) || [], 'Files' ) !== -1 ) {
6054                 // File information is not available at this point for security so just assume
6055                 // it is acceptable for now.
6056                 // https://bugzilla.mozilla.org/show_bug.cgi?id=640534
6057                 hasDroppableFile = true;
6058         }
6060         this.$element.toggleClass( 'oo-ui-selectFileWidget-canDrop', hasDroppableFile );
6061         if ( !hasDroppableFile ) {
6062                 dt.dropEffect = 'none';
6063         }
6065         return false;
6069  * Handle drag leave events
6071  * @private
6072  * @param {jQuery.Event} e Drag event
6073  */
6074 OO.ui.SelectFileWidget.prototype.onDragLeave = function () {
6075         this.$element.removeClass( 'oo-ui-selectFileWidget-canDrop' );
6079  * Handle drop events
6081  * @private
6082  * @param {jQuery.Event} e Drop event
6083  * @return {undefined|boolean} False to prevent default if event is handled
6084  */
6085 OO.ui.SelectFileWidget.prototype.onDrop = function ( e ) {
6086         var dt = e.originalEvent.dataTransfer;
6088         e.preventDefault();
6089         e.stopPropagation();
6090         this.$element.removeClass( 'oo-ui-selectFileWidget-canDrop' );
6092         if ( this.isDisabled() ) {
6093                 return false;
6094         }
6096         var files = this.filterFiles( dt.files || [] );
6097         this.setValue( files );
6099         return false;
6103  * @inheritdoc
6104  */
6105 OO.ui.SelectFileWidget.prototype.setDisabled = function ( disabled ) {
6106         disabled = disabled || !this.constructor.static.isSupported();
6108         // Parent method
6109         OO.ui.SelectFileWidget.super.prototype.setDisabled.call( this, disabled );
6113  * SearchWidgets combine a {@link OO.ui.TextInputWidget text input field},
6114  * where users can type a search query, and a menu of search results,
6115  * which is displayed beneath the query field.
6116  * Unlike {@link OO.ui.mixin.LookupElement lookup menus}, search result menus are always visible
6117  * to the user. Users can choose an item from the menu or type a query into the text field to
6118  * search for a matching result item.
6119  * In general, search widgets are used inside a separate {@link OO.ui.Dialog dialog} window.
6121  * Each time the query is changed, the search result menu is cleared and repopulated. Please see
6122  * the [OOUI demos][1] for an example.
6124  * [1]: https://doc.wikimedia.org/oojs-ui/master/demos/#SearchInputWidget-type-search
6126  * @class
6127  * @extends OO.ui.Widget
6129  * @constructor
6130  * @param {Object} [config] Configuration options
6131  * @cfg {string|jQuery} [placeholder] Placeholder text for query input
6132  * @cfg {string} [value] Initial query value
6133  * @cfg {OO.ui.InputWidget} [input] {@link OO.ui.InputWidget Input widget} for search. Defaults
6134  *  to a {@link OO.ui.SearchInputWidget search input widget} if not provided.
6135  */
6136 OO.ui.SearchWidget = function OoUiSearchWidget( config ) {
6137         // Configuration initialization
6138         config = config || {};
6140         // Parent constructor
6141         OO.ui.SearchWidget.super.call( this, config );
6143         // Properties
6144         this.query = config.input || new OO.ui.SearchInputWidget( {
6145                 placeholder: config.placeholder,
6146                 value: config.value
6147         } );
6148         this.results = new OO.ui.SelectWidget();
6149         this.results.setFocusOwner( this.query.$input );
6150         this.$query = $( '<div>' );
6151         this.$results = $( '<div>' );
6153         // Events
6154         this.query.connect( this, {
6155                 change: 'onQueryChange',
6156                 enter: 'onQueryEnter'
6157         } );
6158         this.query.$input.on( 'keydown', this.onQueryKeydown.bind( this ) );
6160         // Initialization
6161         this.$query
6162                 .addClass( 'oo-ui-searchWidget-query' )
6163                 .append( this.query.$element );
6164         this.$results
6165                 .addClass( 'oo-ui-searchWidget-results' )
6166                 .append( this.results.$element );
6167         this.$element
6168                 .addClass( 'oo-ui-searchWidget' )
6169                 .append( this.$results, this.$query );
6172 /* Setup */
6174 OO.inheritClass( OO.ui.SearchWidget, OO.ui.Widget );
6176 /* Methods */
6179  * Handle query key down events.
6181  * @private
6182  * @param {jQuery.Event} e Key down event
6183  */
6184 OO.ui.SearchWidget.prototype.onQueryKeydown = function ( e ) {
6185         var dir = e.which === OO.ui.Keys.DOWN ? 1 : ( e.which === OO.ui.Keys.UP ? -1 : 0 );
6187         if ( dir ) {
6188                 var highlightedItem = this.results.findHighlightedItem() || this.results.findSelectedItem();
6189                 var nextItem = this.results.findRelativeSelectableItem( highlightedItem, dir );
6190                 // nextItem may be null if there are no results
6191                 this.results.highlightItem( nextItem );
6192                 if ( nextItem ) {
6193                         nextItem.scrollElementIntoView();
6194                 }
6195         }
6199  * Handle select widget select events.
6201  * Clears existing results. Subclasses should repopulate items according to new query.
6203  * @private
6204  * @param {string} value New value
6205  */
6206 OO.ui.SearchWidget.prototype.onQueryChange = function () {
6207         // Reset
6208         this.results.clearItems();
6212  * Handle select widget enter key events.
6214  * Chooses highlighted item.
6216  * @private
6217  * @param {string} value New value
6218  */
6219 OO.ui.SearchWidget.prototype.onQueryEnter = function () {
6220         var highlightedItem = this.results.findHighlightedItem();
6221         if ( highlightedItem ) {
6222                 this.results.chooseItem( highlightedItem );
6223         }
6227  * Get the query input.
6229  * @return {OO.ui.TextInputWidget} Query input
6230  */
6231 OO.ui.SearchWidget.prototype.getQuery = function () {
6232         return this.query;
6236  * Get the search results menu.
6238  * @return {OO.ui.SelectWidget} Menu of search results
6239  */
6240 OO.ui.SearchWidget.prototype.getResults = function () {
6241         return this.results;
6244 }( OO ) );
6246 //# sourceMappingURL=oojs-ui-widgets.js.map.json