Check that file is executable in Installer::locateExecutable
[mediawiki.git] / resources / lib / oojs-ui / oojs-ui-widgets.js
blobcc010a632e8096390d300304bfd7dcbfee99920e
1 /*!
2  * OOjs UI v0.17.4
3  * https://www.mediawiki.org/wiki/OOjs_UI
4  *
5  * Copyright 2011–2016 OOjs UI Team and other contributors.
6  * Released under the MIT license
7  * http://oojs.mit-license.org
8  *
9  * Date: 2016-05-31T21:50:52Z
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 the whole element
27  */
28 OO.ui.mixin.DraggableElement = function OoUiMixinDraggableElement( config ) {
29         config = config || {};
31         // Properties
32         this.index = null;
33         this.$handle = config.$handle || this.$element;
34         this.wasHandleUsed = null;
36         // Initialize and events
37         this.$element.addClass( 'oo-ui-draggableElement' )
38                 // We make the entire element draggable, not just the handle, so that
39                 // the whole element appears to move. wasHandleUsed prevents drags from
40                 // starting outside the handle
41                 .attr( 'draggable', true )
42                 .on( {
43                         mousedown: this.onDragMouseDown.bind( this ),
44                         dragstart: this.onDragStart.bind( this ),
45                         dragover: this.onDragOver.bind( this ),
46                         dragend: this.onDragEnd.bind( this ),
47                         drop: this.onDrop.bind( this )
48                 } );
49         this.$handle.addClass( 'oo-ui-draggableElement-handle' );
52 OO.initClass( OO.ui.mixin.DraggableElement );
54 /* Events */
56 /**
57  * @event dragstart
58  *
59  * A dragstart event is emitted when the user clicks and begins dragging an item.
60  * @param {OO.ui.mixin.DraggableElement} item The item the user has clicked and is dragging with the mouse.
61  */
63 /**
64  * @event dragend
65  * A dragend event is emitted when the user drags an item and releases the mouse,
66  * thus terminating the drag operation.
67  */
69 /**
70  * @event drop
71  * A drop event is emitted when the user drags an item and then releases the mouse button
72  * over a valid target.
73  */
75 /* Static Properties */
77 /**
78  * @inheritdoc OO.ui.mixin.ButtonElement
79  */
80 OO.ui.mixin.DraggableElement.static.cancelButtonMouseDownEvents = false;
82 /* Methods */
84 /**
85  * Respond to mousedown event.
86  *
87  * @private
88  * @param {jQuery.Event} e jQuery event
89  */
90 OO.ui.mixin.DraggableElement.prototype.onDragMouseDown = function ( e ) {
91         this.wasHandleUsed =
92                 // Optimization: if the handle is the whole element this is always true
93                 this.$handle[ 0 ] === this.$element[ 0 ] ||
94                 // Check the mousedown occurred inside the handle
95                 OO.ui.contains( this.$handle[ 0 ], e.target, true );
98 /**
99  * Respond to dragstart event.
101  * @private
102  * @param {jQuery.Event} e jQuery event
103  * @fires dragstart
104  */
105 OO.ui.mixin.DraggableElement.prototype.onDragStart = function ( e ) {
106         var element = this,
107                 dataTransfer = e.originalEvent.dataTransfer;
109         if ( !this.wasHandleUsed ) {
110                 return false;
111         }
113         // Define drop effect
114         dataTransfer.dropEffect = 'none';
115         dataTransfer.effectAllowed = 'move';
116         // Support: Firefox
117         // We must set up a dataTransfer data property or Firefox seems to
118         // ignore the fact the element is draggable.
119         try {
120                 dataTransfer.setData( 'application-x/OOjs-UI-draggable', this.getIndex() );
121         } catch ( err ) {
122                 // The above is only for Firefox. Move on if it fails.
123         }
124         // Briefly add a 'clone' class to style the browser's native drag image
125         this.$element.addClass( 'oo-ui-draggableElement-clone' );
126         // Add placeholder class after the browser has rendered the clone
127         setTimeout( function () {
128                 element.$element
129                         .removeClass( 'oo-ui-draggableElement-clone' )
130                         .addClass( 'oo-ui-draggableElement-placeholder' );
131         } );
132         // Emit event
133         this.emit( 'dragstart', this );
134         return true;
138  * Respond to dragend event.
140  * @private
141  * @fires dragend
142  */
143 OO.ui.mixin.DraggableElement.prototype.onDragEnd = function () {
144         this.$element.removeClass( 'oo-ui-draggableElement-placeholder' );
145         this.emit( 'dragend' );
149  * Handle drop event.
151  * @private
152  * @param {jQuery.Event} e jQuery event
153  * @fires drop
154  */
155 OO.ui.mixin.DraggableElement.prototype.onDrop = function ( e ) {
156         e.preventDefault();
157         this.emit( 'drop', e );
161  * In order for drag/drop to work, the dragover event must
162  * return false and stop propogation.
164  * @private
165  */
166 OO.ui.mixin.DraggableElement.prototype.onDragOver = function ( e ) {
167         e.preventDefault();
171  * Set item index.
172  * Store it in the DOM so we can access from the widget drag event
174  * @private
175  * @param {number} index Item index
176  */
177 OO.ui.mixin.DraggableElement.prototype.setIndex = function ( index ) {
178         if ( this.index !== index ) {
179                 this.index = index;
180                 this.$element.data( 'index', index );
181         }
185  * Get item index
187  * @private
188  * @return {number} Item index
189  */
190 OO.ui.mixin.DraggableElement.prototype.getIndex = function () {
191         return this.index;
195  * DraggableGroupElement is a mixin class used to create a group element to
196  * contain draggable elements, which are items that can be clicked and dragged by a mouse.
197  * The class is used with OO.ui.mixin.DraggableElement.
199  * @abstract
200  * @class
201  * @mixins OO.ui.mixin.GroupElement
203  * @constructor
204  * @param {Object} [config] Configuration options
205  * @cfg {string} [orientation] Item orientation: 'horizontal' or 'vertical'. The orientation
206  *  should match the layout of the items. Items displayed in a single row
207  *  or in several rows should use horizontal orientation. The vertical orientation should only be
208  *  used when the items are displayed in a single column. Defaults to 'vertical'
209  */
210 OO.ui.mixin.DraggableGroupElement = function OoUiMixinDraggableGroupElement( config ) {
211         // Configuration initialization
212         config = config || {};
214         // Parent constructor
215         OO.ui.mixin.GroupElement.call( this, config );
217         // Properties
218         this.orientation = config.orientation || 'vertical';
219         this.dragItem = null;
220         this.itemKeys = {};
221         this.dir = null;
222         this.itemsOrder = null;
224         // Events
225         this.aggregate( {
226                 dragstart: 'itemDragStart',
227                 dragend: 'itemDragEnd',
228                 drop: 'itemDrop'
229         } );
230         this.connect( this, {
231                 itemDragStart: 'onItemDragStart',
232                 itemDrop: 'onItemDropOrDragEnd',
233                 itemDragEnd: 'onItemDropOrDragEnd'
234         } );
236         // Initialize
237         if ( Array.isArray( config.items ) ) {
238                 this.addItems( config.items );
239         }
240         this.$element
241                 .addClass( 'oo-ui-draggableGroupElement' )
242                 .append( this.$status )
243                 .toggleClass( 'oo-ui-draggableGroupElement-horizontal', this.orientation === 'horizontal' );
246 /* Setup */
247 OO.mixinClass( OO.ui.mixin.DraggableGroupElement, OO.ui.mixin.GroupElement );
249 /* Events */
252  * An item has been dragged to a new position, but not yet dropped.
254  * @event drag
255  * @param {OO.ui.mixin.DraggableElement} item Dragged item
256  * @param {number} [newIndex] New index for the item
257  */
260  * And item has been dropped at a new position.
262  * @event reorder
263  * @param {OO.ui.mixin.DraggableElement} item Reordered item
264  * @param {number} [newIndex] New index for the item
265  */
267 /* Methods */
270  * Respond to item drag start event
272  * @private
273  * @param {OO.ui.mixin.DraggableElement} item Dragged item
274  */
275 OO.ui.mixin.DraggableGroupElement.prototype.onItemDragStart = function ( item ) {
276         // Make a shallow copy of this.items so we can re-order it during previews
277         // without affecting the original array.
278         this.itemsOrder = this.items.slice();
279         this.updateIndexes();
280         if ( this.orientation === 'horizontal' ) {
281                 // Calculate and cache directionality on drag start - it's a little
282                 // expensive and it shouldn't change while dragging.
283                 this.dir = this.$element.css( 'direction' );
284         }
285         this.setDragItem( item );
289  * Update the index properties of the items
290  */
291 OO.ui.mixin.DraggableGroupElement.prototype.updateIndexes = function () {
292         var i, len;
294         // Map the index of each object
295         for ( i = 0, len = this.itemsOrder.length; i < len; i++ ) {
296                 this.itemsOrder[ i ].setIndex( i );
297         }
301  * Handle drop or dragend event and switch the order of the items accordingly
303  * @private
304  * @param {OO.ui.mixin.DraggableElement} item Dropped item
305  */
306 OO.ui.mixin.DraggableGroupElement.prototype.onItemDropOrDragEnd = function () {
307         var targetIndex, originalIndex,
308                 item = this.getDragItem();
310         // TODO: Figure out a way to configure a list of legally droppable
311         // elements even if they are not yet in the list
312         if ( item ) {
313                 originalIndex = this.items.indexOf( item );
314                 // If the item has moved forward, add one to the index to account for the left shift
315                 targetIndex = item.getIndex() + ( item.getIndex() > originalIndex ? 1 : 0 );
316                 if ( targetIndex !== originalIndex ) {
317                         this.reorder( this.getDragItem(), targetIndex );
318                         this.emit( 'reorder', this.getDragItem(), targetIndex );
319                 }
320                 this.updateIndexes();
321         }
322         this.unsetDragItem();
323         // Return false to prevent propogation
324         return false;
328  * Respond to dragover event
330  * @private
331  * @param {jQuery.Event} e Dragover event
332  * @fires reorder
333  */
334 OO.ui.mixin.DraggableGroupElement.prototype.onDragOver = function ( e ) {
335         var overIndex, targetIndex,
336                 item = this.getDragItem(),
337                 dragItemIndex = item.getIndex();
339         // Get the OptionWidget item we are dragging over
340         overIndex = $( e.target ).closest( '.oo-ui-draggableElement' ).data( 'index' );
342         if ( overIndex !== undefined && overIndex !== dragItemIndex ) {
343                 targetIndex = overIndex + ( overIndex > dragItemIndex ? 1 : 0 );
345                 if ( targetIndex > 0 ) {
346                         this.$group.children().eq( targetIndex - 1 ).after( item.$element );
347                 } else {
348                         this.$group.prepend( item.$element );
349                 }
350                 // Move item in itemsOrder array
351                 this.itemsOrder.splice( overIndex, 0,
352                         this.itemsOrder.splice( dragItemIndex, 1 )[ 0 ]
353                 );
354                 this.updateIndexes();
355                 this.emit( 'drag', item, targetIndex );
356         }
357         // Prevent default
358         e.preventDefault();
362  * Reorder the items in the group
364  * @param {OO.ui.mixin.DraggableElement} item Reordered item
365  * @param {number} newIndex New index
366  */
367 OO.ui.mixin.DraggableGroupElement.prototype.reorder = function ( item, newIndex ) {
368         this.addItems( [ item ], newIndex );
372  * Set a dragged item
374  * @param {OO.ui.mixin.DraggableElement} item Dragged item
375  */
376 OO.ui.mixin.DraggableGroupElement.prototype.setDragItem = function ( item ) {
377         this.dragItem = item;
378         this.$element.on( 'dragover', this.onDragOver.bind( this ) );
379         this.$element.addClass( 'oo-ui-draggableGroupElement-dragging' );
383  * Unset the current dragged item
384  */
385 OO.ui.mixin.DraggableGroupElement.prototype.unsetDragItem = function () {
386         this.dragItem = null;
387         this.$element.off( 'dragover' );
388         this.$element.removeClass( 'oo-ui-draggableGroupElement-dragging' );
392  * Get the item that is currently being dragged.
394  * @return {OO.ui.mixin.DraggableElement|null} The currently dragged item, or `null` if no item is being dragged
395  */
396 OO.ui.mixin.DraggableGroupElement.prototype.getDragItem = function () {
397         return this.dragItem;
401  * RequestManager is a mixin that manages the lifecycle of a promise-backed request for a widget, such as
402  * the {@link OO.ui.mixin.LookupElement}.
404  * @class
405  * @abstract
407  * @constructor
408  */
409 OO.ui.mixin.RequestManager = function OoUiMixinRequestManager() {
410         this.requestCache = {};
411         this.requestQuery = null;
412         this.requestRequest = null;
415 /* Setup */
417 OO.initClass( OO.ui.mixin.RequestManager );
420  * Get request results for the current query.
422  * @return {jQuery.Promise} Promise object which will be passed response data as the first argument of
423  *   the done event. If the request was aborted to make way for a subsequent request, this promise
424  *   may not be rejected, depending on what jQuery feels like doing.
425  */
426 OO.ui.mixin.RequestManager.prototype.getRequestData = function () {
427         var widget = this,
428                 value = this.getRequestQuery(),
429                 deferred = $.Deferred(),
430                 ourRequest;
432         this.abortRequest();
433         if ( Object.prototype.hasOwnProperty.call( this.requestCache, value ) ) {
434                 deferred.resolve( this.requestCache[ value ] );
435         } else {
436                 if ( this.pushPending ) {
437                         this.pushPending();
438                 }
439                 this.requestQuery = value;
440                 ourRequest = this.requestRequest = this.getRequest();
441                 ourRequest
442                         .always( function () {
443                                 // We need to pop pending even if this is an old request, otherwise
444                                 // the widget will remain pending forever.
445                                 // TODO: this assumes that an aborted request will fail or succeed soon after
446                                 // being aborted, or at least eventually. It would be nice if we could popPending()
447                                 // at abort time, but only if we knew that we hadn't already called popPending()
448                                 // for that request.
449                                 if ( widget.popPending ) {
450                                         widget.popPending();
451                                 }
452                         } )
453                         .done( function ( response ) {
454                                 // If this is an old request (and aborting it somehow caused it to still succeed),
455                                 // ignore its success completely
456                                 if ( ourRequest === widget.requestRequest ) {
457                                         widget.requestQuery = null;
458                                         widget.requestRequest = null;
459                                         widget.requestCache[ value ] = widget.getRequestCacheDataFromResponse( response );
460                                         deferred.resolve( widget.requestCache[ value ] );
461                                 }
462                         } )
463                         .fail( function () {
464                                 // If this is an old request (or a request failing because it's being aborted),
465                                 // ignore its failure completely
466                                 if ( ourRequest === widget.requestRequest ) {
467                                         widget.requestQuery = null;
468                                         widget.requestRequest = null;
469                                         deferred.reject();
470                                 }
471                         } );
472         }
473         return deferred.promise();
477  * Abort the currently pending request, if any.
479  * @private
480  */
481 OO.ui.mixin.RequestManager.prototype.abortRequest = function () {
482         var oldRequest = this.requestRequest;
483         if ( oldRequest ) {
484                 // First unset this.requestRequest to the fail handler will notice
485                 // that the request is no longer current
486                 this.requestRequest = null;
487                 this.requestQuery = null;
488                 oldRequest.abort();
489         }
493  * Get the query to be made.
495  * @protected
496  * @method
497  * @abstract
498  * @return {string} query to be used
499  */
500 OO.ui.mixin.RequestManager.prototype.getRequestQuery = null;
503  * Get a new request object of the current query value.
505  * @protected
506  * @method
507  * @abstract
508  * @return {jQuery.Promise} jQuery AJAX object, or promise object with an .abort() method
509  */
510 OO.ui.mixin.RequestManager.prototype.getRequest = null;
513  * Pre-process data returned by the request from #getRequest.
515  * The return value of this function will be cached, and any further queries for the given value
516  * will use the cache rather than doing API requests.
518  * @protected
519  * @method
520  * @abstract
521  * @param {Mixed} response Response from server
522  * @return {Mixed} Cached result data
523  */
524 OO.ui.mixin.RequestManager.prototype.getRequestCacheDataFromResponse = null;
527  * LookupElement is a mixin that creates a {@link OO.ui.FloatingMenuSelectWidget menu} of suggested values for
528  * a {@link OO.ui.TextInputWidget text input widget}. Suggested values are based on the characters the user types
529  * into the text input field and, in general, the menu is only displayed when the user types. If a suggested value is chosen
530  * from the lookup menu, that value becomes the value of the input field.
532  * Note that a new menu of suggested items is displayed when a value is chosen from the lookup menu. If this is
533  * not the desired behavior, disable lookup menus with the #setLookupsDisabled method, then set the value, then
534  * re-enable lookups.
536  * See the [OOjs UI demos][1] for an example.
538  * [1]: https://tools.wmflabs.org/oojs-ui/oojs-ui/demos/index.html#widgets-apex-vector-ltr
540  * @class
541  * @abstract
542  * @mixins OO.ui.mixin.RequestManager
544  * @constructor
545  * @param {Object} [config] Configuration options
546  * @cfg {jQuery} [$overlay] Overlay for the lookup menu; defaults to relative positioning
547  * @cfg {jQuery} [$container=this.$element] The container element. The lookup menu is rendered beneath the specified element.
548  * @cfg {boolean} [allowSuggestionsWhenEmpty=false] Request and display a lookup menu when the text input is empty.
549  *  By default, the lookup menu is not generated and displayed until the user begins to type.
550  * @cfg {boolean} [highlightFirst=true] Whether the first lookup result should be highlighted (so, that the user can
551  *  take it over into the input with simply pressing return) automatically or not.
552  */
553 OO.ui.mixin.LookupElement = function OoUiMixinLookupElement( config ) {
554         // Configuration initialization
555         config = $.extend( { highlightFirst: true }, config );
557         // Mixin constructors
558         OO.ui.mixin.RequestManager.call( this, config );
560         // Properties
561         this.$overlay = config.$overlay || this.$element;
562         this.lookupMenu = new OO.ui.FloatingMenuSelectWidget( {
563                 widget: this,
564                 input: this,
565                 $container: config.$container || this.$element
566         } );
568         this.allowSuggestionsWhenEmpty = config.allowSuggestionsWhenEmpty || false;
570         this.lookupsDisabled = false;
571         this.lookupInputFocused = false;
572         this.lookupHighlightFirstItem = config.highlightFirst;
574         // Events
575         this.$input.on( {
576                 focus: this.onLookupInputFocus.bind( this ),
577                 blur: this.onLookupInputBlur.bind( this ),
578                 mousedown: this.onLookupInputMouseDown.bind( this )
579         } );
580         this.connect( this, { change: 'onLookupInputChange' } );
581         this.lookupMenu.connect( this, {
582                 toggle: 'onLookupMenuToggle',
583                 choose: 'onLookupMenuItemChoose'
584         } );
586         // Initialization
587         this.$element.addClass( 'oo-ui-lookupElement' );
588         this.lookupMenu.$element.addClass( 'oo-ui-lookupElement-menu' );
589         this.$overlay.append( this.lookupMenu.$element );
592 /* Setup */
594 OO.mixinClass( OO.ui.mixin.LookupElement, OO.ui.mixin.RequestManager );
596 /* Methods */
599  * Handle input focus event.
601  * @protected
602  * @param {jQuery.Event} e Input focus event
603  */
604 OO.ui.mixin.LookupElement.prototype.onLookupInputFocus = function () {
605         this.lookupInputFocused = true;
606         this.populateLookupMenu();
610  * Handle input blur event.
612  * @protected
613  * @param {jQuery.Event} e Input blur event
614  */
615 OO.ui.mixin.LookupElement.prototype.onLookupInputBlur = function () {
616         this.closeLookupMenu();
617         this.lookupInputFocused = false;
621  * Handle input mouse down event.
623  * @protected
624  * @param {jQuery.Event} e Input mouse down event
625  */
626 OO.ui.mixin.LookupElement.prototype.onLookupInputMouseDown = function () {
627         // Only open the menu if the input was already focused.
628         // This way we allow the user to open the menu again after closing it with Esc
629         // by clicking in the input. Opening (and populating) the menu when initially
630         // clicking into the input is handled by the focus handler.
631         if ( this.lookupInputFocused && !this.lookupMenu.isVisible() ) {
632                 this.populateLookupMenu();
633         }
637  * Handle input change event.
639  * @protected
640  * @param {string} value New input value
641  */
642 OO.ui.mixin.LookupElement.prototype.onLookupInputChange = function () {
643         if ( this.lookupInputFocused ) {
644                 this.populateLookupMenu();
645         }
649  * Handle the lookup menu being shown/hidden.
651  * @protected
652  * @param {boolean} visible Whether the lookup menu is now visible.
653  */
654 OO.ui.mixin.LookupElement.prototype.onLookupMenuToggle = function ( visible ) {
655         if ( !visible ) {
656                 // When the menu is hidden, abort any active request and clear the menu.
657                 // This has to be done here in addition to closeLookupMenu(), because
658                 // MenuSelectWidget will close itself when the user presses Esc.
659                 this.abortLookupRequest();
660                 this.lookupMenu.clearItems();
661         }
665  * Handle menu item 'choose' event, updating the text input value to the value of the clicked item.
667  * @protected
668  * @param {OO.ui.MenuOptionWidget} item Selected item
669  */
670 OO.ui.mixin.LookupElement.prototype.onLookupMenuItemChoose = function ( item ) {
671         this.setValue( item.getData() );
675  * Get lookup menu.
677  * @private
678  * @return {OO.ui.FloatingMenuSelectWidget}
679  */
680 OO.ui.mixin.LookupElement.prototype.getLookupMenu = function () {
681         return this.lookupMenu;
685  * Disable or re-enable lookups.
687  * When lookups are disabled, calls to #populateLookupMenu will be ignored.
689  * @param {boolean} disabled Disable lookups
690  */
691 OO.ui.mixin.LookupElement.prototype.setLookupsDisabled = function ( disabled ) {
692         this.lookupsDisabled = !!disabled;
696  * Open the menu. If there are no entries in the menu, this does nothing.
698  * @private
699  * @chainable
700  */
701 OO.ui.mixin.LookupElement.prototype.openLookupMenu = function () {
702         if ( !this.lookupMenu.isEmpty() ) {
703                 this.lookupMenu.toggle( true );
704         }
705         return this;
709  * Close the menu, empty it, and abort any pending request.
711  * @private
712  * @chainable
713  */
714 OO.ui.mixin.LookupElement.prototype.closeLookupMenu = function () {
715         this.lookupMenu.toggle( false );
716         this.abortLookupRequest();
717         this.lookupMenu.clearItems();
718         return this;
722  * Request menu items based on the input's current value, and when they arrive,
723  * populate the menu with these items and show the menu.
725  * If lookups have been disabled with #setLookupsDisabled, this function does nothing.
727  * @private
728  * @chainable
729  */
730 OO.ui.mixin.LookupElement.prototype.populateLookupMenu = function () {
731         var widget = this,
732                 value = this.getValue();
734         if ( this.lookupsDisabled || this.isReadOnly() ) {
735                 return;
736         }
738         // If the input is empty, clear the menu, unless suggestions when empty are allowed.
739         if ( !this.allowSuggestionsWhenEmpty && value === '' ) {
740                 this.closeLookupMenu();
741         // Skip population if there is already a request pending for the current value
742         } else if ( value !== this.lookupQuery ) {
743                 this.getLookupMenuItems()
744                         .done( function ( items ) {
745                                 widget.lookupMenu.clearItems();
746                                 if ( items.length ) {
747                                         widget.lookupMenu
748                                                 .addItems( items )
749                                                 .toggle( true );
750                                         widget.initializeLookupMenuSelection();
751                                 } else {
752                                         widget.lookupMenu.toggle( false );
753                                 }
754                         } )
755                         .fail( function () {
756                                 widget.lookupMenu.clearItems();
757                         } );
758         }
760         return this;
764  * Highlight the first selectable item in the menu, if configured.
766  * @private
767  * @chainable
768  */
769 OO.ui.mixin.LookupElement.prototype.initializeLookupMenuSelection = function () {
770         if ( this.lookupHighlightFirstItem && !this.lookupMenu.getSelectedItem() ) {
771                 this.lookupMenu.highlightItem( this.lookupMenu.getFirstSelectableItem() );
772         }
776  * Get lookup menu items for the current query.
778  * @private
779  * @return {jQuery.Promise} Promise object which will be passed menu items as the first argument of
780  *   the done event. If the request was aborted to make way for a subsequent request, this promise
781  *   will not be rejected: it will remain pending forever.
782  */
783 OO.ui.mixin.LookupElement.prototype.getLookupMenuItems = function () {
784         return this.getRequestData().then( function ( data ) {
785                 return this.getLookupMenuOptionsFromData( data );
786         }.bind( this ) );
790  * Abort the currently pending lookup request, if any.
792  * @private
793  */
794 OO.ui.mixin.LookupElement.prototype.abortLookupRequest = function () {
795         this.abortRequest();
799  * Get a new request object of the current lookup query value.
801  * @protected
802  * @method
803  * @abstract
804  * @return {jQuery.Promise} jQuery AJAX object, or promise object with an .abort() method
805  */
806 OO.ui.mixin.LookupElement.prototype.getLookupRequest = null;
809  * Pre-process data returned by the request from #getLookupRequest.
811  * The return value of this function will be cached, and any further queries for the given value
812  * will use the cache rather than doing API requests.
814  * @protected
815  * @method
816  * @abstract
817  * @param {Mixed} response Response from server
818  * @return {Mixed} Cached result data
819  */
820 OO.ui.mixin.LookupElement.prototype.getLookupCacheDataFromResponse = null;
823  * Get a list of menu option widgets from the (possibly cached) data returned by
824  * #getLookupCacheDataFromResponse.
826  * @protected
827  * @method
828  * @abstract
829  * @param {Mixed} data Cached result data, usually an array
830  * @return {OO.ui.MenuOptionWidget[]} Menu items
831  */
832 OO.ui.mixin.LookupElement.prototype.getLookupMenuOptionsFromData = null;
835  * Set the read-only state of the widget.
837  * This will also disable/enable the lookups functionality.
839  * @param {boolean} readOnly Make input read-only
840  * @chainable
841  */
842 OO.ui.mixin.LookupElement.prototype.setReadOnly = function ( readOnly ) {
843         // Parent method
844         // Note: Calling #setReadOnly this way assumes this is mixed into an OO.ui.TextInputWidget
845         OO.ui.TextInputWidget.prototype.setReadOnly.call( this, readOnly );
847         // During construction, #setReadOnly is called before the OO.ui.mixin.LookupElement constructor
848         if ( this.isReadOnly() && this.lookupMenu ) {
849                 this.closeLookupMenu();
850         }
852         return this;
856  * @inheritdoc OO.ui.mixin.RequestManager
857  */
858 OO.ui.mixin.LookupElement.prototype.getRequestQuery = function () {
859         return this.getValue();
863  * @inheritdoc OO.ui.mixin.RequestManager
864  */
865 OO.ui.mixin.LookupElement.prototype.getRequest = function () {
866         return this.getLookupRequest();
870  * @inheritdoc OO.ui.mixin.RequestManager
871  */
872 OO.ui.mixin.LookupElement.prototype.getRequestCacheDataFromResponse = function ( response ) {
873         return this.getLookupCacheDataFromResponse( response );
877  * CardLayouts are used within {@link OO.ui.IndexLayout index layouts} to create cards that users can select and display
878  * from the index's optional {@link OO.ui.TabSelectWidget tab} navigation. Cards are usually not instantiated directly,
879  * rather extended to include the required content and functionality.
881  * Each card must have a unique symbolic name, which is passed to the constructor. In addition, the card's tab
882  * item is customized (with a label) using the #setupTabItem method. See
883  * {@link OO.ui.IndexLayout IndexLayout} for an example.
885  * @class
886  * @extends OO.ui.PanelLayout
888  * @constructor
889  * @param {string} name Unique symbolic name of card
890  * @param {Object} [config] Configuration options
891  * @cfg {jQuery|string|Function|OO.ui.HtmlSnippet} [label] Label for card's tab
892  */
893 OO.ui.CardLayout = function OoUiCardLayout( name, config ) {
894         // Allow passing positional parameters inside the config object
895         if ( OO.isPlainObject( name ) && config === undefined ) {
896                 config = name;
897                 name = config.name;
898         }
900         // Configuration initialization
901         config = $.extend( { scrollable: true }, config );
903         // Parent constructor
904         OO.ui.CardLayout.parent.call( this, config );
906         // Properties
907         this.name = name;
908         this.label = config.label;
909         this.tabItem = null;
910         this.active = false;
912         // Initialization
913         this.$element.addClass( 'oo-ui-cardLayout' );
916 /* Setup */
918 OO.inheritClass( OO.ui.CardLayout, OO.ui.PanelLayout );
920 /* Events */
923  * An 'active' event is emitted when the card becomes active. Cards become active when they are
924  * shown in a index layout that is configured to display only one card at a time.
926  * @event active
927  * @param {boolean} active Card is active
928  */
930 /* Methods */
933  * Get the symbolic name of the card.
935  * @return {string} Symbolic name of card
936  */
937 OO.ui.CardLayout.prototype.getName = function () {
938         return this.name;
942  * Check if card is active.
944  * Cards become active when they are shown in a {@link OO.ui.IndexLayout index layout} that is configured to display
945  * only one card at a time. Additional CSS is applied to the card's tab item to reflect the active state.
947  * @return {boolean} Card is active
948  */
949 OO.ui.CardLayout.prototype.isActive = function () {
950         return this.active;
954  * Get tab item.
956  * The tab item allows users to access the card from the index's tab
957  * navigation. The tab item itself can be customized (with a label, level, etc.) using the #setupTabItem method.
959  * @return {OO.ui.TabOptionWidget|null} Tab option widget
960  */
961 OO.ui.CardLayout.prototype.getTabItem = function () {
962         return this.tabItem;
966  * Set or unset the tab item.
968  * Specify a {@link OO.ui.TabOptionWidget tab option} to set it,
969  * or `null` to clear the tab item. To customize the tab item itself (e.g., to set a label or tab
970  * level), use #setupTabItem instead of this method.
972  * @param {OO.ui.TabOptionWidget|null} tabItem Tab option widget, null to clear
973  * @chainable
974  */
975 OO.ui.CardLayout.prototype.setTabItem = function ( tabItem ) {
976         this.tabItem = tabItem || null;
977         if ( tabItem ) {
978                 this.setupTabItem();
979         }
980         return this;
984  * Set up the tab item.
986  * Use this method to customize the tab item (e.g., to add a label or tab level). To set or unset
987  * the tab item itself (with a {@link OO.ui.TabOptionWidget tab option} or `null`), use
988  * the #setTabItem method instead.
990  * @param {OO.ui.TabOptionWidget} tabItem Tab option widget to set up
991  * @chainable
992  */
993 OO.ui.CardLayout.prototype.setupTabItem = function () {
994         if ( this.label ) {
995                 this.tabItem.setLabel( this.label );
996         }
997         return this;
1001  * Set the card to its 'active' state.
1003  * Cards become active when they are shown in a index layout that is configured to display only one card at a time. Additional
1004  * CSS is applied to the tab item to reflect the card's active state. Outside of the index
1005  * context, setting the active state on a card does nothing.
1007  * @param {boolean} active Card is active
1008  * @fires active
1009  */
1010 OO.ui.CardLayout.prototype.setActive = function ( active ) {
1011         active = !!active;
1013         if ( active !== this.active ) {
1014                 this.active = active;
1015                 this.$element.toggleClass( 'oo-ui-cardLayout-active', this.active );
1016                 this.emit( 'active', this.active );
1017         }
1021  * PageLayouts are used within {@link OO.ui.BookletLayout booklet layouts} to create pages that users can select and display
1022  * from the booklet's optional {@link OO.ui.OutlineSelectWidget outline} navigation. Pages are usually not instantiated directly,
1023  * rather extended to include the required content and functionality.
1025  * Each page must have a unique symbolic name, which is passed to the constructor. In addition, the page's outline
1026  * item is customized (with a label, outline level, etc.) using the #setupOutlineItem method. See
1027  * {@link OO.ui.BookletLayout BookletLayout} for an example.
1029  * @class
1030  * @extends OO.ui.PanelLayout
1032  * @constructor
1033  * @param {string} name Unique symbolic name of page
1034  * @param {Object} [config] Configuration options
1035  */
1036 OO.ui.PageLayout = function OoUiPageLayout( name, config ) {
1037         // Allow passing positional parameters inside the config object
1038         if ( OO.isPlainObject( name ) && config === undefined ) {
1039                 config = name;
1040                 name = config.name;
1041         }
1043         // Configuration initialization
1044         config = $.extend( { scrollable: true }, config );
1046         // Parent constructor
1047         OO.ui.PageLayout.parent.call( this, config );
1049         // Properties
1050         this.name = name;
1051         this.outlineItem = null;
1052         this.active = false;
1054         // Initialization
1055         this.$element.addClass( 'oo-ui-pageLayout' );
1058 /* Setup */
1060 OO.inheritClass( OO.ui.PageLayout, OO.ui.PanelLayout );
1062 /* Events */
1065  * An 'active' event is emitted when the page becomes active. Pages become active when they are
1066  * shown in a booklet layout that is configured to display only one page at a time.
1068  * @event active
1069  * @param {boolean} active Page is active
1070  */
1072 /* Methods */
1075  * Get the symbolic name of the page.
1077  * @return {string} Symbolic name of page
1078  */
1079 OO.ui.PageLayout.prototype.getName = function () {
1080         return this.name;
1084  * Check if page is active.
1086  * Pages become active when they are shown in a {@link OO.ui.BookletLayout booklet layout} that is configured to display
1087  * only one page at a time. Additional CSS is applied to the page's outline item to reflect the active state.
1089  * @return {boolean} Page is active
1090  */
1091 OO.ui.PageLayout.prototype.isActive = function () {
1092         return this.active;
1096  * Get outline item.
1098  * The outline item allows users to access the page from the booklet's outline
1099  * navigation. The outline item itself can be customized (with a label, level, etc.) using the #setupOutlineItem method.
1101  * @return {OO.ui.OutlineOptionWidget|null} Outline option widget
1102  */
1103 OO.ui.PageLayout.prototype.getOutlineItem = function () {
1104         return this.outlineItem;
1108  * Set or unset the outline item.
1110  * Specify an {@link OO.ui.OutlineOptionWidget outline option} to set it,
1111  * or `null` to clear the outline item. To customize the outline item itself (e.g., to set a label or outline
1112  * level), use #setupOutlineItem instead of this method.
1114  * @param {OO.ui.OutlineOptionWidget|null} outlineItem Outline option widget, null to clear
1115  * @chainable
1116  */
1117 OO.ui.PageLayout.prototype.setOutlineItem = function ( outlineItem ) {
1118         this.outlineItem = outlineItem || null;
1119         if ( outlineItem ) {
1120                 this.setupOutlineItem();
1121         }
1122         return this;
1126  * Set up the outline item.
1128  * Use this method to customize the outline item (e.g., to add a label or outline level). To set or unset
1129  * the outline item itself (with an {@link OO.ui.OutlineOptionWidget outline option} or `null`), use
1130  * the #setOutlineItem method instead.
1132  * @param {OO.ui.OutlineOptionWidget} outlineItem Outline option widget to set up
1133  * @chainable
1134  */
1135 OO.ui.PageLayout.prototype.setupOutlineItem = function () {
1136         return this;
1140  * Set the page to its 'active' state.
1142  * Pages become active when they are shown in a booklet layout that is configured to display only one page at a time. Additional
1143  * CSS is applied to the outline item to reflect the page's active state. Outside of the booklet
1144  * context, setting the active state on a page does nothing.
1146  * @param {boolean} active Page is active
1147  * @fires active
1148  */
1149 OO.ui.PageLayout.prototype.setActive = function ( active ) {
1150         active = !!active;
1152         if ( active !== this.active ) {
1153                 this.active = active;
1154                 this.$element.toggleClass( 'oo-ui-pageLayout-active', active );
1155                 this.emit( 'active', this.active );
1156         }
1160  * StackLayouts contain a series of {@link OO.ui.PanelLayout panel layouts}. By default, only one panel is displayed
1161  * at a time, though the stack layout can also be configured to show all contained panels, one after another,
1162  * by setting the #continuous option to 'true'.
1164  *     @example
1165  *     // A stack layout with two panels, configured to be displayed continously
1166  *     var myStack = new OO.ui.StackLayout( {
1167  *         items: [
1168  *             new OO.ui.PanelLayout( {
1169  *                 $content: $( '<p>Panel One</p>' ),
1170  *                 padded: true,
1171  *                 framed: true
1172  *             } ),
1173  *             new OO.ui.PanelLayout( {
1174  *                 $content: $( '<p>Panel Two</p>' ),
1175  *                 padded: true,
1176  *                 framed: true
1177  *             } )
1178  *         ],
1179  *         continuous: true
1180  *     } );
1181  *     $( 'body' ).append( myStack.$element );
1183  * @class
1184  * @extends OO.ui.PanelLayout
1185  * @mixins OO.ui.mixin.GroupElement
1187  * @constructor
1188  * @param {Object} [config] Configuration options
1189  * @cfg {boolean} [continuous=false] Show all panels, one after another. By default, only one panel is displayed at a time.
1190  * @cfg {OO.ui.Layout[]} [items] Panel layouts to add to the stack layout.
1191  */
1192 OO.ui.StackLayout = function OoUiStackLayout( config ) {
1193         // Configuration initialization
1194         config = $.extend( { scrollable: true }, config );
1196         // Parent constructor
1197         OO.ui.StackLayout.parent.call( this, config );
1199         // Mixin constructors
1200         OO.ui.mixin.GroupElement.call( this, $.extend( {}, config, { $group: this.$element } ) );
1202         // Properties
1203         this.currentItem = null;
1204         this.continuous = !!config.continuous;
1206         // Initialization
1207         this.$element.addClass( 'oo-ui-stackLayout' );
1208         if ( this.continuous ) {
1209                 this.$element.addClass( 'oo-ui-stackLayout-continuous' );
1210                 this.$element.on( 'scroll', OO.ui.debounce( this.onScroll.bind( this ), 250 ) );
1211         }
1212         if ( Array.isArray( config.items ) ) {
1213                 this.addItems( config.items );
1214         }
1217 /* Setup */
1219 OO.inheritClass( OO.ui.StackLayout, OO.ui.PanelLayout );
1220 OO.mixinClass( OO.ui.StackLayout, OO.ui.mixin.GroupElement );
1222 /* Events */
1225  * A 'set' event is emitted when panels are {@link #addItems added}, {@link #removeItems removed},
1226  * {@link #clearItems cleared} or {@link #setItem displayed}.
1228  * @event set
1229  * @param {OO.ui.Layout|null} item Current panel or `null` if no panel is shown
1230  */
1233  * When used in continuous mode, this event is emitted when the user scrolls down
1234  * far enough such that currentItem is no longer visible.
1236  * @event visibleItemChange
1237  * @param {OO.ui.PanelLayout} panel The next visible item in the layout
1238  */
1240 /* Methods */
1243  * Handle scroll events from the layout element
1245  * @param {jQuery.Event} e
1246  * @fires visibleItemChange
1247  */
1248 OO.ui.StackLayout.prototype.onScroll = function () {
1249         var currentRect,
1250                 len = this.items.length,
1251                 currentIndex = this.items.indexOf( this.currentItem ),
1252                 newIndex = currentIndex,
1253                 containerRect = this.$element[ 0 ].getBoundingClientRect();
1255         if ( !containerRect || ( !containerRect.top && !containerRect.bottom ) ) {
1256                 // Can't get bounding rect, possibly not attached.
1257                 return;
1258         }
1260         function getRect( item ) {
1261                 return item.$element[ 0 ].getBoundingClientRect();
1262         }
1264         function isVisible( item ) {
1265                 var rect = getRect( item );
1266                 return rect.bottom > containerRect.top && rect.top < containerRect.bottom;
1267         }
1269         currentRect = getRect( this.currentItem );
1271         if ( currentRect.bottom < containerRect.top ) {
1272                 // Scrolled down past current item
1273                 while ( ++newIndex < len ) {
1274                         if ( isVisible( this.items[ newIndex ] ) ) {
1275                                 break;
1276                         }
1277                 }
1278         } else if ( currentRect.top > containerRect.bottom ) {
1279                 // Scrolled up past current item
1280                 while ( --newIndex >= 0 ) {
1281                         if ( isVisible( this.items[ newIndex ] ) ) {
1282                                 break;
1283                         }
1284                 }
1285         }
1287         if ( newIndex !== currentIndex ) {
1288                 this.emit( 'visibleItemChange', this.items[ newIndex ] );
1289         }
1293  * Get the current panel.
1295  * @return {OO.ui.Layout|null}
1296  */
1297 OO.ui.StackLayout.prototype.getCurrentItem = function () {
1298         return this.currentItem;
1302  * Unset the current item.
1304  * @private
1305  * @param {OO.ui.StackLayout} layout
1306  * @fires set
1307  */
1308 OO.ui.StackLayout.prototype.unsetCurrentItem = function () {
1309         var prevItem = this.currentItem;
1310         if ( prevItem === null ) {
1311                 return;
1312         }
1314         this.currentItem = null;
1315         this.emit( 'set', null );
1319  * Add panel layouts to the stack layout.
1321  * Panels will be added to the end of the stack layout array unless the optional index parameter specifies a different
1322  * insertion point. Adding a panel that is already in the stack will move it to the end of the array or the point specified
1323  * by the index.
1325  * @param {OO.ui.Layout[]} items Panels to add
1326  * @param {number} [index] Index of the insertion point
1327  * @chainable
1328  */
1329 OO.ui.StackLayout.prototype.addItems = function ( items, index ) {
1330         // Update the visibility
1331         this.updateHiddenState( items, this.currentItem );
1333         // Mixin method
1334         OO.ui.mixin.GroupElement.prototype.addItems.call( this, items, index );
1336         if ( !this.currentItem && items.length ) {
1337                 this.setItem( items[ 0 ] );
1338         }
1340         return this;
1344  * Remove the specified panels from the stack layout.
1346  * Removed panels are detached from the DOM, not removed, so that they may be reused. To remove all panels,
1347  * you may wish to use the #clearItems method instead.
1349  * @param {OO.ui.Layout[]} items Panels to remove
1350  * @chainable
1351  * @fires set
1352  */
1353 OO.ui.StackLayout.prototype.removeItems = function ( items ) {
1354         // Mixin method
1355         OO.ui.mixin.GroupElement.prototype.removeItems.call( this, items );
1357         if ( items.indexOf( this.currentItem ) !== -1 ) {
1358                 if ( this.items.length ) {
1359                         this.setItem( this.items[ 0 ] );
1360                 } else {
1361                         this.unsetCurrentItem();
1362                 }
1363         }
1365         return this;
1369  * Clear all panels from the stack layout.
1371  * Cleared panels are detached from the DOM, not removed, so that they may be reused. To remove only
1372  * a subset of panels, use the #removeItems method.
1374  * @chainable
1375  * @fires set
1376  */
1377 OO.ui.StackLayout.prototype.clearItems = function () {
1378         this.unsetCurrentItem();
1379         OO.ui.mixin.GroupElement.prototype.clearItems.call( this );
1381         return this;
1385  * Show the specified panel.
1387  * If another panel is currently displayed, it will be hidden.
1389  * @param {OO.ui.Layout} item Panel to show
1390  * @chainable
1391  * @fires set
1392  */
1393 OO.ui.StackLayout.prototype.setItem = function ( item ) {
1394         if ( item !== this.currentItem ) {
1395                 this.updateHiddenState( this.items, item );
1397                 if ( this.items.indexOf( item ) !== -1 ) {
1398                         this.currentItem = item;
1399                         this.emit( 'set', item );
1400                 } else {
1401                         this.unsetCurrentItem();
1402                 }
1403         }
1405         return this;
1409  * Update the visibility of all items in case of non-continuous view.
1411  * Ensure all items are hidden except for the selected one.
1412  * This method does nothing when the stack is continuous.
1414  * @private
1415  * @param {OO.ui.Layout[]} items Item list iterate over
1416  * @param {OO.ui.Layout} [selectedItem] Selected item to show
1417  */
1418 OO.ui.StackLayout.prototype.updateHiddenState = function ( items, selectedItem ) {
1419         var i, len;
1421         if ( !this.continuous ) {
1422                 for ( i = 0, len = items.length; i < len; i++ ) {
1423                         if ( !selectedItem || selectedItem !== items[ i ] ) {
1424                                 items[ i ].$element.addClass( 'oo-ui-element-hidden' );
1425                         }
1426                 }
1427                 if ( selectedItem ) {
1428                         selectedItem.$element.removeClass( 'oo-ui-element-hidden' );
1429                 }
1430         }
1434  * MenuLayouts combine a menu and a content {@link OO.ui.PanelLayout panel}. The menu is positioned relative to the content (after, before, top, or bottom)
1435  * and its size is customized with the #menuSize config. The content area will fill all remaining space.
1437  *     @example
1438  *     var menuLayout = new OO.ui.MenuLayout( {
1439  *         position: 'top'
1440  *     } ),
1441  *         menuPanel = new OO.ui.PanelLayout( { padded: true, expanded: true, scrollable: true } ),
1442  *         contentPanel = new OO.ui.PanelLayout( { padded: true, expanded: true, scrollable: true } ),
1443  *         select = new OO.ui.SelectWidget( {
1444  *             items: [
1445  *                 new OO.ui.OptionWidget( {
1446  *                     data: 'before',
1447  *                     label: 'Before',
1448  *                 } ),
1449  *                 new OO.ui.OptionWidget( {
1450  *                     data: 'after',
1451  *                     label: 'After',
1452  *                 } ),
1453  *                 new OO.ui.OptionWidget( {
1454  *                     data: 'top',
1455  *                     label: 'Top',
1456  *                 } ),
1457  *                 new OO.ui.OptionWidget( {
1458  *                     data: 'bottom',
1459  *                     label: 'Bottom',
1460  *                 } )
1461  *              ]
1462  *         } ).on( 'select', function ( item ) {
1463  *            menuLayout.setMenuPosition( item.getData() );
1464  *         } );
1466  *     menuLayout.$menu.append(
1467  *         menuPanel.$element.append( '<b>Menu panel</b>', select.$element )
1468  *     );
1469  *     menuLayout.$content.append(
1470  *         contentPanel.$element.append( '<b>Content panel</b>', '<p>Note that the menu is positioned relative to the content panel: top, bottom, after, before.</p>')
1471  *     );
1472  *     $( 'body' ).append( menuLayout.$element );
1474  * If menu size needs to be overridden, it can be accomplished using CSS similar to the snippet
1475  * below. MenuLayout's CSS will override the appropriate values with 'auto' or '0' to display the
1476  * menu correctly. If `menuPosition` is known beforehand, CSS rules corresponding to other positions
1477  * may be omitted.
1479  *     .oo-ui-menuLayout-menu {
1480  *         height: 200px;
1481  *         width: 200px;
1482  *     }
1483  *     .oo-ui-menuLayout-content {
1484  *         top: 200px;
1485  *         left: 200px;
1486  *         right: 200px;
1487  *         bottom: 200px;
1488  *     }
1490  * @class
1491  * @extends OO.ui.Layout
1493  * @constructor
1494  * @param {Object} [config] Configuration options
1495  * @cfg {boolean} [showMenu=true] Show menu
1496  * @cfg {string} [menuPosition='before'] Position of menu: `top`, `after`, `bottom` or `before`
1497  */
1498 OO.ui.MenuLayout = function OoUiMenuLayout( config ) {
1499         // Configuration initialization
1500         config = $.extend( {
1501                 showMenu: true,
1502                 menuPosition: 'before'
1503         }, config );
1505         // Parent constructor
1506         OO.ui.MenuLayout.parent.call( this, config );
1508         /**
1509          * Menu DOM node
1510          *
1511          * @property {jQuery}
1512          */
1513         this.$menu = $( '<div>' );
1514         /**
1515          * Content DOM node
1516          *
1517          * @property {jQuery}
1518          */
1519         this.$content = $( '<div>' );
1521         // Initialization
1522         this.$menu
1523                 .addClass( 'oo-ui-menuLayout-menu' );
1524         this.$content.addClass( 'oo-ui-menuLayout-content' );
1525         this.$element
1526                 .addClass( 'oo-ui-menuLayout' )
1527                 .append( this.$content, this.$menu );
1528         this.setMenuPosition( config.menuPosition );
1529         this.toggleMenu( config.showMenu );
1532 /* Setup */
1534 OO.inheritClass( OO.ui.MenuLayout, OO.ui.Layout );
1536 /* Methods */
1539  * Toggle menu.
1541  * @param {boolean} showMenu Show menu, omit to toggle
1542  * @chainable
1543  */
1544 OO.ui.MenuLayout.prototype.toggleMenu = function ( showMenu ) {
1545         showMenu = showMenu === undefined ? !this.showMenu : !!showMenu;
1547         if ( this.showMenu !== showMenu ) {
1548                 this.showMenu = showMenu;
1549                 this.$element
1550                         .toggleClass( 'oo-ui-menuLayout-showMenu', this.showMenu )
1551                         .toggleClass( 'oo-ui-menuLayout-hideMenu', !this.showMenu );
1552         }
1554         return this;
1558  * Check if menu is visible
1560  * @return {boolean} Menu is visible
1561  */
1562 OO.ui.MenuLayout.prototype.isMenuVisible = function () {
1563         return this.showMenu;
1567  * Set menu position.
1569  * @param {string} position Position of menu, either `top`, `after`, `bottom` or `before`
1570  * @throws {Error} If position value is not supported
1571  * @chainable
1572  */
1573 OO.ui.MenuLayout.prototype.setMenuPosition = function ( position ) {
1574         this.$element.removeClass( 'oo-ui-menuLayout-' + this.menuPosition );
1575         this.menuPosition = position;
1576         this.$element.addClass( 'oo-ui-menuLayout-' + position );
1578         return this;
1582  * Get menu position.
1584  * @return {string} Menu position
1585  */
1586 OO.ui.MenuLayout.prototype.getMenuPosition = function () {
1587         return this.menuPosition;
1591  * BookletLayouts contain {@link OO.ui.PageLayout page layouts} as well as
1592  * an {@link OO.ui.OutlineSelectWidget outline} that allows users to easily navigate
1593  * through the pages and select which one to display. By default, only one page is
1594  * displayed at a time and the outline is hidden. When a user navigates to a new page,
1595  * the booklet layout automatically focuses on the first focusable element, unless the
1596  * default setting is changed. Optionally, booklets can be configured to show
1597  * {@link OO.ui.OutlineControlsWidget controls} for adding, moving, and removing items.
1599  *     @example
1600  *     // Example of a BookletLayout that contains two PageLayouts.
1602  *     function PageOneLayout( name, config ) {
1603  *         PageOneLayout.parent.call( this, name, config );
1604  *         this.$element.append( '<p>First page</p><p>(This booklet has an outline, displayed on the left)</p>' );
1605  *     }
1606  *     OO.inheritClass( PageOneLayout, OO.ui.PageLayout );
1607  *     PageOneLayout.prototype.setupOutlineItem = function () {
1608  *         this.outlineItem.setLabel( 'Page One' );
1609  *     };
1611  *     function PageTwoLayout( name, config ) {
1612  *         PageTwoLayout.parent.call( this, name, config );
1613  *         this.$element.append( '<p>Second page</p>' );
1614  *     }
1615  *     OO.inheritClass( PageTwoLayout, OO.ui.PageLayout );
1616  *     PageTwoLayout.prototype.setupOutlineItem = function () {
1617  *         this.outlineItem.setLabel( 'Page Two' );
1618  *     };
1620  *     var page1 = new PageOneLayout( 'one' ),
1621  *         page2 = new PageTwoLayout( 'two' );
1623  *     var booklet = new OO.ui.BookletLayout( {
1624  *         outlined: true
1625  *     } );
1627  *     booklet.addPages ( [ page1, page2 ] );
1628  *     $( 'body' ).append( booklet.$element );
1630  * @class
1631  * @extends OO.ui.MenuLayout
1633  * @constructor
1634  * @param {Object} [config] Configuration options
1635  * @cfg {boolean} [continuous=false] Show all pages, one after another
1636  * @cfg {boolean} [autoFocus=true] Focus on the first focusable element when a new page is displayed.
1637  * @cfg {boolean} [outlined=false] Show the outline. The outline is used to navigate through the pages of the booklet.
1638  * @cfg {boolean} [editable=false] Show controls for adding, removing and reordering pages
1639  */
1640 OO.ui.BookletLayout = function OoUiBookletLayout( config ) {
1641         // Configuration initialization
1642         config = config || {};
1644         // Parent constructor
1645         OO.ui.BookletLayout.parent.call( this, config );
1647         // Properties
1648         this.currentPageName = null;
1649         this.pages = {};
1650         this.ignoreFocus = false;
1651         this.stackLayout = new OO.ui.StackLayout( { continuous: !!config.continuous } );
1652         this.$content.append( this.stackLayout.$element );
1653         this.autoFocus = config.autoFocus === undefined || !!config.autoFocus;
1654         this.outlineVisible = false;
1655         this.outlined = !!config.outlined;
1656         if ( this.outlined ) {
1657                 this.editable = !!config.editable;
1658                 this.outlineControlsWidget = null;
1659                 this.outlineSelectWidget = new OO.ui.OutlineSelectWidget();
1660                 this.outlinePanel = new OO.ui.PanelLayout( { scrollable: true } );
1661                 this.$menu.append( this.outlinePanel.$element );
1662                 this.outlineVisible = true;
1663                 if ( this.editable ) {
1664                         this.outlineControlsWidget = new OO.ui.OutlineControlsWidget(
1665                                 this.outlineSelectWidget
1666                         );
1667                 }
1668         }
1669         this.toggleMenu( this.outlined );
1671         // Events
1672         this.stackLayout.connect( this, { set: 'onStackLayoutSet' } );
1673         if ( this.outlined ) {
1674                 this.outlineSelectWidget.connect( this, { select: 'onOutlineSelectWidgetSelect' } );
1675                 this.scrolling = false;
1676                 this.stackLayout.connect( this, { visibleItemChange: 'onStackLayoutVisibleItemChange' } );
1677         }
1678         if ( this.autoFocus ) {
1679                 // Event 'focus' does not bubble, but 'focusin' does
1680                 this.stackLayout.$element.on( 'focusin', this.onStackLayoutFocus.bind( this ) );
1681         }
1683         // Initialization
1684         this.$element.addClass( 'oo-ui-bookletLayout' );
1685         this.stackLayout.$element.addClass( 'oo-ui-bookletLayout-stackLayout' );
1686         if ( this.outlined ) {
1687                 this.outlinePanel.$element
1688                         .addClass( 'oo-ui-bookletLayout-outlinePanel' )
1689                         .append( this.outlineSelectWidget.$element );
1690                 if ( this.editable ) {
1691                         this.outlinePanel.$element
1692                                 .addClass( 'oo-ui-bookletLayout-outlinePanel-editable' )
1693                                 .append( this.outlineControlsWidget.$element );
1694                 }
1695         }
1698 /* Setup */
1700 OO.inheritClass( OO.ui.BookletLayout, OO.ui.MenuLayout );
1702 /* Events */
1705  * A 'set' event is emitted when a page is {@link #setPage set} to be displayed by the booklet layout.
1706  * @event set
1707  * @param {OO.ui.PageLayout} page Current page
1708  */
1711  * An 'add' event is emitted when pages are {@link #addPages added} to the booklet layout.
1713  * @event add
1714  * @param {OO.ui.PageLayout[]} page Added pages
1715  * @param {number} index Index pages were added at
1716  */
1719  * A 'remove' event is emitted when pages are {@link #clearPages cleared} or
1720  * {@link #removePages removed} from the booklet.
1722  * @event remove
1723  * @param {OO.ui.PageLayout[]} pages Removed pages
1724  */
1726 /* Methods */
1729  * Handle stack layout focus.
1731  * @private
1732  * @param {jQuery.Event} e Focusin event
1733  */
1734 OO.ui.BookletLayout.prototype.onStackLayoutFocus = function ( e ) {
1735         var name, $target;
1737         // Find the page that an element was focused within
1738         $target = $( e.target ).closest( '.oo-ui-pageLayout' );
1739         for ( name in this.pages ) {
1740                 // Check for page match, exclude current page to find only page changes
1741                 if ( this.pages[ name ].$element[ 0 ] === $target[ 0 ] && name !== this.currentPageName ) {
1742                         this.setPage( name );
1743                         break;
1744                 }
1745         }
1749  * Handle visibleItemChange events from the stackLayout
1751  * The next visible page is set as the current page by selecting it
1752  * in the outline
1754  * @param {OO.ui.PageLayout} page The next visible page in the layout
1755  */
1756 OO.ui.BookletLayout.prototype.onStackLayoutVisibleItemChange = function ( page ) {
1757         // Set a flag to so that the resulting call to #onStackLayoutSet doesn't
1758         // try and scroll the item into view again.
1759         this.scrolling = true;
1760         this.outlineSelectWidget.selectItemByData( page.getName() );
1761         this.scrolling = false;
1765  * Handle stack layout set events.
1767  * @private
1768  * @param {OO.ui.PanelLayout|null} page The page panel that is now the current panel
1769  */
1770 OO.ui.BookletLayout.prototype.onStackLayoutSet = function ( page ) {
1771         var layout = this;
1772         if ( !this.scrolling && page ) {
1773                 page.scrollElementIntoView( { complete: function () {
1774                         if ( layout.autoFocus ) {
1775                                 layout.focus();
1776                         }
1777                 } } );
1778         }
1782  * Focus the first input in the current page.
1784  * If no page is selected, the first selectable page will be selected.
1785  * If the focus is already in an element on the current page, nothing will happen.
1787  * @param {number} [itemIndex] A specific item to focus on
1788  */
1789 OO.ui.BookletLayout.prototype.focus = function ( itemIndex ) {
1790         var page,
1791                 items = this.stackLayout.getItems();
1793         if ( itemIndex !== undefined && items[ itemIndex ] ) {
1794                 page = items[ itemIndex ];
1795         } else {
1796                 page = this.stackLayout.getCurrentItem();
1797         }
1799         if ( !page && this.outlined ) {
1800                 this.selectFirstSelectablePage();
1801                 page = this.stackLayout.getCurrentItem();
1802         }
1803         if ( !page ) {
1804                 return;
1805         }
1806         // Only change the focus if is not already in the current page
1807         if ( !OO.ui.contains( page.$element[ 0 ], this.getElementDocument().activeElement, true ) ) {
1808                 page.focus();
1809         }
1813  * Find the first focusable input in the booklet layout and focus
1814  * on it.
1815  */
1816 OO.ui.BookletLayout.prototype.focusFirstFocusable = function () {
1817         OO.ui.findFocusable( this.stackLayout.$element ).focus();
1821  * Handle outline widget select events.
1823  * @private
1824  * @param {OO.ui.OptionWidget|null} item Selected item
1825  */
1826 OO.ui.BookletLayout.prototype.onOutlineSelectWidgetSelect = function ( item ) {
1827         if ( item ) {
1828                 this.setPage( item.getData() );
1829         }
1833  * Check if booklet has an outline.
1835  * @return {boolean} Booklet has an outline
1836  */
1837 OO.ui.BookletLayout.prototype.isOutlined = function () {
1838         return this.outlined;
1842  * Check if booklet has editing controls.
1844  * @return {boolean} Booklet is editable
1845  */
1846 OO.ui.BookletLayout.prototype.isEditable = function () {
1847         return this.editable;
1851  * Check if booklet has a visible outline.
1853  * @return {boolean} Outline is visible
1854  */
1855 OO.ui.BookletLayout.prototype.isOutlineVisible = function () {
1856         return this.outlined && this.outlineVisible;
1860  * Hide or show the outline.
1862  * @param {boolean} [show] Show outline, omit to invert current state
1863  * @chainable
1864  */
1865 OO.ui.BookletLayout.prototype.toggleOutline = function ( show ) {
1866         if ( this.outlined ) {
1867                 show = show === undefined ? !this.outlineVisible : !!show;
1868                 this.outlineVisible = show;
1869                 this.toggleMenu( show );
1870         }
1872         return this;
1876  * Get the page closest to the specified page.
1878  * @param {OO.ui.PageLayout} page Page to use as a reference point
1879  * @return {OO.ui.PageLayout|null} Page closest to the specified page
1880  */
1881 OO.ui.BookletLayout.prototype.getClosestPage = function ( page ) {
1882         var next, prev, level,
1883                 pages = this.stackLayout.getItems(),
1884                 index = pages.indexOf( page );
1886         if ( index !== -1 ) {
1887                 next = pages[ index + 1 ];
1888                 prev = pages[ index - 1 ];
1889                 // Prefer adjacent pages at the same level
1890                 if ( this.outlined ) {
1891                         level = this.outlineSelectWidget.getItemFromData( page.getName() ).getLevel();
1892                         if (
1893                                 prev &&
1894                                 level === this.outlineSelectWidget.getItemFromData( prev.getName() ).getLevel()
1895                         ) {
1896                                 return prev;
1897                         }
1898                         if (
1899                                 next &&
1900                                 level === this.outlineSelectWidget.getItemFromData( next.getName() ).getLevel()
1901                         ) {
1902                                 return next;
1903                         }
1904                 }
1905         }
1906         return prev || next || null;
1910  * Get the outline widget.
1912  * If the booklet is not outlined, the method will return `null`.
1914  * @return {OO.ui.OutlineSelectWidget|null} Outline widget, or null if the booklet is not outlined
1915  */
1916 OO.ui.BookletLayout.prototype.getOutline = function () {
1917         return this.outlineSelectWidget;
1921  * Get the outline controls widget.
1923  * If the outline is not editable, the method will return `null`.
1925  * @return {OO.ui.OutlineControlsWidget|null} The outline controls widget.
1926  */
1927 OO.ui.BookletLayout.prototype.getOutlineControls = function () {
1928         return this.outlineControlsWidget;
1932  * Get a page by its symbolic name.
1934  * @param {string} name Symbolic name of page
1935  * @return {OO.ui.PageLayout|undefined} Page, if found
1936  */
1937 OO.ui.BookletLayout.prototype.getPage = function ( name ) {
1938         return this.pages[ name ];
1942  * Get the current page.
1944  * @return {OO.ui.PageLayout|undefined} Current page, if found
1945  */
1946 OO.ui.BookletLayout.prototype.getCurrentPage = function () {
1947         var name = this.getCurrentPageName();
1948         return name ? this.getPage( name ) : undefined;
1952  * Get the symbolic name of the current page.
1954  * @return {string|null} Symbolic name of the current page
1955  */
1956 OO.ui.BookletLayout.prototype.getCurrentPageName = function () {
1957         return this.currentPageName;
1961  * Add pages to the booklet layout
1963  * When pages are added with the same names as existing pages, the existing pages will be
1964  * automatically removed before the new pages are added.
1966  * @param {OO.ui.PageLayout[]} pages Pages to add
1967  * @param {number} index Index of the insertion point
1968  * @fires add
1969  * @chainable
1970  */
1971 OO.ui.BookletLayout.prototype.addPages = function ( pages, index ) {
1972         var i, len, name, page, item, currentIndex,
1973                 stackLayoutPages = this.stackLayout.getItems(),
1974                 remove = [],
1975                 items = [];
1977         // Remove pages with same names
1978         for ( i = 0, len = pages.length; i < len; i++ ) {
1979                 page = pages[ i ];
1980                 name = page.getName();
1982                 if ( Object.prototype.hasOwnProperty.call( this.pages, name ) ) {
1983                         // Correct the insertion index
1984                         currentIndex = stackLayoutPages.indexOf( this.pages[ name ] );
1985                         if ( currentIndex !== -1 && currentIndex + 1 < index ) {
1986                                 index--;
1987                         }
1988                         remove.push( this.pages[ name ] );
1989                 }
1990         }
1991         if ( remove.length ) {
1992                 this.removePages( remove );
1993         }
1995         // Add new pages
1996         for ( i = 0, len = pages.length; i < len; i++ ) {
1997                 page = pages[ i ];
1998                 name = page.getName();
1999                 this.pages[ page.getName() ] = page;
2000                 if ( this.outlined ) {
2001                         item = new OO.ui.OutlineOptionWidget( { data: name } );
2002                         page.setOutlineItem( item );
2003                         items.push( item );
2004                 }
2005         }
2007         if ( this.outlined && items.length ) {
2008                 this.outlineSelectWidget.addItems( items, index );
2009                 this.selectFirstSelectablePage();
2010         }
2011         this.stackLayout.addItems( pages, index );
2012         this.emit( 'add', pages, index );
2014         return this;
2018  * Remove the specified pages from the booklet layout.
2020  * To remove all pages from the booklet, you may wish to use the #clearPages method instead.
2022  * @param {OO.ui.PageLayout[]} pages An array of pages to remove
2023  * @fires remove
2024  * @chainable
2025  */
2026 OO.ui.BookletLayout.prototype.removePages = function ( pages ) {
2027         var i, len, name, page,
2028                 items = [];
2030         for ( i = 0, len = pages.length; i < len; i++ ) {
2031                 page = pages[ i ];
2032                 name = page.getName();
2033                 delete this.pages[ name ];
2034                 if ( this.outlined ) {
2035                         items.push( this.outlineSelectWidget.getItemFromData( name ) );
2036                         page.setOutlineItem( null );
2037                 }
2038         }
2039         if ( this.outlined && items.length ) {
2040                 this.outlineSelectWidget.removeItems( items );
2041                 this.selectFirstSelectablePage();
2042         }
2043         this.stackLayout.removeItems( pages );
2044         this.emit( 'remove', pages );
2046         return this;
2050  * Clear all pages from the booklet layout.
2052  * To remove only a subset of pages from the booklet, use the #removePages method.
2054  * @fires remove
2055  * @chainable
2056  */
2057 OO.ui.BookletLayout.prototype.clearPages = function () {
2058         var i, len,
2059                 pages = this.stackLayout.getItems();
2061         this.pages = {};
2062         this.currentPageName = null;
2063         if ( this.outlined ) {
2064                 this.outlineSelectWidget.clearItems();
2065                 for ( i = 0, len = pages.length; i < len; i++ ) {
2066                         pages[ i ].setOutlineItem( null );
2067                 }
2068         }
2069         this.stackLayout.clearItems();
2071         this.emit( 'remove', pages );
2073         return this;
2077  * Set the current page by symbolic name.
2079  * @fires set
2080  * @param {string} name Symbolic name of page
2081  */
2082 OO.ui.BookletLayout.prototype.setPage = function ( name ) {
2083         var selectedItem,
2084                 $focused,
2085                 page = this.pages[ name ],
2086                 previousPage = this.currentPageName && this.pages[ this.currentPageName ];
2088         if ( name !== this.currentPageName ) {
2089                 if ( this.outlined ) {
2090                         selectedItem = this.outlineSelectWidget.getSelectedItem();
2091                         if ( selectedItem && selectedItem.getData() !== name ) {
2092                                 this.outlineSelectWidget.selectItemByData( name );
2093                         }
2094                 }
2095                 if ( page ) {
2096                         if ( previousPage ) {
2097                                 previousPage.setActive( false );
2098                                 // Blur anything focused if the next page doesn't have anything focusable.
2099                                 // This is not needed if the next page has something focusable (because once it is focused
2100                                 // this blur happens automatically). If the layout is non-continuous, this check is
2101                                 // meaningless because the next page is not visible yet and thus can't hold focus.
2102                                 if (
2103                                         this.autoFocus &&
2104                                         this.stackLayout.continuous &&
2105                                         OO.ui.findFocusable( page.$element ).length !== 0
2106                                 ) {
2107                                         $focused = previousPage.$element.find( ':focus' );
2108                                         if ( $focused.length ) {
2109                                                 $focused[ 0 ].blur();
2110                                         }
2111                                 }
2112                         }
2113                         this.currentPageName = name;
2114                         page.setActive( true );
2115                         this.stackLayout.setItem( page );
2116                         if ( !this.stackLayout.continuous && previousPage ) {
2117                                 // This should not be necessary, since any inputs on the previous page should have been
2118                                 // blurred when it was hidden, but browsers are not very consistent about this.
2119                                 $focused = previousPage.$element.find( ':focus' );
2120                                 if ( $focused.length ) {
2121                                         $focused[ 0 ].blur();
2122                                 }
2123                         }
2124                         this.emit( 'set', page );
2125                 }
2126         }
2130  * Select the first selectable page.
2132  * @chainable
2133  */
2134 OO.ui.BookletLayout.prototype.selectFirstSelectablePage = function () {
2135         if ( !this.outlineSelectWidget.getSelectedItem() ) {
2136                 this.outlineSelectWidget.selectItem( this.outlineSelectWidget.getFirstSelectableItem() );
2137         }
2139         return this;
2143  * IndexLayouts contain {@link OO.ui.CardLayout card layouts} as well as
2144  * {@link OO.ui.TabSelectWidget tabs} that allow users to easily navigate through the cards and
2145  * select which one to display. By default, only one card is displayed at a time. When a user
2146  * navigates to a new card, the index layout automatically focuses on the first focusable element,
2147  * unless the default setting is changed.
2149  * TODO: This class is similar to BookletLayout, we may want to refactor to reduce duplication
2151  *     @example
2152  *     // Example of a IndexLayout that contains two CardLayouts.
2154  *     function CardOneLayout( name, config ) {
2155  *         CardOneLayout.parent.call( this, name, config );
2156  *         this.$element.append( '<p>First card</p>' );
2157  *     }
2158  *     OO.inheritClass( CardOneLayout, OO.ui.CardLayout );
2159  *     CardOneLayout.prototype.setupTabItem = function () {
2160  *         this.tabItem.setLabel( 'Card one' );
2161  *     };
2163  *     var card1 = new CardOneLayout( 'one' ),
2164  *         card2 = new CardLayout( 'two', { label: 'Card two' } );
2166  *     card2.$element.append( '<p>Second card</p>' );
2168  *     var index = new OO.ui.IndexLayout();
2170  *     index.addCards ( [ card1, card2 ] );
2171  *     $( 'body' ).append( index.$element );
2173  * @class
2174  * @extends OO.ui.MenuLayout
2176  * @constructor
2177  * @param {Object} [config] Configuration options
2178  * @cfg {boolean} [continuous=false] Show all cards, one after another
2179  * @cfg {boolean} [expanded=true] Expand the content panel to fill the entire parent element.
2180  * @cfg {boolean} [autoFocus=true] Focus on the first focusable element when a new card is displayed.
2181  */
2182 OO.ui.IndexLayout = function OoUiIndexLayout( config ) {
2183         // Configuration initialization
2184         config = $.extend( {}, config, { menuPosition: 'top' } );
2186         // Parent constructor
2187         OO.ui.IndexLayout.parent.call( this, config );
2189         // Properties
2190         this.currentCardName = null;
2191         this.cards = {};
2192         this.ignoreFocus = false;
2193         this.stackLayout = new OO.ui.StackLayout( {
2194                 continuous: !!config.continuous,
2195                 expanded: config.expanded
2196         } );
2197         this.$content.append( this.stackLayout.$element );
2198         this.autoFocus = config.autoFocus === undefined || !!config.autoFocus;
2200         this.tabSelectWidget = new OO.ui.TabSelectWidget();
2201         this.tabPanel = new OO.ui.PanelLayout();
2202         this.$menu.append( this.tabPanel.$element );
2204         this.toggleMenu( true );
2206         // Events
2207         this.stackLayout.connect( this, { set: 'onStackLayoutSet' } );
2208         this.tabSelectWidget.connect( this, { select: 'onTabSelectWidgetSelect' } );
2209         if ( this.autoFocus ) {
2210                 // Event 'focus' does not bubble, but 'focusin' does
2211                 this.stackLayout.$element.on( 'focusin', this.onStackLayoutFocus.bind( this ) );
2212         }
2214         // Initialization
2215         this.$element.addClass( 'oo-ui-indexLayout' );
2216         this.stackLayout.$element.addClass( 'oo-ui-indexLayout-stackLayout' );
2217         this.tabPanel.$element
2218                 .addClass( 'oo-ui-indexLayout-tabPanel' )
2219                 .append( this.tabSelectWidget.$element );
2222 /* Setup */
2224 OO.inheritClass( OO.ui.IndexLayout, OO.ui.MenuLayout );
2226 /* Events */
2229  * A 'set' event is emitted when a card is {@link #setCard set} to be displayed by the index layout.
2230  * @event set
2231  * @param {OO.ui.CardLayout} card Current card
2232  */
2235  * An 'add' event is emitted when cards are {@link #addCards added} to the index layout.
2237  * @event add
2238  * @param {OO.ui.CardLayout[]} card Added cards
2239  * @param {number} index Index cards were added at
2240  */
2243  * A 'remove' event is emitted when cards are {@link #clearCards cleared} or
2244  * {@link #removeCards removed} from the index.
2246  * @event remove
2247  * @param {OO.ui.CardLayout[]} cards Removed cards
2248  */
2250 /* Methods */
2253  * Handle stack layout focus.
2255  * @private
2256  * @param {jQuery.Event} e Focusin event
2257  */
2258 OO.ui.IndexLayout.prototype.onStackLayoutFocus = function ( e ) {
2259         var name, $target;
2261         // Find the card that an element was focused within
2262         $target = $( e.target ).closest( '.oo-ui-cardLayout' );
2263         for ( name in this.cards ) {
2264                 // Check for card match, exclude current card to find only card changes
2265                 if ( this.cards[ name ].$element[ 0 ] === $target[ 0 ] && name !== this.currentCardName ) {
2266                         this.setCard( name );
2267                         break;
2268                 }
2269         }
2273  * Handle stack layout set events.
2275  * @private
2276  * @param {OO.ui.PanelLayout|null} card The card panel that is now the current panel
2277  */
2278 OO.ui.IndexLayout.prototype.onStackLayoutSet = function ( card ) {
2279         var layout = this;
2280         if ( card ) {
2281                 card.scrollElementIntoView( { complete: function () {
2282                         if ( layout.autoFocus ) {
2283                                 layout.focus();
2284                         }
2285                 } } );
2286         }
2290  * Focus the first input in the current card.
2292  * If no card is selected, the first selectable card will be selected.
2293  * If the focus is already in an element on the current card, nothing will happen.
2295  * @param {number} [itemIndex] A specific item to focus on
2296  */
2297 OO.ui.IndexLayout.prototype.focus = function ( itemIndex ) {
2298         var card,
2299                 items = this.stackLayout.getItems();
2301         if ( itemIndex !== undefined && items[ itemIndex ] ) {
2302                 card = items[ itemIndex ];
2303         } else {
2304                 card = this.stackLayout.getCurrentItem();
2305         }
2307         if ( !card ) {
2308                 this.selectFirstSelectableCard();
2309                 card = this.stackLayout.getCurrentItem();
2310         }
2311         if ( !card ) {
2312                 return;
2313         }
2314         // Only change the focus if is not already in the current page
2315         if ( !OO.ui.contains( card.$element[ 0 ], this.getElementDocument().activeElement, true ) ) {
2316                 card.focus();
2317         }
2321  * Find the first focusable input in the index layout and focus
2322  * on it.
2323  */
2324 OO.ui.IndexLayout.prototype.focusFirstFocusable = function () {
2325         OO.ui.findFocusable( this.stackLayout.$element ).focus();
2329  * Handle tab widget select events.
2331  * @private
2332  * @param {OO.ui.OptionWidget|null} item Selected item
2333  */
2334 OO.ui.IndexLayout.prototype.onTabSelectWidgetSelect = function ( item ) {
2335         if ( item ) {
2336                 this.setCard( item.getData() );
2337         }
2341  * Get the card closest to the specified card.
2343  * @param {OO.ui.CardLayout} card Card to use as a reference point
2344  * @return {OO.ui.CardLayout|null} Card closest to the specified card
2345  */
2346 OO.ui.IndexLayout.prototype.getClosestCard = function ( card ) {
2347         var next, prev, level,
2348                 cards = this.stackLayout.getItems(),
2349                 index = cards.indexOf( card );
2351         if ( index !== -1 ) {
2352                 next = cards[ index + 1 ];
2353                 prev = cards[ index - 1 ];
2354                 // Prefer adjacent cards at the same level
2355                 level = this.tabSelectWidget.getItemFromData( card.getName() ).getLevel();
2356                 if (
2357                         prev &&
2358                         level === this.tabSelectWidget.getItemFromData( prev.getName() ).getLevel()
2359                 ) {
2360                         return prev;
2361                 }
2362                 if (
2363                         next &&
2364                         level === this.tabSelectWidget.getItemFromData( next.getName() ).getLevel()
2365                 ) {
2366                         return next;
2367                 }
2368         }
2369         return prev || next || null;
2373  * Get the tabs widget.
2375  * @return {OO.ui.TabSelectWidget} Tabs widget
2376  */
2377 OO.ui.IndexLayout.prototype.getTabs = function () {
2378         return this.tabSelectWidget;
2382  * Get a card by its symbolic name.
2384  * @param {string} name Symbolic name of card
2385  * @return {OO.ui.CardLayout|undefined} Card, if found
2386  */
2387 OO.ui.IndexLayout.prototype.getCard = function ( name ) {
2388         return this.cards[ name ];
2392  * Get the current card.
2394  * @return {OO.ui.CardLayout|undefined} Current card, if found
2395  */
2396 OO.ui.IndexLayout.prototype.getCurrentCard = function () {
2397         var name = this.getCurrentCardName();
2398         return name ? this.getCard( name ) : undefined;
2402  * Get the symbolic name of the current card.
2404  * @return {string|null} Symbolic name of the current card
2405  */
2406 OO.ui.IndexLayout.prototype.getCurrentCardName = function () {
2407         return this.currentCardName;
2411  * Add cards to the index layout
2413  * When cards are added with the same names as existing cards, the existing cards will be
2414  * automatically removed before the new cards are added.
2416  * @param {OO.ui.CardLayout[]} cards Cards to add
2417  * @param {number} index Index of the insertion point
2418  * @fires add
2419  * @chainable
2420  */
2421 OO.ui.IndexLayout.prototype.addCards = function ( cards, index ) {
2422         var i, len, name, card, item, currentIndex,
2423                 stackLayoutCards = this.stackLayout.getItems(),
2424                 remove = [],
2425                 items = [];
2427         // Remove cards with same names
2428         for ( i = 0, len = cards.length; i < len; i++ ) {
2429                 card = cards[ i ];
2430                 name = card.getName();
2432                 if ( Object.prototype.hasOwnProperty.call( this.cards, name ) ) {
2433                         // Correct the insertion index
2434                         currentIndex = stackLayoutCards.indexOf( this.cards[ name ] );
2435                         if ( currentIndex !== -1 && currentIndex + 1 < index ) {
2436                                 index--;
2437                         }
2438                         remove.push( this.cards[ name ] );
2439                 }
2440         }
2441         if ( remove.length ) {
2442                 this.removeCards( remove );
2443         }
2445         // Add new cards
2446         for ( i = 0, len = cards.length; i < len; i++ ) {
2447                 card = cards[ i ];
2448                 name = card.getName();
2449                 this.cards[ card.getName() ] = card;
2450                 item = new OO.ui.TabOptionWidget( { data: name } );
2451                 card.setTabItem( item );
2452                 items.push( item );
2453         }
2455         if ( items.length ) {
2456                 this.tabSelectWidget.addItems( items, index );
2457                 this.selectFirstSelectableCard();
2458         }
2459         this.stackLayout.addItems( cards, index );
2460         this.emit( 'add', cards, index );
2462         return this;
2466  * Remove the specified cards from the index layout.
2468  * To remove all cards from the index, you may wish to use the #clearCards method instead.
2470  * @param {OO.ui.CardLayout[]} cards An array of cards to remove
2471  * @fires remove
2472  * @chainable
2473  */
2474 OO.ui.IndexLayout.prototype.removeCards = function ( cards ) {
2475         var i, len, name, card,
2476                 items = [];
2478         for ( i = 0, len = cards.length; i < len; i++ ) {
2479                 card = cards[ i ];
2480                 name = card.getName();
2481                 delete this.cards[ name ];
2482                 items.push( this.tabSelectWidget.getItemFromData( name ) );
2483                 card.setTabItem( null );
2484         }
2485         if ( items.length ) {
2486                 this.tabSelectWidget.removeItems( items );
2487                 this.selectFirstSelectableCard();
2488         }
2489         this.stackLayout.removeItems( cards );
2490         this.emit( 'remove', cards );
2492         return this;
2496  * Clear all cards from the index layout.
2498  * To remove only a subset of cards from the index, use the #removeCards method.
2500  * @fires remove
2501  * @chainable
2502  */
2503 OO.ui.IndexLayout.prototype.clearCards = function () {
2504         var i, len,
2505                 cards = this.stackLayout.getItems();
2507         this.cards = {};
2508         this.currentCardName = null;
2509         this.tabSelectWidget.clearItems();
2510         for ( i = 0, len = cards.length; i < len; i++ ) {
2511                 cards[ i ].setTabItem( null );
2512         }
2513         this.stackLayout.clearItems();
2515         this.emit( 'remove', cards );
2517         return this;
2521  * Set the current card by symbolic name.
2523  * @fires set
2524  * @param {string} name Symbolic name of card
2525  */
2526 OO.ui.IndexLayout.prototype.setCard = function ( name ) {
2527         var selectedItem,
2528                 $focused,
2529                 card = this.cards[ name ],
2530                 previousCard = this.currentCardName && this.cards[ this.currentCardName ];
2532         if ( name !== this.currentCardName ) {
2533                 selectedItem = this.tabSelectWidget.getSelectedItem();
2534                 if ( selectedItem && selectedItem.getData() !== name ) {
2535                         this.tabSelectWidget.selectItemByData( name );
2536                 }
2537                 if ( card ) {
2538                         if ( previousCard ) {
2539                                 previousCard.setActive( false );
2540                                 // Blur anything focused if the next card doesn't have anything focusable.
2541                                 // This is not needed if the next card has something focusable (because once it is focused
2542                                 // this blur happens automatically). If the layout is non-continuous, this check is
2543                                 // meaningless because the next card is not visible yet and thus can't hold focus.
2544                                 if (
2545                                         this.autoFocus &&
2546                                         this.stackLayout.continuous &&
2547                                         OO.ui.findFocusable( card.$element ).length !== 0
2548                                 ) {
2549                                         $focused = previousCard.$element.find( ':focus' );
2550                                         if ( $focused.length ) {
2551                                                 $focused[ 0 ].blur();
2552                                         }
2553                                 }
2554                         }
2555                         this.currentCardName = name;
2556                         card.setActive( true );
2557                         this.stackLayout.setItem( card );
2558                         if ( !this.stackLayout.continuous && previousCard ) {
2559                                 // This should not be necessary, since any inputs on the previous card should have been
2560                                 // blurred when it was hidden, but browsers are not very consistent about this.
2561                                 $focused = previousCard.$element.find( ':focus' );
2562                                 if ( $focused.length ) {
2563                                         $focused[ 0 ].blur();
2564                                 }
2565                         }
2566                         this.emit( 'set', card );
2567                 }
2568         }
2572  * Select the first selectable card.
2574  * @chainable
2575  */
2576 OO.ui.IndexLayout.prototype.selectFirstSelectableCard = function () {
2577         if ( !this.tabSelectWidget.getSelectedItem() ) {
2578                 this.tabSelectWidget.selectItem( this.tabSelectWidget.getFirstSelectableItem() );
2579         }
2581         return this;
2585  * ToggleWidget implements basic behavior of widgets with an on/off state.
2586  * Please see OO.ui.ToggleButtonWidget and OO.ui.ToggleSwitchWidget for examples.
2588  * @abstract
2589  * @class
2590  * @extends OO.ui.Widget
2592  * @constructor
2593  * @param {Object} [config] Configuration options
2594  * @cfg {boolean} [value=false] The toggle’s initial on/off state.
2595  *  By default, the toggle is in the 'off' state.
2596  */
2597 OO.ui.ToggleWidget = function OoUiToggleWidget( config ) {
2598         // Configuration initialization
2599         config = config || {};
2601         // Parent constructor
2602         OO.ui.ToggleWidget.parent.call( this, config );
2604         // Properties
2605         this.value = null;
2607         // Initialization
2608         this.$element.addClass( 'oo-ui-toggleWidget' );
2609         this.setValue( !!config.value );
2612 /* Setup */
2614 OO.inheritClass( OO.ui.ToggleWidget, OO.ui.Widget );
2616 /* Events */
2619  * @event change
2621  * A change event is emitted when the on/off state of the toggle changes.
2623  * @param {boolean} value Value representing the new state of the toggle
2624  */
2626 /* Methods */
2629  * Get the value representing the toggle’s state.
2631  * @return {boolean} The on/off state of the toggle
2632  */
2633 OO.ui.ToggleWidget.prototype.getValue = function () {
2634         return this.value;
2638  * Set the state of the toggle: `true` for 'on', `false' for 'off'.
2640  * @param {boolean} value The state of the toggle
2641  * @fires change
2642  * @chainable
2643  */
2644 OO.ui.ToggleWidget.prototype.setValue = function ( value ) {
2645         value = !!value;
2646         if ( this.value !== value ) {
2647                 this.value = value;
2648                 this.emit( 'change', value );
2649                 this.$element.toggleClass( 'oo-ui-toggleWidget-on', value );
2650                 this.$element.toggleClass( 'oo-ui-toggleWidget-off', !value );
2651                 this.$element.attr( 'aria-checked', value.toString() );
2652         }
2653         return this;
2657  * ToggleButtons are buttons that have a state (‘on’ or ‘off’) that is represented by a
2658  * Boolean value. Like other {@link OO.ui.ButtonWidget buttons}, toggle buttons can be
2659  * configured with {@link OO.ui.mixin.IconElement icons}, {@link OO.ui.mixin.IndicatorElement indicators},
2660  * {@link OO.ui.mixin.TitledElement titles}, {@link OO.ui.mixin.FlaggedElement styling flags},
2661  * and {@link OO.ui.mixin.LabelElement labels}. Please see
2662  * the [OOjs UI documentation][1] on MediaWiki for more information.
2664  *     @example
2665  *     // Toggle buttons in the 'off' and 'on' state.
2666  *     var toggleButton1 = new OO.ui.ToggleButtonWidget( {
2667  *         label: 'Toggle Button off'
2668  *     } );
2669  *     var toggleButton2 = new OO.ui.ToggleButtonWidget( {
2670  *         label: 'Toggle Button on',
2671  *         value: true
2672  *     } );
2673  *     // Append the buttons to the DOM.
2674  *     $( 'body' ).append( toggleButton1.$element, toggleButton2.$element );
2676  * [1]: https://www.mediawiki.org/wiki/OOjs_UI/Widgets/Buttons_and_Switches#Toggle_buttons
2678  * @class
2679  * @extends OO.ui.ToggleWidget
2680  * @mixins OO.ui.mixin.ButtonElement
2681  * @mixins OO.ui.mixin.IconElement
2682  * @mixins OO.ui.mixin.IndicatorElement
2683  * @mixins OO.ui.mixin.LabelElement
2684  * @mixins OO.ui.mixin.TitledElement
2685  * @mixins OO.ui.mixin.FlaggedElement
2686  * @mixins OO.ui.mixin.TabIndexedElement
2688  * @constructor
2689  * @param {Object} [config] Configuration options
2690  * @cfg {boolean} [value=false] The toggle button’s initial on/off
2691  *  state. By default, the button is in the 'off' state.
2692  */
2693 OO.ui.ToggleButtonWidget = function OoUiToggleButtonWidget( config ) {
2694         // Configuration initialization
2695         config = config || {};
2697         // Parent constructor
2698         OO.ui.ToggleButtonWidget.parent.call( this, config );
2700         // Mixin constructors
2701         OO.ui.mixin.ButtonElement.call( this, config );
2702         OO.ui.mixin.IconElement.call( this, config );
2703         OO.ui.mixin.IndicatorElement.call( this, config );
2704         OO.ui.mixin.LabelElement.call( this, config );
2705         OO.ui.mixin.TitledElement.call( this, $.extend( {}, config, { $titled: this.$button } ) );
2706         OO.ui.mixin.FlaggedElement.call( this, config );
2707         OO.ui.mixin.TabIndexedElement.call( this, $.extend( {}, config, { $tabIndexed: this.$button } ) );
2709         // Events
2710         this.connect( this, { click: 'onAction' } );
2712         // Initialization
2713         this.$button.append( this.$icon, this.$label, this.$indicator );
2714         this.$element
2715                 .addClass( 'oo-ui-toggleButtonWidget' )
2716                 .append( this.$button );
2719 /* Setup */
2721 OO.inheritClass( OO.ui.ToggleButtonWidget, OO.ui.ToggleWidget );
2722 OO.mixinClass( OO.ui.ToggleButtonWidget, OO.ui.mixin.ButtonElement );
2723 OO.mixinClass( OO.ui.ToggleButtonWidget, OO.ui.mixin.IconElement );
2724 OO.mixinClass( OO.ui.ToggleButtonWidget, OO.ui.mixin.IndicatorElement );
2725 OO.mixinClass( OO.ui.ToggleButtonWidget, OO.ui.mixin.LabelElement );
2726 OO.mixinClass( OO.ui.ToggleButtonWidget, OO.ui.mixin.TitledElement );
2727 OO.mixinClass( OO.ui.ToggleButtonWidget, OO.ui.mixin.FlaggedElement );
2728 OO.mixinClass( OO.ui.ToggleButtonWidget, OO.ui.mixin.TabIndexedElement );
2730 /* Methods */
2733  * Handle the button action being triggered.
2735  * @private
2736  */
2737 OO.ui.ToggleButtonWidget.prototype.onAction = function () {
2738         this.setValue( !this.value );
2742  * @inheritdoc
2743  */
2744 OO.ui.ToggleButtonWidget.prototype.setValue = function ( value ) {
2745         value = !!value;
2746         if ( value !== this.value ) {
2747                 // Might be called from parent constructor before ButtonElement constructor
2748                 if ( this.$button ) {
2749                         this.$button.attr( 'aria-pressed', value.toString() );
2750                 }
2751                 this.setActive( value );
2752         }
2754         // Parent method
2755         OO.ui.ToggleButtonWidget.parent.prototype.setValue.call( this, value );
2757         return this;
2761  * @inheritdoc
2762  */
2763 OO.ui.ToggleButtonWidget.prototype.setButtonElement = function ( $button ) {
2764         if ( this.$button ) {
2765                 this.$button.removeAttr( 'aria-pressed' );
2766         }
2767         OO.ui.mixin.ButtonElement.prototype.setButtonElement.call( this, $button );
2768         this.$button.attr( 'aria-pressed', this.value.toString() );
2772  * ToggleSwitches are switches that slide on and off. Their state is represented by a Boolean
2773  * value (`true` for ‘on’, and `false` otherwise, the default). The ‘off’ state is represented
2774  * visually by a slider in the leftmost position.
2776  *     @example
2777  *     // Toggle switches in the 'off' and 'on' position.
2778  *     var toggleSwitch1 = new OO.ui.ToggleSwitchWidget();
2779  *     var toggleSwitch2 = new OO.ui.ToggleSwitchWidget( {
2780  *         value: true
2781  *     } );
2783  *     // Create a FieldsetLayout to layout and label switches
2784  *     var fieldset = new OO.ui.FieldsetLayout( {
2785  *        label: 'Toggle switches'
2786  *     } );
2787  *     fieldset.addItems( [
2788  *         new OO.ui.FieldLayout( toggleSwitch1, { label: 'Off', align: 'top' } ),
2789  *         new OO.ui.FieldLayout( toggleSwitch2, { label: 'On', align: 'top' } )
2790  *     ] );
2791  *     $( 'body' ).append( fieldset.$element );
2793  * @class
2794  * @extends OO.ui.ToggleWidget
2795  * @mixins OO.ui.mixin.TabIndexedElement
2797  * @constructor
2798  * @param {Object} [config] Configuration options
2799  * @cfg {boolean} [value=false] The toggle switch’s initial on/off state.
2800  *  By default, the toggle switch is in the 'off' position.
2801  */
2802 OO.ui.ToggleSwitchWidget = function OoUiToggleSwitchWidget( config ) {
2803         // Parent constructor
2804         OO.ui.ToggleSwitchWidget.parent.call( this, config );
2806         // Mixin constructors
2807         OO.ui.mixin.TabIndexedElement.call( this, config );
2809         // Properties
2810         this.dragging = false;
2811         this.dragStart = null;
2812         this.sliding = false;
2813         this.$glow = $( '<span>' );
2814         this.$grip = $( '<span>' );
2816         // Events
2817         this.$element.on( {
2818                 click: this.onClick.bind( this ),
2819                 keypress: this.onKeyPress.bind( this )
2820         } );
2822         // Initialization
2823         this.$glow.addClass( 'oo-ui-toggleSwitchWidget-glow' );
2824         this.$grip.addClass( 'oo-ui-toggleSwitchWidget-grip' );
2825         this.$element
2826                 .addClass( 'oo-ui-toggleSwitchWidget' )
2827                 .attr( 'role', 'checkbox' )
2828                 .append( this.$glow, this.$grip );
2831 /* Setup */
2833 OO.inheritClass( OO.ui.ToggleSwitchWidget, OO.ui.ToggleWidget );
2834 OO.mixinClass( OO.ui.ToggleSwitchWidget, OO.ui.mixin.TabIndexedElement );
2836 /* Methods */
2839  * Handle mouse click events.
2841  * @private
2842  * @param {jQuery.Event} e Mouse click event
2843  */
2844 OO.ui.ToggleSwitchWidget.prototype.onClick = function ( e ) {
2845         if ( !this.isDisabled() && e.which === OO.ui.MouseButtons.LEFT ) {
2846                 this.setValue( !this.value );
2847         }
2848         return false;
2852  * Handle key press events.
2854  * @private
2855  * @param {jQuery.Event} e Key press event
2856  */
2857 OO.ui.ToggleSwitchWidget.prototype.onKeyPress = function ( e ) {
2858         if ( !this.isDisabled() && ( e.which === OO.ui.Keys.SPACE || e.which === OO.ui.Keys.ENTER ) ) {
2859                 this.setValue( !this.value );
2860                 return false;
2861         }
2865  * OutlineControlsWidget is a set of controls for an {@link OO.ui.OutlineSelectWidget outline select widget}.
2866  * Controls include moving items up and down, removing items, and adding different kinds of items.
2868  * **Currently, this class is only used by {@link OO.ui.BookletLayout booklet layouts}.**
2870  * @class
2871  * @extends OO.ui.Widget
2872  * @mixins OO.ui.mixin.GroupElement
2873  * @mixins OO.ui.mixin.IconElement
2875  * @constructor
2876  * @param {OO.ui.OutlineSelectWidget} outline Outline to control
2877  * @param {Object} [config] Configuration options
2878  * @cfg {Object} [abilities] List of abilties
2879  * @cfg {boolean} [abilities.move=true] Allow moving movable items
2880  * @cfg {boolean} [abilities.remove=true] Allow removing removable items
2881  */
2882 OO.ui.OutlineControlsWidget = function OoUiOutlineControlsWidget( outline, config ) {
2883         // Allow passing positional parameters inside the config object
2884         if ( OO.isPlainObject( outline ) && config === undefined ) {
2885                 config = outline;
2886                 outline = config.outline;
2887         }
2889         // Configuration initialization
2890         config = $.extend( { icon: 'add' }, config );
2892         // Parent constructor
2893         OO.ui.OutlineControlsWidget.parent.call( this, config );
2895         // Mixin constructors
2896         OO.ui.mixin.GroupElement.call( this, config );
2897         OO.ui.mixin.IconElement.call( this, config );
2899         // Properties
2900         this.outline = outline;
2901         this.$movers = $( '<div>' );
2902         this.upButton = new OO.ui.ButtonWidget( {
2903                 framed: false,
2904                 icon: 'collapse',
2905                 title: OO.ui.msg( 'ooui-outline-control-move-up' )
2906         } );
2907         this.downButton = new OO.ui.ButtonWidget( {
2908                 framed: false,
2909                 icon: 'expand',
2910                 title: OO.ui.msg( 'ooui-outline-control-move-down' )
2911         } );
2912         this.removeButton = new OO.ui.ButtonWidget( {
2913                 framed: false,
2914                 icon: 'remove',
2915                 title: OO.ui.msg( 'ooui-outline-control-remove' )
2916         } );
2917         this.abilities = { move: true, remove: true };
2919         // Events
2920         outline.connect( this, {
2921                 select: 'onOutlineChange',
2922                 add: 'onOutlineChange',
2923                 remove: 'onOutlineChange'
2924         } );
2925         this.upButton.connect( this, { click: [ 'emit', 'move', -1 ] } );
2926         this.downButton.connect( this, { click: [ 'emit', 'move', 1 ] } );
2927         this.removeButton.connect( this, { click: [ 'emit', 'remove' ] } );
2929         // Initialization
2930         this.$element.addClass( 'oo-ui-outlineControlsWidget' );
2931         this.$group.addClass( 'oo-ui-outlineControlsWidget-items' );
2932         this.$movers
2933                 .addClass( 'oo-ui-outlineControlsWidget-movers' )
2934                 .append( this.removeButton.$element, this.upButton.$element, this.downButton.$element );
2935         this.$element.append( this.$icon, this.$group, this.$movers );
2936         this.setAbilities( config.abilities || {} );
2939 /* Setup */
2941 OO.inheritClass( OO.ui.OutlineControlsWidget, OO.ui.Widget );
2942 OO.mixinClass( OO.ui.OutlineControlsWidget, OO.ui.mixin.GroupElement );
2943 OO.mixinClass( OO.ui.OutlineControlsWidget, OO.ui.mixin.IconElement );
2945 /* Events */
2948  * @event move
2949  * @param {number} places Number of places to move
2950  */
2953  * @event remove
2954  */
2956 /* Methods */
2959  * Set abilities.
2961  * @param {Object} abilities List of abilties
2962  * @param {boolean} [abilities.move] Allow moving movable items
2963  * @param {boolean} [abilities.remove] Allow removing removable items
2964  */
2965 OO.ui.OutlineControlsWidget.prototype.setAbilities = function ( abilities ) {
2966         var ability;
2968         for ( ability in this.abilities ) {
2969                 if ( abilities[ ability ] !== undefined ) {
2970                         this.abilities[ ability ] = !!abilities[ ability ];
2971                 }
2972         }
2974         this.onOutlineChange();
2978  * Handle outline change events.
2980  * @private
2981  */
2982 OO.ui.OutlineControlsWidget.prototype.onOutlineChange = function () {
2983         var i, len, firstMovable, lastMovable,
2984                 items = this.outline.getItems(),
2985                 selectedItem = this.outline.getSelectedItem(),
2986                 movable = this.abilities.move && selectedItem && selectedItem.isMovable(),
2987                 removable = this.abilities.remove && selectedItem && selectedItem.isRemovable();
2989         if ( movable ) {
2990                 i = -1;
2991                 len = items.length;
2992                 while ( ++i < len ) {
2993                         if ( items[ i ].isMovable() ) {
2994                                 firstMovable = items[ i ];
2995                                 break;
2996                         }
2997                 }
2998                 i = len;
2999                 while ( i-- ) {
3000                         if ( items[ i ].isMovable() ) {
3001                                 lastMovable = items[ i ];
3002                                 break;
3003                         }
3004                 }
3005         }
3006         this.upButton.setDisabled( !movable || selectedItem === firstMovable );
3007         this.downButton.setDisabled( !movable || selectedItem === lastMovable );
3008         this.removeButton.setDisabled( !removable );
3012  * OutlineOptionWidget is an item in an {@link OO.ui.OutlineSelectWidget OutlineSelectWidget}.
3014  * Currently, this class is only used by {@link OO.ui.BookletLayout booklet layouts}, which contain
3015  * {@link OO.ui.PageLayout page layouts}. See {@link OO.ui.BookletLayout BookletLayout}
3016  * for an example.
3018  * @class
3019  * @extends OO.ui.DecoratedOptionWidget
3021  * @constructor
3022  * @param {Object} [config] Configuration options
3023  * @cfg {number} [level] Indentation level
3024  * @cfg {boolean} [movable] Allow modification from {@link OO.ui.OutlineControlsWidget outline controls}.
3025  */
3026 OO.ui.OutlineOptionWidget = function OoUiOutlineOptionWidget( config ) {
3027         // Configuration initialization
3028         config = config || {};
3030         // Parent constructor
3031         OO.ui.OutlineOptionWidget.parent.call( this, config );
3033         // Properties
3034         this.level = 0;
3035         this.movable = !!config.movable;
3036         this.removable = !!config.removable;
3038         // Initialization
3039         this.$element.addClass( 'oo-ui-outlineOptionWidget' );
3040         this.setLevel( config.level );
3043 /* Setup */
3045 OO.inheritClass( OO.ui.OutlineOptionWidget, OO.ui.DecoratedOptionWidget );
3047 /* Static Properties */
3049 OO.ui.OutlineOptionWidget.static.highlightable = false;
3051 OO.ui.OutlineOptionWidget.static.scrollIntoViewOnSelect = true;
3053 OO.ui.OutlineOptionWidget.static.levelClass = 'oo-ui-outlineOptionWidget-level-';
3055 OO.ui.OutlineOptionWidget.static.levels = 3;
3057 /* Methods */
3060  * Check if item is movable.
3062  * Movability is used by {@link OO.ui.OutlineControlsWidget outline controls}.
3064  * @return {boolean} Item is movable
3065  */
3066 OO.ui.OutlineOptionWidget.prototype.isMovable = function () {
3067         return this.movable;
3071  * Check if item is removable.
3073  * Removability is used by {@link OO.ui.OutlineControlsWidget outline controls}.
3075  * @return {boolean} Item is removable
3076  */
3077 OO.ui.OutlineOptionWidget.prototype.isRemovable = function () {
3078         return this.removable;
3082  * Get indentation level.
3084  * @return {number} Indentation level
3085  */
3086 OO.ui.OutlineOptionWidget.prototype.getLevel = function () {
3087         return this.level;
3091  * Set movability.
3093  * Movability is used by {@link OO.ui.OutlineControlsWidget outline controls}.
3095  * @param {boolean} movable Item is movable
3096  * @chainable
3097  */
3098 OO.ui.OutlineOptionWidget.prototype.setMovable = function ( movable ) {
3099         this.movable = !!movable;
3100         this.updateThemeClasses();
3101         return this;
3105  * Set removability.
3107  * Removability is used by {@link OO.ui.OutlineControlsWidget outline controls}.
3109  * @param {boolean} removable Item is removable
3110  * @chainable
3111  */
3112 OO.ui.OutlineOptionWidget.prototype.setRemovable = function ( removable ) {
3113         this.removable = !!removable;
3114         this.updateThemeClasses();
3115         return this;
3119  * Set indentation level.
3121  * @param {number} [level=0] Indentation level, in the range of [0,#maxLevel]
3122  * @chainable
3123  */
3124 OO.ui.OutlineOptionWidget.prototype.setLevel = function ( level ) {
3125         var levels = this.constructor.static.levels,
3126                 levelClass = this.constructor.static.levelClass,
3127                 i = levels;
3129         this.level = level ? Math.max( 0, Math.min( levels - 1, level ) ) : 0;
3130         while ( i-- ) {
3131                 if ( this.level === i ) {
3132                         this.$element.addClass( levelClass + i );
3133                 } else {
3134                         this.$element.removeClass( levelClass + i );
3135                 }
3136         }
3137         this.updateThemeClasses();
3139         return this;
3143  * OutlineSelectWidget is a structured list that contains {@link OO.ui.OutlineOptionWidget outline options}
3144  * A set of controls can be provided with an {@link OO.ui.OutlineControlsWidget outline controls} widget.
3146  * **Currently, this class is only used by {@link OO.ui.BookletLayout booklet layouts}.**
3148  * @class
3149  * @extends OO.ui.SelectWidget
3150  * @mixins OO.ui.mixin.TabIndexedElement
3152  * @constructor
3153  * @param {Object} [config] Configuration options
3154  */
3155 OO.ui.OutlineSelectWidget = function OoUiOutlineSelectWidget( config ) {
3156         // Parent constructor
3157         OO.ui.OutlineSelectWidget.parent.call( this, config );
3159         // Mixin constructors
3160         OO.ui.mixin.TabIndexedElement.call( this, config );
3162         // Events
3163         this.$element.on( {
3164                 focus: this.bindKeyDownListener.bind( this ),
3165                 blur: this.unbindKeyDownListener.bind( this )
3166         } );
3168         // Initialization
3169         this.$element.addClass( 'oo-ui-outlineSelectWidget' );
3172 /* Setup */
3174 OO.inheritClass( OO.ui.OutlineSelectWidget, OO.ui.SelectWidget );
3175 OO.mixinClass( OO.ui.OutlineSelectWidget, OO.ui.mixin.TabIndexedElement );
3178  * ButtonOptionWidget is a special type of {@link OO.ui.mixin.ButtonElement button element} that
3179  * can be selected and configured with data. The class is
3180  * used with OO.ui.ButtonSelectWidget to create a selection of button options. Please see the
3181  * [OOjs UI documentation on MediaWiki] [1] for more information.
3183  * [1]: https://www.mediawiki.org/wiki/OOjs_UI/Widgets/Selects_and_Options#Button_selects_and_options
3185  * @class
3186  * @extends OO.ui.OptionWidget
3187  * @mixins OO.ui.mixin.ButtonElement
3188  * @mixins OO.ui.mixin.IconElement
3189  * @mixins OO.ui.mixin.IndicatorElement
3190  * @mixins OO.ui.mixin.TitledElement
3192  * @constructor
3193  * @param {Object} [config] Configuration options
3194  */
3195 OO.ui.ButtonOptionWidget = function OoUiButtonOptionWidget( config ) {
3196         // Configuration initialization
3197         config = config || {};
3199         // Parent constructor
3200         OO.ui.ButtonOptionWidget.parent.call( this, config );
3202         // Mixin constructors
3203         OO.ui.mixin.ButtonElement.call( this, config );
3204         OO.ui.mixin.IconElement.call( this, config );
3205         OO.ui.mixin.IndicatorElement.call( this, config );
3206         OO.ui.mixin.TitledElement.call( this, $.extend( {}, config, { $titled: this.$button } ) );
3208         // Initialization
3209         this.$element.addClass( 'oo-ui-buttonOptionWidget' );
3210         this.$button.append( this.$icon, this.$label, this.$indicator );
3211         this.$element.append( this.$button );
3214 /* Setup */
3216 OO.inheritClass( OO.ui.ButtonOptionWidget, OO.ui.OptionWidget );
3217 OO.mixinClass( OO.ui.ButtonOptionWidget, OO.ui.mixin.ButtonElement );
3218 OO.mixinClass( OO.ui.ButtonOptionWidget, OO.ui.mixin.IconElement );
3219 OO.mixinClass( OO.ui.ButtonOptionWidget, OO.ui.mixin.IndicatorElement );
3220 OO.mixinClass( OO.ui.ButtonOptionWidget, OO.ui.mixin.TitledElement );
3222 /* Static Properties */
3224 // Allow button mouse down events to pass through so they can be handled by the parent select widget
3225 OO.ui.ButtonOptionWidget.static.cancelButtonMouseDownEvents = false;
3227 OO.ui.ButtonOptionWidget.static.highlightable = false;
3229 /* Methods */
3232  * @inheritdoc
3233  */
3234 OO.ui.ButtonOptionWidget.prototype.setSelected = function ( state ) {
3235         OO.ui.ButtonOptionWidget.parent.prototype.setSelected.call( this, state );
3237         if ( this.constructor.static.selectable ) {
3238                 this.setActive( state );
3239         }
3241         return this;
3245  * ButtonSelectWidget is a {@link OO.ui.SelectWidget select widget} that contains
3246  * button options and is used together with
3247  * OO.ui.ButtonOptionWidget. The ButtonSelectWidget provides an interface for
3248  * highlighting, choosing, and selecting mutually exclusive options. Please see
3249  * the [OOjs UI documentation on MediaWiki] [1] for more information.
3251  *     @example
3252  *     // Example: A ButtonSelectWidget that contains three ButtonOptionWidgets
3253  *     var option1 = new OO.ui.ButtonOptionWidget( {
3254  *         data: 1,
3255  *         label: 'Option 1',
3256  *         title: 'Button option 1'
3257  *     } );
3259  *     var option2 = new OO.ui.ButtonOptionWidget( {
3260  *         data: 2,
3261  *         label: 'Option 2',
3262  *         title: 'Button option 2'
3263  *     } );
3265  *     var option3 = new OO.ui.ButtonOptionWidget( {
3266  *         data: 3,
3267  *         label: 'Option 3',
3268  *         title: 'Button option 3'
3269  *     } );
3271  *     var buttonSelect=new OO.ui.ButtonSelectWidget( {
3272  *         items: [ option1, option2, option3 ]
3273  *     } );
3274  *     $( 'body' ).append( buttonSelect.$element );
3276  * [1]: https://www.mediawiki.org/wiki/OOjs_UI/Widgets/Selects_and_Options
3278  * @class
3279  * @extends OO.ui.SelectWidget
3280  * @mixins OO.ui.mixin.TabIndexedElement
3282  * @constructor
3283  * @param {Object} [config] Configuration options
3284  */
3285 OO.ui.ButtonSelectWidget = function OoUiButtonSelectWidget( config ) {
3286         // Parent constructor
3287         OO.ui.ButtonSelectWidget.parent.call( this, config );
3289         // Mixin constructors
3290         OO.ui.mixin.TabIndexedElement.call( this, config );
3292         // Events
3293         this.$element.on( {
3294                 focus: this.bindKeyDownListener.bind( this ),
3295                 blur: this.unbindKeyDownListener.bind( this )
3296         } );
3298         // Initialization
3299         this.$element.addClass( 'oo-ui-buttonSelectWidget' );
3302 /* Setup */
3304 OO.inheritClass( OO.ui.ButtonSelectWidget, OO.ui.SelectWidget );
3305 OO.mixinClass( OO.ui.ButtonSelectWidget, OO.ui.mixin.TabIndexedElement );
3308  * TabOptionWidget is an item in a {@link OO.ui.TabSelectWidget TabSelectWidget}.
3310  * Currently, this class is only used by {@link OO.ui.IndexLayout index layouts}, which contain
3311  * {@link OO.ui.CardLayout card layouts}. See {@link OO.ui.IndexLayout IndexLayout}
3312  * for an example.
3314  * @class
3315  * @extends OO.ui.OptionWidget
3317  * @constructor
3318  * @param {Object} [config] Configuration options
3319  */
3320 OO.ui.TabOptionWidget = function OoUiTabOptionWidget( config ) {
3321         // Configuration initialization
3322         config = config || {};
3324         // Parent constructor
3325         OO.ui.TabOptionWidget.parent.call( this, config );
3327         // Initialization
3328         this.$element.addClass( 'oo-ui-tabOptionWidget' );
3331 /* Setup */
3333 OO.inheritClass( OO.ui.TabOptionWidget, OO.ui.OptionWidget );
3335 /* Static Properties */
3337 OO.ui.TabOptionWidget.static.highlightable = false;
3340  * TabSelectWidget is a list that contains {@link OO.ui.TabOptionWidget tab options}
3342  * **Currently, this class is only used by {@link OO.ui.IndexLayout index layouts}.**
3344  * @class
3345  * @extends OO.ui.SelectWidget
3346  * @mixins OO.ui.mixin.TabIndexedElement
3348  * @constructor
3349  * @param {Object} [config] Configuration options
3350  */
3351 OO.ui.TabSelectWidget = function OoUiTabSelectWidget( config ) {
3352         // Parent constructor
3353         OO.ui.TabSelectWidget.parent.call( this, config );
3355         // Mixin constructors
3356         OO.ui.mixin.TabIndexedElement.call( this, config );
3358         // Events
3359         this.$element.on( {
3360                 focus: this.bindKeyDownListener.bind( this ),
3361                 blur: this.unbindKeyDownListener.bind( this )
3362         } );
3364         // Initialization
3365         this.$element.addClass( 'oo-ui-tabSelectWidget' );
3368 /* Setup */
3370 OO.inheritClass( OO.ui.TabSelectWidget, OO.ui.SelectWidget );
3371 OO.mixinClass( OO.ui.TabSelectWidget, OO.ui.mixin.TabIndexedElement );
3374  * CapsuleItemWidgets are used within a {@link OO.ui.CapsuleMultiselectWidget
3375  * CapsuleMultiselectWidget} to display the selected items.
3377  * @class
3378  * @extends OO.ui.Widget
3379  * @mixins OO.ui.mixin.ItemWidget
3380  * @mixins OO.ui.mixin.LabelElement
3381  * @mixins OO.ui.mixin.FlaggedElement
3382  * @mixins OO.ui.mixin.TabIndexedElement
3384  * @constructor
3385  * @param {Object} [config] Configuration options
3386  */
3387 OO.ui.CapsuleItemWidget = function OoUiCapsuleItemWidget( config ) {
3388         // Configuration initialization
3389         config = config || {};
3391         // Parent constructor
3392         OO.ui.CapsuleItemWidget.parent.call( this, config );
3394         // Mixin constructors
3395         OO.ui.mixin.ItemWidget.call( this );
3396         OO.ui.mixin.LabelElement.call( this, config );
3397         OO.ui.mixin.FlaggedElement.call( this, config );
3398         OO.ui.mixin.TabIndexedElement.call( this, config );
3400         // Events
3401         this.closeButton = new OO.ui.ButtonWidget( {
3402                 framed: false,
3403                 indicator: 'clear',
3404                 tabIndex: -1
3405         } ).on( 'click', this.onCloseClick.bind( this ) );
3407         this.on( 'disable', function ( disabled ) {
3408                 this.closeButton.setDisabled( disabled );
3409         }.bind( this ) );
3411         // Initialization
3412         this.$element
3413                 .on( {
3414                         click: this.onClick.bind( this ),
3415                         keydown: this.onKeyDown.bind( this )
3416                 } )
3417                 .addClass( 'oo-ui-capsuleItemWidget' )
3418                 .append( this.$label, this.closeButton.$element );
3421 /* Setup */
3423 OO.inheritClass( OO.ui.CapsuleItemWidget, OO.ui.Widget );
3424 OO.mixinClass( OO.ui.CapsuleItemWidget, OO.ui.mixin.ItemWidget );
3425 OO.mixinClass( OO.ui.CapsuleItemWidget, OO.ui.mixin.LabelElement );
3426 OO.mixinClass( OO.ui.CapsuleItemWidget, OO.ui.mixin.FlaggedElement );
3427 OO.mixinClass( OO.ui.CapsuleItemWidget, OO.ui.mixin.TabIndexedElement );
3429 /* Methods */
3432  * Handle close icon clicks
3433  */
3434 OO.ui.CapsuleItemWidget.prototype.onCloseClick = function () {
3435         var element = this.getElementGroup();
3437         if ( element && $.isFunction( element.removeItems ) ) {
3438                 element.removeItems( [ this ] );
3439                 element.focus();
3440         }
3444  * Handle click event for the entire capsule
3445  */
3446 OO.ui.CapsuleItemWidget.prototype.onClick = function () {
3447         var element = this.getElementGroup();
3449         if ( !this.isDisabled() && element && $.isFunction( element.editItem ) ) {
3450                 element.editItem( this );
3451         }
3455  * Handle keyDown event for the entire capsule
3456  */
3457 OO.ui.CapsuleItemWidget.prototype.onKeyDown = function ( e ) {
3458         var element = this.getElementGroup();
3460         if ( e.keyCode === OO.ui.Keys.BACKSPACE || e.keyCode === OO.ui.Keys.DELETE ) {
3461                 element.removeItems( [ this ] );
3462                 element.focus();
3463                 return false;
3464         } else if ( e.keyCode === OO.ui.Keys.ENTER ) {
3465                 element.editItem( this );
3466                 return false;
3467         } else if ( e.keyCode === OO.ui.Keys.LEFT ) {
3468                 element.getPreviousItem( this ).focus();
3469         } else if ( e.keyCode === OO.ui.Keys.RIGHT ) {
3470                 element.getNextItem( this ).focus();
3471         }
3475  * Focuses the capsule
3476  */
3477 OO.ui.CapsuleItemWidget.prototype.focus = function () {
3478         this.$element.focus();
3482  * CapsuleMultiselectWidgets are something like a {@link OO.ui.ComboBoxInputWidget combo box widget}
3483  * that allows for selecting multiple values.
3485  * For more information about menus and options, please see the [OOjs UI documentation on MediaWiki][1].
3487  *     @example
3488  *     // Example: A CapsuleMultiselectWidget.
3489  *     var capsule = new OO.ui.CapsuleMultiselectWidget( {
3490  *         label: 'CapsuleMultiselectWidget',
3491  *         selected: [ 'Option 1', 'Option 3' ],
3492  *         menu: {
3493  *             items: [
3494  *                 new OO.ui.MenuOptionWidget( {
3495  *                     data: 'Option 1',
3496  *                     label: 'Option One'
3497  *                 } ),
3498  *                 new OO.ui.MenuOptionWidget( {
3499  *                     data: 'Option 2',
3500  *                     label: 'Option Two'
3501  *                 } ),
3502  *                 new OO.ui.MenuOptionWidget( {
3503  *                     data: 'Option 3',
3504  *                     label: 'Option Three'
3505  *                 } ),
3506  *                 new OO.ui.MenuOptionWidget( {
3507  *                     data: 'Option 4',
3508  *                     label: 'Option Four'
3509  *                 } ),
3510  *                 new OO.ui.MenuOptionWidget( {
3511  *                     data: 'Option 5',
3512  *                     label: 'Option Five'
3513  *                 } )
3514  *             ]
3515  *         }
3516  *     } );
3517  *     $( 'body' ).append( capsule.$element );
3519  * [1]: https://www.mediawiki.org/wiki/OOjs_UI/Widgets/Selects_and_Options#Menu_selects_and_options
3521  * @class
3522  * @extends OO.ui.Widget
3523  * @mixins OO.ui.mixin.GroupElement
3524  * @mixins OO.ui.mixin.PopupElement
3525  * @mixins OO.ui.mixin.TabIndexedElement
3526  * @mixins OO.ui.mixin.IndicatorElement
3527  * @mixins OO.ui.mixin.IconElement
3528  * @uses OO.ui.CapsuleItemWidget
3529  * @uses OO.ui.FloatingMenuSelectWidget
3531  * @constructor
3532  * @param {Object} [config] Configuration options
3533  * @cfg {boolean} [allowArbitrary=false] Allow data items to be added even if not present in the menu.
3534  * @cfg {Object} [menu] (required) Configuration options to pass to the
3535  *  {@link OO.ui.MenuSelectWidget menu select widget}.
3536  * @cfg {Object} [popup] Configuration options to pass to the {@link OO.ui.PopupWidget popup widget}.
3537  *  If specified, this popup will be shown instead of the menu (but the menu
3538  *  will still be used for item labels and allowArbitrary=false). The widgets
3539  *  in the popup should use {@link #addItemsFromData} or {@link #addItems} as necessary.
3540  * @cfg {jQuery} [$overlay=this.$element] Render the menu or popup into a separate layer.
3541  *  This configuration is useful in cases where the expanded menu is larger than
3542  *  its containing `<div>`. The specified overlay layer is usually on top of
3543  *  the containing `<div>` and has a larger area. By default, the menu uses
3544  *  relative positioning.
3545  */
3546 OO.ui.CapsuleMultiselectWidget = function OoUiCapsuleMultiselectWidget( config ) {
3547         var $tabFocus;
3549         // Parent constructor
3550         OO.ui.CapsuleMultiselectWidget.parent.call( this, config );
3552         // Configuration initialization
3553         config = $.extend( {
3554                 allowArbitrary: false,
3555                 $overlay: this.$element
3556         }, config );
3558         // Properties (must be set before mixin constructor calls)
3559         this.$input = config.popup ? null : $( '<input>' );
3560         this.$handle = $( '<div>' );
3562         // Mixin constructors
3563         OO.ui.mixin.GroupElement.call( this, config );
3564         if ( config.popup ) {
3565                 config.popup = $.extend( {}, config.popup, {
3566                         align: 'forwards',
3567                         anchor: false
3568                 } );
3569                 OO.ui.mixin.PopupElement.call( this, config );
3570                 $tabFocus = $( '<span>' );
3571                 OO.ui.mixin.TabIndexedElement.call( this, $.extend( {}, config, { $tabIndexed: $tabFocus } ) );
3572         } else {
3573                 this.popup = null;
3574                 $tabFocus = null;
3575                 OO.ui.mixin.TabIndexedElement.call( this, $.extend( {}, config, { $tabIndexed: this.$input } ) );
3576         }
3577         OO.ui.mixin.IndicatorElement.call( this, config );
3578         OO.ui.mixin.IconElement.call( this, config );
3580         // Properties
3581         this.$content = $( '<div>' );
3582         this.allowArbitrary = config.allowArbitrary;
3583         this.$overlay = config.$overlay;
3584         this.menu = new OO.ui.FloatingMenuSelectWidget( $.extend(
3585                 {
3586                         widget: this,
3587                         $input: this.$input,
3588                         $container: this.$element,
3589                         filterFromInput: true,
3590                         disabled: this.isDisabled()
3591                 },
3592                 config.menu
3593         ) );
3595         // Events
3596         if ( this.popup ) {
3597                 $tabFocus.on( {
3598                         focus: this.onFocusForPopup.bind( this )
3599                 } );
3600                 this.popup.$element.on( 'focusout', this.onPopupFocusOut.bind( this ) );
3601                 if ( this.popup.$autoCloseIgnore ) {
3602                         this.popup.$autoCloseIgnore.on( 'focusout', this.onPopupFocusOut.bind( this ) );
3603                 }
3604                 this.popup.connect( this, {
3605                         toggle: function ( visible ) {
3606                                 $tabFocus.toggle( !visible );
3607                         }
3608                 } );
3609         } else {
3610                 this.$input.on( {
3611                         focus: this.onInputFocus.bind( this ),
3612                         blur: this.onInputBlur.bind( this ),
3613                         'propertychange change click mouseup keydown keyup input cut paste select focus':
3614                                 OO.ui.debounce( this.updateInputSize.bind( this ) ),
3615                         keydown: this.onKeyDown.bind( this ),
3616                         keypress: this.onKeyPress.bind( this )
3617                 } );
3618         }
3619         this.menu.connect( this, {
3620                 choose: 'onMenuChoose',
3621                 add: 'onMenuItemsChange',
3622                 remove: 'onMenuItemsChange'
3623         } );
3624         this.$handle.on( {
3625                 mousedown: this.onMouseDown.bind( this )
3626         } );
3628         // Initialization
3629         if ( this.$input ) {
3630                 this.$input.prop( 'disabled', this.isDisabled() );
3631                 this.$input.attr( {
3632                         role: 'combobox',
3633                         'aria-autocomplete': 'list'
3634                 } );
3635                 this.updateInputSize();
3636         }
3637         if ( config.data ) {
3638                 this.setItemsFromData( config.data );
3639         }
3640         this.$content.addClass( 'oo-ui-capsuleMultiselectWidget-content' )
3641                 .append( this.$group );
3642         this.$group.addClass( 'oo-ui-capsuleMultiselectWidget-group' );
3643         this.$handle.addClass( 'oo-ui-capsuleMultiselectWidget-handle' )
3644                 .append( this.$indicator, this.$icon, this.$content );
3645         this.$element.addClass( 'oo-ui-capsuleMultiselectWidget' )
3646                 .append( this.$handle );
3647         if ( this.popup ) {
3648                 this.$content.append( $tabFocus );
3649                 this.$overlay.append( this.popup.$element );
3650         } else {
3651                 this.$content.append( this.$input );
3652                 this.$overlay.append( this.menu.$element );
3653         }
3654         this.onMenuItemsChange();
3657 /* Setup */
3659 OO.inheritClass( OO.ui.CapsuleMultiselectWidget, OO.ui.Widget );
3660 OO.mixinClass( OO.ui.CapsuleMultiselectWidget, OO.ui.mixin.GroupElement );
3661 OO.mixinClass( OO.ui.CapsuleMultiselectWidget, OO.ui.mixin.PopupElement );
3662 OO.mixinClass( OO.ui.CapsuleMultiselectWidget, OO.ui.mixin.TabIndexedElement );
3663 OO.mixinClass( OO.ui.CapsuleMultiselectWidget, OO.ui.mixin.IndicatorElement );
3664 OO.mixinClass( OO.ui.CapsuleMultiselectWidget, OO.ui.mixin.IconElement );
3666 /* Events */
3669  * @event change
3671  * A change event is emitted when the set of selected items changes.
3673  * @param {Mixed[]} datas Data of the now-selected items
3674  */
3677  * @event resize
3679  * A resize event is emitted when the widget's dimensions change to accomodate newly added items or
3680  * current user input.
3681  */
3683 /* Methods */
3686  * Construct a OO.ui.CapsuleItemWidget (or a subclass thereof) from given label and data.
3688  * @protected
3689  * @param {Mixed} data Custom data of any type.
3690  * @param {string} label The label text.
3691  * @return {OO.ui.CapsuleItemWidget}
3692  */
3693 OO.ui.CapsuleMultiselectWidget.prototype.createItemWidget = function ( data, label ) {
3694         return new OO.ui.CapsuleItemWidget( { data: data, label: label } );
3698  * Get the data of the items in the capsule
3700  * @return {Mixed[]}
3701  */
3702 OO.ui.CapsuleMultiselectWidget.prototype.getItemsData = function () {
3703         return this.getItems().map( function ( item ) {
3704                 return item.data;
3705         } );
3709  * Set the items in the capsule by providing data
3711  * @chainable
3712  * @param {Mixed[]} datas
3713  * @return {OO.ui.CapsuleMultiselectWidget}
3714  */
3715 OO.ui.CapsuleMultiselectWidget.prototype.setItemsFromData = function ( datas ) {
3716         var widget = this,
3717                 menu = this.menu,
3718                 items = this.getItems();
3720         $.each( datas, function ( i, data ) {
3721                 var j, label,
3722                         item = menu.getItemFromData( data );
3724                 if ( item ) {
3725                         label = item.label;
3726                 } else if ( widget.allowArbitrary ) {
3727                         label = String( data );
3728                 } else {
3729                         return;
3730                 }
3732                 item = null;
3733                 for ( j = 0; j < items.length; j++ ) {
3734                         if ( items[ j ].data === data && items[ j ].label === label ) {
3735                                 item = items[ j ];
3736                                 items.splice( j, 1 );
3737                                 break;
3738                         }
3739                 }
3740                 if ( !item ) {
3741                         item = widget.createItemWidget( data, label );
3742                 }
3743                 widget.addItems( [ item ], i );
3744         } );
3746         if ( items.length ) {
3747                 widget.removeItems( items );
3748         }
3750         return this;
3754  * Add items to the capsule by providing their data
3756  * @chainable
3757  * @param {Mixed[]} datas
3758  * @return {OO.ui.CapsuleMultiselectWidget}
3759  */
3760 OO.ui.CapsuleMultiselectWidget.prototype.addItemsFromData = function ( datas ) {
3761         var widget = this,
3762                 menu = this.menu,
3763                 items = [];
3765         $.each( datas, function ( i, data ) {
3766                 var item;
3768                 if ( !widget.getItemFromData( data ) ) {
3769                         item = menu.getItemFromData( data );
3770                         if ( item ) {
3771                                 items.push( widget.createItemWidget( data, item.label ) );
3772                         } else if ( widget.allowArbitrary ) {
3773                                 items.push( widget.createItemWidget( data, String( data ) ) );
3774                         }
3775                 }
3776         } );
3778         if ( items.length ) {
3779                 this.addItems( items );
3780         }
3782         return this;
3786  * Add items to the capsule by providing a label
3788  * @param {string} label
3789  * @return {boolean} Whether the item was added or not
3790  */
3791 OO.ui.CapsuleMultiselectWidget.prototype.addItemFromLabel = function ( label ) {
3792         var item = this.menu.getItemFromLabel( label, true );
3793         if ( item ) {
3794                 this.addItemsFromData( [ item.data ] );
3795                 return true;
3796         } else if ( this.allowArbitrary && this.$input.val().trim() !== '' ) {
3797                 this.addItemsFromData( [ label ] );
3798                 return true;
3799         }
3800         return false;
3804  * Remove items by data
3806  * @chainable
3807  * @param {Mixed[]} datas
3808  * @return {OO.ui.CapsuleMultiselectWidget}
3809  */
3810 OO.ui.CapsuleMultiselectWidget.prototype.removeItemsFromData = function ( datas ) {
3811         var widget = this,
3812                 items = [];
3814         $.each( datas, function ( i, data ) {
3815                 var item = widget.getItemFromData( data );
3816                 if ( item ) {
3817                         items.push( item );
3818                 }
3819         } );
3821         if ( items.length ) {
3822                 this.removeItems( items );
3823         }
3825         return this;
3829  * @inheritdoc
3830  */
3831 OO.ui.CapsuleMultiselectWidget.prototype.addItems = function ( items ) {
3832         var same, i, l,
3833                 oldItems = this.items.slice();
3835         OO.ui.mixin.GroupElement.prototype.addItems.call( this, items );
3837         if ( this.items.length !== oldItems.length ) {
3838                 same = false;
3839         } else {
3840                 same = true;
3841                 for ( i = 0, l = oldItems.length; same && i < l; i++ ) {
3842                         same = same && this.items[ i ] === oldItems[ i ];
3843                 }
3844         }
3845         if ( !same ) {
3846                 this.emit( 'change', this.getItemsData() );
3847                 this.updateIfHeightChanged();
3848         }
3850         return this;
3854  * Removes the item from the list and copies its label to `this.$input`.
3856  * @param {Object} item
3857  */
3858 OO.ui.CapsuleMultiselectWidget.prototype.editItem = function ( item ) {
3859         this.$input.val( item.label );
3860         this.updateInputSize();
3861         this.focus();
3862         this.removeItems( [ item ] );
3866  * @inheritdoc
3867  */
3868 OO.ui.CapsuleMultiselectWidget.prototype.removeItems = function ( items ) {
3869         var same, i, l,
3870                 oldItems = this.items.slice();
3872         OO.ui.mixin.GroupElement.prototype.removeItems.call( this, items );
3874         if ( this.items.length !== oldItems.length ) {
3875                 same = false;
3876         } else {
3877                 same = true;
3878                 for ( i = 0, l = oldItems.length; same && i < l; i++ ) {
3879                         same = same && this.items[ i ] === oldItems[ i ];
3880                 }
3881         }
3882         if ( !same ) {
3883                 this.emit( 'change', this.getItemsData() );
3884                 this.updateIfHeightChanged();
3885         }
3887         return this;
3891  * @inheritdoc
3892  */
3893 OO.ui.CapsuleMultiselectWidget.prototype.clearItems = function () {
3894         if ( this.items.length ) {
3895                 OO.ui.mixin.GroupElement.prototype.clearItems.call( this );
3896                 this.emit( 'change', this.getItemsData() );
3897                 this.updateIfHeightChanged();
3898         }
3899         return this;
3903  * Given an item, returns the item after it. If its the last item,
3904  * returns `this.$input`. If no item is passed, returns the very first
3905  * item.
3907  * @param {OO.ui.CapsuleItemWidget} [item]
3908  * @return {OO.ui.CapsuleItemWidget|jQuery|boolean}
3909  */
3910 OO.ui.CapsuleMultiselectWidget.prototype.getNextItem = function ( item ) {
3911         var itemIndex;
3913         if ( item === undefined ) {
3914                 return this.items[ 0 ];
3915         }
3917         itemIndex = this.items.indexOf( item );
3918         if ( itemIndex < 0 ) { // Item not in list
3919                 return false;
3920         } else if ( itemIndex === this.items.length - 1 ) { // Last item
3921                 return this.$input;
3922         } else {
3923                 return this.items[ itemIndex + 1 ];
3924         }
3928  * Given an item, returns the item before it. If its the first item,
3929  * returns `this.$input`. If no item is passed, returns the very last
3930  * item.
3932  * @param {OO.ui.CapsuleItemWidget} [item]
3933  * @return {OO.ui.CapsuleItemWidget|jQuery|boolean}
3934  */
3935 OO.ui.CapsuleMultiselectWidget.prototype.getPreviousItem = function ( item ) {
3936         var itemIndex;
3938         if ( item === undefined ) {
3939                 return this.items[ this.items.length - 1 ];
3940         }
3942         itemIndex = this.items.indexOf( item );
3943         if ( itemIndex < 0 ) { // Item not in list
3944                 return false;
3945         } else if ( itemIndex === 0 ) { // First item
3946                 return this.$input;
3947         } else {
3948                 return this.items[ itemIndex - 1 ];
3949         }
3953  * Get the capsule widget's menu.
3955  * @return {OO.ui.MenuSelectWidget} Menu widget
3956  */
3957 OO.ui.CapsuleMultiselectWidget.prototype.getMenu = function () {
3958         return this.menu;
3962  * Handle focus events
3964  * @private
3965  * @param {jQuery.Event} event
3966  */
3967 OO.ui.CapsuleMultiselectWidget.prototype.onInputFocus = function () {
3968         if ( !this.isDisabled() ) {
3969                 this.menu.toggle( true );
3970         }
3974  * Handle blur events
3976  * @private
3977  * @param {jQuery.Event} event
3978  */
3979 OO.ui.CapsuleMultiselectWidget.prototype.onInputBlur = function () {
3980         this.addItemFromLabel( this.$input.val() );
3981         this.clearInput();
3985  * Handle focus events
3987  * @private
3988  * @param {jQuery.Event} event
3989  */
3990 OO.ui.CapsuleMultiselectWidget.prototype.onFocusForPopup = function () {
3991         if ( !this.isDisabled() ) {
3992                 this.popup.setSize( this.$handle.width() );
3993                 this.popup.toggle( true );
3994                 OO.ui.findFocusable( this.popup.$element ).focus();
3995         }
3999  * Handles popup focus out events.
4001  * @private
4002  * @param {jQuery.Event} e Focus out event
4003  */
4004 OO.ui.CapsuleMultiselectWidget.prototype.onPopupFocusOut = function () {
4005         var widget = this.popup;
4007         setTimeout( function () {
4008                 if (
4009                         widget.isVisible() &&
4010                         !OO.ui.contains( widget.$element[ 0 ], document.activeElement, true ) &&
4011                         ( !widget.$autoCloseIgnore || !widget.$autoCloseIgnore.has( document.activeElement ).length )
4012                 ) {
4013                         widget.toggle( false );
4014                 }
4015         } );
4019  * Handle mouse down events.
4021  * @private
4022  * @param {jQuery.Event} e Mouse down event
4023  */
4024 OO.ui.CapsuleMultiselectWidget.prototype.onMouseDown = function ( e ) {
4025         if ( e.which === OO.ui.MouseButtons.LEFT ) {
4026                 this.focus();
4027                 return false;
4028         } else {
4029                 this.updateInputSize();
4030         }
4034  * Handle key press events.
4036  * @private
4037  * @param {jQuery.Event} e Key press event
4038  */
4039 OO.ui.CapsuleMultiselectWidget.prototype.onKeyPress = function ( e ) {
4040         if ( !this.isDisabled() ) {
4041                 if ( e.which === OO.ui.Keys.ESCAPE ) {
4042                         this.clearInput();
4043                         return false;
4044                 }
4046                 if ( !this.popup ) {
4047                         this.menu.toggle( true );
4048                         if ( e.which === OO.ui.Keys.ENTER ) {
4049                                 if ( this.addItemFromLabel( this.$input.val() ) ) {
4050                                         this.clearInput();
4051                                 }
4052                                 return false;
4053                         }
4055                         // Make sure the input gets resized.
4056                         setTimeout( this.updateInputSize.bind( this ), 0 );
4057                 }
4058         }
4062  * Handle key down events.
4064  * @private
4065  * @param {jQuery.Event} e Key down event
4066  */
4067 OO.ui.CapsuleMultiselectWidget.prototype.onKeyDown = function ( e ) {
4068         if (
4069                 !this.isDisabled() &&
4070                 this.$input.val() === '' &&
4071                 this.items.length
4072         ) {
4073                 // 'keypress' event is not triggered for Backspace
4074                 if ( e.keyCode === OO.ui.Keys.BACKSPACE ) {
4075                         if ( e.metaKey || e.ctrlKey ) {
4076                                 this.removeItems( this.items.slice( -1 ) );
4077                         } else {
4078                                 this.editItem( this.items[ this.items.length - 1 ] );
4079                         }
4080                         return false;
4081                 } else if ( e.keyCode === OO.ui.Keys.LEFT ) {
4082                         this.getPreviousItem().focus();
4083                 } else if ( e.keyCode === OO.ui.Keys.RIGHT ) {
4084                         this.getNextItem().focus();
4085                 }
4086         }
4090  * Update the dimensions of the text input field to encompass all available area.
4092  * @private
4093  * @param {jQuery.Event} e Event of some sort
4094  */
4095 OO.ui.CapsuleMultiselectWidget.prototype.updateInputSize = function () {
4096         var $lastItem, direction, contentWidth, currentWidth, bestWidth;
4097         if ( !this.isDisabled() ) {
4098                 this.$input.css( 'width', '1em' );
4099                 $lastItem = this.$group.children().last();
4100                 direction = OO.ui.Element.static.getDir( this.$handle );
4101                 contentWidth = this.$input[ 0 ].scrollWidth;
4102                 currentWidth = this.$input.width();
4104                 if ( contentWidth < currentWidth ) {
4105                         // All is fine, don't perform expensive calculations
4106                         return;
4107                 }
4109                 if ( !$lastItem.length ) {
4110                         bestWidth = this.$content.innerWidth();
4111                 } else {
4112                         bestWidth = direction === 'ltr' ?
4113                                 this.$content.innerWidth() - $lastItem.position().left - $lastItem.outerWidth() :
4114                                 $lastItem.position().left;
4115                 }
4116                 // Some safety margin for sanity, because I *really* don't feel like finding out where the few
4117                 // pixels this is off by are coming from.
4118                 bestWidth -= 10;
4119                 if ( contentWidth > bestWidth ) {
4120                         // This will result in the input getting shifted to the next line
4121                         bestWidth = this.$content.innerWidth() - 10;
4122                 }
4123                 this.$input.width( Math.floor( bestWidth ) );
4124                 this.updateIfHeightChanged();
4125         }
4129  * Determine if widget height changed, and if so, update menu position and emit 'resize' event.
4131  * @private
4132  */
4133 OO.ui.CapsuleMultiselectWidget.prototype.updateIfHeightChanged = function () {
4134         var height = this.$element.height();
4135         if ( height !== this.height ) {
4136                 this.height = height;
4137                 this.menu.position();
4138                 this.emit( 'resize' );
4139         }
4143  * Handle menu choose events.
4145  * @private
4146  * @param {OO.ui.OptionWidget} item Chosen item
4147  */
4148 OO.ui.CapsuleMultiselectWidget.prototype.onMenuChoose = function ( item ) {
4149         if ( item && item.isVisible() ) {
4150                 this.addItemsFromData( [ item.getData() ] );
4151                 this.clearInput();
4152         }
4156  * Handle menu item change events.
4158  * @private
4159  */
4160 OO.ui.CapsuleMultiselectWidget.prototype.onMenuItemsChange = function () {
4161         this.setItemsFromData( this.getItemsData() );
4162         this.$element.toggleClass( 'oo-ui-capsuleMultiselectWidget-empty', this.menu.isEmpty() );
4166  * Clear the input field
4168  * @private
4169  */
4170 OO.ui.CapsuleMultiselectWidget.prototype.clearInput = function () {
4171         if ( this.$input ) {
4172                 this.$input.val( '' );
4173                 this.updateInputSize();
4174         }
4175         if ( this.popup ) {
4176                 this.popup.toggle( false );
4177         }
4178         this.menu.toggle( false );
4179         this.menu.selectItem();
4180         this.menu.highlightItem();
4184  * @inheritdoc
4185  */
4186 OO.ui.CapsuleMultiselectWidget.prototype.setDisabled = function ( disabled ) {
4187         var i, len;
4189         // Parent method
4190         OO.ui.CapsuleMultiselectWidget.parent.prototype.setDisabled.call( this, disabled );
4192         if ( this.$input ) {
4193                 this.$input.prop( 'disabled', this.isDisabled() );
4194         }
4195         if ( this.menu ) {
4196                 this.menu.setDisabled( this.isDisabled() );
4197         }
4198         if ( this.popup ) {
4199                 this.popup.setDisabled( this.isDisabled() );
4200         }
4202         if ( this.items ) {
4203                 for ( i = 0, len = this.items.length; i < len; i++ ) {
4204                         this.items[ i ].updateDisabled();
4205                 }
4206         }
4208         return this;
4212  * Focus the widget
4214  * @chainable
4215  * @return {OO.ui.CapsuleMultiselectWidget}
4216  */
4217 OO.ui.CapsuleMultiselectWidget.prototype.focus = function () {
4218         if ( !this.isDisabled() ) {
4219                 if ( this.popup ) {
4220                         this.popup.setSize( this.$handle.width() );
4221                         this.popup.toggle( true );
4222                         OO.ui.findFocusable( this.popup.$element ).focus();
4223                 } else {
4224                         this.updateInputSize();
4225                         this.menu.toggle( true );
4226                         this.$input.focus();
4227                 }
4228         }
4229         return this;
4233  * @class
4234  * @deprecated since 0.17.3; use OO.ui.CapsuleMultiselectWidget instead
4235  */
4236 OO.ui.CapsuleMultiSelectWidget = OO.ui.CapsuleMultiselectWidget;
4239  * SelectFileWidgets allow for selecting files, using the HTML5 File API. These
4240  * widgets can be configured with {@link OO.ui.mixin.IconElement icons} and {@link
4241  * OO.ui.mixin.IndicatorElement indicators}.
4242  * Please see the [OOjs UI documentation on MediaWiki] [1] for more information and examples.
4244  *     @example
4245  *     // Example of a file select widget
4246  *     var selectFile = new OO.ui.SelectFileWidget();
4247  *     $( 'body' ).append( selectFile.$element );
4249  * [1]: https://www.mediawiki.org/wiki/OOjs_UI/Widgets
4251  * @class
4252  * @extends OO.ui.Widget
4253  * @mixins OO.ui.mixin.IconElement
4254  * @mixins OO.ui.mixin.IndicatorElement
4255  * @mixins OO.ui.mixin.PendingElement
4256  * @mixins OO.ui.mixin.LabelElement
4258  * @constructor
4259  * @param {Object} [config] Configuration options
4260  * @cfg {string[]|null} [accept=null] MIME types to accept. null accepts all types.
4261  * @cfg {string} [placeholder] Text to display when no file is selected.
4262  * @cfg {string} [notsupported] Text to display when file support is missing in the browser.
4263  * @cfg {boolean} [droppable=true] Whether to accept files by drag and drop.
4264  * @cfg {boolean} [showDropTarget=false] Whether to show a drop target. Requires droppable to be true.
4265  * @cfg {number} [thumbnailSizeLimit=20] File size limit in MiB above which to not try and show a
4266  *  preview (for performance)
4267  */
4268 OO.ui.SelectFileWidget = function OoUiSelectFileWidget( config ) {
4269         var dragHandler;
4271         // Configuration initialization
4272         config = $.extend( {
4273                 accept: null,
4274                 placeholder: OO.ui.msg( 'ooui-selectfile-placeholder' ),
4275                 notsupported: OO.ui.msg( 'ooui-selectfile-not-supported' ),
4276                 droppable: true,
4277                 showDropTarget: false,
4278                 thumbnailSizeLimit: 20
4279         }, config );
4281         // Parent constructor
4282         OO.ui.SelectFileWidget.parent.call( this, config );
4284         // Mixin constructors
4285         OO.ui.mixin.IconElement.call( this, config );
4286         OO.ui.mixin.IndicatorElement.call( this, config );
4287         OO.ui.mixin.PendingElement.call( this, $.extend( {}, config, { $pending: this.$info } ) );
4288         OO.ui.mixin.LabelElement.call( this, config );
4290         // Properties
4291         this.$info = $( '<span>' );
4292         this.showDropTarget = config.showDropTarget;
4293         this.thumbnailSizeLimit = config.thumbnailSizeLimit;
4294         this.isSupported = this.constructor.static.isSupported();
4295         this.currentFile = null;
4296         if ( Array.isArray( config.accept ) ) {
4297                 this.accept = config.accept;
4298         } else {
4299                 this.accept = null;
4300         }
4301         this.placeholder = config.placeholder;
4302         this.notsupported = config.notsupported;
4303         this.onFileSelectedHandler = this.onFileSelected.bind( this );
4305         this.selectButton = new OO.ui.ButtonWidget( {
4306                 classes: [ 'oo-ui-selectFileWidget-selectButton' ],
4307                 label: OO.ui.msg( 'ooui-selectfile-button-select' ),
4308                 disabled: this.disabled || !this.isSupported
4309         } );
4311         this.clearButton = new OO.ui.ButtonWidget( {
4312                 classes: [ 'oo-ui-selectFileWidget-clearButton' ],
4313                 framed: false,
4314                 icon: 'close',
4315                 disabled: this.disabled
4316         } );
4318         // Events
4319         this.selectButton.$button.on( {
4320                 keypress: this.onKeyPress.bind( this )
4321         } );
4322         this.clearButton.connect( this, {
4323                 click: 'onClearClick'
4324         } );
4325         if ( config.droppable ) {
4326                 dragHandler = this.onDragEnterOrOver.bind( this );
4327                 this.$element.on( {
4328                         dragenter: dragHandler,
4329                         dragover: dragHandler,
4330                         dragleave: this.onDragLeave.bind( this ),
4331                         drop: this.onDrop.bind( this )
4332                 } );
4333         }
4335         // Initialization
4336         this.addInput();
4337         this.$label.addClass( 'oo-ui-selectFileWidget-label' );
4338         this.$info
4339                 .addClass( 'oo-ui-selectFileWidget-info' )
4340                 .append( this.$icon, this.$label, this.clearButton.$element, this.$indicator );
4342         if ( config.droppable && config.showDropTarget ) {
4343                 this.selectButton.setIcon( 'upload' );
4344                 this.$thumbnail = $( '<div>' ).addClass( 'oo-ui-selectFileWidget-thumbnail' );
4345                 this.setPendingElement( this.$thumbnail );
4346                 this.$dropTarget = $( '<div>' )
4347                         .addClass( 'oo-ui-selectFileWidget-dropTarget' )
4348                         .on( {
4349                                 click: this.onDropTargetClick.bind( this )
4350                         } )
4351                         .append(
4352                                 this.$thumbnail,
4353                                 this.$info,
4354                                 this.selectButton.$element,
4355                                 $( '<span>' )
4356                                         .addClass( 'oo-ui-selectFileWidget-dropLabel' )
4357                                         .text( OO.ui.msg( 'ooui-selectfile-dragdrop-placeholder' ) )
4358                         );
4359                 this.$element.append( this.$dropTarget );
4360         } else {
4361                 this.$element
4362                         .addClass( 'oo-ui-selectFileWidget' )
4363                         .append( this.$info, this.selectButton.$element );
4364         }
4365         this.updateUI();
4368 /* Setup */
4370 OO.inheritClass( OO.ui.SelectFileWidget, OO.ui.Widget );
4371 OO.mixinClass( OO.ui.SelectFileWidget, OO.ui.mixin.IconElement );
4372 OO.mixinClass( OO.ui.SelectFileWidget, OO.ui.mixin.IndicatorElement );
4373 OO.mixinClass( OO.ui.SelectFileWidget, OO.ui.mixin.PendingElement );
4374 OO.mixinClass( OO.ui.SelectFileWidget, OO.ui.mixin.LabelElement );
4376 /* Static Properties */
4379  * Check if this widget is supported
4381  * @static
4382  * @return {boolean}
4383  */
4384 OO.ui.SelectFileWidget.static.isSupported = function () {
4385         var $input;
4386         if ( OO.ui.SelectFileWidget.static.isSupportedCache === null ) {
4387                 $input = $( '<input>' ).attr( 'type', 'file' );
4388                 OO.ui.SelectFileWidget.static.isSupportedCache = $input[ 0 ].files !== undefined;
4389         }
4390         return OO.ui.SelectFileWidget.static.isSupportedCache;
4393 OO.ui.SelectFileWidget.static.isSupportedCache = null;
4395 /* Events */
4398  * @event change
4400  * A change event is emitted when the on/off state of the toggle changes.
4402  * @param {File|null} value New value
4403  */
4405 /* Methods */
4408  * Get the current value of the field
4410  * @return {File|null}
4411  */
4412 OO.ui.SelectFileWidget.prototype.getValue = function () {
4413         return this.currentFile;
4417  * Set the current value of the field
4419  * @param {File|null} file File to select
4420  */
4421 OO.ui.SelectFileWidget.prototype.setValue = function ( file ) {
4422         if ( this.currentFile !== file ) {
4423                 this.currentFile = file;
4424                 this.updateUI();
4425                 this.emit( 'change', this.currentFile );
4426         }
4430  * Focus the widget.
4432  * Focusses the select file button.
4434  * @chainable
4435  */
4436 OO.ui.SelectFileWidget.prototype.focus = function () {
4437         this.selectButton.$button[ 0 ].focus();
4438         return this;
4442  * Update the user interface when a file is selected or unselected
4444  * @protected
4445  */
4446 OO.ui.SelectFileWidget.prototype.updateUI = function () {
4447         var $label;
4448         if ( !this.isSupported ) {
4449                 this.$element.addClass( 'oo-ui-selectFileWidget-notsupported' );
4450                 this.$element.removeClass( 'oo-ui-selectFileWidget-empty' );
4451                 this.setLabel( this.notsupported );
4452         } else {
4453                 this.$element.addClass( 'oo-ui-selectFileWidget-supported' );
4454                 if ( this.currentFile ) {
4455                         this.$element.removeClass( 'oo-ui-selectFileWidget-empty' );
4456                         $label = $( [] );
4457                         $label = $label.add(
4458                                 $( '<span>' )
4459                                         .addClass( 'oo-ui-selectFileWidget-fileName' )
4460                                         .text( this.currentFile.name )
4461                         );
4462                         if ( this.currentFile.type !== '' ) {
4463                                 $label = $label.add(
4464                                         $( '<span>' )
4465                                                 .addClass( 'oo-ui-selectFileWidget-fileType' )
4466                                                 .text( this.currentFile.type )
4467                                 );
4468                         }
4469                         this.setLabel( $label );
4471                         if ( this.showDropTarget ) {
4472                                 this.pushPending();
4473                                 this.loadAndGetImageUrl().done( function ( url ) {
4474                                         this.$thumbnail.css( 'background-image', 'url( ' + url + ' )' );
4475                                 }.bind( this ) ).fail( function () {
4476                                         this.$thumbnail.append(
4477                                                 new OO.ui.IconWidget( {
4478                                                         icon: 'attachment',
4479                                                         classes: [ 'oo-ui-selectFileWidget-noThumbnail-icon' ]
4480                                                 } ).$element
4481                                         );
4482                                 }.bind( this ) ).always( function () {
4483                                         this.popPending();
4484                                 }.bind( this ) );
4485                                 this.$dropTarget.off( 'click' );
4486                         }
4487                 } else {
4488                         if ( this.showDropTarget ) {
4489                                 this.$dropTarget.off( 'click' );
4490                                 this.$dropTarget.on( {
4491                                         click: this.onDropTargetClick.bind( this )
4492                                 } );
4493                                 this.$thumbnail
4494                                         .empty()
4495                                         .css( 'background-image', '' );
4496                         }
4497                         this.$element.addClass( 'oo-ui-selectFileWidget-empty' );
4498                         this.setLabel( this.placeholder );
4499                 }
4500         }
4504  * If the selected file is an image, get its URL and load it.
4506  * @return {jQuery.Promise} Promise resolves with the image URL after it has loaded
4507  */
4508 OO.ui.SelectFileWidget.prototype.loadAndGetImageUrl = function () {
4509         var deferred = $.Deferred(),
4510                 file = this.currentFile,
4511                 reader = new FileReader();
4513         if (
4514                 file &&
4515                 ( OO.getProp( file, 'type' ) || '' ).indexOf( 'image/' ) === 0 &&
4516                 file.size < this.thumbnailSizeLimit * 1024 * 1024
4517         ) {
4518                 reader.onload = function ( event ) {
4519                         var img = document.createElement( 'img' );
4520                         img.addEventListener( 'load', function () {
4521                                 if (
4522                                         img.naturalWidth === 0 ||
4523                                         img.naturalHeight === 0 ||
4524                                         img.complete === false
4525                                 ) {
4526                                         deferred.reject();
4527                                 } else {
4528                                         deferred.resolve( event.target.result );
4529                                 }
4530                         } );
4531                         img.src = event.target.result;
4532                 };
4533                 reader.readAsDataURL( file );
4534         } else {
4535                 deferred.reject();
4536         }
4538         return deferred.promise();
4542  * Add the input to the widget
4544  * @private
4545  */
4546 OO.ui.SelectFileWidget.prototype.addInput = function () {
4547         if ( this.$input ) {
4548                 this.$input.remove();
4549         }
4551         if ( !this.isSupported ) {
4552                 this.$input = null;
4553                 return;
4554         }
4556         this.$input = $( '<input>' ).attr( 'type', 'file' );
4557         this.$input.on( 'change', this.onFileSelectedHandler );
4558         this.$input.on( 'click', function ( e ) {
4559                 // Prevents dropTarget to get clicked which calls
4560                 // a click on this input
4561                 e.stopPropagation();
4562         } );
4563         this.$input.attr( {
4564                 tabindex: -1
4565         } );
4566         if ( this.accept ) {
4567                 this.$input.attr( 'accept', this.accept.join( ', ' ) );
4568         }
4569         this.selectButton.$button.append( this.$input );
4573  * Determine if we should accept this file
4575  * @private
4576  * @param {string} mimeType File MIME type
4577  * @return {boolean}
4578  */
4579 OO.ui.SelectFileWidget.prototype.isAllowedType = function ( mimeType ) {
4580         var i, mimeTest;
4582         if ( !this.accept || !mimeType ) {
4583                 return true;
4584         }
4586         for ( i = 0; i < this.accept.length; i++ ) {
4587                 mimeTest = this.accept[ i ];
4588                 if ( mimeTest === mimeType ) {
4589                         return true;
4590                 } else if ( mimeTest.substr( -2 ) === '/*' ) {
4591                         mimeTest = mimeTest.substr( 0, mimeTest.length - 1 );
4592                         if ( mimeType.substr( 0, mimeTest.length ) === mimeTest ) {
4593                                 return true;
4594                         }
4595                 }
4596         }
4598         return false;
4602  * Handle file selection from the input
4604  * @private
4605  * @param {jQuery.Event} e
4606  */
4607 OO.ui.SelectFileWidget.prototype.onFileSelected = function ( e ) {
4608         var file = OO.getProp( e.target, 'files', 0 ) || null;
4610         if ( file && !this.isAllowedType( file.type ) ) {
4611                 file = null;
4612         }
4614         this.setValue( file );
4615         this.addInput();
4619  * Handle clear button click events.
4621  * @private
4622  */
4623 OO.ui.SelectFileWidget.prototype.onClearClick = function () {
4624         this.setValue( null );
4625         return false;
4629  * Handle key press events.
4631  * @private
4632  * @param {jQuery.Event} e Key press event
4633  */
4634 OO.ui.SelectFileWidget.prototype.onKeyPress = function ( e ) {
4635         if ( this.isSupported && !this.isDisabled() && this.$input &&
4636                 ( e.which === OO.ui.Keys.SPACE || e.which === OO.ui.Keys.ENTER )
4637         ) {
4638                 this.$input.click();
4639                 return false;
4640         }
4644  * Handle drop target click events.
4646  * @private
4647  * @param {jQuery.Event} e Key press event
4648  */
4649 OO.ui.SelectFileWidget.prototype.onDropTargetClick = function () {
4650         if ( this.isSupported && !this.isDisabled() && this.$input ) {
4651                 this.$input.click();
4652                 return false;
4653         }
4657  * Handle drag enter and over events
4659  * @private
4660  * @param {jQuery.Event} e Drag event
4661  */
4662 OO.ui.SelectFileWidget.prototype.onDragEnterOrOver = function ( e ) {
4663         var itemOrFile,
4664                 droppableFile = false,
4665                 dt = e.originalEvent.dataTransfer;
4667         e.preventDefault();
4668         e.stopPropagation();
4670         if ( this.isDisabled() || !this.isSupported ) {
4671                 this.$element.removeClass( 'oo-ui-selectFileWidget-canDrop' );
4672                 dt.dropEffect = 'none';
4673                 return false;
4674         }
4676         // DataTransferItem and File both have a type property, but in Chrome files
4677         // have no information at this point.
4678         itemOrFile = OO.getProp( dt, 'items', 0 ) || OO.getProp( dt, 'files', 0 );
4679         if ( itemOrFile ) {
4680                 if ( this.isAllowedType( itemOrFile.type ) ) {
4681                         droppableFile = true;
4682                 }
4683         // dt.types is Array-like, but not an Array
4684         } else if ( Array.prototype.indexOf.call( OO.getProp( dt, 'types' ) || [], 'Files' ) !== -1 ) {
4685                 // File information is not available at this point for security so just assume
4686                 // it is acceptable for now.
4687                 // https://bugzilla.mozilla.org/show_bug.cgi?id=640534
4688                 droppableFile = true;
4689         }
4691         this.$element.toggleClass( 'oo-ui-selectFileWidget-canDrop', droppableFile );
4692         if ( !droppableFile ) {
4693                 dt.dropEffect = 'none';
4694         }
4696         return false;
4700  * Handle drag leave events
4702  * @private
4703  * @param {jQuery.Event} e Drag event
4704  */
4705 OO.ui.SelectFileWidget.prototype.onDragLeave = function () {
4706         this.$element.removeClass( 'oo-ui-selectFileWidget-canDrop' );
4710  * Handle drop events
4712  * @private
4713  * @param {jQuery.Event} e Drop event
4714  */
4715 OO.ui.SelectFileWidget.prototype.onDrop = function ( e ) {
4716         var file = null,
4717                 dt = e.originalEvent.dataTransfer;
4719         e.preventDefault();
4720         e.stopPropagation();
4721         this.$element.removeClass( 'oo-ui-selectFileWidget-canDrop' );
4723         if ( this.isDisabled() || !this.isSupported ) {
4724                 return false;
4725         }
4727         file = OO.getProp( dt, 'files', 0 );
4728         if ( file && !this.isAllowedType( file.type ) ) {
4729                 file = null;
4730         }
4731         if ( file ) {
4732                 this.setValue( file );
4733         }
4735         return false;
4739  * @inheritdoc
4740  */
4741 OO.ui.SelectFileWidget.prototype.setDisabled = function ( disabled ) {
4742         OO.ui.SelectFileWidget.parent.prototype.setDisabled.call( this, disabled );
4743         if ( this.selectButton ) {
4744                 this.selectButton.setDisabled( disabled );
4745         }
4746         if ( this.clearButton ) {
4747                 this.clearButton.setDisabled( disabled );
4748         }
4749         return this;
4753  * Progress bars visually display the status of an operation, such as a download,
4754  * and can be either determinate or indeterminate:
4756  * - **determinate** process bars show the percent of an operation that is complete.
4758  * - **indeterminate** process bars use a visual display of motion to indicate that an operation
4759  *   is taking place. Because the extent of an indeterminate operation is unknown, the bar does
4760  *   not use percentages.
4762  * The value of the `progress` configuration determines whether the bar is determinate or indeterminate.
4764  *     @example
4765  *     // Examples of determinate and indeterminate progress bars.
4766  *     var progressBar1 = new OO.ui.ProgressBarWidget( {
4767  *         progress: 33
4768  *     } );
4769  *     var progressBar2 = new OO.ui.ProgressBarWidget();
4771  *     // Create a FieldsetLayout to layout progress bars
4772  *     var fieldset = new OO.ui.FieldsetLayout;
4773  *     fieldset.addItems( [
4774  *        new OO.ui.FieldLayout( progressBar1, {label: 'Determinate', align: 'top'}),
4775  *        new OO.ui.FieldLayout( progressBar2, {label: 'Indeterminate', align: 'top'})
4776  *     ] );
4777  *     $( 'body' ).append( fieldset.$element );
4779  * @class
4780  * @extends OO.ui.Widget
4782  * @constructor
4783  * @param {Object} [config] Configuration options
4784  * @cfg {number|boolean} [progress=false] The type of progress bar (determinate or indeterminate).
4785  *  To create a determinate progress bar, specify a number that reflects the initial percent complete.
4786  *  By default, the progress bar is indeterminate.
4787  */
4788 OO.ui.ProgressBarWidget = function OoUiProgressBarWidget( config ) {
4789         // Configuration initialization
4790         config = config || {};
4792         // Parent constructor
4793         OO.ui.ProgressBarWidget.parent.call( this, config );
4795         // Properties
4796         this.$bar = $( '<div>' );
4797         this.progress = null;
4799         // Initialization
4800         this.setProgress( config.progress !== undefined ? config.progress : false );
4801         this.$bar.addClass( 'oo-ui-progressBarWidget-bar' );
4802         this.$element
4803                 .attr( {
4804                         role: 'progressbar',
4805                         'aria-valuemin': 0,
4806                         'aria-valuemax': 100
4807                 } )
4808                 .addClass( 'oo-ui-progressBarWidget' )
4809                 .append( this.$bar );
4812 /* Setup */
4814 OO.inheritClass( OO.ui.ProgressBarWidget, OO.ui.Widget );
4816 /* Static Properties */
4818 OO.ui.ProgressBarWidget.static.tagName = 'div';
4820 /* Methods */
4823  * Get the percent of the progress that has been completed. Indeterminate progresses will return `false`.
4825  * @return {number|boolean} Progress percent
4826  */
4827 OO.ui.ProgressBarWidget.prototype.getProgress = function () {
4828         return this.progress;
4832  * Set the percent of the process completed or `false` for an indeterminate process.
4834  * @param {number|boolean} progress Progress percent or `false` for indeterminate
4835  */
4836 OO.ui.ProgressBarWidget.prototype.setProgress = function ( progress ) {
4837         this.progress = progress;
4839         if ( progress !== false ) {
4840                 this.$bar.css( 'width', this.progress + '%' );
4841                 this.$element.attr( 'aria-valuenow', this.progress );
4842         } else {
4843                 this.$bar.css( 'width', '' );
4844                 this.$element.removeAttr( 'aria-valuenow' );
4845         }
4846         this.$element.toggleClass( 'oo-ui-progressBarWidget-indeterminate', !progress );
4850  * SearchWidgets combine a {@link OO.ui.TextInputWidget text input field}, where users can type a search query,
4851  * and a menu of search results, which is displayed beneath the query
4852  * field. Unlike {@link OO.ui.mixin.LookupElement lookup menus}, search result menus are always visible to the user.
4853  * Users can choose an item from the menu or type a query into the text field to search for a matching result item.
4854  * In general, search widgets are used inside a separate {@link OO.ui.Dialog dialog} window.
4856  * Each time the query is changed, the search result menu is cleared and repopulated. Please see
4857  * the [OOjs UI demos][1] for an example.
4859  * [1]: https://tools.wmflabs.org/oojs-ui/oojs-ui/demos/#dialogs-mediawiki-vector-ltr
4861  * @class
4862  * @extends OO.ui.Widget
4864  * @constructor
4865  * @param {Object} [config] Configuration options
4866  * @cfg {string|jQuery} [placeholder] Placeholder text for query input
4867  * @cfg {string} [value] Initial query value
4868  */
4869 OO.ui.SearchWidget = function OoUiSearchWidget( config ) {
4870         // Configuration initialization
4871         config = config || {};
4873         // Parent constructor
4874         OO.ui.SearchWidget.parent.call( this, config );
4876         // Properties
4877         this.query = new OO.ui.TextInputWidget( {
4878                 icon: 'search',
4879                 placeholder: config.placeholder,
4880                 value: config.value
4881         } );
4882         this.results = new OO.ui.SelectWidget();
4883         this.$query = $( '<div>' );
4884         this.$results = $( '<div>' );
4886         // Events
4887         this.query.connect( this, {
4888                 change: 'onQueryChange',
4889                 enter: 'onQueryEnter'
4890         } );
4891         this.query.$input.on( 'keydown', this.onQueryKeydown.bind( this ) );
4893         // Initialization
4894         this.$query
4895                 .addClass( 'oo-ui-searchWidget-query' )
4896                 .append( this.query.$element );
4897         this.$results
4898                 .addClass( 'oo-ui-searchWidget-results' )
4899                 .append( this.results.$element );
4900         this.$element
4901                 .addClass( 'oo-ui-searchWidget' )
4902                 .append( this.$results, this.$query );
4905 /* Setup */
4907 OO.inheritClass( OO.ui.SearchWidget, OO.ui.Widget );
4909 /* Methods */
4912  * Handle query key down events.
4914  * @private
4915  * @param {jQuery.Event} e Key down event
4916  */
4917 OO.ui.SearchWidget.prototype.onQueryKeydown = function ( e ) {
4918         var highlightedItem, nextItem,
4919                 dir = e.which === OO.ui.Keys.DOWN ? 1 : ( e.which === OO.ui.Keys.UP ? -1 : 0 );
4921         if ( dir ) {
4922                 highlightedItem = this.results.getHighlightedItem();
4923                 if ( !highlightedItem ) {
4924                         highlightedItem = this.results.getSelectedItem();
4925                 }
4926                 nextItem = this.results.getRelativeSelectableItem( highlightedItem, dir );
4927                 this.results.highlightItem( nextItem );
4928                 nextItem.scrollElementIntoView();
4929         }
4933  * Handle select widget select events.
4935  * Clears existing results. Subclasses should repopulate items according to new query.
4937  * @private
4938  * @param {string} value New value
4939  */
4940 OO.ui.SearchWidget.prototype.onQueryChange = function () {
4941         // Reset
4942         this.results.clearItems();
4946  * Handle select widget enter key events.
4948  * Chooses highlighted item.
4950  * @private
4951  * @param {string} value New value
4952  */
4953 OO.ui.SearchWidget.prototype.onQueryEnter = function () {
4954         var highlightedItem = this.results.getHighlightedItem();
4955         if ( highlightedItem ) {
4956                 this.results.chooseItem( highlightedItem );
4957         }
4961  * Get the query input.
4963  * @return {OO.ui.TextInputWidget} Query input
4964  */
4965 OO.ui.SearchWidget.prototype.getQuery = function () {
4966         return this.query;
4970  * Get the search results menu.
4972  * @return {OO.ui.SelectWidget} Menu of search results
4973  */
4974 OO.ui.SearchWidget.prototype.getResults = function () {
4975         return this.results;
4979  * NumberInputWidgets combine a {@link OO.ui.TextInputWidget text input} (where a value
4980  * can be entered manually) and two {@link OO.ui.ButtonWidget button widgets}
4981  * (to adjust the value in increments) to allow the user to enter a number.
4983  *     @example
4984  *     // Example: A NumberInputWidget.
4985  *     var numberInput = new OO.ui.NumberInputWidget( {
4986  *         label: 'NumberInputWidget',
4987  *         input: { value: 5 },
4988  *         min: 1,
4989  *         max: 10
4990  *     } );
4991  *     $( 'body' ).append( numberInput.$element );
4993  * @class
4994  * @extends OO.ui.Widget
4996  * @constructor
4997  * @param {Object} [config] Configuration options
4998  * @cfg {Object} [input] Configuration options to pass to the {@link OO.ui.TextInputWidget text input widget}.
4999  * @cfg {Object} [minusButton] Configuration options to pass to the {@link OO.ui.ButtonWidget decrementing button widget}.
5000  * @cfg {Object} [plusButton] Configuration options to pass to the {@link OO.ui.ButtonWidget incrementing button widget}.
5001  * @cfg {boolean} [isInteger=false] Whether the field accepts only integer values.
5002  * @cfg {number} [min=-Infinity] Minimum allowed value
5003  * @cfg {number} [max=Infinity] Maximum allowed value
5004  * @cfg {number} [step=1] Delta when using the buttons or up/down arrow keys
5005  * @cfg {number|null} [pageStep] Delta when using the page-up/page-down keys. Defaults to 10 times #step.
5006  * @cfg {boolean} [showButtons=true] Whether to show the plus and minus buttons.
5007  */
5008 OO.ui.NumberInputWidget = function OoUiNumberInputWidget( config ) {
5009         // Configuration initialization
5010         config = $.extend( {
5011                 isInteger: false,
5012                 min: -Infinity,
5013                 max: Infinity,
5014                 step: 1,
5015                 pageStep: null,
5016                 showButtons: true
5017         }, config );
5019         // Parent constructor
5020         OO.ui.NumberInputWidget.parent.call( this, config );
5022         // Properties
5023         this.input = new OO.ui.TextInputWidget( $.extend(
5024                 {
5025                         disabled: this.isDisabled(),
5026                         type: 'number'
5027                 },
5028                 config.input
5029         ) );
5030         if ( config.showButtons ) {
5031                 this.minusButton = new OO.ui.ButtonWidget( $.extend(
5032                         {
5033                                 disabled: this.isDisabled(),
5034                                 tabIndex: -1
5035                         },
5036                         config.minusButton,
5037                         {
5038                                 classes: [ 'oo-ui-numberInputWidget-minusButton' ],
5039                                 label: '−'
5040                         }
5041                 ) );
5042                 this.plusButton = new OO.ui.ButtonWidget( $.extend(
5043                         {
5044                                 disabled: this.isDisabled(),
5045                                 tabIndex: -1
5046                         },
5047                         config.plusButton,
5048                         {
5049                                 classes: [ 'oo-ui-numberInputWidget-plusButton' ],
5050                                 label: '+'
5051                         }
5052                 ) );
5053         }
5055         // Events
5056         this.input.connect( this, {
5057                 change: this.emit.bind( this, 'change' ),
5058                 enter: this.emit.bind( this, 'enter' )
5059         } );
5060         this.input.$input.on( {
5061                 keydown: this.onKeyDown.bind( this ),
5062                 'wheel mousewheel DOMMouseScroll': this.onWheel.bind( this )
5063         } );
5064         if ( config.showButtons ) {
5065                 this.plusButton.connect( this, {
5066                         click: [ 'onButtonClick', +1 ]
5067                 } );
5068                 this.minusButton.connect( this, {
5069                         click: [ 'onButtonClick', -1 ]
5070                 } );
5071         }
5073         // Initialization
5074         this.setIsInteger( !!config.isInteger );
5075         this.setRange( config.min, config.max );
5076         this.setStep( config.step, config.pageStep );
5078         this.$field = $( '<div>' ).addClass( 'oo-ui-numberInputWidget-field' )
5079                 .append( this.input.$element );
5080         this.$element.addClass( 'oo-ui-numberInputWidget' ).append( this.$field );
5081         if ( config.showButtons ) {
5082                 this.$field
5083                         .prepend( this.minusButton.$element )
5084                         .append( this.plusButton.$element );
5085                 this.$element.addClass( 'oo-ui-numberInputWidget-buttoned' );
5086         }
5087         this.input.setValidation( this.validateNumber.bind( this ) );
5090 /* Setup */
5092 OO.inheritClass( OO.ui.NumberInputWidget, OO.ui.Widget );
5094 /* Events */
5097  * A `change` event is emitted when the value of the input changes.
5099  * @event change
5100  */
5103  * An `enter` event is emitted when the user presses 'enter' inside the text box.
5105  * @event enter
5106  */
5108 /* Methods */
5111  * Set whether only integers are allowed
5113  * @param {boolean} flag
5114  */
5115 OO.ui.NumberInputWidget.prototype.setIsInteger = function ( flag ) {
5116         this.isInteger = !!flag;
5117         this.input.setValidityFlag();
5121  * Get whether only integers are allowed
5123  * @return {boolean} Flag value
5124  */
5125 OO.ui.NumberInputWidget.prototype.getIsInteger = function () {
5126         return this.isInteger;
5130  * Set the range of allowed values
5132  * @param {number} min Minimum allowed value
5133  * @param {number} max Maximum allowed value
5134  */
5135 OO.ui.NumberInputWidget.prototype.setRange = function ( min, max ) {
5136         if ( min > max ) {
5137                 throw new Error( 'Minimum (' + min + ') must not be greater than maximum (' + max + ')' );
5138         }
5139         this.min = min;
5140         this.max = max;
5141         this.input.setValidityFlag();
5145  * Get the current range
5147  * @return {number[]} Minimum and maximum values
5148  */
5149 OO.ui.NumberInputWidget.prototype.getRange = function () {
5150         return [ this.min, this.max ];
5154  * Set the stepping deltas
5156  * @param {number} step Normal step
5157  * @param {number|null} pageStep Page step. If null, 10 * step will be used.
5158  */
5159 OO.ui.NumberInputWidget.prototype.setStep = function ( step, pageStep ) {
5160         if ( step <= 0 ) {
5161                 throw new Error( 'Step value must be positive' );
5162         }
5163         if ( pageStep === null ) {
5164                 pageStep = step * 10;
5165         } else if ( pageStep <= 0 ) {
5166                 throw new Error( 'Page step value must be positive' );
5167         }
5168         this.step = step;
5169         this.pageStep = pageStep;
5173  * Get the current stepping values
5175  * @return {number[]} Step and page step
5176  */
5177 OO.ui.NumberInputWidget.prototype.getStep = function () {
5178         return [ this.step, this.pageStep ];
5182  * Get the current value of the widget
5184  * @return {string}
5185  */
5186 OO.ui.NumberInputWidget.prototype.getValue = function () {
5187         return this.input.getValue();
5191  * Get the current value of the widget as a number
5193  * @return {number} May be NaN, or an invalid number
5194  */
5195 OO.ui.NumberInputWidget.prototype.getNumericValue = function () {
5196         return +this.input.getValue();
5200  * Set the value of the widget
5202  * @param {string} value Invalid values are allowed
5203  */
5204 OO.ui.NumberInputWidget.prototype.setValue = function ( value ) {
5205         this.input.setValue( value );
5209  * Adjust the value of the widget
5211  * @param {number} delta Adjustment amount
5212  */
5213 OO.ui.NumberInputWidget.prototype.adjustValue = function ( delta ) {
5214         var n, v = this.getNumericValue();
5216         delta = +delta;
5217         if ( isNaN( delta ) || !isFinite( delta ) ) {
5218                 throw new Error( 'Delta must be a finite number' );
5219         }
5221         if ( isNaN( v ) ) {
5222                 n = 0;
5223         } else {
5224                 n = v + delta;
5225                 n = Math.max( Math.min( n, this.max ), this.min );
5226                 if ( this.isInteger ) {
5227                         n = Math.round( n );
5228                 }
5229         }
5231         if ( n !== v ) {
5232                 this.setValue( n );
5233         }
5237  * Validate input
5239  * @private
5240  * @param {string} value Field value
5241  * @return {boolean}
5242  */
5243 OO.ui.NumberInputWidget.prototype.validateNumber = function ( value ) {
5244         var n = +value;
5245         if ( isNaN( n ) || !isFinite( n ) ) {
5246                 return false;
5247         }
5249         /*jshint bitwise: false */
5250         if ( this.isInteger && ( n | 0 ) !== n ) {
5251                 return false;
5252         }
5253         /*jshint bitwise: true */
5255         if ( n < this.min || n > this.max ) {
5256                 return false;
5257         }
5259         return true;
5263  * Handle mouse click events.
5265  * @private
5266  * @param {number} dir +1 or -1
5267  */
5268 OO.ui.NumberInputWidget.prototype.onButtonClick = function ( dir ) {
5269         this.adjustValue( dir * this.step );
5273  * Handle mouse wheel events.
5275  * @private
5276  * @param {jQuery.Event} event
5277  */
5278 OO.ui.NumberInputWidget.prototype.onWheel = function ( event ) {
5279         var delta = 0;
5281         if ( !this.isDisabled() && this.input.$input.is( ':focus' ) ) {
5282                 // Standard 'wheel' event
5283                 if ( event.originalEvent.deltaMode !== undefined ) {
5284                         this.sawWheelEvent = true;
5285                 }
5286                 if ( event.originalEvent.deltaY ) {
5287                         delta = -event.originalEvent.deltaY;
5288                 } else if ( event.originalEvent.deltaX ) {
5289                         delta = event.originalEvent.deltaX;
5290                 }
5292                 // Non-standard events
5293                 if ( !this.sawWheelEvent ) {
5294                         if ( event.originalEvent.wheelDeltaX ) {
5295                                 delta = -event.originalEvent.wheelDeltaX;
5296                         } else if ( event.originalEvent.wheelDeltaY ) {
5297                                 delta = event.originalEvent.wheelDeltaY;
5298                         } else if ( event.originalEvent.wheelDelta ) {
5299                                 delta = event.originalEvent.wheelDelta;
5300                         } else if ( event.originalEvent.detail ) {
5301                                 delta = -event.originalEvent.detail;
5302                         }
5303                 }
5305                 if ( delta ) {
5306                         delta = delta < 0 ? -1 : 1;
5307                         this.adjustValue( delta * this.step );
5308                 }
5310                 return false;
5311         }
5315  * Handle key down events.
5317  * @private
5318  * @param {jQuery.Event} e Key down event
5319  */
5320 OO.ui.NumberInputWidget.prototype.onKeyDown = function ( e ) {
5321         if ( !this.isDisabled() ) {
5322                 switch ( e.which ) {
5323                         case OO.ui.Keys.UP:
5324                                 this.adjustValue( this.step );
5325                                 return false;
5326                         case OO.ui.Keys.DOWN:
5327                                 this.adjustValue( -this.step );
5328                                 return false;
5329                         case OO.ui.Keys.PAGEUP:
5330                                 this.adjustValue( this.pageStep );
5331                                 return false;
5332                         case OO.ui.Keys.PAGEDOWN:
5333                                 this.adjustValue( -this.pageStep );
5334                                 return false;
5335                 }
5336         }
5340  * @inheritdoc
5341  */
5342 OO.ui.NumberInputWidget.prototype.setDisabled = function ( disabled ) {
5343         // Parent method
5344         OO.ui.NumberInputWidget.parent.prototype.setDisabled.call( this, disabled );
5346         if ( this.input ) {
5347                 this.input.setDisabled( this.isDisabled() );
5348         }
5349         if ( this.minusButton ) {
5350                 this.minusButton.setDisabled( this.isDisabled() );
5351         }
5352         if ( this.plusButton ) {
5353                 this.plusButton.setDisabled( this.isDisabled() );
5354         }
5356         return this;
5359 }( OO ) );