Update OOUI to v0.30.0
[lhc/web/wiklou.git] / resources / lib / ooui / oojs-ui-widgets.js
1 /*!
2 * OOUI v0.30.0
3 * https://www.mediawiki.org/wiki/OOUI
4 *
5 * Copyright 2011–2018 OOUI Team and other contributors.
6 * Released under the MIT license
7 * http://oojs.mit-license.org
8 *
9 * Date: 2018-12-20T06:10:28Z
10 */
11 ( function ( OO ) {
12
13 'use strict';
14
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 * @cfg {boolean} [draggable] The items are draggable. This can change with #toggleDraggable
28 * but the draggable state should be called from the DraggableGroupElement, which updates
29 * the whole group
30 */
31 OO.ui.mixin.DraggableElement = function OoUiMixinDraggableElement( config ) {
32 config = config || {};
33
34 // Properties
35 this.index = null;
36 this.$handle = config.$handle || this.$element;
37 this.wasHandleUsed = null;
38
39 // Initialize and events
40 this.$element
41 .addClass( 'oo-ui-draggableElement' )
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' );
50 this.toggleDraggable( config.draggable === undefined ? true : !!config.draggable );
51 };
52
53 OO.initClass( OO.ui.mixin.DraggableElement );
54
55 /* Events */
56
57 /**
58 * @event dragstart
59 *
60 * A dragstart event is emitted when the user clicks and begins dragging an item.
61 * @param {OO.ui.mixin.DraggableElement} item The item the user has clicked and is dragging with the mouse.
62 */
63
64 /**
65 * @event dragend
66 * A dragend event is emitted when the user drags an item and releases the mouse,
67 * thus terminating the drag operation.
68 */
69
70 /**
71 * @event drop
72 * A drop event is emitted when the user drags an item and then releases the mouse button
73 * over a valid target.
74 */
75
76 /* Static Properties */
77
78 /**
79 * @inheritdoc OO.ui.mixin.ButtonElement
80 */
81 OO.ui.mixin.DraggableElement.static.cancelButtonMouseDownEvents = false;
82
83 /* Methods */
84
85 /**
86 * Change the draggable state of this widget.
87 * This allows users to temporarily halt the dragging operations.
88 *
89 * @param {boolean} isDraggable Widget supports draggable operations
90 * @fires draggable
91 */
92 OO.ui.mixin.DraggableElement.prototype.toggleDraggable = function ( isDraggable ) {
93 isDraggable = isDraggable !== undefined ? !!isDraggable : !this.draggable;
94
95 if ( this.draggable !== isDraggable ) {
96 this.draggable = isDraggable;
97
98 this.$handle.toggleClass( 'oo-ui-draggableElement-undraggable', !this.draggable );
99
100 // We make the entire element draggable, not just the handle, so that
101 // the whole element appears to move. wasHandleUsed prevents drags from
102 // starting outside the handle
103 this.$element.prop( 'draggable', this.draggable );
104 }
105 };
106
107 /**
108 * Check the draggable state of this widget
109 *
110 * @return {boolean} Widget supports draggable operations
111 */
112 OO.ui.mixin.DraggableElement.prototype.isDraggable = function () {
113 return this.draggable;
114 };
115
116 /**
117 * Respond to mousedown event.
118 *
119 * @private
120 * @param {jQuery.Event} e Drag event
121 */
122 OO.ui.mixin.DraggableElement.prototype.onDragMouseDown = function ( e ) {
123 if ( !this.isDraggable() ) {
124 return;
125 }
126
127 this.wasHandleUsed =
128 // Optimization: if the handle is the whole element this is always true
129 this.$handle[ 0 ] === this.$element[ 0 ] ||
130 // Check the mousedown occurred inside the handle
131 OO.ui.contains( this.$handle[ 0 ], e.target, true );
132 };
133
134 /**
135 * Respond to dragstart event.
136 *
137 * @private
138 * @param {jQuery.Event} e Drag event
139 * @return {boolean} False if the event is cancelled
140 * @fires dragstart
141 */
142 OO.ui.mixin.DraggableElement.prototype.onDragStart = function ( e ) {
143 var element = this,
144 dataTransfer = e.originalEvent.dataTransfer;
145
146 if ( !this.wasHandleUsed || !this.isDraggable() ) {
147 return false;
148 }
149
150 // Define drop effect
151 dataTransfer.dropEffect = 'none';
152 dataTransfer.effectAllowed = 'move';
153 // Support: Firefox
154 // We must set up a dataTransfer data property or Firefox seems to
155 // ignore the fact the element is draggable.
156 try {
157 dataTransfer.setData( 'application-x/OOUI-draggable', this.getIndex() );
158 } catch ( err ) {
159 // The above is only for Firefox. Move on if it fails.
160 }
161 // Briefly add a 'clone' class to style the browser's native drag image
162 this.$element.addClass( 'oo-ui-draggableElement-clone' );
163 // Add placeholder class after the browser has rendered the clone
164 setTimeout( function () {
165 element.$element
166 .removeClass( 'oo-ui-draggableElement-clone' )
167 .addClass( 'oo-ui-draggableElement-placeholder' );
168 } );
169 // Emit event
170 this.emit( 'dragstart', this );
171 return true;
172 };
173
174 /**
175 * Respond to dragend event.
176 *
177 * @private
178 * @fires dragend
179 */
180 OO.ui.mixin.DraggableElement.prototype.onDragEnd = function () {
181 this.$element.removeClass( 'oo-ui-draggableElement-placeholder' );
182 this.emit( 'dragend' );
183 };
184
185 /**
186 * Handle drop event.
187 *
188 * @private
189 * @param {jQuery.Event} e Drop event
190 * @fires drop
191 */
192 OO.ui.mixin.DraggableElement.prototype.onDrop = function ( e ) {
193 e.preventDefault();
194 this.emit( 'drop', e );
195 };
196
197 /**
198 * In order for drag/drop to work, the dragover event must
199 * return false and stop propogation.
200 *
201 * @param {jQuery.Event} e Drag event
202 * @private
203 */
204 OO.ui.mixin.DraggableElement.prototype.onDragOver = function ( e ) {
205 e.preventDefault();
206 };
207
208 /**
209 * Set item index.
210 * Store it in the DOM so we can access from the widget drag event
211 *
212 * @private
213 * @param {number} index Item index
214 */
215 OO.ui.mixin.DraggableElement.prototype.setIndex = function ( index ) {
216 if ( this.index !== index ) {
217 this.index = index;
218 this.$element.data( 'index', index );
219 }
220 };
221
222 /**
223 * Get item index
224 *
225 * @private
226 * @return {number} Item index
227 */
228 OO.ui.mixin.DraggableElement.prototype.getIndex = function () {
229 return this.index;
230 };
231
232 /**
233 * DraggableGroupElement is a mixin class used to create a group element to
234 * contain draggable elements, which are items that can be clicked and dragged by a mouse.
235 * The class is used with OO.ui.mixin.DraggableElement.
236 *
237 * @abstract
238 * @class
239 * @mixins OO.ui.mixin.GroupElement
240 *
241 * @constructor
242 * @param {Object} [config] Configuration options
243 * @cfg {string} [orientation] Item orientation: 'horizontal' or 'vertical'. The orientation
244 * should match the layout of the items. Items displayed in a single row
245 * or in several rows should use horizontal orientation. The vertical orientation should only be
246 * used when the items are displayed in a single column. Defaults to 'vertical'
247 * @cfg {boolean} [draggable] The items are draggable. This can change with #toggleDraggable
248 */
249 OO.ui.mixin.DraggableGroupElement = function OoUiMixinDraggableGroupElement( config ) {
250 // Configuration initialization
251 config = config || {};
252
253 // Parent constructor
254 OO.ui.mixin.GroupElement.call( this, config );
255
256 // Properties
257 this.orientation = config.orientation || 'vertical';
258 this.dragItem = null;
259 this.itemKeys = {};
260 this.dir = null;
261 this.itemsOrder = null;
262 this.draggable = config.draggable === undefined ? true : !!config.draggable;
263
264 // Events
265 this.aggregate( {
266 dragstart: 'itemDragStart',
267 dragend: 'itemDragEnd',
268 drop: 'itemDrop'
269 } );
270 this.connect( this, {
271 itemDragStart: 'onItemDragStart',
272 itemDrop: 'onItemDropOrDragEnd',
273 itemDragEnd: 'onItemDropOrDragEnd'
274 } );
275
276 // Initialize
277 if ( Array.isArray( config.items ) ) {
278 this.addItems( config.items );
279 }
280 this.$element
281 .addClass( 'oo-ui-draggableGroupElement' )
282 .toggleClass( 'oo-ui-draggableGroupElement-horizontal', this.orientation === 'horizontal' );
283 };
284
285 /* Setup */
286 OO.mixinClass( OO.ui.mixin.DraggableGroupElement, OO.ui.mixin.GroupElement );
287
288 /* Events */
289
290 /**
291 * An item has been dragged to a new position, but not yet dropped.
292 *
293 * @event drag
294 * @param {OO.ui.mixin.DraggableElement} item Dragged item
295 * @param {number} [newIndex] New index for the item
296 */
297
298 /**
299 * An item has been dropped at a new position.
300 *
301 * @event reorder
302 * @param {OO.ui.mixin.DraggableElement} item Reordered item
303 * @param {number} [newIndex] New index for the item
304 */
305
306 /**
307 * Draggable state of this widget has changed.
308 *
309 * @event draggable
310 * @param {boolean} [draggable] Widget is draggable
311 */
312
313 /* Methods */
314
315 /**
316 * Change the draggable state of this widget.
317 * This allows users to temporarily halt the dragging operations.
318 *
319 * @param {boolean} isDraggable Widget supports draggable operations
320 * @fires draggable
321 */
322 OO.ui.mixin.DraggableGroupElement.prototype.toggleDraggable = function ( isDraggable ) {
323 isDraggable = isDraggable !== undefined ? !!isDraggable : !this.draggable;
324
325 if ( this.draggable !== isDraggable ) {
326 this.draggable = isDraggable;
327
328 // Tell the items their draggable state changed
329 this.getItems().forEach( function ( item ) {
330 item.toggleDraggable( this.draggable );
331 }.bind( this ) );
332
333 // Emit event
334 this.emit( 'draggable', this.draggable );
335 }
336 };
337
338 /**
339 * Check the draggable state of this widget
340 *
341 * @return {boolean} Widget supports draggable operations
342 */
343 OO.ui.mixin.DraggableGroupElement.prototype.isDraggable = function () {
344 return this.draggable;
345 };
346
347 /**
348 * Respond to item drag start event
349 *
350 * @private
351 * @param {OO.ui.mixin.DraggableElement} item Dragged item
352 */
353 OO.ui.mixin.DraggableGroupElement.prototype.onItemDragStart = function ( item ) {
354 if ( !this.isDraggable() ) {
355 return;
356 }
357 // Make a shallow copy of this.items so we can re-order it during previews
358 // without affecting the original array.
359 this.itemsOrder = this.items.slice();
360 this.updateIndexes();
361 if ( this.orientation === 'horizontal' ) {
362 // Calculate and cache directionality on drag start - it's a little
363 // expensive and it shouldn't change while dragging.
364 this.dir = this.$element.css( 'direction' );
365 }
366 this.setDragItem( item );
367 };
368
369 /**
370 * Update the index properties of the items
371 */
372 OO.ui.mixin.DraggableGroupElement.prototype.updateIndexes = function () {
373 var i, len;
374
375 // Map the index of each object
376 for ( i = 0, len = this.itemsOrder.length; i < len; i++ ) {
377 this.itemsOrder[ i ].setIndex( i );
378 }
379 };
380
381 /**
382 * Handle drop or dragend event and switch the order of the items accordingly
383 *
384 * @private
385 * @param {OO.ui.mixin.DraggableElement} item Dropped item
386 * @return {OO.ui.Element} The element, for chaining
387 */
388 OO.ui.mixin.DraggableGroupElement.prototype.onItemDropOrDragEnd = function () {
389 var targetIndex, originalIndex,
390 item = this.getDragItem();
391
392 // TODO: Figure out a way to configure a list of legally droppable
393 // elements even if they are not yet in the list
394 if ( item ) {
395 originalIndex = this.items.indexOf( item );
396 // If the item has moved forward, add one to the index to account for the left shift
397 targetIndex = item.getIndex() + ( item.getIndex() > originalIndex ? 1 : 0 );
398 if ( targetIndex !== originalIndex ) {
399 this.reorder( this.getDragItem(), targetIndex );
400 this.emit( 'reorder', this.getDragItem(), targetIndex );
401 }
402 this.updateIndexes();
403 }
404 this.unsetDragItem();
405 // Return false to prevent propogation
406 return false;
407 };
408
409 /**
410 * Respond to dragover event
411 *
412 * @private
413 * @param {jQuery.Event} e Dragover event
414 * @fires reorder
415 */
416 OO.ui.mixin.DraggableGroupElement.prototype.onDragOver = function ( e ) {
417 var overIndex, targetIndex,
418 item = this.getDragItem(),
419 dragItemIndex = item.getIndex();
420
421 // Get the OptionWidget item we are dragging over
422 overIndex = $( e.target ).closest( '.oo-ui-draggableElement' ).data( 'index' );
423
424 if ( overIndex !== undefined && overIndex !== dragItemIndex ) {
425 targetIndex = overIndex + ( overIndex > dragItemIndex ? 1 : 0 );
426
427 if ( targetIndex > 0 ) {
428 this.$group.children().eq( targetIndex - 1 ).after( item.$element );
429 } else {
430 this.$group.prepend( item.$element );
431 }
432 // Move item in itemsOrder array
433 this.itemsOrder.splice( overIndex, 0,
434 this.itemsOrder.splice( dragItemIndex, 1 )[ 0 ]
435 );
436 this.updateIndexes();
437 this.emit( 'drag', item, targetIndex );
438 }
439 // Prevent default
440 e.preventDefault();
441 };
442
443 /**
444 * Reorder the items in the group
445 *
446 * @param {OO.ui.mixin.DraggableElement} item Reordered item
447 * @param {number} newIndex New index
448 */
449 OO.ui.mixin.DraggableGroupElement.prototype.reorder = function ( item, newIndex ) {
450 this.addItems( [ item ], newIndex );
451 };
452
453 /**
454 * Set a dragged item
455 *
456 * @param {OO.ui.mixin.DraggableElement} item Dragged item
457 */
458 OO.ui.mixin.DraggableGroupElement.prototype.setDragItem = function ( item ) {
459 if ( this.dragItem !== item ) {
460 this.dragItem = item;
461 this.$element.on( 'dragover', this.onDragOver.bind( this ) );
462 this.$element.addClass( 'oo-ui-draggableGroupElement-dragging' );
463 }
464 };
465
466 /**
467 * Unset the current dragged item
468 */
469 OO.ui.mixin.DraggableGroupElement.prototype.unsetDragItem = function () {
470 if ( this.dragItem ) {
471 this.dragItem = null;
472 this.$element.off( 'dragover' );
473 this.$element.removeClass( 'oo-ui-draggableGroupElement-dragging' );
474 }
475 };
476
477 /**
478 * Get the item that is currently being dragged.
479 *
480 * @return {OO.ui.mixin.DraggableElement|null} The currently dragged item, or `null` if no item is being dragged
481 */
482 OO.ui.mixin.DraggableGroupElement.prototype.getDragItem = function () {
483 return this.dragItem;
484 };
485
486 /**
487 * RequestManager is a mixin that manages the lifecycle of a promise-backed request for a widget, such as
488 * the {@link OO.ui.mixin.LookupElement}.
489 *
490 * @class
491 * @abstract
492 *
493 * @constructor
494 */
495 OO.ui.mixin.RequestManager = function OoUiMixinRequestManager() {
496 this.requestCache = {};
497 this.requestQuery = null;
498 this.requestRequest = null;
499 };
500
501 /* Setup */
502
503 OO.initClass( OO.ui.mixin.RequestManager );
504
505 /**
506 * Get request results for the current query.
507 *
508 * @return {jQuery.Promise} Promise object which will be passed response data as the first argument of
509 * the done event. If the request was aborted to make way for a subsequent request, this promise
510 * may not be rejected, depending on what jQuery feels like doing.
511 */
512 OO.ui.mixin.RequestManager.prototype.getRequestData = function () {
513 var widget = this,
514 value = this.getRequestQuery(),
515 deferred = $.Deferred(),
516 ourRequest;
517
518 this.abortRequest();
519 if ( Object.prototype.hasOwnProperty.call( this.requestCache, value ) ) {
520 deferred.resolve( this.requestCache[ value ] );
521 } else {
522 if ( this.pushPending ) {
523 this.pushPending();
524 }
525 this.requestQuery = value;
526 ourRequest = this.requestRequest = this.getRequest();
527 ourRequest
528 .always( function () {
529 // We need to pop pending even if this is an old request, otherwise
530 // the widget will remain pending forever.
531 // TODO: this assumes that an aborted request will fail or succeed soon after
532 // being aborted, or at least eventually. It would be nice if we could popPending()
533 // at abort time, but only if we knew that we hadn't already called popPending()
534 // for that request.
535 if ( widget.popPending ) {
536 widget.popPending();
537 }
538 } )
539 .done( function ( response ) {
540 // If this is an old request (and aborting it somehow caused it to still succeed),
541 // ignore its success completely
542 if ( ourRequest === widget.requestRequest ) {
543 widget.requestQuery = null;
544 widget.requestRequest = null;
545 widget.requestCache[ value ] = widget.getRequestCacheDataFromResponse( response );
546 deferred.resolve( widget.requestCache[ value ] );
547 }
548 } )
549 .fail( function () {
550 // If this is an old request (or a request failing because it's being aborted),
551 // ignore its failure completely
552 if ( ourRequest === widget.requestRequest ) {
553 widget.requestQuery = null;
554 widget.requestRequest = null;
555 deferred.reject();
556 }
557 } );
558 }
559 return deferred.promise();
560 };
561
562 /**
563 * Abort the currently pending request, if any.
564 *
565 * @private
566 */
567 OO.ui.mixin.RequestManager.prototype.abortRequest = function () {
568 var oldRequest = this.requestRequest;
569 if ( oldRequest ) {
570 // First unset this.requestRequest to the fail handler will notice
571 // that the request is no longer current
572 this.requestRequest = null;
573 this.requestQuery = null;
574 oldRequest.abort();
575 }
576 };
577
578 /**
579 * Get the query to be made.
580 *
581 * @protected
582 * @method
583 * @abstract
584 * @return {string} query to be used
585 */
586 OO.ui.mixin.RequestManager.prototype.getRequestQuery = null;
587
588 /**
589 * Get a new request object of the current query value.
590 *
591 * @protected
592 * @method
593 * @abstract
594 * @return {jQuery.Promise} jQuery AJAX object, or promise object with an .abort() method
595 */
596 OO.ui.mixin.RequestManager.prototype.getRequest = null;
597
598 /**
599 * Pre-process data returned by the request from #getRequest.
600 *
601 * The return value of this function will be cached, and any further queries for the given value
602 * will use the cache rather than doing API requests.
603 *
604 * @protected
605 * @method
606 * @abstract
607 * @param {Mixed} response Response from server
608 * @return {Mixed} Cached result data
609 */
610 OO.ui.mixin.RequestManager.prototype.getRequestCacheDataFromResponse = null;
611
612 /**
613 * LookupElement is a mixin that creates a {@link OO.ui.MenuSelectWidget menu} of suggested values for
614 * a {@link OO.ui.TextInputWidget text input widget}. Suggested values are based on the characters the user types
615 * into the text input field and, in general, the menu is only displayed when the user types. If a suggested value is chosen
616 * from the lookup menu, that value becomes the value of the input field.
617 *
618 * Note that a new menu of suggested items is displayed when a value is chosen from the lookup menu. If this is
619 * not the desired behavior, disable lookup menus with the #setLookupsDisabled method, then set the value, then
620 * re-enable lookups.
621 *
622 * See the [OOUI demos][1] for an example.
623 *
624 * [1]: https://doc.wikimedia.org/oojs-ui/master/demos/#LookupElement-try-inputting-an-integer
625 *
626 * @class
627 * @abstract
628 * @mixins OO.ui.mixin.RequestManager
629 *
630 * @constructor
631 * @param {Object} [config] Configuration options
632 * @cfg {jQuery} [$overlay] Overlay for the lookup menu; defaults to relative positioning.
633 * See <https://www.mediawiki.org/wiki/OOUI/Concepts#Overlays>.
634 * @cfg {jQuery} [$container=this.$element] The container element. The lookup menu is rendered beneath the specified element.
635 * @cfg {Object} [menu] Configuration options to pass to {@link OO.ui.MenuSelectWidget menu select widget}
636 * @cfg {boolean} [allowSuggestionsWhenEmpty=false] Request and display a lookup menu when the text input is empty.
637 * By default, the lookup menu is not generated and displayed until the user begins to type.
638 * @cfg {boolean} [highlightFirst=true] Whether the first lookup result should be highlighted (so, that the user can
639 * take it over into the input with simply pressing return) automatically or not.
640 */
641 OO.ui.mixin.LookupElement = function OoUiMixinLookupElement( config ) {
642 // Configuration initialization
643 config = $.extend( { highlightFirst: true }, config );
644
645 // Mixin constructors
646 OO.ui.mixin.RequestManager.call( this, config );
647
648 // Properties
649 this.$overlay = ( config.$overlay === true ? OO.ui.getDefaultOverlay() : config.$overlay ) || this.$element;
650 this.lookupMenu = new OO.ui.MenuSelectWidget( $.extend( {
651 widget: this,
652 input: this,
653 $floatableContainer: config.$container || this.$element
654 }, config.menu ) );
655
656 this.allowSuggestionsWhenEmpty = config.allowSuggestionsWhenEmpty || false;
657
658 this.lookupsDisabled = false;
659 this.lookupInputFocused = false;
660 this.lookupHighlightFirstItem = config.highlightFirst;
661
662 // Events
663 this.$input.on( {
664 focus: this.onLookupInputFocus.bind( this ),
665 blur: this.onLookupInputBlur.bind( this ),
666 mousedown: this.onLookupInputMouseDown.bind( this )
667 } );
668 this.connect( this, { change: 'onLookupInputChange' } );
669 this.lookupMenu.connect( this, {
670 toggle: 'onLookupMenuToggle',
671 choose: 'onLookupMenuItemChoose'
672 } );
673
674 // Initialization
675 this.$input.attr( {
676 role: 'combobox',
677 'aria-expanded': 'false',
678 'aria-owns': this.lookupMenu.getElementId(),
679 'aria-autocomplete': 'list'
680 } );
681 this.$element.addClass( 'oo-ui-lookupElement' );
682 this.lookupMenu.$element.addClass( 'oo-ui-lookupElement-menu' );
683 this.$overlay.append( this.lookupMenu.$element );
684 };
685
686 /* Setup */
687
688 OO.mixinClass( OO.ui.mixin.LookupElement, OO.ui.mixin.RequestManager );
689
690 /* Methods */
691
692 /**
693 * Handle input focus event.
694 *
695 * @protected
696 * @param {jQuery.Event} e Input focus event
697 */
698 OO.ui.mixin.LookupElement.prototype.onLookupInputFocus = function () {
699 this.lookupInputFocused = true;
700 this.populateLookupMenu();
701 };
702
703 /**
704 * Handle input blur event.
705 *
706 * @protected
707 * @param {jQuery.Event} e Input blur event
708 */
709 OO.ui.mixin.LookupElement.prototype.onLookupInputBlur = function () {
710 this.closeLookupMenu();
711 this.lookupInputFocused = false;
712 };
713
714 /**
715 * Handle input mouse down event.
716 *
717 * @protected
718 * @param {jQuery.Event} e Input mouse down event
719 */
720 OO.ui.mixin.LookupElement.prototype.onLookupInputMouseDown = function () {
721 // Only open the menu if the input was already focused.
722 // This way we allow the user to open the menu again after closing it with Esc
723 // by clicking in the input. Opening (and populating) the menu when initially
724 // clicking into the input is handled by the focus handler.
725 if ( this.lookupInputFocused && !this.lookupMenu.isVisible() ) {
726 this.populateLookupMenu();
727 }
728 };
729
730 /**
731 * Handle input change event.
732 *
733 * @protected
734 * @param {string} value New input value
735 */
736 OO.ui.mixin.LookupElement.prototype.onLookupInputChange = function () {
737 if ( this.lookupInputFocused ) {
738 this.populateLookupMenu();
739 }
740 };
741
742 /**
743 * Handle the lookup menu being shown/hidden.
744 *
745 * @protected
746 * @param {boolean} visible Whether the lookup menu is now visible.
747 */
748 OO.ui.mixin.LookupElement.prototype.onLookupMenuToggle = function ( visible ) {
749 if ( !visible ) {
750 // When the menu is hidden, abort any active request and clear the menu.
751 // This has to be done here in addition to closeLookupMenu(), because
752 // MenuSelectWidget will close itself when the user presses Esc.
753 this.abortLookupRequest();
754 this.lookupMenu.clearItems();
755 }
756 };
757
758 /**
759 * Handle menu item 'choose' event, updating the text input value to the value of the clicked item.
760 *
761 * @protected
762 * @param {OO.ui.MenuOptionWidget} item Selected item
763 */
764 OO.ui.mixin.LookupElement.prototype.onLookupMenuItemChoose = function ( item ) {
765 this.setValue( item.getData() );
766 };
767
768 /**
769 * Get lookup menu.
770 *
771 * @private
772 * @return {OO.ui.MenuSelectWidget}
773 */
774 OO.ui.mixin.LookupElement.prototype.getLookupMenu = function () {
775 return this.lookupMenu;
776 };
777
778 /**
779 * Disable or re-enable lookups.
780 *
781 * When lookups are disabled, calls to #populateLookupMenu will be ignored.
782 *
783 * @param {boolean} disabled Disable lookups
784 */
785 OO.ui.mixin.LookupElement.prototype.setLookupsDisabled = function ( disabled ) {
786 this.lookupsDisabled = !!disabled;
787 };
788
789 /**
790 * Open the menu. If there are no entries in the menu, this does nothing.
791 *
792 * @private
793 * @chainable
794 * @return {OO.ui.Element} The element, for chaining
795 */
796 OO.ui.mixin.LookupElement.prototype.openLookupMenu = function () {
797 if ( !this.lookupMenu.isEmpty() ) {
798 this.lookupMenu.toggle( true );
799 }
800 return this;
801 };
802
803 /**
804 * Close the menu, empty it, and abort any pending request.
805 *
806 * @private
807 * @chainable
808 * @return {OO.ui.Element} The element, for chaining
809 */
810 OO.ui.mixin.LookupElement.prototype.closeLookupMenu = function () {
811 this.lookupMenu.toggle( false );
812 this.abortLookupRequest();
813 this.lookupMenu.clearItems();
814 return this;
815 };
816
817 /**
818 * Request menu items based on the input's current value, and when they arrive,
819 * populate the menu with these items and show the menu.
820 *
821 * If lookups have been disabled with #setLookupsDisabled, this function does nothing.
822 *
823 * @private
824 * @chainable
825 * @return {OO.ui.Element} The element, for chaining
826 */
827 OO.ui.mixin.LookupElement.prototype.populateLookupMenu = function () {
828 var widget = this,
829 value = this.getValue();
830
831 if ( this.lookupsDisabled || this.isReadOnly() ) {
832 return;
833 }
834
835 // If the input is empty, clear the menu, unless suggestions when empty are allowed.
836 if ( !this.allowSuggestionsWhenEmpty && value === '' ) {
837 this.closeLookupMenu();
838 // Skip population if there is already a request pending for the current value
839 } else if ( value !== this.lookupQuery ) {
840 this.getLookupMenuItems()
841 .done( function ( items ) {
842 widget.lookupMenu.clearItems();
843 if ( items.length ) {
844 widget.lookupMenu
845 .addItems( items )
846 .toggle( true );
847 widget.initializeLookupMenuSelection();
848 } else {
849 widget.lookupMenu.toggle( false );
850 }
851 } )
852 .fail( function () {
853 widget.lookupMenu.clearItems();
854 widget.lookupMenu.toggle( false );
855 } );
856 }
857
858 return this;
859 };
860
861 /**
862 * Highlight the first selectable item in the menu, if configured.
863 *
864 * @private
865 * @chainable
866 */
867 OO.ui.mixin.LookupElement.prototype.initializeLookupMenuSelection = function () {
868 if ( this.lookupHighlightFirstItem && !this.lookupMenu.findSelectedItem() ) {
869 this.lookupMenu.highlightItem( this.lookupMenu.findFirstSelectableItem() );
870 }
871 };
872
873 /**
874 * Get lookup menu items for the current query.
875 *
876 * @private
877 * @return {jQuery.Promise} Promise object which will be passed menu items as the first argument of
878 * the done event. If the request was aborted to make way for a subsequent request, this promise
879 * will not be rejected: it will remain pending forever.
880 */
881 OO.ui.mixin.LookupElement.prototype.getLookupMenuItems = function () {
882 return this.getRequestData().then( function ( data ) {
883 return this.getLookupMenuOptionsFromData( data );
884 }.bind( this ) );
885 };
886
887 /**
888 * Abort the currently pending lookup request, if any.
889 *
890 * @private
891 */
892 OO.ui.mixin.LookupElement.prototype.abortLookupRequest = function () {
893 this.abortRequest();
894 };
895
896 /**
897 * Get a new request object of the current lookup query value.
898 *
899 * @protected
900 * @method
901 * @abstract
902 * @return {jQuery.Promise} jQuery AJAX object, or promise object with an .abort() method
903 */
904 OO.ui.mixin.LookupElement.prototype.getLookupRequest = null;
905
906 /**
907 * Pre-process data returned by the request from #getLookupRequest.
908 *
909 * The return value of this function will be cached, and any further queries for the given value
910 * will use the cache rather than doing API requests.
911 *
912 * @protected
913 * @method
914 * @abstract
915 * @param {Mixed} response Response from server
916 * @return {Mixed} Cached result data
917 */
918 OO.ui.mixin.LookupElement.prototype.getLookupCacheDataFromResponse = null;
919
920 /**
921 * Get a list of menu option widgets from the (possibly cached) data returned by
922 * #getLookupCacheDataFromResponse.
923 *
924 * @protected
925 * @method
926 * @abstract
927 * @param {Mixed} data Cached result data, usually an array
928 * @return {OO.ui.MenuOptionWidget[]} Menu items
929 */
930 OO.ui.mixin.LookupElement.prototype.getLookupMenuOptionsFromData = null;
931
932 /**
933 * Set the read-only state of the widget.
934 *
935 * This will also disable/enable the lookups functionality.
936 *
937 * @param {boolean} readOnly Make input read-only
938 * @chainable
939 * @return {OO.ui.Element} The element, for chaining
940 */
941 OO.ui.mixin.LookupElement.prototype.setReadOnly = function ( readOnly ) {
942 // Parent method
943 // Note: Calling #setReadOnly this way assumes this is mixed into an OO.ui.TextInputWidget
944 OO.ui.TextInputWidget.prototype.setReadOnly.call( this, readOnly );
945
946 // During construction, #setReadOnly is called before the OO.ui.mixin.LookupElement constructor
947 if ( this.isReadOnly() && this.lookupMenu ) {
948 this.closeLookupMenu();
949 }
950
951 return this;
952 };
953
954 /**
955 * @inheritdoc OO.ui.mixin.RequestManager
956 */
957 OO.ui.mixin.LookupElement.prototype.getRequestQuery = function () {
958 return this.getValue();
959 };
960
961 /**
962 * @inheritdoc OO.ui.mixin.RequestManager
963 */
964 OO.ui.mixin.LookupElement.prototype.getRequest = function () {
965 return this.getLookupRequest();
966 };
967
968 /**
969 * @inheritdoc OO.ui.mixin.RequestManager
970 */
971 OO.ui.mixin.LookupElement.prototype.getRequestCacheDataFromResponse = function ( response ) {
972 return this.getLookupCacheDataFromResponse( response );
973 };
974
975 /**
976 * TabPanelLayouts are used within {@link OO.ui.IndexLayout index layouts} to create tab panels that
977 * users can select and display from the index's optional {@link OO.ui.TabSelectWidget tab}
978 * navigation. TabPanels are usually not instantiated directly, rather extended to include the
979 * required content and functionality.
980 *
981 * Each tab panel must have a unique symbolic name, which is passed to the constructor. In addition,
982 * the tab panel's tab item is customized (with a label) using the #setupTabItem method. See
983 * {@link OO.ui.IndexLayout IndexLayout} for an example.
984 *
985 * @class
986 * @extends OO.ui.PanelLayout
987 *
988 * @constructor
989 * @param {string} name Unique symbolic name of tab panel
990 * @param {Object} [config] Configuration options
991 * @cfg {jQuery|string|Function|OO.ui.HtmlSnippet} [label] Label for tab panel's tab
992 */
993 OO.ui.TabPanelLayout = function OoUiTabPanelLayout( name, config ) {
994 // Allow passing positional parameters inside the config object
995 if ( OO.isPlainObject( name ) && config === undefined ) {
996 config = name;
997 name = config.name;
998 }
999
1000 // Configuration initialization
1001 config = $.extend( { scrollable: true }, config );
1002
1003 // Parent constructor
1004 OO.ui.TabPanelLayout.parent.call( this, config );
1005
1006 // Properties
1007 this.name = name;
1008 this.label = config.label;
1009 this.tabItem = null;
1010 this.active = false;
1011
1012 // Initialization
1013 this.$element
1014 .addClass( 'oo-ui-tabPanelLayout' )
1015 .attr( 'role', 'tabpanel' );
1016 };
1017
1018 /* Setup */
1019
1020 OO.inheritClass( OO.ui.TabPanelLayout, OO.ui.PanelLayout );
1021
1022 /* Events */
1023
1024 /**
1025 * An 'active' event is emitted when the tab panel becomes active. Tab panels become active when they are
1026 * shown in a index layout that is configured to display only one tab panel at a time.
1027 *
1028 * @event active
1029 * @param {boolean} active Tab panel is active
1030 */
1031
1032 /* Methods */
1033
1034 /**
1035 * Get the symbolic name of the tab panel.
1036 *
1037 * @return {string} Symbolic name of tab panel
1038 */
1039 OO.ui.TabPanelLayout.prototype.getName = function () {
1040 return this.name;
1041 };
1042
1043 /**
1044 * Check if tab panel is active.
1045 *
1046 * Tab panels become active when they are shown in a {@link OO.ui.IndexLayout index layout} that is configured to
1047 * display only one tab panel at a time. Additional CSS is applied to the tab panel's tab item to reflect the
1048 * active state.
1049 *
1050 * @return {boolean} Tab panel is active
1051 */
1052 OO.ui.TabPanelLayout.prototype.isActive = function () {
1053 return this.active;
1054 };
1055
1056 /**
1057 * Get tab item.
1058 *
1059 * The tab item allows users to access the tab panel from the index's tab
1060 * navigation. The tab item itself can be customized (with a label, level, etc.) using the #setupTabItem method.
1061 *
1062 * @return {OO.ui.TabOptionWidget|null} Tab option widget
1063 */
1064 OO.ui.TabPanelLayout.prototype.getTabItem = function () {
1065 return this.tabItem;
1066 };
1067
1068 /**
1069 * Set or unset the tab item.
1070 *
1071 * Specify a {@link OO.ui.TabOptionWidget tab option} to set it,
1072 * or `null` to clear the tab item. To customize the tab item itself (e.g., to set a label or tab
1073 * level), use #setupTabItem instead of this method.
1074 *
1075 * @param {OO.ui.TabOptionWidget|null} tabItem Tab option widget, null to clear
1076 * @chainable
1077 * @return {OO.ui.TabPanelLayout} The layout, for chaining
1078 */
1079 OO.ui.TabPanelLayout.prototype.setTabItem = function ( tabItem ) {
1080 this.tabItem = tabItem || null;
1081 if ( tabItem ) {
1082 this.setupTabItem();
1083 }
1084 return this;
1085 };
1086
1087 /**
1088 * Set up the tab item.
1089 *
1090 * Use this method to customize the tab item (e.g., to add a label or tab level). To set or unset
1091 * the tab item itself (with a {@link OO.ui.TabOptionWidget tab option} or `null`), use
1092 * the #setTabItem method instead.
1093 *
1094 * @param {OO.ui.TabOptionWidget} tabItem Tab option widget to set up
1095 * @chainable
1096 * @return {OO.ui.TabPanelLayout} The layout, for chaining
1097 */
1098 OO.ui.TabPanelLayout.prototype.setupTabItem = function () {
1099 this.$element.attr( 'aria-labelledby', this.tabItem.getElementId() );
1100
1101 this.tabItem.$element.attr( 'aria-controls', this.getElementId() );
1102
1103 if ( this.label ) {
1104 this.tabItem.setLabel( this.label );
1105 }
1106 return this;
1107 };
1108
1109 /**
1110 * Set the tab panel to its 'active' state.
1111 *
1112 * Tab panels become active when they are shown in a index layout that is configured to display only
1113 * one tab panel at a time. Additional CSS is applied to the tab item to reflect the tab panel's
1114 * active state. Outside of the index context, setting the active state on a tab panel does nothing.
1115 *
1116 * @param {boolean} active Tab panel is active
1117 * @fires active
1118 */
1119 OO.ui.TabPanelLayout.prototype.setActive = function ( active ) {
1120 active = !!active;
1121
1122 if ( active !== this.active ) {
1123 this.active = active;
1124 this.$element.toggleClass( 'oo-ui-tabPanelLayout-active', this.active );
1125 this.emit( 'active', this.active );
1126 }
1127 };
1128
1129 /**
1130 * PageLayouts are used within {@link OO.ui.BookletLayout booklet layouts} to create pages that users can select and display
1131 * from the booklet's optional {@link OO.ui.OutlineSelectWidget outline} navigation. Pages are usually not instantiated directly,
1132 * rather extended to include the required content and functionality.
1133 *
1134 * Each page must have a unique symbolic name, which is passed to the constructor. In addition, the page's outline
1135 * item is customized (with a label, outline level, etc.) using the #setupOutlineItem method. See
1136 * {@link OO.ui.BookletLayout BookletLayout} for an example.
1137 *
1138 * @class
1139 * @extends OO.ui.PanelLayout
1140 *
1141 * @constructor
1142 * @param {string} name Unique symbolic name of page
1143 * @param {Object} [config] Configuration options
1144 */
1145 OO.ui.PageLayout = function OoUiPageLayout( name, config ) {
1146 // Allow passing positional parameters inside the config object
1147 if ( OO.isPlainObject( name ) && config === undefined ) {
1148 config = name;
1149 name = config.name;
1150 }
1151
1152 // Configuration initialization
1153 config = $.extend( { scrollable: true }, config );
1154
1155 // Parent constructor
1156 OO.ui.PageLayout.parent.call( this, config );
1157
1158 // Properties
1159 this.name = name;
1160 this.outlineItem = null;
1161 this.active = false;
1162
1163 // Initialization
1164 this.$element.addClass( 'oo-ui-pageLayout' );
1165 };
1166
1167 /* Setup */
1168
1169 OO.inheritClass( OO.ui.PageLayout, OO.ui.PanelLayout );
1170
1171 /* Events */
1172
1173 /**
1174 * An 'active' event is emitted when the page becomes active. Pages become active when they are
1175 * shown in a booklet layout that is configured to display only one page at a time.
1176 *
1177 * @event active
1178 * @param {boolean} active Page is active
1179 */
1180
1181 /* Methods */
1182
1183 /**
1184 * Get the symbolic name of the page.
1185 *
1186 * @return {string} Symbolic name of page
1187 */
1188 OO.ui.PageLayout.prototype.getName = function () {
1189 return this.name;
1190 };
1191
1192 /**
1193 * Check if page is active.
1194 *
1195 * Pages become active when they are shown in a {@link OO.ui.BookletLayout booklet layout} that is configured to display
1196 * only one page at a time. Additional CSS is applied to the page's outline item to reflect the active state.
1197 *
1198 * @return {boolean} Page is active
1199 */
1200 OO.ui.PageLayout.prototype.isActive = function () {
1201 return this.active;
1202 };
1203
1204 /**
1205 * Get outline item.
1206 *
1207 * The outline item allows users to access the page from the booklet's outline
1208 * navigation. The outline item itself can be customized (with a label, level, etc.) using the #setupOutlineItem method.
1209 *
1210 * @return {OO.ui.OutlineOptionWidget|null} Outline option widget
1211 */
1212 OO.ui.PageLayout.prototype.getOutlineItem = function () {
1213 return this.outlineItem;
1214 };
1215
1216 /**
1217 * Set or unset the outline item.
1218 *
1219 * Specify an {@link OO.ui.OutlineOptionWidget outline option} to set it,
1220 * or `null` to clear the outline item. To customize the outline item itself (e.g., to set a label or outline
1221 * level), use #setupOutlineItem instead of this method.
1222 *
1223 * @param {OO.ui.OutlineOptionWidget|null} outlineItem Outline option widget, null to clear
1224 * @chainable
1225 * @return {OO.ui.PageLayout} The layout, for chaining
1226 */
1227 OO.ui.PageLayout.prototype.setOutlineItem = function ( outlineItem ) {
1228 this.outlineItem = outlineItem || null;
1229 if ( outlineItem ) {
1230 this.setupOutlineItem();
1231 }
1232 return this;
1233 };
1234
1235 /**
1236 * Set up the outline item.
1237 *
1238 * Use this method to customize the outline item (e.g., to add a label or outline level). To set or unset
1239 * the outline item itself (with an {@link OO.ui.OutlineOptionWidget outline option} or `null`), use
1240 * the #setOutlineItem method instead.
1241 *
1242 * @param {OO.ui.OutlineOptionWidget} outlineItem Outline option widget to set up
1243 * @chainable
1244 * @return {OO.ui.PageLayout} The layout, for chaining
1245 */
1246 OO.ui.PageLayout.prototype.setupOutlineItem = function () {
1247 return this;
1248 };
1249
1250 /**
1251 * Set the page to its 'active' state.
1252 *
1253 * Pages become active when they are shown in a booklet layout that is configured to display only one page at a time. Additional
1254 * CSS is applied to the outline item to reflect the page's active state. Outside of the booklet
1255 * context, setting the active state on a page does nothing.
1256 *
1257 * @param {boolean} active Page is active
1258 * @fires active
1259 */
1260 OO.ui.PageLayout.prototype.setActive = function ( active ) {
1261 active = !!active;
1262
1263 if ( active !== this.active ) {
1264 this.active = active;
1265 this.$element.toggleClass( 'oo-ui-pageLayout-active', active );
1266 this.emit( 'active', this.active );
1267 }
1268 };
1269
1270 /**
1271 * StackLayouts contain a series of {@link OO.ui.PanelLayout panel layouts}. By default, only one panel is displayed
1272 * at a time, though the stack layout can also be configured to show all contained panels, one after another,
1273 * by setting the #continuous option to 'true'.
1274 *
1275 * @example
1276 * // A stack layout with two panels, configured to be displayed continuously
1277 * var myStack = new OO.ui.StackLayout( {
1278 * items: [
1279 * new OO.ui.PanelLayout( {
1280 * $content: $( '<p>Panel One</p>' ),
1281 * padded: true,
1282 * framed: true
1283 * } ),
1284 * new OO.ui.PanelLayout( {
1285 * $content: $( '<p>Panel Two</p>' ),
1286 * padded: true,
1287 * framed: true
1288 * } )
1289 * ],
1290 * continuous: true
1291 * } );
1292 * $( 'body' ).append( myStack.$element );
1293 *
1294 * @class
1295 * @extends OO.ui.PanelLayout
1296 * @mixins OO.ui.mixin.GroupElement
1297 *
1298 * @constructor
1299 * @param {Object} [config] Configuration options
1300 * @cfg {boolean} [continuous=false] Show all panels, one after another. By default, only one panel is displayed at a time.
1301 * @cfg {OO.ui.Layout[]} [items] Panel layouts to add to the stack layout.
1302 */
1303 OO.ui.StackLayout = function OoUiStackLayout( config ) {
1304 // Configuration initialization
1305 // Make the layout scrollable in continuous mode, otherwise each
1306 // panel is responsible for its own scrolling.
1307 config = $.extend( { scrollable: !!( config && config.continuous ) }, config );
1308
1309 // Parent constructor
1310 OO.ui.StackLayout.parent.call( this, config );
1311
1312 // Mixin constructors
1313 OO.ui.mixin.GroupElement.call( this, $.extend( {}, config, { $group: this.$element } ) );
1314
1315 // Properties
1316 this.currentItem = null;
1317 this.continuous = !!config.continuous;
1318
1319 // Initialization
1320 this.$element.addClass( 'oo-ui-stackLayout' );
1321 if ( this.continuous ) {
1322 this.$element.addClass( 'oo-ui-stackLayout-continuous' );
1323 this.$element.on( 'scroll', OO.ui.debounce( this.onScroll.bind( this ), 250 ) );
1324 }
1325 if ( Array.isArray( config.items ) ) {
1326 this.addItems( config.items );
1327 }
1328 };
1329
1330 /* Setup */
1331
1332 OO.inheritClass( OO.ui.StackLayout, OO.ui.PanelLayout );
1333 OO.mixinClass( OO.ui.StackLayout, OO.ui.mixin.GroupElement );
1334
1335 /* Events */
1336
1337 /**
1338 * A 'set' event is emitted when panels are {@link #addItems added}, {@link #removeItems removed},
1339 * {@link #clearItems cleared} or {@link #setItem displayed}.
1340 *
1341 * @event set
1342 * @param {OO.ui.Layout|null} item Current panel or `null` if no panel is shown
1343 */
1344
1345 /**
1346 * When used in continuous mode, this event is emitted when the user scrolls down
1347 * far enough such that currentItem is no longer visible.
1348 *
1349 * @event visibleItemChange
1350 * @param {OO.ui.PanelLayout} panel The next visible item in the layout
1351 */
1352
1353 /* Methods */
1354
1355 /**
1356 * Handle scroll events from the layout element
1357 *
1358 * @param {jQuery.Event} e
1359 * @fires visibleItemChange
1360 */
1361 OO.ui.StackLayout.prototype.onScroll = function () {
1362 var currentRect,
1363 len = this.items.length,
1364 currentIndex = this.items.indexOf( this.currentItem ),
1365 newIndex = currentIndex,
1366 containerRect = this.$element[ 0 ].getBoundingClientRect();
1367
1368 if ( !containerRect || ( !containerRect.top && !containerRect.bottom ) ) {
1369 // Can't get bounding rect, possibly not attached.
1370 return;
1371 }
1372
1373 function getRect( item ) {
1374 return item.$element[ 0 ].getBoundingClientRect();
1375 }
1376
1377 function isVisible( item ) {
1378 var rect = getRect( item );
1379 return rect.bottom > containerRect.top && rect.top < containerRect.bottom;
1380 }
1381
1382 currentRect = getRect( this.currentItem );
1383
1384 if ( currentRect.bottom < containerRect.top ) {
1385 // Scrolled down past current item
1386 while ( ++newIndex < len ) {
1387 if ( isVisible( this.items[ newIndex ] ) ) {
1388 break;
1389 }
1390 }
1391 } else if ( currentRect.top > containerRect.bottom ) {
1392 // Scrolled up past current item
1393 while ( --newIndex >= 0 ) {
1394 if ( isVisible( this.items[ newIndex ] ) ) {
1395 break;
1396 }
1397 }
1398 }
1399
1400 if ( newIndex !== currentIndex ) {
1401 this.emit( 'visibleItemChange', this.items[ newIndex ] );
1402 }
1403 };
1404
1405 /**
1406 * Get the current panel.
1407 *
1408 * @return {OO.ui.Layout|null}
1409 */
1410 OO.ui.StackLayout.prototype.getCurrentItem = function () {
1411 return this.currentItem;
1412 };
1413
1414 /**
1415 * Unset the current item.
1416 *
1417 * @private
1418 * @param {OO.ui.StackLayout} layout
1419 * @fires set
1420 */
1421 OO.ui.StackLayout.prototype.unsetCurrentItem = function () {
1422 var prevItem = this.currentItem;
1423 if ( prevItem === null ) {
1424 return;
1425 }
1426
1427 this.currentItem = null;
1428 this.emit( 'set', null );
1429 };
1430
1431 /**
1432 * Add panel layouts to the stack layout.
1433 *
1434 * Panels will be added to the end of the stack layout array unless the optional index parameter specifies a different
1435 * insertion point. Adding a panel that is already in the stack will move it to the end of the array or the point specified
1436 * by the index.
1437 *
1438 * @param {OO.ui.Layout[]} items Panels to add
1439 * @param {number} [index] Index of the insertion point
1440 * @chainable
1441 * @return {OO.ui.StackLayout} The layout, for chaining
1442 */
1443 OO.ui.StackLayout.prototype.addItems = function ( items, index ) {
1444 // Update the visibility
1445 this.updateHiddenState( items, this.currentItem );
1446
1447 // Mixin method
1448 OO.ui.mixin.GroupElement.prototype.addItems.call( this, items, index );
1449
1450 if ( !this.currentItem && items.length ) {
1451 this.setItem( items[ 0 ] );
1452 }
1453
1454 return this;
1455 };
1456
1457 /**
1458 * Remove the specified panels from the stack layout.
1459 *
1460 * Removed panels are detached from the DOM, not removed, so that they may be reused. To remove all panels,
1461 * you may wish to use the #clearItems method instead.
1462 *
1463 * @param {OO.ui.Layout[]} items Panels to remove
1464 * @chainable
1465 * @return {OO.ui.StackLayout} The layout, for chaining
1466 * @fires set
1467 */
1468 OO.ui.StackLayout.prototype.removeItems = function ( items ) {
1469 // Mixin method
1470 OO.ui.mixin.GroupElement.prototype.removeItems.call( this, items );
1471
1472 if ( items.indexOf( this.currentItem ) !== -1 ) {
1473 if ( this.items.length ) {
1474 this.setItem( this.items[ 0 ] );
1475 } else {
1476 this.unsetCurrentItem();
1477 }
1478 }
1479
1480 return this;
1481 };
1482
1483 /**
1484 * Clear all panels from the stack layout.
1485 *
1486 * Cleared panels are detached from the DOM, not removed, so that they may be reused. To remove only
1487 * a subset of panels, use the #removeItems method.
1488 *
1489 * @chainable
1490 * @return {OO.ui.StackLayout} The layout, for chaining
1491 * @fires set
1492 */
1493 OO.ui.StackLayout.prototype.clearItems = function () {
1494 this.unsetCurrentItem();
1495 OO.ui.mixin.GroupElement.prototype.clearItems.call( this );
1496
1497 return this;
1498 };
1499
1500 /**
1501 * Show the specified panel.
1502 *
1503 * If another panel is currently displayed, it will be hidden.
1504 *
1505 * @param {OO.ui.Layout} item Panel to show
1506 * @chainable
1507 * @return {OO.ui.StackLayout} The layout, for chaining
1508 * @fires set
1509 */
1510 OO.ui.StackLayout.prototype.setItem = function ( item ) {
1511 if ( item !== this.currentItem ) {
1512 this.updateHiddenState( this.items, item );
1513
1514 if ( this.items.indexOf( item ) !== -1 ) {
1515 this.currentItem = item;
1516 this.emit( 'set', item );
1517 } else {
1518 this.unsetCurrentItem();
1519 }
1520 }
1521
1522 return this;
1523 };
1524
1525 /**
1526 * Reset the scroll offset of all panels, or the container if continuous
1527 *
1528 * @inheritdoc
1529 */
1530 OO.ui.StackLayout.prototype.resetScroll = function () {
1531 if ( this.continuous ) {
1532 // Parent method
1533 return OO.ui.StackLayout.parent.prototype.resetScroll.call( this );
1534 }
1535 // Reset each panel
1536 this.getItems().forEach( function ( panel ) {
1537 var hidden = panel.$element.hasClass( 'oo-ui-element-hidden' );
1538 // Scroll can only be reset when panel is visible
1539 panel.$element.removeClass( 'oo-ui-element-hidden' );
1540 panel.resetScroll();
1541 if ( hidden ) {
1542 panel.$element.addClass( 'oo-ui-element-hidden' );
1543 }
1544 } );
1545
1546 return this;
1547 };
1548
1549 /**
1550 * Update the visibility of all items in case of non-continuous view.
1551 *
1552 * Ensure all items are hidden except for the selected one.
1553 * This method does nothing when the stack is continuous.
1554 *
1555 * @private
1556 * @param {OO.ui.Layout[]} items Item list iterate over
1557 * @param {OO.ui.Layout} [selectedItem] Selected item to show
1558 */
1559 OO.ui.StackLayout.prototype.updateHiddenState = function ( items, selectedItem ) {
1560 var i, len;
1561
1562 if ( !this.continuous ) {
1563 for ( i = 0, len = items.length; i < len; i++ ) {
1564 if ( !selectedItem || selectedItem !== items[ i ] ) {
1565 items[ i ].$element.addClass( 'oo-ui-element-hidden' );
1566 items[ i ].$element.attr( 'aria-hidden', 'true' );
1567 }
1568 }
1569 if ( selectedItem ) {
1570 selectedItem.$element.removeClass( 'oo-ui-element-hidden' );
1571 selectedItem.$element.removeAttr( 'aria-hidden' );
1572 }
1573 }
1574 };
1575
1576 /**
1577 * 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)
1578 * and its size is customized with the #menuSize config. The content area will fill all remaining space.
1579 *
1580 * @example
1581 * var menuLayout,
1582 * menuPanel = new OO.ui.PanelLayout( { padded: true, expanded: true, scrollable: true } ),
1583 * contentPanel = new OO.ui.PanelLayout( { padded: true, expanded: true, scrollable: true } ),
1584 * select = new OO.ui.SelectWidget( {
1585 * items: [
1586 * new OO.ui.OptionWidget( {
1587 * data: 'before',
1588 * label: 'Before'
1589 * } ),
1590 * new OO.ui.OptionWidget( {
1591 * data: 'after',
1592 * label: 'After'
1593 * } ),
1594 * new OO.ui.OptionWidget( {
1595 * data: 'top',
1596 * label: 'Top'
1597 * } ),
1598 * new OO.ui.OptionWidget( {
1599 * data: 'bottom',
1600 * label: 'Bottom'
1601 * } )
1602 * ]
1603 * } ).on( 'select', function ( item ) {
1604 * menuLayout.setMenuPosition( item.getData() );
1605 * } );
1606 *
1607 * menuLayout = new OO.ui.MenuLayout( {
1608 * position: 'top',
1609 * menuPanel: menuPanel,
1610 * contentPanel: contentPanel
1611 * } )
1612 * menuLayout.$menu.append(
1613 * menuPanel.$element.append( '<b>Menu panel</b>', select.$element )
1614 * );
1615 * menuLayout.$content.append(
1616 * contentPanel.$element.append( '<b>Content panel</b>', '<p>Note that the menu is positioned relative to the content panel: top, bottom, after, before.</p>')
1617 * );
1618 * $( 'body' ).append( menuLayout.$element );
1619 *
1620 * If menu size needs to be overridden, it can be accomplished using CSS similar to the snippet
1621 * below. MenuLayout's CSS will override the appropriate values with 'auto' or '0' to display the
1622 * menu correctly. If `menuPosition` is known beforehand, CSS rules corresponding to other positions
1623 * may be omitted.
1624 *
1625 * .oo-ui-menuLayout-menu {
1626 * width: 200px;
1627 * height: 200px;
1628 * }
1629 *
1630 * .oo-ui-menuLayout-content {
1631 * top: 200px;
1632 * left: 200px;
1633 * right: 200px;
1634 * bottom: 200px;
1635 * }
1636 *
1637 * @class
1638 * @extends OO.ui.Layout
1639 *
1640 * @constructor
1641 * @param {Object} [config] Configuration options
1642 * @cfg {OO.ui.PanelLayout} [menuPanel] Menu panel
1643 * @cfg {OO.ui.PanelLayout} [contentPanel] Content panel
1644 * @cfg {boolean} [expanded=true] Expand the layout to fill the entire parent element.
1645 * @cfg {boolean} [showMenu=true] Show menu
1646 * @cfg {string} [menuPosition='before'] Position of menu: `top`, `after`, `bottom` or `before`
1647 */
1648 OO.ui.MenuLayout = function OoUiMenuLayout( config ) {
1649 // Configuration initialization
1650 config = $.extend( {
1651 expanded: true,
1652 showMenu: true,
1653 menuPosition: 'before'
1654 }, config );
1655
1656 // Parent constructor
1657 OO.ui.MenuLayout.parent.call( this, config );
1658
1659 this.menuPanel = null;
1660 this.contentPanel = null;
1661 this.expanded = !!config.expanded;
1662 /**
1663 * Menu DOM node
1664 *
1665 * @property {jQuery}
1666 */
1667 this.$menu = $( '<div>' );
1668 /**
1669 * Content DOM node
1670 *
1671 * @property {jQuery}
1672 */
1673 this.$content = $( '<div>' );
1674
1675 // Initialization
1676 this.$menu
1677 .addClass( 'oo-ui-menuLayout-menu' );
1678 this.$content.addClass( 'oo-ui-menuLayout-content' );
1679 this.$element
1680 .addClass( 'oo-ui-menuLayout' );
1681 if ( config.expanded ) {
1682 this.$element.addClass( 'oo-ui-menuLayout-expanded' );
1683 } else {
1684 this.$element.addClass( 'oo-ui-menuLayout-static' );
1685 }
1686 if ( config.menuPanel ) {
1687 this.setMenuPanel( config.menuPanel );
1688 }
1689 if ( config.contentPanel ) {
1690 this.setContentPanel( config.contentPanel );
1691 }
1692 this.setMenuPosition( config.menuPosition );
1693 this.toggleMenu( config.showMenu );
1694 };
1695
1696 /* Setup */
1697
1698 OO.inheritClass( OO.ui.MenuLayout, OO.ui.Layout );
1699
1700 /* Methods */
1701
1702 /**
1703 * Toggle menu.
1704 *
1705 * @param {boolean} showMenu Show menu, omit to toggle
1706 * @chainable
1707 * @return {OO.ui.MenuLayout} The layout, for chaining
1708 */
1709 OO.ui.MenuLayout.prototype.toggleMenu = function ( showMenu ) {
1710 showMenu = showMenu === undefined ? !this.showMenu : !!showMenu;
1711
1712 if ( this.showMenu !== showMenu ) {
1713 this.showMenu = showMenu;
1714 this.$element
1715 .toggleClass( 'oo-ui-menuLayout-showMenu', this.showMenu )
1716 .toggleClass( 'oo-ui-menuLayout-hideMenu', !this.showMenu );
1717 this.$menu.attr( 'aria-hidden', this.showMenu ? 'false' : 'true' );
1718 }
1719
1720 return this;
1721 };
1722
1723 /**
1724 * Check if menu is visible
1725 *
1726 * @return {boolean} Menu is visible
1727 */
1728 OO.ui.MenuLayout.prototype.isMenuVisible = function () {
1729 return this.showMenu;
1730 };
1731
1732 /**
1733 * Set menu position.
1734 *
1735 * @param {string} position Position of menu, either `top`, `after`, `bottom` or `before`
1736 * @throws {Error} If position value is not supported
1737 * @chainable
1738 * @return {OO.ui.MenuLayout} The layout, for chaining
1739 */
1740 OO.ui.MenuLayout.prototype.setMenuPosition = function ( position ) {
1741 this.$element.removeClass( 'oo-ui-menuLayout-' + this.menuPosition );
1742 this.menuPosition = position;
1743 if ( this.menuPosition === 'top' || this.menuPosition === 'before' ) {
1744 this.$element.append( this.$menu, this.$content );
1745 } else {
1746 this.$element.append( this.$content, this.$menu );
1747 }
1748 this.$element.addClass( 'oo-ui-menuLayout-' + position );
1749
1750 return this;
1751 };
1752
1753 /**
1754 * Get menu position.
1755 *
1756 * @return {string} Menu position
1757 */
1758 OO.ui.MenuLayout.prototype.getMenuPosition = function () {
1759 return this.menuPosition;
1760 };
1761
1762 /**
1763 * Set the menu panel.
1764 *
1765 * @param {OO.ui.PanelLayout} menuPanel Menu panel
1766 */
1767 OO.ui.MenuLayout.prototype.setMenuPanel = function ( menuPanel ) {
1768 this.menuPanel = menuPanel;
1769 this.$menu.append( this.menuPanel.$element );
1770 };
1771
1772 /**
1773 * Set the content panel.
1774 *
1775 * @param {OO.ui.PanelLayout} contentPanel Content panel
1776 */
1777 OO.ui.MenuLayout.prototype.setContentPanel = function ( contentPanel ) {
1778 this.contentPanel = contentPanel;
1779 this.$content.append( this.contentPanel.$element );
1780 };
1781
1782 /**
1783 * Clear the menu panel.
1784 */
1785 OO.ui.MenuLayout.prototype.clearMenuPanel = function () {
1786 this.menuPanel = null;
1787 this.$menu.empty();
1788 };
1789
1790 /**
1791 * Clear the content panel.
1792 */
1793 OO.ui.MenuLayout.prototype.clearContentPanel = function () {
1794 this.contentPanel = null;
1795 this.$content.empty();
1796 };
1797
1798 /**
1799 * Reset the scroll offset of all panels and the tab select widget
1800 *
1801 * @inheritdoc
1802 */
1803 OO.ui.MenuLayout.prototype.resetScroll = function () {
1804 if ( this.menuPanel ) {
1805 this.menuPanel.resetScroll();
1806 }
1807 if ( this.contentPanel ) {
1808 this.contentPanel.resetScroll();
1809 }
1810
1811 return this;
1812 };
1813
1814 /**
1815 * BookletLayouts contain {@link OO.ui.PageLayout page layouts} as well as
1816 * an {@link OO.ui.OutlineSelectWidget outline} that allows users to easily navigate
1817 * through the pages and select which one to display. By default, only one page is
1818 * displayed at a time and the outline is hidden. When a user navigates to a new page,
1819 * the booklet layout automatically focuses on the first focusable element, unless the
1820 * default setting is changed. Optionally, booklets can be configured to show
1821 * {@link OO.ui.OutlineControlsWidget controls} for adding, moving, and removing items.
1822 *
1823 * @example
1824 * // Example of a BookletLayout that contains two PageLayouts.
1825 *
1826 * function PageOneLayout( name, config ) {
1827 * PageOneLayout.parent.call( this, name, config );
1828 * this.$element.append( '<p>First page</p><p>(This booklet has an outline, displayed on the left)</p>' );
1829 * }
1830 * OO.inheritClass( PageOneLayout, OO.ui.PageLayout );
1831 * PageOneLayout.prototype.setupOutlineItem = function () {
1832 * this.outlineItem.setLabel( 'Page One' );
1833 * };
1834 *
1835 * function PageTwoLayout( name, config ) {
1836 * PageTwoLayout.parent.call( this, name, config );
1837 * this.$element.append( '<p>Second page</p>' );
1838 * }
1839 * OO.inheritClass( PageTwoLayout, OO.ui.PageLayout );
1840 * PageTwoLayout.prototype.setupOutlineItem = function () {
1841 * this.outlineItem.setLabel( 'Page Two' );
1842 * };
1843 *
1844 * var page1 = new PageOneLayout( 'one' ),
1845 * page2 = new PageTwoLayout( 'two' );
1846 *
1847 * var booklet = new OO.ui.BookletLayout( {
1848 * outlined: true
1849 * } );
1850 *
1851 * booklet.addPages( [ page1, page2 ] );
1852 * $( 'body' ).append( booklet.$element );
1853 *
1854 * @class
1855 * @extends OO.ui.MenuLayout
1856 *
1857 * @constructor
1858 * @param {Object} [config] Configuration options
1859 * @cfg {boolean} [continuous=false] Show all pages, one after another
1860 * @cfg {boolean} [autoFocus=true] Focus on the first focusable element when a new page is displayed. Disabled on mobile.
1861 * @cfg {boolean} [outlined=false] Show the outline. The outline is used to navigate through the pages of the booklet.
1862 * @cfg {boolean} [editable=false] Show controls for adding, removing and reordering pages
1863 */
1864 OO.ui.BookletLayout = function OoUiBookletLayout( config ) {
1865 // Configuration initialization
1866 config = config || {};
1867
1868 // Parent constructor
1869 OO.ui.BookletLayout.parent.call( this, config );
1870
1871 // Properties
1872 this.currentPageName = null;
1873 this.pages = {};
1874 this.ignoreFocus = false;
1875 this.stackLayout = new OO.ui.StackLayout( {
1876 continuous: !!config.continuous,
1877 expanded: this.expanded
1878 } );
1879 this.setContentPanel( this.stackLayout );
1880 this.autoFocus = config.autoFocus === undefined || !!config.autoFocus;
1881 this.outlineVisible = false;
1882 this.outlined = !!config.outlined;
1883 if ( this.outlined ) {
1884 this.editable = !!config.editable;
1885 this.outlineControlsWidget = null;
1886 this.outlineSelectWidget = new OO.ui.OutlineSelectWidget();
1887 this.outlinePanel = new OO.ui.PanelLayout( {
1888 expanded: this.expanded,
1889 scrollable: true
1890 } );
1891 this.setMenuPanel( this.outlinePanel );
1892 this.outlineVisible = true;
1893 if ( this.editable ) {
1894 this.outlineControlsWidget = new OO.ui.OutlineControlsWidget(
1895 this.outlineSelectWidget
1896 );
1897 }
1898 }
1899 this.toggleMenu( this.outlined );
1900
1901 // Events
1902 this.stackLayout.connect( this, { set: 'onStackLayoutSet' } );
1903 if ( this.outlined ) {
1904 this.outlineSelectWidget.connect( this, { select: 'onOutlineSelectWidgetSelect' } );
1905 this.scrolling = false;
1906 this.stackLayout.connect( this, { visibleItemChange: 'onStackLayoutVisibleItemChange' } );
1907 }
1908 if ( this.autoFocus ) {
1909 // Event 'focus' does not bubble, but 'focusin' does
1910 this.stackLayout.$element.on( 'focusin', this.onStackLayoutFocus.bind( this ) );
1911 }
1912
1913 // Initialization
1914 this.$element.addClass( 'oo-ui-bookletLayout' );
1915 this.stackLayout.$element.addClass( 'oo-ui-bookletLayout-stackLayout' );
1916 if ( this.outlined ) {
1917 this.outlinePanel.$element
1918 .addClass( 'oo-ui-bookletLayout-outlinePanel' )
1919 .append( this.outlineSelectWidget.$element );
1920 if ( this.editable ) {
1921 this.outlinePanel.$element
1922 .addClass( 'oo-ui-bookletLayout-outlinePanel-editable' )
1923 .append( this.outlineControlsWidget.$element );
1924 }
1925 }
1926 };
1927
1928 /* Setup */
1929
1930 OO.inheritClass( OO.ui.BookletLayout, OO.ui.MenuLayout );
1931
1932 /* Events */
1933
1934 /**
1935 * A 'set' event is emitted when a page is {@link #setPage set} to be displayed by the booklet layout.
1936 * @event set
1937 * @param {OO.ui.PageLayout} page Current page
1938 */
1939
1940 /**
1941 * An 'add' event is emitted when pages are {@link #addPages added} to the booklet layout.
1942 *
1943 * @event add
1944 * @param {OO.ui.PageLayout[]} page Added pages
1945 * @param {number} index Index pages were added at
1946 */
1947
1948 /**
1949 * A 'remove' event is emitted when pages are {@link #clearPages cleared} or
1950 * {@link #removePages removed} from the booklet.
1951 *
1952 * @event remove
1953 * @param {OO.ui.PageLayout[]} pages Removed pages
1954 */
1955
1956 /* Methods */
1957
1958 /**
1959 * Handle stack layout focus.
1960 *
1961 * @private
1962 * @param {jQuery.Event} e Focusin event
1963 */
1964 OO.ui.BookletLayout.prototype.onStackLayoutFocus = function ( e ) {
1965 var name, $target;
1966
1967 // Find the page that an element was focused within
1968 $target = $( e.target ).closest( '.oo-ui-pageLayout' );
1969 for ( name in this.pages ) {
1970 // Check for page match, exclude current page to find only page changes
1971 if ( this.pages[ name ].$element[ 0 ] === $target[ 0 ] && name !== this.currentPageName ) {
1972 this.setPage( name );
1973 break;
1974 }
1975 }
1976 };
1977
1978 /**
1979 * Handle visibleItemChange events from the stackLayout
1980 *
1981 * The next visible page is set as the current page by selecting it
1982 * in the outline
1983 *
1984 * @param {OO.ui.PageLayout} page The next visible page in the layout
1985 */
1986 OO.ui.BookletLayout.prototype.onStackLayoutVisibleItemChange = function ( page ) {
1987 // Set a flag to so that the resulting call to #onStackLayoutSet doesn't
1988 // try and scroll the item into view again.
1989 this.scrolling = true;
1990 this.outlineSelectWidget.selectItemByData( page.getName() );
1991 this.scrolling = false;
1992 };
1993
1994 /**
1995 * Handle stack layout set events.
1996 *
1997 * @private
1998 * @param {OO.ui.PanelLayout|null} page The page panel that is now the current panel
1999 */
2000 OO.ui.BookletLayout.prototype.onStackLayoutSet = function ( page ) {
2001 var promise, layout = this;
2002 // If everything is unselected, do nothing
2003 if ( !page ) {
2004 return;
2005 }
2006 // For continuous BookletLayouts, scroll the selected page into view first
2007 if ( this.stackLayout.continuous && !this.scrolling ) {
2008 promise = page.scrollElementIntoView();
2009 } else {
2010 promise = $.Deferred().resolve();
2011 }
2012 // Focus the first element on the newly selected panel.
2013 // Don't focus if the page was set by scrolling.
2014 if ( this.autoFocus && !OO.ui.isMobile() && !this.scrolling ) {
2015 promise.done( function () {
2016 layout.focus();
2017 } );
2018 }
2019 };
2020
2021 /**
2022 * Focus the first input in the current page.
2023 *
2024 * If no page is selected, the first selectable page will be selected.
2025 * If the focus is already in an element on the current page, nothing will happen.
2026 *
2027 * @param {number} [itemIndex] A specific item to focus on
2028 */
2029 OO.ui.BookletLayout.prototype.focus = function ( itemIndex ) {
2030 var page,
2031 items = this.stackLayout.getItems();
2032
2033 if ( itemIndex !== undefined && items[ itemIndex ] ) {
2034 page = items[ itemIndex ];
2035 } else {
2036 page = this.stackLayout.getCurrentItem();
2037 }
2038
2039 if ( !page && this.outlined ) {
2040 this.selectFirstSelectablePage();
2041 page = this.stackLayout.getCurrentItem();
2042 }
2043 if ( !page ) {
2044 return;
2045 }
2046 // Only change the focus if is not already in the current page
2047 if ( !OO.ui.contains( page.$element[ 0 ], this.getElementDocument().activeElement, true ) ) {
2048 page.focus();
2049 }
2050 };
2051
2052 /**
2053 * Find the first focusable input in the booklet layout and focus
2054 * on it.
2055 */
2056 OO.ui.BookletLayout.prototype.focusFirstFocusable = function () {
2057 OO.ui.findFocusable( this.stackLayout.$element ).focus();
2058 };
2059
2060 /**
2061 * Handle outline widget select events.
2062 *
2063 * @private
2064 * @param {OO.ui.OptionWidget|null} item Selected item
2065 */
2066 OO.ui.BookletLayout.prototype.onOutlineSelectWidgetSelect = function ( item ) {
2067 if ( item ) {
2068 this.setPage( item.getData() );
2069 }
2070 };
2071
2072 /**
2073 * Check if booklet has an outline.
2074 *
2075 * @return {boolean} Booklet has an outline
2076 */
2077 OO.ui.BookletLayout.prototype.isOutlined = function () {
2078 return this.outlined;
2079 };
2080
2081 /**
2082 * Check if booklet has editing controls.
2083 *
2084 * @return {boolean} Booklet is editable
2085 */
2086 OO.ui.BookletLayout.prototype.isEditable = function () {
2087 return this.editable;
2088 };
2089
2090 /**
2091 * Check if booklet has a visible outline.
2092 *
2093 * @return {boolean} Outline is visible
2094 */
2095 OO.ui.BookletLayout.prototype.isOutlineVisible = function () {
2096 return this.outlined && this.outlineVisible;
2097 };
2098
2099 /**
2100 * Hide or show the outline.
2101 *
2102 * @param {boolean} [show] Show outline, omit to invert current state
2103 * @chainable
2104 * @return {OO.ui.BookletLayout} The layout, for chaining
2105 */
2106 OO.ui.BookletLayout.prototype.toggleOutline = function ( show ) {
2107 var booklet = this;
2108
2109 if ( this.outlined ) {
2110 show = show === undefined ? !this.outlineVisible : !!show;
2111 this.outlineVisible = show;
2112 this.toggleMenu( show );
2113 if ( show && this.editable ) {
2114 // HACK: Kill dumb scrollbars when the sidebar stops animating, see T161798. Only necessary when
2115 // outline controls are present, delay matches transition on `.oo-ui-menuLayout-menu`.
2116 setTimeout( function () {
2117 OO.ui.Element.static.reconsiderScrollbars( booklet.outlinePanel.$element[ 0 ] );
2118 }, OO.ui.theme.getDialogTransitionDuration() );
2119 }
2120 }
2121
2122 return this;
2123 };
2124
2125 /**
2126 * Find the page closest to the specified page.
2127 *
2128 * @param {OO.ui.PageLayout} page Page to use as a reference point
2129 * @return {OO.ui.PageLayout|null} Page closest to the specified page
2130 */
2131 OO.ui.BookletLayout.prototype.findClosestPage = function ( page ) {
2132 var next, prev, level,
2133 pages = this.stackLayout.getItems(),
2134 index = pages.indexOf( page );
2135
2136 if ( index !== -1 ) {
2137 next = pages[ index + 1 ];
2138 prev = pages[ index - 1 ];
2139 // Prefer adjacent pages at the same level
2140 if ( this.outlined ) {
2141 level = this.outlineSelectWidget.findItemFromData( page.getName() ).getLevel();
2142 if (
2143 prev &&
2144 level === this.outlineSelectWidget.findItemFromData( prev.getName() ).getLevel()
2145 ) {
2146 return prev;
2147 }
2148 if (
2149 next &&
2150 level === this.outlineSelectWidget.findItemFromData( next.getName() ).getLevel()
2151 ) {
2152 return next;
2153 }
2154 }
2155 }
2156 return prev || next || null;
2157 };
2158
2159 /**
2160 * Get the outline widget.
2161 *
2162 * If the booklet is not outlined, the method will return `null`.
2163 *
2164 * @return {OO.ui.OutlineSelectWidget|null} Outline widget, or null if the booklet is not outlined
2165 */
2166 OO.ui.BookletLayout.prototype.getOutline = function () {
2167 return this.outlineSelectWidget;
2168 };
2169
2170 /**
2171 * Get the outline controls widget.
2172 *
2173 * If the outline is not editable, the method will return `null`.
2174 *
2175 * @return {OO.ui.OutlineControlsWidget|null} The outline controls widget.
2176 */
2177 OO.ui.BookletLayout.prototype.getOutlineControls = function () {
2178 return this.outlineControlsWidget;
2179 };
2180
2181 /**
2182 * Get a page by its symbolic name.
2183 *
2184 * @param {string} name Symbolic name of page
2185 * @return {OO.ui.PageLayout|undefined} Page, if found
2186 */
2187 OO.ui.BookletLayout.prototype.getPage = function ( name ) {
2188 return this.pages[ name ];
2189 };
2190
2191 /**
2192 * Get the current page.
2193 *
2194 * @return {OO.ui.PageLayout|undefined} Current page, if found
2195 */
2196 OO.ui.BookletLayout.prototype.getCurrentPage = function () {
2197 var name = this.getCurrentPageName();
2198 return name ? this.getPage( name ) : undefined;
2199 };
2200
2201 /**
2202 * Get the symbolic name of the current page.
2203 *
2204 * @return {string|null} Symbolic name of the current page
2205 */
2206 OO.ui.BookletLayout.prototype.getCurrentPageName = function () {
2207 return this.currentPageName;
2208 };
2209
2210 /**
2211 * Add pages to the booklet layout
2212 *
2213 * When pages are added with the same names as existing pages, the existing pages will be
2214 * automatically removed before the new pages are added.
2215 *
2216 * @param {OO.ui.PageLayout[]} pages Pages to add
2217 * @param {number} index Index of the insertion point
2218 * @fires add
2219 * @chainable
2220 * @return {OO.ui.BookletLayout} The layout, for chaining
2221 */
2222 OO.ui.BookletLayout.prototype.addPages = function ( pages, index ) {
2223 var i, len, name, page, item, currentIndex,
2224 stackLayoutPages = this.stackLayout.getItems(),
2225 remove = [],
2226 items = [];
2227
2228 // Remove pages with same names
2229 for ( i = 0, len = pages.length; i < len; i++ ) {
2230 page = pages[ i ];
2231 name = page.getName();
2232
2233 if ( Object.prototype.hasOwnProperty.call( this.pages, name ) ) {
2234 // Correct the insertion index
2235 currentIndex = stackLayoutPages.indexOf( this.pages[ name ] );
2236 if ( currentIndex !== -1 && currentIndex + 1 < index ) {
2237 index--;
2238 }
2239 remove.push( this.pages[ name ] );
2240 }
2241 }
2242 if ( remove.length ) {
2243 this.removePages( remove );
2244 }
2245
2246 // Add new pages
2247 for ( i = 0, len = pages.length; i < len; i++ ) {
2248 page = pages[ i ];
2249 name = page.getName();
2250 this.pages[ page.getName() ] = page;
2251 if ( this.outlined ) {
2252 item = new OO.ui.OutlineOptionWidget( { data: name } );
2253 page.setOutlineItem( item );
2254 items.push( item );
2255 }
2256 }
2257
2258 if ( this.outlined && items.length ) {
2259 this.outlineSelectWidget.addItems( items, index );
2260 this.selectFirstSelectablePage();
2261 }
2262 this.stackLayout.addItems( pages, index );
2263 this.emit( 'add', pages, index );
2264
2265 return this;
2266 };
2267
2268 /**
2269 * Remove the specified pages from the booklet layout.
2270 *
2271 * To remove all pages from the booklet, you may wish to use the #clearPages method instead.
2272 *
2273 * @param {OO.ui.PageLayout[]} pages An array of pages to remove
2274 * @fires remove
2275 * @chainable
2276 * @return {OO.ui.BookletLayout} The layout, for chaining
2277 */
2278 OO.ui.BookletLayout.prototype.removePages = function ( pages ) {
2279 var i, len, name, page,
2280 items = [];
2281
2282 for ( i = 0, len = pages.length; i < len; i++ ) {
2283 page = pages[ i ];
2284 name = page.getName();
2285 delete this.pages[ name ];
2286 if ( this.outlined ) {
2287 items.push( this.outlineSelectWidget.findItemFromData( name ) );
2288 page.setOutlineItem( null );
2289 }
2290 }
2291 if ( this.outlined && items.length ) {
2292 this.outlineSelectWidget.removeItems( items );
2293 this.selectFirstSelectablePage();
2294 }
2295 this.stackLayout.removeItems( pages );
2296 this.emit( 'remove', pages );
2297
2298 return this;
2299 };
2300
2301 /**
2302 * Clear all pages from the booklet layout.
2303 *
2304 * To remove only a subset of pages from the booklet, use the #removePages method.
2305 *
2306 * @fires remove
2307 * @chainable
2308 * @return {OO.ui.BookletLayout} The layout, for chaining
2309 */
2310 OO.ui.BookletLayout.prototype.clearPages = function () {
2311 var i, len,
2312 pages = this.stackLayout.getItems();
2313
2314 this.pages = {};
2315 this.currentPageName = null;
2316 if ( this.outlined ) {
2317 this.outlineSelectWidget.clearItems();
2318 for ( i = 0, len = pages.length; i < len; i++ ) {
2319 pages[ i ].setOutlineItem( null );
2320 }
2321 }
2322 this.stackLayout.clearItems();
2323
2324 this.emit( 'remove', pages );
2325
2326 return this;
2327 };
2328
2329 /**
2330 * Set the current page by symbolic name.
2331 *
2332 * @fires set
2333 * @param {string} name Symbolic name of page
2334 */
2335 OO.ui.BookletLayout.prototype.setPage = function ( name ) {
2336 var selectedItem,
2337 $focused,
2338 page = this.pages[ name ],
2339 previousPage = this.currentPageName && this.pages[ this.currentPageName ];
2340
2341 if ( name !== this.currentPageName ) {
2342 if ( this.outlined ) {
2343 selectedItem = this.outlineSelectWidget.findSelectedItem();
2344 if ( selectedItem && selectedItem.getData() !== name ) {
2345 this.outlineSelectWidget.selectItemByData( name );
2346 }
2347 }
2348 if ( page ) {
2349 if ( previousPage ) {
2350 previousPage.setActive( false );
2351 // Blur anything focused if the next page doesn't have anything focusable.
2352 // This is not needed if the next page has something focusable (because once it is focused
2353 // this blur happens automatically). If the layout is non-continuous, this check is
2354 // meaningless because the next page is not visible yet and thus can't hold focus.
2355 if (
2356 this.autoFocus &&
2357 !OO.ui.isMobile() &&
2358 this.stackLayout.continuous &&
2359 OO.ui.findFocusable( page.$element ).length !== 0
2360 ) {
2361 $focused = previousPage.$element.find( ':focus' );
2362 if ( $focused.length ) {
2363 $focused[ 0 ].blur();
2364 }
2365 }
2366 }
2367 this.currentPageName = name;
2368 page.setActive( true );
2369 this.stackLayout.setItem( page );
2370 if ( !this.stackLayout.continuous && previousPage ) {
2371 // This should not be necessary, since any inputs on the previous page should have been
2372 // blurred when it was hidden, but browsers are not very consistent about this.
2373 $focused = previousPage.$element.find( ':focus' );
2374 if ( $focused.length ) {
2375 $focused[ 0 ].blur();
2376 }
2377 }
2378 this.emit( 'set', page );
2379 }
2380 }
2381 };
2382
2383 /**
2384 * For outlined-continuous booklets, also reset the outlineSelectWidget to the first item.
2385 *
2386 * @inheritdoc
2387 */
2388 OO.ui.BookletLayout.prototype.resetScroll = function () {
2389 // Parent method
2390 OO.ui.BookletLayout.parent.prototype.resetScroll.call( this );
2391
2392 if ( this.outlined && this.stackLayout.continuous && this.outlineSelectWidget.findFirstSelectableItem() ) {
2393 this.scrolling = true;
2394 this.outlineSelectWidget.selectItem( this.outlineSelectWidget.findFirstSelectableItem() );
2395 this.scrolling = false;
2396 }
2397 return this;
2398 };
2399
2400 /**
2401 * Select the first selectable page.
2402 *
2403 * @chainable
2404 * @return {OO.ui.BookletLayout} The layout, for chaining
2405 */
2406 OO.ui.BookletLayout.prototype.selectFirstSelectablePage = function () {
2407 if ( !this.outlineSelectWidget.findSelectedItem() ) {
2408 this.outlineSelectWidget.selectItem( this.outlineSelectWidget.findFirstSelectableItem() );
2409 }
2410
2411 return this;
2412 };
2413
2414 /**
2415 * IndexLayouts contain {@link OO.ui.TabPanelLayout tab panel layouts} as well as
2416 * {@link OO.ui.TabSelectWidget tabs} that allow users to easily navigate through the tab panels and
2417 * select which one to display. By default, only one tab panel is displayed at a time. When a user
2418 * navigates to a new tab panel, the index layout automatically focuses on the first focusable element,
2419 * unless the default setting is changed.
2420 *
2421 * TODO: This class is similar to BookletLayout, we may want to refactor to reduce duplication
2422 *
2423 * @example
2424 * // Example of a IndexLayout that contains two TabPanelLayouts.
2425 *
2426 * function TabPanelOneLayout( name, config ) {
2427 * TabPanelOneLayout.parent.call( this, name, config );
2428 * this.$element.append( '<p>First tab panel</p>' );
2429 * }
2430 * OO.inheritClass( TabPanelOneLayout, OO.ui.TabPanelLayout );
2431 * TabPanelOneLayout.prototype.setupTabItem = function () {
2432 * this.tabItem.setLabel( 'Tab panel one' );
2433 * };
2434 *
2435 * var tabPanel1 = new TabPanelOneLayout( 'one' ),
2436 * tabPanel2 = new OO.ui.TabPanelLayout( 'two', { label: 'Tab panel two' } );
2437 *
2438 * tabPanel2.$element.append( '<p>Second tab panel</p>' );
2439 *
2440 * var index = new OO.ui.IndexLayout();
2441 *
2442 * index.addTabPanels( [ tabPanel1, tabPanel2 ] );
2443 * $( 'body' ).append( index.$element );
2444 *
2445 * @class
2446 * @extends OO.ui.MenuLayout
2447 *
2448 * @constructor
2449 * @param {Object} [config] Configuration options
2450 * @cfg {boolean} [continuous=false] Show all tab panels, one after another
2451 * @cfg {boolean} [autoFocus=true] Focus on the first focusable element when a new tab panel is displayed. Disabled on mobile.
2452 */
2453 OO.ui.IndexLayout = function OoUiIndexLayout( config ) {
2454 // Configuration initialization
2455 config = $.extend( {}, config, { menuPosition: 'top' } );
2456
2457 // Parent constructor
2458 OO.ui.IndexLayout.parent.call( this, config );
2459
2460 // Properties
2461 this.currentTabPanelName = null;
2462 this.tabPanels = {};
2463
2464 this.ignoreFocus = false;
2465 this.stackLayout = new OO.ui.StackLayout( {
2466 continuous: !!config.continuous,
2467 expanded: this.expanded
2468 } );
2469 this.setContentPanel( this.stackLayout );
2470 this.autoFocus = config.autoFocus === undefined || !!config.autoFocus;
2471
2472 this.tabSelectWidget = new OO.ui.TabSelectWidget();
2473 this.tabPanel = new OO.ui.PanelLayout( {
2474 expanded: this.expanded
2475 } );
2476 this.setMenuPanel( this.tabPanel );
2477
2478 this.toggleMenu( true );
2479
2480 // Events
2481 this.stackLayout.connect( this, { set: 'onStackLayoutSet' } );
2482 this.tabSelectWidget.connect( this, { select: 'onTabSelectWidgetSelect' } );
2483 if ( this.autoFocus ) {
2484 // Event 'focus' does not bubble, but 'focusin' does
2485 this.stackLayout.$element.on( 'focusin', this.onStackLayoutFocus.bind( this ) );
2486 }
2487
2488 // Initialization
2489 this.$element.addClass( 'oo-ui-indexLayout' );
2490 this.stackLayout.$element.addClass( 'oo-ui-indexLayout-stackLayout' );
2491 this.tabPanel.$element
2492 .addClass( 'oo-ui-indexLayout-tabPanel' )
2493 .append( this.tabSelectWidget.$element );
2494 };
2495
2496 /* Setup */
2497
2498 OO.inheritClass( OO.ui.IndexLayout, OO.ui.MenuLayout );
2499
2500 /* Events */
2501
2502 /**
2503 * A 'set' event is emitted when a tab panel is {@link #setTabPanel set} to be displayed by the index layout.
2504 * @event set
2505 * @param {OO.ui.TabPanelLayout} tabPanel Current tab panel
2506 */
2507
2508 /**
2509 * An 'add' event is emitted when tab panels are {@link #addTabPanels added} to the index layout.
2510 *
2511 * @event add
2512 * @param {OO.ui.TabPanelLayout[]} tabPanel Added tab panels
2513 * @param {number} index Index tab panels were added at
2514 */
2515
2516 /**
2517 * A 'remove' event is emitted when tab panels are {@link #clearTabPanels cleared} or
2518 * {@link #removeTabPanels removed} from the index.
2519 *
2520 * @event remove
2521 * @param {OO.ui.TabPanelLayout[]} tabPanel Removed tab panels
2522 */
2523
2524 /* Methods */
2525
2526 /**
2527 * Handle stack layout focus.
2528 *
2529 * @private
2530 * @param {jQuery.Event} e Focusing event
2531 */
2532 OO.ui.IndexLayout.prototype.onStackLayoutFocus = function ( e ) {
2533 var name, $target;
2534
2535 // Find the tab panel that an element was focused within
2536 $target = $( e.target ).closest( '.oo-ui-tabPanelLayout' );
2537 for ( name in this.tabPanels ) {
2538 // Check for tab panel match, exclude current tab panel to find only tab panel changes
2539 if ( this.tabPanels[ name ].$element[ 0 ] === $target[ 0 ] && name !== this.currentTabPanelName ) {
2540 this.setTabPanel( name );
2541 break;
2542 }
2543 }
2544 };
2545
2546 /**
2547 * Handle stack layout set events.
2548 *
2549 * @private
2550 * @param {OO.ui.PanelLayout|null} tabPanel The tab panel that is now the current panel
2551 */
2552 OO.ui.IndexLayout.prototype.onStackLayoutSet = function ( tabPanel ) {
2553 // If everything is unselected, do nothing
2554 if ( !tabPanel ) {
2555 return;
2556 }
2557 // Focus the first element on the newly selected panel
2558 if ( this.autoFocus && !OO.ui.isMobile() ) {
2559 this.focus();
2560 }
2561 };
2562
2563 /**
2564 * Focus the first input in the current tab panel.
2565 *
2566 * If no tab panel is selected, the first selectable tab panel will be selected.
2567 * If the focus is already in an element on the current tab panel, nothing will happen.
2568 *
2569 * @param {number} [itemIndex] A specific item to focus on
2570 */
2571 OO.ui.IndexLayout.prototype.focus = function ( itemIndex ) {
2572 var tabPanel,
2573 items = this.stackLayout.getItems();
2574
2575 if ( itemIndex !== undefined && items[ itemIndex ] ) {
2576 tabPanel = items[ itemIndex ];
2577 } else {
2578 tabPanel = this.stackLayout.getCurrentItem();
2579 }
2580
2581 if ( !tabPanel ) {
2582 this.selectFirstSelectableTabPanel();
2583 tabPanel = this.stackLayout.getCurrentItem();
2584 }
2585 if ( !tabPanel ) {
2586 return;
2587 }
2588 // Only change the focus if is not already in the current page
2589 if ( !OO.ui.contains( tabPanel.$element[ 0 ], this.getElementDocument().activeElement, true ) ) {
2590 tabPanel.focus();
2591 }
2592 };
2593
2594 /**
2595 * Find the first focusable input in the index layout and focus
2596 * on it.
2597 */
2598 OO.ui.IndexLayout.prototype.focusFirstFocusable = function () {
2599 OO.ui.findFocusable( this.stackLayout.$element ).focus();
2600 };
2601
2602 /**
2603 * Handle tab widget select events.
2604 *
2605 * @private
2606 * @param {OO.ui.OptionWidget|null} item Selected item
2607 */
2608 OO.ui.IndexLayout.prototype.onTabSelectWidgetSelect = function ( item ) {
2609 if ( item ) {
2610 this.setTabPanel( item.getData() );
2611 }
2612 };
2613
2614 /**
2615 * Get the tab panel closest to the specified tab panel.
2616 *
2617 * @param {OO.ui.TabPanelLayout} tabPanel Tab panel to use as a reference point
2618 * @return {OO.ui.TabPanelLayout|null} Tab panel closest to the specified
2619 */
2620 OO.ui.IndexLayout.prototype.getClosestTabPanel = function ( tabPanel ) {
2621 var next, prev, level,
2622 tabPanels = this.stackLayout.getItems(),
2623 index = tabPanels.indexOf( tabPanel );
2624
2625 if ( index !== -1 ) {
2626 next = tabPanels[ index + 1 ];
2627 prev = tabPanels[ index - 1 ];
2628 // Prefer adjacent tab panels at the same level
2629 level = this.tabSelectWidget.findItemFromData( tabPanel.getName() ).getLevel();
2630 if (
2631 prev &&
2632 level === this.tabSelectWidget.findItemFromData( prev.getName() ).getLevel()
2633 ) {
2634 return prev;
2635 }
2636 if (
2637 next &&
2638 level === this.tabSelectWidget.findItemFromData( next.getName() ).getLevel()
2639 ) {
2640 return next;
2641 }
2642 }
2643 return prev || next || null;
2644 };
2645
2646 /**
2647 * Get the tabs widget.
2648 *
2649 * @return {OO.ui.TabSelectWidget} Tabs widget
2650 */
2651 OO.ui.IndexLayout.prototype.getTabs = function () {
2652 return this.tabSelectWidget;
2653 };
2654
2655 /**
2656 * Get a tab panel by its symbolic name.
2657 *
2658 * @param {string} name Symbolic name of tab panel
2659 * @return {OO.ui.TabPanelLayout|undefined} Tab panel, if found
2660 */
2661 OO.ui.IndexLayout.prototype.getTabPanel = function ( name ) {
2662 return this.tabPanels[ name ];
2663 };
2664
2665 /**
2666 * Get the current tab panel.
2667 *
2668 * @return {OO.ui.TabPanelLayout|undefined} Current tab panel, if found
2669 */
2670 OO.ui.IndexLayout.prototype.getCurrentTabPanel = function () {
2671 var name = this.getCurrentTabPanelName();
2672 return name ? this.getTabPanel( name ) : undefined;
2673 };
2674
2675 /**
2676 * Get the symbolic name of the current tab panel.
2677 *
2678 * @return {string|null} Symbolic name of the current tab panel
2679 */
2680 OO.ui.IndexLayout.prototype.getCurrentTabPanelName = function () {
2681 return this.currentTabPanelName;
2682 };
2683
2684 /**
2685 * Add tab panels to the index layout
2686 *
2687 * When tab panels are added with the same names as existing tab panels, the existing tab panels
2688 * will be automatically removed before the new tab panels are added.
2689 *
2690 * @param {OO.ui.TabPanelLayout[]} tabPanels Tab panels to add
2691 * @param {number} index Index of the insertion point
2692 * @fires add
2693 * @chainable
2694 * @return {OO.ui.BookletLayout} The layout, for chaining
2695 */
2696 OO.ui.IndexLayout.prototype.addTabPanels = function ( tabPanels, index ) {
2697 var i, len, name, tabPanel, item, currentIndex,
2698 stackLayoutTabPanels = this.stackLayout.getItems(),
2699 remove = [],
2700 items = [];
2701
2702 // Remove tab panels with same names
2703 for ( i = 0, len = tabPanels.length; i < len; i++ ) {
2704 tabPanel = tabPanels[ i ];
2705 name = tabPanel.getName();
2706
2707 if ( Object.prototype.hasOwnProperty.call( this.tabPanels, name ) ) {
2708 // Correct the insertion index
2709 currentIndex = stackLayoutTabPanels.indexOf( this.tabPanels[ name ] );
2710 if ( currentIndex !== -1 && currentIndex + 1 < index ) {
2711 index--;
2712 }
2713 remove.push( this.tabPanels[ name ] );
2714 }
2715 }
2716 if ( remove.length ) {
2717 this.removeTabPanels( remove );
2718 }
2719
2720 // Add new tab panels
2721 for ( i = 0, len = tabPanels.length; i < len; i++ ) {
2722 tabPanel = tabPanels[ i ];
2723 name = tabPanel.getName();
2724 this.tabPanels[ tabPanel.getName() ] = tabPanel;
2725 item = new OO.ui.TabOptionWidget( { data: name } );
2726 tabPanel.setTabItem( item );
2727 items.push( item );
2728 }
2729
2730 if ( items.length ) {
2731 this.tabSelectWidget.addItems( items, index );
2732 this.selectFirstSelectableTabPanel();
2733 }
2734 this.stackLayout.addItems( tabPanels, index );
2735 this.emit( 'add', tabPanels, index );
2736
2737 return this;
2738 };
2739
2740 /**
2741 * Remove the specified tab panels from the index layout.
2742 *
2743 * To remove all tab panels from the index, you may wish to use the #clearTabPanels method instead.
2744 *
2745 * @param {OO.ui.TabPanelLayout[]} tabPanels An array of tab panels to remove
2746 * @fires remove
2747 * @chainable
2748 * @return {OO.ui.BookletLayout} The layout, for chaining
2749 */
2750 OO.ui.IndexLayout.prototype.removeTabPanels = function ( tabPanels ) {
2751 var i, len, name, tabPanel,
2752 items = [];
2753
2754 for ( i = 0, len = tabPanels.length; i < len; i++ ) {
2755 tabPanel = tabPanels[ i ];
2756 name = tabPanel.getName();
2757 delete this.tabPanels[ name ];
2758 items.push( this.tabSelectWidget.findItemFromData( name ) );
2759 tabPanel.setTabItem( null );
2760 }
2761 if ( items.length ) {
2762 this.tabSelectWidget.removeItems( items );
2763 this.selectFirstSelectableTabPanel();
2764 }
2765 this.stackLayout.removeItems( tabPanels );
2766 this.emit( 'remove', tabPanels );
2767
2768 return this;
2769 };
2770
2771 /**
2772 * Clear all tab panels from the index layout.
2773 *
2774 * To remove only a subset of tab panels from the index, use the #removeTabPanels method.
2775 *
2776 * @fires remove
2777 * @chainable
2778 * @return {OO.ui.BookletLayout} The layout, for chaining
2779 */
2780 OO.ui.IndexLayout.prototype.clearTabPanels = function () {
2781 var i, len,
2782 tabPanels = this.stackLayout.getItems();
2783
2784 this.tabPanels = {};
2785 this.currentTabPanelName = null;
2786 this.tabSelectWidget.clearItems();
2787 for ( i = 0, len = tabPanels.length; i < len; i++ ) {
2788 tabPanels[ i ].setTabItem( null );
2789 }
2790 this.stackLayout.clearItems();
2791
2792 this.emit( 'remove', tabPanels );
2793
2794 return this;
2795 };
2796
2797 /**
2798 * Set the current tab panel by symbolic name.
2799 *
2800 * @fires set
2801 * @param {string} name Symbolic name of tab panel
2802 */
2803 OO.ui.IndexLayout.prototype.setTabPanel = function ( name ) {
2804 var selectedItem,
2805 $focused,
2806 previousTabPanel,
2807 tabPanel = this.tabPanels[ name ];
2808
2809 if ( name !== this.currentTabPanelName ) {
2810 previousTabPanel = this.getCurrentTabPanel();
2811 selectedItem = this.tabSelectWidget.findSelectedItem();
2812 if ( selectedItem && selectedItem.getData() !== name ) {
2813 this.tabSelectWidget.selectItemByData( name );
2814 }
2815 if ( tabPanel ) {
2816 if ( previousTabPanel ) {
2817 previousTabPanel.setActive( false );
2818 // Blur anything focused if the next tab panel doesn't have anything focusable.
2819 // This is not needed if the next tab panel has something focusable (because once it is focused
2820 // this blur happens automatically). If the layout is non-continuous, this check is
2821 // meaningless because the next tab panel is not visible yet and thus can't hold focus.
2822 if (
2823 this.autoFocus &&
2824 !OO.ui.isMobile() &&
2825 this.stackLayout.continuous &&
2826 OO.ui.findFocusable( tabPanel.$element ).length !== 0
2827 ) {
2828 $focused = previousTabPanel.$element.find( ':focus' );
2829 if ( $focused.length ) {
2830 $focused[ 0 ].blur();
2831 }
2832 }
2833 }
2834 this.currentTabPanelName = name;
2835 tabPanel.setActive( true );
2836 this.stackLayout.setItem( tabPanel );
2837 if ( !this.stackLayout.continuous && previousTabPanel ) {
2838 // This should not be necessary, since any inputs on the previous tab panel should have been
2839 // blurred when it was hidden, but browsers are not very consistent about this.
2840 $focused = previousTabPanel.$element.find( ':focus' );
2841 if ( $focused.length ) {
2842 $focused[ 0 ].blur();
2843 }
2844 }
2845 this.emit( 'set', tabPanel );
2846 }
2847 }
2848 };
2849
2850 /**
2851 * Select the first selectable tab panel.
2852 *
2853 * @chainable
2854 * @return {OO.ui.BookletLayout} The layout, for chaining
2855 */
2856 OO.ui.IndexLayout.prototype.selectFirstSelectableTabPanel = function () {
2857 if ( !this.tabSelectWidget.findSelectedItem() ) {
2858 this.tabSelectWidget.selectItem( this.tabSelectWidget.findFirstSelectableItem() );
2859 }
2860
2861 return this;
2862 };
2863
2864 /**
2865 * ToggleWidget implements basic behavior of widgets with an on/off state.
2866 * Please see OO.ui.ToggleButtonWidget and OO.ui.ToggleSwitchWidget for examples.
2867 *
2868 * @abstract
2869 * @class
2870 * @extends OO.ui.Widget
2871 *
2872 * @constructor
2873 * @param {Object} [config] Configuration options
2874 * @cfg {boolean} [value=false] The toggle’s initial on/off state.
2875 * By default, the toggle is in the 'off' state.
2876 */
2877 OO.ui.ToggleWidget = function OoUiToggleWidget( config ) {
2878 // Configuration initialization
2879 config = config || {};
2880
2881 // Parent constructor
2882 OO.ui.ToggleWidget.parent.call( this, config );
2883
2884 // Properties
2885 this.value = null;
2886
2887 // Initialization
2888 this.$element.addClass( 'oo-ui-toggleWidget' );
2889 this.setValue( !!config.value );
2890 };
2891
2892 /* Setup */
2893
2894 OO.inheritClass( OO.ui.ToggleWidget, OO.ui.Widget );
2895
2896 /* Events */
2897
2898 /**
2899 * @event change
2900 *
2901 * A change event is emitted when the on/off state of the toggle changes.
2902 *
2903 * @param {boolean} value Value representing the new state of the toggle
2904 */
2905
2906 /* Methods */
2907
2908 /**
2909 * Get the value representing the toggle’s state.
2910 *
2911 * @return {boolean} The on/off state of the toggle
2912 */
2913 OO.ui.ToggleWidget.prototype.getValue = function () {
2914 return this.value;
2915 };
2916
2917 /**
2918 * Set the state of the toggle: `true` for 'on', `false` for 'off'.
2919 *
2920 * @param {boolean} value The state of the toggle
2921 * @fires change
2922 * @chainable
2923 * @return {OO.ui.Widget} The widget, for chaining
2924 */
2925 OO.ui.ToggleWidget.prototype.setValue = function ( value ) {
2926 value = !!value;
2927 if ( this.value !== value ) {
2928 this.value = value;
2929 this.emit( 'change', value );
2930 this.$element.toggleClass( 'oo-ui-toggleWidget-on', value );
2931 this.$element.toggleClass( 'oo-ui-toggleWidget-off', !value );
2932 }
2933 return this;
2934 };
2935
2936 /**
2937 * ToggleButtons are buttons that have a state (‘on’ or ‘off’) that is represented by a
2938 * Boolean value. Like other {@link OO.ui.ButtonWidget buttons}, toggle buttons can be
2939 * configured with {@link OO.ui.mixin.IconElement icons}, {@link OO.ui.mixin.IndicatorElement indicators},
2940 * {@link OO.ui.mixin.TitledElement titles}, {@link OO.ui.mixin.FlaggedElement styling flags},
2941 * and {@link OO.ui.mixin.LabelElement labels}. Please see
2942 * the [OOUI documentation][1] on MediaWiki for more information.
2943 *
2944 * @example
2945 * // Toggle buttons in the 'off' and 'on' state.
2946 * var toggleButton1 = new OO.ui.ToggleButtonWidget( {
2947 * label: 'Toggle Button off'
2948 * } );
2949 * var toggleButton2 = new OO.ui.ToggleButtonWidget( {
2950 * label: 'Toggle Button on',
2951 * value: true
2952 * } );
2953 * // Append the buttons to the DOM.
2954 * $( 'body' ).append( toggleButton1.$element, toggleButton2.$element );
2955 *
2956 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Buttons_and_Switches#Toggle_buttons
2957 *
2958 * @class
2959 * @extends OO.ui.ToggleWidget
2960 * @mixins OO.ui.mixin.ButtonElement
2961 * @mixins OO.ui.mixin.IconElement
2962 * @mixins OO.ui.mixin.IndicatorElement
2963 * @mixins OO.ui.mixin.LabelElement
2964 * @mixins OO.ui.mixin.TitledElement
2965 * @mixins OO.ui.mixin.FlaggedElement
2966 * @mixins OO.ui.mixin.TabIndexedElement
2967 *
2968 * @constructor
2969 * @param {Object} [config] Configuration options
2970 * @cfg {boolean} [value=false] The toggle button’s initial on/off
2971 * state. By default, the button is in the 'off' state.
2972 */
2973 OO.ui.ToggleButtonWidget = function OoUiToggleButtonWidget( config ) {
2974 // Configuration initialization
2975 config = config || {};
2976
2977 // Parent constructor
2978 OO.ui.ToggleButtonWidget.parent.call( this, config );
2979
2980 // Mixin constructors
2981 OO.ui.mixin.ButtonElement.call( this, $.extend( {}, config, { active: this.active } ) );
2982 OO.ui.mixin.IconElement.call( this, config );
2983 OO.ui.mixin.IndicatorElement.call( this, config );
2984 OO.ui.mixin.LabelElement.call( this, config );
2985 OO.ui.mixin.TitledElement.call( this, $.extend( {}, config, { $titled: this.$button } ) );
2986 OO.ui.mixin.FlaggedElement.call( this, config );
2987 OO.ui.mixin.TabIndexedElement.call( this, $.extend( {}, config, { $tabIndexed: this.$button } ) );
2988
2989 // Events
2990 this.connect( this, { click: 'onAction' } );
2991
2992 // Initialization
2993 this.$button.append( this.$icon, this.$label, this.$indicator );
2994 this.$element
2995 .addClass( 'oo-ui-toggleButtonWidget' )
2996 .append( this.$button );
2997 };
2998
2999 /* Setup */
3000
3001 OO.inheritClass( OO.ui.ToggleButtonWidget, OO.ui.ToggleWidget );
3002 OO.mixinClass( OO.ui.ToggleButtonWidget, OO.ui.mixin.ButtonElement );
3003 OO.mixinClass( OO.ui.ToggleButtonWidget, OO.ui.mixin.IconElement );
3004 OO.mixinClass( OO.ui.ToggleButtonWidget, OO.ui.mixin.IndicatorElement );
3005 OO.mixinClass( OO.ui.ToggleButtonWidget, OO.ui.mixin.LabelElement );
3006 OO.mixinClass( OO.ui.ToggleButtonWidget, OO.ui.mixin.TitledElement );
3007 OO.mixinClass( OO.ui.ToggleButtonWidget, OO.ui.mixin.FlaggedElement );
3008 OO.mixinClass( OO.ui.ToggleButtonWidget, OO.ui.mixin.TabIndexedElement );
3009
3010 /* Static Properties */
3011
3012 /**
3013 * @static
3014 * @inheritdoc
3015 */
3016 OO.ui.ToggleButtonWidget.static.tagName = 'span';
3017
3018 /* Methods */
3019
3020 /**
3021 * Handle the button action being triggered.
3022 *
3023 * @private
3024 */
3025 OO.ui.ToggleButtonWidget.prototype.onAction = function () {
3026 this.setValue( !this.value );
3027 };
3028
3029 /**
3030 * @inheritdoc
3031 */
3032 OO.ui.ToggleButtonWidget.prototype.setValue = function ( value ) {
3033 value = !!value;
3034 if ( value !== this.value ) {
3035 // Might be called from parent constructor before ButtonElement constructor
3036 if ( this.$button ) {
3037 this.$button.attr( 'aria-pressed', value.toString() );
3038 }
3039 this.setActive( value );
3040 }
3041
3042 // Parent method
3043 OO.ui.ToggleButtonWidget.parent.prototype.setValue.call( this, value );
3044
3045 return this;
3046 };
3047
3048 /**
3049 * @inheritdoc
3050 */
3051 OO.ui.ToggleButtonWidget.prototype.setButtonElement = function ( $button ) {
3052 if ( this.$button ) {
3053 this.$button.removeAttr( 'aria-pressed' );
3054 }
3055 OO.ui.mixin.ButtonElement.prototype.setButtonElement.call( this, $button );
3056 this.$button.attr( 'aria-pressed', this.value.toString() );
3057 };
3058
3059 /**
3060 * ToggleSwitches are switches that slide on and off. Their state is represented by a Boolean
3061 * value (`true` for ‘on’, and `false` otherwise, the default). The ‘off’ state is represented
3062 * visually by a slider in the leftmost position.
3063 *
3064 * @example
3065 * // Toggle switches in the 'off' and 'on' position.
3066 * var toggleSwitch1 = new OO.ui.ToggleSwitchWidget();
3067 * var toggleSwitch2 = new OO.ui.ToggleSwitchWidget( {
3068 * value: true
3069 * } );
3070 *
3071 * // Create a FieldsetLayout to layout and label switches
3072 * var fieldset = new OO.ui.FieldsetLayout( {
3073 * label: 'Toggle switches'
3074 * } );
3075 * fieldset.addItems( [
3076 * new OO.ui.FieldLayout( toggleSwitch1, { label: 'Off', align: 'top' } ),
3077 * new OO.ui.FieldLayout( toggleSwitch2, { label: 'On', align: 'top' } )
3078 * ] );
3079 * $( 'body' ).append( fieldset.$element );
3080 *
3081 * @class
3082 * @extends OO.ui.ToggleWidget
3083 * @mixins OO.ui.mixin.TabIndexedElement
3084 *
3085 * @constructor
3086 * @param {Object} [config] Configuration options
3087 * @cfg {boolean} [value=false] The toggle switch’s initial on/off state.
3088 * By default, the toggle switch is in the 'off' position.
3089 */
3090 OO.ui.ToggleSwitchWidget = function OoUiToggleSwitchWidget( config ) {
3091 // Parent constructor
3092 OO.ui.ToggleSwitchWidget.parent.call( this, config );
3093
3094 // Mixin constructors
3095 OO.ui.mixin.TabIndexedElement.call( this, config );
3096
3097 // Properties
3098 this.dragging = false;
3099 this.dragStart = null;
3100 this.sliding = false;
3101 this.$glow = $( '<span>' );
3102 this.$grip = $( '<span>' );
3103
3104 // Events
3105 this.$element.on( {
3106 click: this.onClick.bind( this ),
3107 keypress: this.onKeyPress.bind( this )
3108 } );
3109
3110 // Initialization
3111 this.$glow.addClass( 'oo-ui-toggleSwitchWidget-glow' );
3112 this.$grip.addClass( 'oo-ui-toggleSwitchWidget-grip' );
3113 this.$element
3114 .addClass( 'oo-ui-toggleSwitchWidget' )
3115 .attr( 'role', 'checkbox' )
3116 .append( this.$glow, this.$grip );
3117 };
3118
3119 /* Setup */
3120
3121 OO.inheritClass( OO.ui.ToggleSwitchWidget, OO.ui.ToggleWidget );
3122 OO.mixinClass( OO.ui.ToggleSwitchWidget, OO.ui.mixin.TabIndexedElement );
3123
3124 /* Methods */
3125
3126 /**
3127 * Handle mouse click events.
3128 *
3129 * @private
3130 * @param {jQuery.Event} e Mouse click event
3131 * @return {undefined/boolean} False to prevent default if event is handled
3132 */
3133 OO.ui.ToggleSwitchWidget.prototype.onClick = function ( e ) {
3134 if ( !this.isDisabled() && e.which === OO.ui.MouseButtons.LEFT ) {
3135 this.setValue( !this.value );
3136 }
3137 return false;
3138 };
3139
3140 /**
3141 * Handle key press events.
3142 *
3143 * @private
3144 * @param {jQuery.Event} e Key press event
3145 * @return {undefined/boolean} False to prevent default if event is handled
3146 */
3147 OO.ui.ToggleSwitchWidget.prototype.onKeyPress = function ( e ) {
3148 if ( !this.isDisabled() && ( e.which === OO.ui.Keys.SPACE || e.which === OO.ui.Keys.ENTER ) ) {
3149 this.setValue( !this.value );
3150 return false;
3151 }
3152 };
3153
3154 /**
3155 * @inheritdoc
3156 */
3157 OO.ui.ToggleSwitchWidget.prototype.setValue = function ( value ) {
3158 OO.ui.ToggleSwitchWidget.parent.prototype.setValue.call( this, value );
3159 this.$element.attr( 'aria-checked', this.value.toString() );
3160 return this;
3161 };
3162
3163 /**
3164 * @inheritdoc
3165 */
3166 OO.ui.ToggleSwitchWidget.prototype.simulateLabelClick = function () {
3167 if ( !this.isDisabled() ) {
3168 this.setValue( !this.value );
3169 }
3170 this.focus();
3171 };
3172
3173 /**
3174 * OutlineControlsWidget is a set of controls for an {@link OO.ui.OutlineSelectWidget outline select widget}.
3175 * Controls include moving items up and down, removing items, and adding different kinds of items.
3176 *
3177 * **Currently, this class is only used by {@link OO.ui.BookletLayout booklet layouts}.**
3178 *
3179 * @class
3180 * @extends OO.ui.Widget
3181 * @mixins OO.ui.mixin.GroupElement
3182 *
3183 * @constructor
3184 * @param {OO.ui.OutlineSelectWidget} outline Outline to control
3185 * @param {Object} [config] Configuration options
3186 * @cfg {Object} [abilities] List of abilties
3187 * @cfg {boolean} [abilities.move=true] Allow moving movable items
3188 * @cfg {boolean} [abilities.remove=true] Allow removing removable items
3189 */
3190 OO.ui.OutlineControlsWidget = function OoUiOutlineControlsWidget( outline, config ) {
3191 // Allow passing positional parameters inside the config object
3192 if ( OO.isPlainObject( outline ) && config === undefined ) {
3193 config = outline;
3194 outline = config.outline;
3195 }
3196
3197 // Configuration initialization
3198 config = config || {};
3199
3200 // Parent constructor
3201 OO.ui.OutlineControlsWidget.parent.call( this, config );
3202
3203 // Mixin constructors
3204 OO.ui.mixin.GroupElement.call( this, config );
3205
3206 // Properties
3207 this.outline = outline;
3208 this.$movers = $( '<div>' );
3209 this.upButton = new OO.ui.ButtonWidget( {
3210 framed: false,
3211 icon: 'collapse',
3212 title: OO.ui.msg( 'ooui-outline-control-move-up' )
3213 } );
3214 this.downButton = new OO.ui.ButtonWidget( {
3215 framed: false,
3216 icon: 'expand',
3217 title: OO.ui.msg( 'ooui-outline-control-move-down' )
3218 } );
3219 this.removeButton = new OO.ui.ButtonWidget( {
3220 framed: false,
3221 icon: 'trash',
3222 title: OO.ui.msg( 'ooui-outline-control-remove' )
3223 } );
3224 this.abilities = { move: true, remove: true };
3225
3226 // Events
3227 outline.connect( this, {
3228 select: 'onOutlineChange',
3229 add: 'onOutlineChange',
3230 remove: 'onOutlineChange'
3231 } );
3232 this.upButton.connect( this, { click: [ 'emit', 'move', -1 ] } );
3233 this.downButton.connect( this, { click: [ 'emit', 'move', 1 ] } );
3234 this.removeButton.connect( this, { click: [ 'emit', 'remove' ] } );
3235
3236 // Initialization
3237 this.$element.addClass( 'oo-ui-outlineControlsWidget' );
3238 this.$group.addClass( 'oo-ui-outlineControlsWidget-items' );
3239 this.$movers
3240 .addClass( 'oo-ui-outlineControlsWidget-movers' )
3241 .append( this.removeButton.$element, this.upButton.$element, this.downButton.$element );
3242 this.$element.append( this.$icon, this.$group, this.$movers );
3243 this.setAbilities( config.abilities || {} );
3244 };
3245
3246 /* Setup */
3247
3248 OO.inheritClass( OO.ui.OutlineControlsWidget, OO.ui.Widget );
3249 OO.mixinClass( OO.ui.OutlineControlsWidget, OO.ui.mixin.GroupElement );
3250
3251 /* Events */
3252
3253 /**
3254 * @event move
3255 * @param {number} places Number of places to move
3256 */
3257
3258 /**
3259 * @event remove
3260 */
3261
3262 /* Methods */
3263
3264 /**
3265 * Set abilities.
3266 *
3267 * @param {Object} abilities List of abilties
3268 * @param {boolean} [abilities.move] Allow moving movable items
3269 * @param {boolean} [abilities.remove] Allow removing removable items
3270 */
3271 OO.ui.OutlineControlsWidget.prototype.setAbilities = function ( abilities ) {
3272 var ability;
3273
3274 for ( ability in this.abilities ) {
3275 if ( abilities[ ability ] !== undefined ) {
3276 this.abilities[ ability ] = !!abilities[ ability ];
3277 }
3278 }
3279
3280 this.onOutlineChange();
3281 };
3282
3283 /**
3284 * Handle outline change events.
3285 *
3286 * @private
3287 */
3288 OO.ui.OutlineControlsWidget.prototype.onOutlineChange = function () {
3289 var i, len, firstMovable, lastMovable,
3290 items = this.outline.getItems(),
3291 selectedItem = this.outline.findSelectedItem(),
3292 movable = this.abilities.move && selectedItem && selectedItem.isMovable(),
3293 removable = this.abilities.remove && selectedItem && selectedItem.isRemovable();
3294
3295 if ( movable ) {
3296 i = -1;
3297 len = items.length;
3298 while ( ++i < len ) {
3299 if ( items[ i ].isMovable() ) {
3300 firstMovable = items[ i ];
3301 break;
3302 }
3303 }
3304 i = len;
3305 while ( i-- ) {
3306 if ( items[ i ].isMovable() ) {
3307 lastMovable = items[ i ];
3308 break;
3309 }
3310 }
3311 }
3312 this.upButton.setDisabled( !movable || selectedItem === firstMovable );
3313 this.downButton.setDisabled( !movable || selectedItem === lastMovable );
3314 this.removeButton.setDisabled( !removable );
3315 };
3316
3317 /**
3318 * OutlineOptionWidget is an item in an {@link OO.ui.OutlineSelectWidget OutlineSelectWidget}.
3319 *
3320 * Currently, this class is only used by {@link OO.ui.BookletLayout booklet layouts}, which contain
3321 * {@link OO.ui.PageLayout page layouts}. See {@link OO.ui.BookletLayout BookletLayout}
3322 * for an example.
3323 *
3324 * @class
3325 * @extends OO.ui.DecoratedOptionWidget
3326 *
3327 * @constructor
3328 * @param {Object} [config] Configuration options
3329 * @cfg {number} [level] Indentation level
3330 * @cfg {boolean} [movable] Allow modification from {@link OO.ui.OutlineControlsWidget outline controls}.
3331 */
3332 OO.ui.OutlineOptionWidget = function OoUiOutlineOptionWidget( config ) {
3333 // Configuration initialization
3334 config = config || {};
3335
3336 // Parent constructor
3337 OO.ui.OutlineOptionWidget.parent.call( this, config );
3338
3339 // Properties
3340 this.level = 0;
3341 this.movable = !!config.movable;
3342 this.removable = !!config.removable;
3343
3344 // Initialization
3345 this.$element.addClass( 'oo-ui-outlineOptionWidget' );
3346 this.setLevel( config.level );
3347 };
3348
3349 /* Setup */
3350
3351 OO.inheritClass( OO.ui.OutlineOptionWidget, OO.ui.DecoratedOptionWidget );
3352
3353 /* Static Properties */
3354
3355 /**
3356 * @static
3357 * @inheritdoc
3358 */
3359 OO.ui.OutlineOptionWidget.static.highlightable = true;
3360
3361 /**
3362 * @static
3363 * @inheritdoc
3364 */
3365 OO.ui.OutlineOptionWidget.static.scrollIntoViewOnSelect = true;
3366
3367 /**
3368 * @static
3369 * @inheritable
3370 * @property {string}
3371 */
3372 OO.ui.OutlineOptionWidget.static.levelClass = 'oo-ui-outlineOptionWidget-level-';
3373
3374 /**
3375 * @static
3376 * @inheritable
3377 * @property {number}
3378 */
3379 OO.ui.OutlineOptionWidget.static.levels = 3;
3380
3381 /* Methods */
3382
3383 /**
3384 * Check if item is movable.
3385 *
3386 * Movability is used by {@link OO.ui.OutlineControlsWidget outline controls}.
3387 *
3388 * @return {boolean} Item is movable
3389 */
3390 OO.ui.OutlineOptionWidget.prototype.isMovable = function () {
3391 return this.movable;
3392 };
3393
3394 /**
3395 * Check if item is removable.
3396 *
3397 * Removability is used by {@link OO.ui.OutlineControlsWidget outline controls}.
3398 *
3399 * @return {boolean} Item is removable
3400 */
3401 OO.ui.OutlineOptionWidget.prototype.isRemovable = function () {
3402 return this.removable;
3403 };
3404
3405 /**
3406 * Get indentation level.
3407 *
3408 * @return {number} Indentation level
3409 */
3410 OO.ui.OutlineOptionWidget.prototype.getLevel = function () {
3411 return this.level;
3412 };
3413
3414 /**
3415 * @inheritdoc
3416 */
3417 OO.ui.OutlineOptionWidget.prototype.setPressed = function ( state ) {
3418 OO.ui.OutlineOptionWidget.parent.prototype.setPressed.call( this, state );
3419 return this;
3420 };
3421
3422 /**
3423 * Set movability.
3424 *
3425 * Movability is used by {@link OO.ui.OutlineControlsWidget outline controls}.
3426 *
3427 * @param {boolean} movable Item is movable
3428 * @chainable
3429 * @return {OO.ui.Widget} The widget, for chaining
3430 */
3431 OO.ui.OutlineOptionWidget.prototype.setMovable = function ( movable ) {
3432 this.movable = !!movable;
3433 this.updateThemeClasses();
3434 return this;
3435 };
3436
3437 /**
3438 * Set removability.
3439 *
3440 * Removability is used by {@link OO.ui.OutlineControlsWidget outline controls}.
3441 *
3442 * @param {boolean} removable Item is removable
3443 * @chainable
3444 * @return {OO.ui.Widget} The widget, for chaining
3445 */
3446 OO.ui.OutlineOptionWidget.prototype.setRemovable = function ( removable ) {
3447 this.removable = !!removable;
3448 this.updateThemeClasses();
3449 return this;
3450 };
3451
3452 /**
3453 * @inheritdoc
3454 */
3455 OO.ui.OutlineOptionWidget.prototype.setSelected = function ( state ) {
3456 OO.ui.OutlineOptionWidget.parent.prototype.setSelected.call( this, state );
3457 return this;
3458 };
3459
3460 /**
3461 * Set indentation level.
3462 *
3463 * @param {number} [level=0] Indentation level, in the range of [0,#maxLevel]
3464 * @chainable
3465 * @return {OO.ui.Widget} The widget, for chaining
3466 */
3467 OO.ui.OutlineOptionWidget.prototype.setLevel = function ( level ) {
3468 var levels = this.constructor.static.levels,
3469 levelClass = this.constructor.static.levelClass,
3470 i = levels;
3471
3472 this.level = level ? Math.max( 0, Math.min( levels - 1, level ) ) : 0;
3473 while ( i-- ) {
3474 if ( this.level === i ) {
3475 this.$element.addClass( levelClass + i );
3476 } else {
3477 this.$element.removeClass( levelClass + i );
3478 }
3479 }
3480 this.updateThemeClasses();
3481
3482 return this;
3483 };
3484
3485 /**
3486 * OutlineSelectWidget is a structured list that contains {@link OO.ui.OutlineOptionWidget outline options}
3487 * A set of controls can be provided with an {@link OO.ui.OutlineControlsWidget outline controls} widget.
3488 *
3489 * **Currently, this class is only used by {@link OO.ui.BookletLayout booklet layouts}.**
3490 *
3491 * @class
3492 * @extends OO.ui.SelectWidget
3493 * @mixins OO.ui.mixin.TabIndexedElement
3494 *
3495 * @constructor
3496 * @param {Object} [config] Configuration options
3497 */
3498 OO.ui.OutlineSelectWidget = function OoUiOutlineSelectWidget( config ) {
3499 // Parent constructor
3500 OO.ui.OutlineSelectWidget.parent.call( this, config );
3501
3502 // Mixin constructors
3503 OO.ui.mixin.TabIndexedElement.call( this, config );
3504
3505 // Events
3506 this.$element.on( {
3507 focus: this.bindDocumentKeyDownListener.bind( this ),
3508 blur: this.unbindDocumentKeyDownListener.bind( this )
3509 } );
3510
3511 // Initialization
3512 this.$element.addClass( 'oo-ui-outlineSelectWidget' );
3513 };
3514
3515 /* Setup */
3516
3517 OO.inheritClass( OO.ui.OutlineSelectWidget, OO.ui.SelectWidget );
3518 OO.mixinClass( OO.ui.OutlineSelectWidget, OO.ui.mixin.TabIndexedElement );
3519
3520 /**
3521 * ButtonOptionWidget is a special type of {@link OO.ui.mixin.ButtonElement button element} that
3522 * can be selected and configured with data. The class is
3523 * used with OO.ui.ButtonSelectWidget to create a selection of button options. Please see the
3524 * [OOUI documentation on MediaWiki] [1] for more information.
3525 *
3526 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Selects_and_Options#Button_selects_and_options
3527 *
3528 * @class
3529 * @extends OO.ui.OptionWidget
3530 * @mixins OO.ui.mixin.ButtonElement
3531 * @mixins OO.ui.mixin.IconElement
3532 * @mixins OO.ui.mixin.IndicatorElement
3533 * @mixins OO.ui.mixin.TitledElement
3534 *
3535 * @constructor
3536 * @param {Object} [config] Configuration options
3537 */
3538 OO.ui.ButtonOptionWidget = function OoUiButtonOptionWidget( config ) {
3539 // Configuration initialization
3540 config = config || {};
3541
3542 // Parent constructor
3543 OO.ui.ButtonOptionWidget.parent.call( this, config );
3544
3545 // Mixin constructors
3546 OO.ui.mixin.ButtonElement.call( this, config );
3547 OO.ui.mixin.IconElement.call( this, config );
3548 OO.ui.mixin.IndicatorElement.call( this, config );
3549 OO.ui.mixin.TitledElement.call( this, $.extend( {}, config, { $titled: this.$button } ) );
3550
3551 // Initialization
3552 this.$element.addClass( 'oo-ui-buttonOptionWidget' );
3553 this.$button.append( this.$icon, this.$label, this.$indicator );
3554 this.$element.append( this.$button );
3555 };
3556
3557 /* Setup */
3558
3559 OO.inheritClass( OO.ui.ButtonOptionWidget, OO.ui.OptionWidget );
3560 OO.mixinClass( OO.ui.ButtonOptionWidget, OO.ui.mixin.ButtonElement );
3561 OO.mixinClass( OO.ui.ButtonOptionWidget, OO.ui.mixin.IconElement );
3562 OO.mixinClass( OO.ui.ButtonOptionWidget, OO.ui.mixin.IndicatorElement );
3563 OO.mixinClass( OO.ui.ButtonOptionWidget, OO.ui.mixin.TitledElement );
3564
3565 /* Static Properties */
3566
3567 /**
3568 * Allow button mouse down events to pass through so they can be handled by the parent select widget
3569 *
3570 * @static
3571 * @inheritdoc
3572 */
3573 OO.ui.ButtonOptionWidget.static.cancelButtonMouseDownEvents = false;
3574
3575 /**
3576 * @static
3577 * @inheritdoc
3578 */
3579 OO.ui.ButtonOptionWidget.static.highlightable = false;
3580
3581 /* Methods */
3582
3583 /**
3584 * @inheritdoc
3585 */
3586 OO.ui.ButtonOptionWidget.prototype.setSelected = function ( state ) {
3587 OO.ui.ButtonOptionWidget.parent.prototype.setSelected.call( this, state );
3588
3589 if ( this.constructor.static.selectable ) {
3590 this.setActive( state );
3591 }
3592
3593 return this;
3594 };
3595
3596 /**
3597 * ButtonSelectWidget is a {@link OO.ui.SelectWidget select widget} that contains
3598 * button options and is used together with
3599 * OO.ui.ButtonOptionWidget. The ButtonSelectWidget provides an interface for
3600 * highlighting, choosing, and selecting mutually exclusive options. Please see
3601 * the [OOUI documentation on MediaWiki] [1] for more information.
3602 *
3603 * @example
3604 * // Example: A ButtonSelectWidget that contains three ButtonOptionWidgets
3605 * var option1 = new OO.ui.ButtonOptionWidget( {
3606 * data: 1,
3607 * label: 'Option 1',
3608 * title: 'Button option 1'
3609 * } );
3610 *
3611 * var option2 = new OO.ui.ButtonOptionWidget( {
3612 * data: 2,
3613 * label: 'Option 2',
3614 * title: 'Button option 2'
3615 * } );
3616 *
3617 * var option3 = new OO.ui.ButtonOptionWidget( {
3618 * data: 3,
3619 * label: 'Option 3',
3620 * title: 'Button option 3'
3621 * } );
3622 *
3623 * var buttonSelect=new OO.ui.ButtonSelectWidget( {
3624 * items: [ option1, option2, option3 ]
3625 * } );
3626 * $( 'body' ).append( buttonSelect.$element );
3627 *
3628 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets/Selects_and_Options
3629 *
3630 * @class
3631 * @extends OO.ui.SelectWidget
3632 * @mixins OO.ui.mixin.TabIndexedElement
3633 *
3634 * @constructor
3635 * @param {Object} [config] Configuration options
3636 */
3637 OO.ui.ButtonSelectWidget = function OoUiButtonSelectWidget( config ) {
3638 // Parent constructor
3639 OO.ui.ButtonSelectWidget.parent.call( this, config );
3640
3641 // Mixin constructors
3642 OO.ui.mixin.TabIndexedElement.call( this, config );
3643
3644 // Events
3645 this.$element.on( {
3646 focus: this.bindDocumentKeyDownListener.bind( this ),
3647 blur: this.unbindDocumentKeyDownListener.bind( this )
3648 } );
3649
3650 // Initialization
3651 this.$element.addClass( 'oo-ui-buttonSelectWidget' );
3652 };
3653
3654 /* Setup */
3655
3656 OO.inheritClass( OO.ui.ButtonSelectWidget, OO.ui.SelectWidget );
3657 OO.mixinClass( OO.ui.ButtonSelectWidget, OO.ui.mixin.TabIndexedElement );
3658
3659 /**
3660 * TabOptionWidget is an item in a {@link OO.ui.TabSelectWidget TabSelectWidget}.
3661 *
3662 * Currently, this class is only used by {@link OO.ui.IndexLayout index layouts}, which contain
3663 * {@link OO.ui.TabPanelLayout tab panel layouts}. See {@link OO.ui.IndexLayout IndexLayout}
3664 * for an example.
3665 *
3666 * @class
3667 * @extends OO.ui.OptionWidget
3668 *
3669 * @constructor
3670 * @param {Object} [config] Configuration options
3671 */
3672 OO.ui.TabOptionWidget = function OoUiTabOptionWidget( config ) {
3673 // Configuration initialization
3674 config = config || {};
3675
3676 // Parent constructor
3677 OO.ui.TabOptionWidget.parent.call( this, config );
3678
3679 // Initialization
3680 this.$element
3681 .addClass( 'oo-ui-tabOptionWidget' )
3682 .attr( 'role', 'tab' );
3683 };
3684
3685 /* Setup */
3686
3687 OO.inheritClass( OO.ui.TabOptionWidget, OO.ui.OptionWidget );
3688
3689 /* Static Properties */
3690
3691 /**
3692 * @static
3693 * @inheritdoc
3694 */
3695 OO.ui.TabOptionWidget.static.highlightable = false;
3696
3697 /**
3698 * TabSelectWidget is a list that contains {@link OO.ui.TabOptionWidget tab options}
3699 *
3700 * **Currently, this class is only used by {@link OO.ui.IndexLayout index layouts}.**
3701 *
3702 * @class
3703 * @extends OO.ui.SelectWidget
3704 * @mixins OO.ui.mixin.TabIndexedElement
3705 *
3706 * @constructor
3707 * @param {Object} [config] Configuration options
3708 */
3709 OO.ui.TabSelectWidget = function OoUiTabSelectWidget( config ) {
3710 // Parent constructor
3711 OO.ui.TabSelectWidget.parent.call( this, config );
3712
3713 // Mixin constructors
3714 OO.ui.mixin.TabIndexedElement.call( this, config );
3715
3716 // Events
3717 this.$element.on( {
3718 focus: this.bindDocumentKeyDownListener.bind( this ),
3719 blur: this.unbindDocumentKeyDownListener.bind( this )
3720 } );
3721
3722 // Initialization
3723 this.$element
3724 .addClass( 'oo-ui-tabSelectWidget' )
3725 .attr( 'role', 'tablist' );
3726 };
3727
3728 /* Setup */
3729
3730 OO.inheritClass( OO.ui.TabSelectWidget, OO.ui.SelectWidget );
3731 OO.mixinClass( OO.ui.TabSelectWidget, OO.ui.mixin.TabIndexedElement );
3732
3733 /**
3734 * TagItemWidgets are used within a {@link OO.ui.TagMultiselectWidget
3735 * TagMultiselectWidget} to display the selected items.
3736 *
3737 * @class
3738 * @extends OO.ui.Widget
3739 * @mixins OO.ui.mixin.ItemWidget
3740 * @mixins OO.ui.mixin.LabelElement
3741 * @mixins OO.ui.mixin.FlaggedElement
3742 * @mixins OO.ui.mixin.TabIndexedElement
3743 * @mixins OO.ui.mixin.DraggableElement
3744 *
3745 * @constructor
3746 * @param {Object} [config] Configuration object
3747 * @cfg {boolean} [valid=true] Item is valid
3748 * @cfg {boolean} [fixed] Item is fixed. This means the item is
3749 * always included in the values and cannot be removed.
3750 */
3751 OO.ui.TagItemWidget = function OoUiTagItemWidget( config ) {
3752 config = config || {};
3753
3754 // Parent constructor
3755 OO.ui.TagItemWidget.parent.call( this, config );
3756
3757 // Mixin constructors
3758 OO.ui.mixin.ItemWidget.call( this );
3759 OO.ui.mixin.LabelElement.call( this, config );
3760 OO.ui.mixin.FlaggedElement.call( this, config );
3761 OO.ui.mixin.TabIndexedElement.call( this, config );
3762 OO.ui.mixin.DraggableElement.call( this, config );
3763
3764 this.valid = config.valid === undefined ? true : !!config.valid;
3765 this.fixed = !!config.fixed;
3766
3767 this.closeButton = new OO.ui.ButtonWidget( {
3768 framed: false,
3769 icon: 'close',
3770 tabIndex: -1,
3771 title: OO.ui.msg( 'ooui-item-remove' )
3772 } );
3773 this.closeButton.setDisabled( this.isDisabled() );
3774
3775 // Events
3776 this.closeButton
3777 .connect( this, { click: 'remove' } );
3778 this.$element
3779 .on( 'click', this.select.bind( this ) )
3780 .on( 'keydown', this.onKeyDown.bind( this ) )
3781 // Prevent propagation of mousedown; the tag item "lives" in the
3782 // clickable area of the TagMultiselectWidget, which listens to
3783 // mousedown to open the menu or popup. We want to prevent that
3784 // for clicks specifically on the tag itself, so the actions taken
3785 // are more deliberate. When the tag is clicked, it will emit the
3786 // selection event (similar to how #OO.ui.MultioptionWidget emits 'change')
3787 // and can be handled separately.
3788 .on( 'mousedown', function ( e ) { e.stopPropagation(); } );
3789
3790 // Initialization
3791 this.$element
3792 .addClass( 'oo-ui-tagItemWidget' )
3793 .append( this.$label, this.closeButton.$element );
3794 };
3795
3796 /* Initialization */
3797
3798 OO.inheritClass( OO.ui.TagItemWidget, OO.ui.Widget );
3799 OO.mixinClass( OO.ui.TagItemWidget, OO.ui.mixin.ItemWidget );
3800 OO.mixinClass( OO.ui.TagItemWidget, OO.ui.mixin.LabelElement );
3801 OO.mixinClass( OO.ui.TagItemWidget, OO.ui.mixin.FlaggedElement );
3802 OO.mixinClass( OO.ui.TagItemWidget, OO.ui.mixin.TabIndexedElement );
3803 OO.mixinClass( OO.ui.TagItemWidget, OO.ui.mixin.DraggableElement );
3804
3805 /* Events */
3806
3807 /**
3808 * @event remove
3809 *
3810 * A remove action was performed on the item
3811 */
3812
3813 /**
3814 * @event navigate
3815 * @param {string} direction Direction of the movement, forward or backwards
3816 *
3817 * A navigate action was performed on the item
3818 */
3819
3820 /**
3821 * @event select
3822 *
3823 * The tag widget was selected. This can occur when the widget
3824 * is either clicked or enter was pressed on it.
3825 */
3826
3827 /**
3828 * @event valid
3829 * @param {boolean} isValid Item is valid
3830 *
3831 * Item validity has changed
3832 */
3833
3834 /**
3835 * @event fixed
3836 * @param {boolean} isFixed Item is fixed
3837 *
3838 * Item fixed state has changed
3839 */
3840
3841 /* Methods */
3842
3843 /**
3844 * Set this item as fixed, meaning it cannot be removed
3845 *
3846 * @param {string} [state] Item is fixed
3847 * @fires fixed
3848 * @return {OO.ui.Widget} The widget, for chaining
3849 */
3850 OO.ui.TagItemWidget.prototype.setFixed = function ( state ) {
3851 state = state === undefined ? !this.fixed : !!state;
3852
3853 if ( this.fixed !== state ) {
3854 this.fixed = state;
3855 if ( this.closeButton ) {
3856 this.closeButton.toggle( !this.fixed );
3857 }
3858
3859 if ( !this.fixed && this.elementGroup && !this.elementGroup.isDraggable() ) {
3860 // Only enable the state of the item if the
3861 // entire group is draggable
3862 this.toggleDraggable( !this.fixed );
3863 }
3864 this.$element.toggleClass( 'oo-ui-tagItemWidget-fixed', this.fixed );
3865
3866 this.emit( 'fixed', this.isFixed() );
3867 }
3868 return this;
3869 };
3870
3871 /**
3872 * Check whether the item is fixed
3873 * @return {boolean}
3874 */
3875 OO.ui.TagItemWidget.prototype.isFixed = function () {
3876 return this.fixed;
3877 };
3878
3879 /**
3880 * @inheritdoc
3881 */
3882 OO.ui.TagItemWidget.prototype.setDisabled = function ( state ) {
3883 if ( state && this.elementGroup && !this.elementGroup.isDisabled() ) {
3884 OO.ui.warnDeprecation( 'TagItemWidget#setDisabled: Disabling individual items is deprecated and will result in inconsistent behavior. Use #setFixed instead. See T193571.' );
3885 }
3886 // Parent method
3887 OO.ui.TagItemWidget.parent.prototype.setDisabled.call( this, state );
3888 if (
3889 !state &&
3890 // Verify we have a group, and that the widget is ready
3891 this.toggleDraggable && this.elementGroup &&
3892 !this.isFixed() &&
3893 !this.elementGroup.isDraggable()
3894 ) {
3895 // Only enable the draggable state of the item if the
3896 // entire group is draggable to begin with, and if the
3897 // item is not fixed
3898 this.toggleDraggable( !state );
3899 }
3900
3901 if ( this.closeButton ) {
3902 this.closeButton.setDisabled( state );
3903 }
3904
3905 return this;
3906 };
3907
3908 /**
3909 * Handle removal of the item
3910 *
3911 * This is mainly for extensibility concerns, so other children
3912 * of this class can change the behavior if they need to. This
3913 * is called by both clicking the 'remove' button but also
3914 * on keypress, which is harder to override if needed.
3915 *
3916 * @fires remove
3917 */
3918 OO.ui.TagItemWidget.prototype.remove = function () {
3919 if ( !this.isDisabled() && !this.isFixed() ) {
3920 this.emit( 'remove' );
3921 }
3922 };
3923
3924 /**
3925 * Handle a keydown event on the widget
3926 *
3927 * @fires navigate
3928 * @fires remove
3929 * @param {jQuery.Event} e Key down event
3930 * @return {boolean|undefined} false to stop the operation
3931 */
3932 OO.ui.TagItemWidget.prototype.onKeyDown = function ( e ) {
3933 var movement;
3934
3935 if ( !this.isDisabled() && !this.isFixed() && ( e.keyCode === OO.ui.Keys.BACKSPACE || e.keyCode === OO.ui.Keys.DELETE ) ) {
3936 this.remove();
3937 return false;
3938 } else if ( e.keyCode === OO.ui.Keys.ENTER ) {
3939 this.select();
3940 return false;
3941 } else if (
3942 e.keyCode === OO.ui.Keys.LEFT ||
3943 e.keyCode === OO.ui.Keys.RIGHT
3944 ) {
3945 if ( OO.ui.Element.static.getDir( this.$element ) === 'rtl' ) {
3946 movement = {
3947 left: 'forwards',
3948 right: 'backwards'
3949 };
3950 } else {
3951 movement = {
3952 left: 'backwards',
3953 right: 'forwards'
3954 };
3955 }
3956
3957 this.emit(
3958 'navigate',
3959 e.keyCode === OO.ui.Keys.LEFT ?
3960 movement.left : movement.right
3961 );
3962 return false;
3963 }
3964 };
3965
3966 /**
3967 * Select this item
3968 *
3969 * @fires select
3970 */
3971 OO.ui.TagItemWidget.prototype.select = function () {
3972 if ( !this.isDisabled() ) {
3973 this.emit( 'select' );
3974 }
3975 };
3976
3977 /**
3978 * Set the valid state of this item
3979 *
3980 * @param {boolean} [valid] Item is valid
3981 * @fires valid
3982 */
3983 OO.ui.TagItemWidget.prototype.toggleValid = function ( valid ) {
3984 valid = valid === undefined ? !this.valid : !!valid;
3985
3986 if ( this.valid !== valid ) {
3987 this.valid = valid;
3988
3989 this.setFlags( { invalid: !this.valid } );
3990
3991 this.emit( 'valid', this.valid );
3992 }
3993 };
3994
3995 /**
3996 * Check whether the item is valid
3997 *
3998 * @return {boolean} Item is valid
3999 */
4000 OO.ui.TagItemWidget.prototype.isValid = function () {
4001 return this.valid;
4002 };
4003
4004 /**
4005 * A basic tag multiselect widget, similar in concept to {@link OO.ui.ComboBoxInputWidget combo box widget}
4006 * that allows the user to add multiple values that are displayed in a tag area.
4007 *
4008 * This widget is a base widget; see {@link OO.ui.MenuTagMultiselectWidget MenuTagMultiselectWidget} and
4009 * {@link OO.ui.PopupTagMultiselectWidget PopupTagMultiselectWidget} for the implementations that use
4010 * a menu and a popup respectively.
4011 *
4012 * @example
4013 * // Example: A basic TagMultiselectWidget.
4014 * var widget = new OO.ui.TagMultiselectWidget( {
4015 * inputPosition: 'outline',
4016 * allowedValues: [ 'Option 1', 'Option 2', 'Option 3' ],
4017 * selected: [ 'Option 1' ]
4018 * } );
4019 * $( 'body' ).append( widget.$element );
4020 *
4021 * @class
4022 * @extends OO.ui.Widget
4023 * @mixins OO.ui.mixin.GroupWidget
4024 * @mixins OO.ui.mixin.DraggableGroupElement
4025 * @mixins OO.ui.mixin.IndicatorElement
4026 * @mixins OO.ui.mixin.IconElement
4027 * @mixins OO.ui.mixin.TabIndexedElement
4028 * @mixins OO.ui.mixin.FlaggedElement
4029 *
4030 * @constructor
4031 * @param {Object} config Configuration object
4032 * @cfg {Object} [input] Configuration options for the input widget
4033 * @cfg {OO.ui.InputWidget} [inputWidget] An optional input widget. If given, it will
4034 * replace the input widget used in the TagMultiselectWidget. If not given,
4035 * TagMultiselectWidget creates its own.
4036 * @cfg {boolean} [inputPosition='inline'] Position of the input. Options are:
4037 * - inline: The input is invisible, but exists inside the tag list, so
4038 * the user types into the tag groups to add tags.
4039 * - outline: The input is underneath the tag area.
4040 * - none: No input supplied
4041 * @cfg {boolean} [allowEditTags=true] Allow editing of the tags by clicking them
4042 * @cfg {boolean} [allowArbitrary=false] Allow data items to be added even if
4043 * not present in the menu.
4044 * @cfg {Object[]} [allowedValues] An array representing the allowed items
4045 * by their datas.
4046 * @cfg {boolean} [allowDuplicates=false] Allow duplicate items to be added
4047 * @cfg {boolean} [allowDisplayInvalidTags=false] Allow the display of
4048 * invalid tags. These tags will display with an invalid state, and
4049 * the widget as a whole will have an invalid state if any invalid tags
4050 * are present.
4051 * @cfg {number} [tagLimit] An optional limit on the number of selected options.
4052 * If 'tagLimit' is set and is reached, the input is disabled, not allowing any
4053 * additions. If 'tagLimit' is unset or is 0, an unlimited number of items can be
4054 * added.
4055 * @cfg {boolean} [allowReordering=true] Allow reordering of the items
4056 * @cfg {Object[]|String[]} [selected] A set of selected tags. If given,
4057 * these will appear in the tag list on initialization, as long as they
4058 * pass the validity tests.
4059 */
4060 OO.ui.TagMultiselectWidget = function OoUiTagMultiselectWidget( config ) {
4061 var inputEvents,
4062 rAF = window.requestAnimationFrame || setTimeout,
4063 widget = this,
4064 $tabFocus = $( '<span>' )
4065 .addClass( 'oo-ui-tagMultiselectWidget-focusTrap' );
4066
4067 config = config || {};
4068
4069 // Parent constructor
4070 OO.ui.TagMultiselectWidget.parent.call( this, config );
4071
4072 // Mixin constructors
4073 OO.ui.mixin.GroupWidget.call( this, config );
4074 OO.ui.mixin.IndicatorElement.call( this, config );
4075 OO.ui.mixin.IconElement.call( this, config );
4076 OO.ui.mixin.TabIndexedElement.call( this, config );
4077 OO.ui.mixin.FlaggedElement.call( this, config );
4078 OO.ui.mixin.DraggableGroupElement.call( this, config );
4079
4080 this.toggleDraggable(
4081 config.allowReordering === undefined ?
4082 true : !!config.allowReordering
4083 );
4084
4085 this.inputPosition =
4086 this.constructor.static.allowedInputPositions.indexOf( config.inputPosition ) > -1 ?
4087 config.inputPosition : 'inline';
4088 this.allowEditTags = config.allowEditTags === undefined ? true : !!config.allowEditTags;
4089 this.allowArbitrary = !!config.allowArbitrary;
4090 this.allowDuplicates = !!config.allowDuplicates;
4091 this.allowedValues = config.allowedValues || [];
4092 this.allowDisplayInvalidTags = config.allowDisplayInvalidTags;
4093 this.hasInput = this.inputPosition !== 'none';
4094 this.tagLimit = config.tagLimit;
4095 this.height = null;
4096 this.valid = true;
4097
4098 this.$content = $( '<div>' )
4099 .addClass( 'oo-ui-tagMultiselectWidget-content' );
4100 this.$handle = $( '<div>' )
4101 .addClass( 'oo-ui-tagMultiselectWidget-handle' )
4102 .append(
4103 this.$indicator,
4104 this.$icon,
4105 this.$content
4106 .append(
4107 this.$group
4108 .addClass( 'oo-ui-tagMultiselectWidget-group' )
4109 )
4110 );
4111
4112 // Events
4113 this.aggregate( {
4114 remove: 'itemRemove',
4115 navigate: 'itemNavigate',
4116 select: 'itemSelect',
4117 fixed: 'itemFixed'
4118 } );
4119 this.connect( this, {
4120 itemRemove: 'onTagRemove',
4121 itemSelect: 'onTagSelect',
4122 itemFixed: 'onTagFixed',
4123 itemNavigate: 'onTagNavigate',
4124 change: 'onChangeTags'
4125 } );
4126 this.$handle.on( {
4127 mousedown: this.onMouseDown.bind( this )
4128 } );
4129
4130 // Initialize
4131 this.$element
4132 .addClass( 'oo-ui-tagMultiselectWidget' )
4133 .append( this.$handle );
4134
4135 if ( this.hasInput ) {
4136 if ( config.inputWidget ) {
4137 this.input = config.inputWidget;
4138 } else {
4139 this.input = new OO.ui.TextInputWidget( $.extend( {
4140 placeholder: config.placeholder,
4141 classes: [ 'oo-ui-tagMultiselectWidget-input' ]
4142 }, config.input ) );
4143 }
4144 this.input.setDisabled( this.isDisabled() );
4145
4146 inputEvents = {
4147 focus: this.onInputFocus.bind( this ),
4148 blur: this.onInputBlur.bind( this ),
4149 'propertychange change click mouseup keydown keyup input cut paste select focus':
4150 OO.ui.debounce( this.updateInputSize.bind( this ) ),
4151 keydown: this.onInputKeyDown.bind( this ),
4152 keypress: this.onInputKeyPress.bind( this )
4153 };
4154
4155 this.input.$input.on( inputEvents );
4156 this.inputPlaceholder = this.input.$input.attr( 'placeholder' );
4157
4158 if ( this.inputPosition === 'outline' ) {
4159 // Override max-height for the input widget
4160 // in the case the widget is outline so it can
4161 // stretch all the way if the widget is wide
4162 this.input.$element.css( 'max-width', 'inherit' );
4163 this.$element
4164 .addClass( 'oo-ui-tagMultiselectWidget-outlined' )
4165 .append( this.input.$element );
4166 } else {
4167 this.$element.addClass( 'oo-ui-tagMultiselectWidget-inlined' );
4168 // HACK: When the widget is using 'inline' input, the
4169 // behavior needs to only use the $input itself
4170 // so we style and size it accordingly (otherwise
4171 // the styling and sizing can get very convoluted
4172 // when the wrapping divs and other elements)
4173 // We are taking advantage of still being able to
4174 // call the widget itself for operations like
4175 // .getValue() and setDisabled() and .focus() but
4176 // having only the $input attached to the DOM
4177 this.$content.append( this.input.$input );
4178 }
4179 } else {
4180 this.$content.append( $tabFocus );
4181 }
4182
4183 this.setTabIndexedElement(
4184 this.hasInput ?
4185 this.input.$input :
4186 $tabFocus
4187 );
4188
4189 if ( config.selected ) {
4190 this.setValue( config.selected );
4191 }
4192
4193 // HACK: Input size needs to be calculated after everything
4194 // else is rendered
4195 rAF( function () {
4196 if ( widget.hasInput ) {
4197 widget.updateInputSize();
4198 }
4199 } );
4200 };
4201
4202 /* Initialization */
4203
4204 OO.inheritClass( OO.ui.TagMultiselectWidget, OO.ui.Widget );
4205 OO.mixinClass( OO.ui.TagMultiselectWidget, OO.ui.mixin.GroupWidget );
4206 OO.mixinClass( OO.ui.TagMultiselectWidget, OO.ui.mixin.DraggableGroupElement );
4207 OO.mixinClass( OO.ui.TagMultiselectWidget, OO.ui.mixin.IndicatorElement );
4208 OO.mixinClass( OO.ui.TagMultiselectWidget, OO.ui.mixin.IconElement );
4209 OO.mixinClass( OO.ui.TagMultiselectWidget, OO.ui.mixin.TabIndexedElement );
4210 OO.mixinClass( OO.ui.TagMultiselectWidget, OO.ui.mixin.FlaggedElement );
4211
4212 /* Static properties */
4213
4214 /**
4215 * Allowed input positions.
4216 * - inline: The input is inside the tag list
4217 * - outline: The input is under the tag list
4218 * - none: There is no input
4219 *
4220 * @property {Array}
4221 */
4222 OO.ui.TagMultiselectWidget.static.allowedInputPositions = [ 'inline', 'outline', 'none' ];
4223
4224 /* Methods */
4225
4226 /**
4227 * Handle mouse down events.
4228 *
4229 * @private
4230 * @param {jQuery.Event} e Mouse down event
4231 * @return {boolean} False to prevent defaults
4232 */
4233 OO.ui.TagMultiselectWidget.prototype.onMouseDown = function ( e ) {
4234 if (
4235 !this.isDisabled() &&
4236 ( !this.hasInput || e.target !== this.input.$input[ 0 ] ) &&
4237 e.which === OO.ui.MouseButtons.LEFT
4238 ) {
4239 this.focus();
4240 return false;
4241 }
4242 };
4243
4244 /**
4245 * Handle key press events.
4246 *
4247 * @private
4248 * @param {jQuery.Event} e Key press event
4249 * @return {boolean} Whether to prevent defaults
4250 */
4251 OO.ui.TagMultiselectWidget.prototype.onInputKeyPress = function ( e ) {
4252 var stopOrContinue,
4253 withMetaKey = e.metaKey || e.ctrlKey;
4254
4255 if ( !this.isDisabled() ) {
4256 if ( e.which === OO.ui.Keys.ENTER ) {
4257 stopOrContinue = this.doInputEnter( e, withMetaKey );
4258 }
4259
4260 // Make sure the input gets resized.
4261 setTimeout( this.updateInputSize.bind( this ), 0 );
4262 return stopOrContinue;
4263 }
4264 };
4265
4266 /**
4267 * Handle key down events.
4268 *
4269 * @private
4270 * @param {jQuery.Event} e Key down event
4271 * @return {boolean}
4272 */
4273 OO.ui.TagMultiselectWidget.prototype.onInputKeyDown = function ( e ) {
4274 var movement, direction,
4275 widget = this,
4276 withMetaKey = e.metaKey || e.ctrlKey,
4277 isMovementInsideInput = function ( direction ) {
4278 var inputRange = widget.input.getRange(),
4279 inputValue = widget.hasInput && widget.input.getValue();
4280
4281 if ( direction === 'forwards' && inputRange.to > inputValue.length - 1 ) {
4282 return false;
4283 }
4284
4285 if ( direction === 'backwards' && inputRange.from <= 0 ) {
4286 return false;
4287 }
4288
4289 return true;
4290 };
4291
4292 if ( !this.isDisabled() ) {
4293 // 'keypress' event is not triggered for Backspace
4294 if ( e.keyCode === OO.ui.Keys.BACKSPACE ) {
4295 return this.doInputBackspace( e, withMetaKey );
4296 } else if ( e.keyCode === OO.ui.Keys.ESCAPE ) {
4297 return this.doInputEscape( e );
4298 } else if (
4299 e.keyCode === OO.ui.Keys.LEFT ||
4300 e.keyCode === OO.ui.Keys.RIGHT
4301 ) {
4302 if ( OO.ui.Element.static.getDir( this.$element ) === 'rtl' ) {
4303 movement = {
4304 left: 'forwards',
4305 right: 'backwards'
4306 };
4307 } else {
4308 movement = {
4309 left: 'backwards',
4310 right: 'forwards'
4311 };
4312 }
4313 direction = e.keyCode === OO.ui.Keys.LEFT ?
4314 movement.left : movement.right;
4315
4316 if ( !this.hasInput || !isMovementInsideInput( direction ) ) {
4317 return this.doInputArrow( e, direction, withMetaKey );
4318 }
4319 }
4320 }
4321 };
4322
4323 /**
4324 * Respond to input focus event
4325 */
4326 OO.ui.TagMultiselectWidget.prototype.onInputFocus = function () {
4327 this.$element.addClass( 'oo-ui-tagMultiselectWidget-focus' );
4328 // Reset validity
4329 this.toggleValid( true );
4330 };
4331
4332 /**
4333 * Respond to input blur event
4334 */
4335 OO.ui.TagMultiselectWidget.prototype.onInputBlur = function () {
4336 this.$element.removeClass( 'oo-ui-tagMultiselectWidget-focus' );
4337
4338 // Set the widget as invalid if there's text in the input
4339 this.addTagFromInput();
4340 this.toggleValid( this.checkValidity() && ( !this.hasInput || !this.input.getValue() ) );
4341 };
4342
4343 /**
4344 * Perform an action after the enter key on the input
4345 *
4346 * @param {jQuery.Event} e Event data
4347 * @param {boolean} [withMetaKey] Whether this key was pressed with
4348 * a meta key like 'ctrl'
4349 * @return {boolean} Whether to prevent defaults
4350 */
4351 OO.ui.TagMultiselectWidget.prototype.doInputEnter = function () {
4352 this.addTagFromInput();
4353 return false;
4354 };
4355
4356 /**
4357 * Perform an action responding to the enter key on the input
4358 *
4359 * @param {jQuery.Event} e Event data
4360 * @param {boolean} [withMetaKey] Whether this key was pressed with
4361 * a meta key like 'ctrl'
4362 * @return {boolean} Whether to prevent defaults
4363 */
4364 OO.ui.TagMultiselectWidget.prototype.doInputBackspace = function ( e, withMetaKey ) {
4365 var items, item;
4366
4367 if (
4368 this.inputPosition === 'inline' &&
4369 this.input.getValue() === '' &&
4370 !this.isEmpty()
4371 ) {
4372 // Delete the last item
4373 items = this.getItems();
4374 item = items[ items.length - 1 ];
4375
4376 if ( !item.isDisabled() && !item.isFixed() ) {
4377 this.removeItems( [ item ] );
4378 // If Ctrl/Cmd was pressed, delete item entirely.
4379 // Otherwise put it into the text field for editing.
4380 if ( !withMetaKey ) {
4381 this.input.setValue( item.getData() );
4382 }
4383 }
4384
4385 return false;
4386 }
4387 };
4388
4389 /**
4390 * Perform an action after the escape key on the input
4391 *
4392 * @param {jQuery.Event} e Event data
4393 */
4394 OO.ui.TagMultiselectWidget.prototype.doInputEscape = function () {
4395 this.clearInput();
4396 };
4397
4398 /**
4399 * Perform an action after the arrow key on the input, select the previous
4400 * item from the input.
4401 * See #getPreviousItem
4402 *
4403 * @param {jQuery.Event} e Event data
4404 * @param {string} direction Direction of the movement; forwards or backwards
4405 * @param {boolean} [withMetaKey] Whether this key was pressed with
4406 * a meta key like 'ctrl'
4407 */
4408 OO.ui.TagMultiselectWidget.prototype.doInputArrow = function ( e, direction ) {
4409 if (
4410 this.inputPosition === 'inline' &&
4411 !this.isEmpty() &&
4412 direction === 'backwards'
4413 ) {
4414 // Get previous item
4415 this.getPreviousItem().focus();
4416 }
4417 };
4418
4419 /**
4420 * Respond to item select event
4421 *
4422 * @param {OO.ui.TagItemWidget} item Selected item
4423 */
4424 OO.ui.TagMultiselectWidget.prototype.onTagSelect = function ( item ) {
4425 if ( this.hasInput && this.allowEditTags && !item.isFixed() ) {
4426 if ( this.input.getValue() ) {
4427 this.addTagFromInput();
4428 }
4429 // 1. Get the label of the tag into the input
4430 this.input.setValue( item.getData() );
4431 // 2. Remove the tag
4432 this.removeItems( [ item ] );
4433 // 3. Focus the input
4434 this.focus();
4435 }
4436 };
4437
4438 /**
4439 * Respond to item fixed state change
4440 *
4441 * @param {OO.ui.TagItemWidget} item Selected item
4442 */
4443 OO.ui.TagMultiselectWidget.prototype.onTagFixed = function ( item ) {
4444 var i,
4445 items = this.getItems();
4446
4447 // Move item to the end of the static items
4448 for ( i = 0; i < items.length; i++ ) {
4449 if ( items[ i ] !== item && !items[ i ].isFixed() ) {
4450 break;
4451 }
4452 }
4453 this.addItems( item, i );
4454 };
4455 /**
4456 * Respond to change event, where items were added, removed, or cleared.
4457 */
4458 OO.ui.TagMultiselectWidget.prototype.onChangeTags = function () {
4459 var isUnderLimit = this.isUnderLimit();
4460
4461 // Reset validity
4462 this.toggleValid(
4463 this.checkValidity() &&
4464 !( this.hasInput && this.input.getValue() )
4465 );
4466
4467 if ( this.hasInput ) {
4468 this.updateInputSize();
4469 if ( !isUnderLimit ) {
4470 // Clear the input
4471 this.input.setValue( '' );
4472 }
4473 if ( this.inputPosition === 'outline' ) {
4474 // Show/clear the placeholder and enable/disable the input
4475 // based on whether we are/aren't under the specified limit
4476 this.input.$input.attr( 'placeholder', isUnderLimit ? this.inputPlaceholder : '' );
4477 this.input.setDisabled( !isUnderLimit );
4478 } else {
4479 // Show/hide the input
4480 this.input.$input.toggleClass( 'oo-ui-element-hidden', !isUnderLimit );
4481 }
4482 }
4483 this.updateIfHeightChanged();
4484 };
4485
4486 /**
4487 * @inheritdoc
4488 */
4489 OO.ui.TagMultiselectWidget.prototype.setDisabled = function ( isDisabled ) {
4490 // Parent method
4491 OO.ui.TagMultiselectWidget.parent.prototype.setDisabled.call( this, isDisabled );
4492
4493 if ( this.hasInput && this.input ) {
4494 if ( !isDisabled ) {
4495 this.updateInputSize();
4496 }
4497 this.input.setDisabled( !!isDisabled && !this.isUnderLimit() );
4498 }
4499
4500 if ( this.items ) {
4501 this.getItems().forEach( function ( item ) {
4502 item.setDisabled( !!isDisabled );
4503 } );
4504 }
4505 };
4506
4507 /**
4508 * Respond to tag remove event
4509 * @param {OO.ui.TagItemWidget} item Removed tag
4510 */
4511 OO.ui.TagMultiselectWidget.prototype.onTagRemove = function ( item ) {
4512 this.removeTagByData( item.getData() );
4513 };
4514
4515 /**
4516 * Respond to navigate event on the tag
4517 *
4518 * @param {OO.ui.TagItemWidget} item Removed tag
4519 * @param {string} direction Direction of movement; 'forwards' or 'backwards'
4520 */
4521 OO.ui.TagMultiselectWidget.prototype.onTagNavigate = function ( item, direction ) {
4522 var firstItem = this.getItems()[ 0 ];
4523
4524 if ( direction === 'forwards' ) {
4525 this.getNextItem( item ).focus();
4526 } else if ( !this.inputPosition === 'inline' || item !== firstItem ) {
4527 // If the widget has an inline input, we want to stop at the starting edge
4528 // of the tags
4529 this.getPreviousItem( item ).focus();
4530 }
4531 };
4532
4533 /**
4534 * Add tag from input value
4535 */
4536 OO.ui.TagMultiselectWidget.prototype.addTagFromInput = function () {
4537 var val = this.input.getValue(),
4538 isValid = this.isAllowedData( val );
4539
4540 if ( !val ) {
4541 return;
4542 }
4543
4544 if ( isValid || this.allowDisplayInvalidTags ) {
4545 this.clearInput();
4546 this.addTag( val );
4547 }
4548 };
4549
4550 /**
4551 * Clear the input
4552 */
4553 OO.ui.TagMultiselectWidget.prototype.clearInput = function () {
4554 this.input.setValue( '' );
4555 };
4556
4557 /**
4558 * Check whether the given value is a duplicate of an existing
4559 * tag already in the list.
4560 *
4561 * @param {string|Object} data Requested value
4562 * @return {boolean} Value is duplicate
4563 */
4564 OO.ui.TagMultiselectWidget.prototype.isDuplicateData = function ( data ) {
4565 return !!this.findItemFromData( data );
4566 };
4567
4568 /**
4569 * Check whether a given value is allowed to be added
4570 *
4571 * @param {string|Object} data Requested value
4572 * @return {boolean} Value is allowed
4573 */
4574 OO.ui.TagMultiselectWidget.prototype.isAllowedData = function ( data ) {
4575 if (
4576 !this.allowDuplicates &&
4577 this.isDuplicateData( data )
4578 ) {
4579 return false;
4580 }
4581
4582 if ( this.allowArbitrary ) {
4583 return true;
4584 }
4585
4586 // Check with allowed values
4587 if (
4588 this.getAllowedValues().some( function ( value ) {
4589 return data === value;
4590 } )
4591 ) {
4592 return true;
4593 }
4594
4595 return false;
4596 };
4597
4598 /**
4599 * Get the allowed values list
4600 *
4601 * @return {string[]} Allowed data values
4602 */
4603 OO.ui.TagMultiselectWidget.prototype.getAllowedValues = function () {
4604 return this.allowedValues;
4605 };
4606
4607 /**
4608 * Add a value to the allowed values list
4609 *
4610 * @param {string} value Allowed data value
4611 */
4612 OO.ui.TagMultiselectWidget.prototype.addAllowedValue = function ( value ) {
4613 if ( this.allowedValues.indexOf( value ) === -1 ) {
4614 this.allowedValues.push( value );
4615 }
4616 };
4617
4618 /**
4619 * Get the datas of the currently selected items
4620 *
4621 * @return {string[]|Object[]} Datas of currently selected items
4622 */
4623 OO.ui.TagMultiselectWidget.prototype.getValue = function () {
4624 return this.getItems()
4625 .filter( function ( item ) {
4626 return item.isValid();
4627 } )
4628 .map( function ( item ) {
4629 return item.getData();
4630 } );
4631 };
4632
4633 /**
4634 * Set the value of this widget by datas.
4635 *
4636 * @param {string|string[]|Object|Object[]} valueObject An object representing the data
4637 * and label of the value. If the widget allows arbitrary values,
4638 * the items will be added as-is. Otherwise, the data value will
4639 * be checked against allowedValues.
4640 * This object must contain at least a data key. Example:
4641 * { data: 'foo', label: 'Foo item' }
4642 * For multiple items, use an array of objects. For example:
4643 * [
4644 * { data: 'foo', label: 'Foo item' },
4645 * { data: 'bar', label: 'Bar item' }
4646 * ]
4647 * Value can also be added with plaintext array, for example:
4648 * [ 'foo', 'bar', 'bla' ] or a single string, like 'foo'
4649 */
4650 OO.ui.TagMultiselectWidget.prototype.setValue = function ( valueObject ) {
4651 valueObject = Array.isArray( valueObject ) ? valueObject : [ valueObject ];
4652
4653 this.clearItems();
4654 valueObject.forEach( function ( obj ) {
4655 if ( typeof obj === 'string' ) {
4656 this.addTag( obj );
4657 } else {
4658 this.addTag( obj.data, obj.label );
4659 }
4660 }.bind( this ) );
4661 };
4662
4663 /**
4664 * Add tag to the display area
4665 *
4666 * @param {string|Object} data Tag data
4667 * @param {string} [label] Tag label. If no label is provided, the
4668 * stringified version of the data will be used instead.
4669 * @return {boolean} Item was added successfully
4670 */
4671 OO.ui.TagMultiselectWidget.prototype.addTag = function ( data, label ) {
4672 var newItemWidget,
4673 isValid = this.isAllowedData( data );
4674
4675 if ( this.isUnderLimit() && ( isValid || this.allowDisplayInvalidTags ) ) {
4676 newItemWidget = this.createTagItemWidget( data, label );
4677 newItemWidget.toggleValid( isValid );
4678 this.addItems( [ newItemWidget ] );
4679 return true;
4680 }
4681
4682 return false;
4683 };
4684
4685 /**
4686 * Check whether the number of current tags is within the limit.
4687 *
4688 * @return {boolean} True if current tag count is within the limit or
4689 * if 'tagLimit' is not set
4690 */
4691 OO.ui.TagMultiselectWidget.prototype.isUnderLimit = function () {
4692 return !this.tagLimit ||
4693 this.getItemCount() < this.tagLimit;
4694 };
4695
4696 /**
4697 * Remove tag by its data property.
4698 *
4699 * @param {string|Object} data Tag data
4700 */
4701 OO.ui.TagMultiselectWidget.prototype.removeTagByData = function ( data ) {
4702 var item = this.findItemFromData( data );
4703
4704 this.removeItems( [ item ] );
4705 };
4706
4707 /**
4708 * Construct a OO.ui.TagItemWidget (or a subclass thereof) from given label and data.
4709 *
4710 * @protected
4711 * @param {string} data Item data
4712 * @param {string} label The label text.
4713 * @return {OO.ui.TagItemWidget}
4714 */
4715 OO.ui.TagMultiselectWidget.prototype.createTagItemWidget = function ( data, label ) {
4716 label = label || data;
4717
4718 return new OO.ui.TagItemWidget( { data: data, label: label } );
4719 };
4720
4721 /**
4722 * Given an item, returns the item after it. If the item is already the
4723 * last item, return `this.input`. If no item is passed, returns the
4724 * very first item.
4725 *
4726 * @protected
4727 * @param {OO.ui.TagItemWidget} [item] Tag item
4728 * @return {OO.ui.Widget} The next widget available.
4729 */
4730 OO.ui.TagMultiselectWidget.prototype.getNextItem = function ( item ) {
4731 var itemIndex = this.items.indexOf( item );
4732
4733 if ( item === undefined || itemIndex === -1 ) {
4734 return this.items[ 0 ];
4735 }
4736
4737 if ( itemIndex === this.items.length - 1 ) { // Last item
4738 if ( this.hasInput ) {
4739 return this.input;
4740 } else {
4741 // Return first item
4742 return this.items[ 0 ];
4743 }
4744 } else {
4745 return this.items[ itemIndex + 1 ];
4746 }
4747 };
4748
4749 /**
4750 * Given an item, returns the item before it. If the item is already the
4751 * first item, return `this.input`. If no item is passed, returns the
4752 * very last item.
4753 *
4754 * @protected
4755 * @param {OO.ui.TagItemWidget} [item] Tag item
4756 * @return {OO.ui.Widget} The previous widget available.
4757 */
4758 OO.ui.TagMultiselectWidget.prototype.getPreviousItem = function ( item ) {
4759 var itemIndex = this.items.indexOf( item );
4760
4761 if ( item === undefined || itemIndex === -1 ) {
4762 return this.items[ this.items.length - 1 ];
4763 }
4764
4765 if ( itemIndex === 0 ) {
4766 if ( this.hasInput ) {
4767 return this.input;
4768 } else {
4769 // Return the last item
4770 return this.items[ this.items.length - 1 ];
4771 }
4772 } else {
4773 return this.items[ itemIndex - 1 ];
4774 }
4775 };
4776
4777 /**
4778 * Update the dimensions of the text input field to encompass all available area.
4779 * This is especially relevant for when the input is at the edge of a line
4780 * and should get smaller. The usual operation (as an inline-block with min-width)
4781 * does not work in that case, pushing the input downwards to the next line.
4782 *
4783 * @private
4784 */
4785 OO.ui.TagMultiselectWidget.prototype.updateInputSize = function () {
4786 var $lastItem, direction, contentWidth, currentWidth, bestWidth;
4787 if ( this.inputPosition === 'inline' && !this.isDisabled() ) {
4788 if ( this.input.$input[ 0 ].scrollWidth === 0 ) {
4789 // Input appears to be attached but not visible.
4790 // Don't attempt to adjust its size, because our measurements
4791 // are going to fail anyway.
4792 return;
4793 }
4794 this.input.$input.css( 'width', '1em' );
4795 $lastItem = this.$group.children().last();
4796 direction = OO.ui.Element.static.getDir( this.$handle );
4797
4798 // Get the width of the input with the placeholder text as
4799 // the value and save it so that we don't keep recalculating
4800 if (
4801 this.contentWidthWithPlaceholder === undefined &&
4802 this.input.getValue() === '' &&
4803 this.input.$input.attr( 'placeholder' ) !== undefined
4804 ) {
4805 this.input.setValue( this.input.$input.attr( 'placeholder' ) );
4806 this.contentWidthWithPlaceholder = this.input.$input[ 0 ].scrollWidth;
4807 this.input.setValue( '' );
4808
4809 }
4810
4811 // Always keep the input wide enough for the placeholder text
4812 contentWidth = Math.max(
4813 this.input.$input[ 0 ].scrollWidth,
4814 // undefined arguments in Math.max lead to NaN
4815 ( this.contentWidthWithPlaceholder === undefined ) ?
4816 0 : this.contentWidthWithPlaceholder
4817 );
4818 currentWidth = this.input.$input.width();
4819
4820 if ( contentWidth < currentWidth ) {
4821 this.updateIfHeightChanged();
4822 // All is fine, don't perform expensive calculations
4823 return;
4824 }
4825
4826 if ( $lastItem.length === 0 ) {
4827 bestWidth = this.$content.innerWidth();
4828 } else {
4829 bestWidth = direction === 'ltr' ?
4830 this.$content.innerWidth() - $lastItem.position().left - $lastItem.outerWidth() :
4831 $lastItem.position().left;
4832 }
4833
4834 // Some safety margin for sanity, because I *really* don't feel like finding out where the few
4835 // pixels this is off by are coming from.
4836 bestWidth -= 13;
4837 if ( contentWidth > bestWidth ) {
4838 // This will result in the input getting shifted to the next line
4839 bestWidth = this.$content.innerWidth() - 13;
4840 }
4841 this.input.$input.width( Math.floor( bestWidth ) );
4842 this.updateIfHeightChanged();
4843 } else {
4844 this.updateIfHeightChanged();
4845 }
4846 };
4847
4848 /**
4849 * Determine if widget height changed, and if so,
4850 * emit the resize event. This is useful for when there are either
4851 * menus or popups attached to the bottom of the widget, to allow
4852 * them to change their positioning in case the widget moved down
4853 * or up.
4854 *
4855 * @private
4856 */
4857 OO.ui.TagMultiselectWidget.prototype.updateIfHeightChanged = function () {
4858 var height = this.$element.height();
4859 if ( height !== this.height ) {
4860 this.height = height;
4861 this.emit( 'resize' );
4862 }
4863 };
4864
4865 /**
4866 * Check whether all items in the widget are valid
4867 *
4868 * @return {boolean} Widget is valid
4869 */
4870 OO.ui.TagMultiselectWidget.prototype.checkValidity = function () {
4871 return this.getItems().every( function ( item ) {
4872 return item.isValid();
4873 } );
4874 };
4875
4876 /**
4877 * Set the valid state of this item
4878 *
4879 * @param {boolean} [valid] Item is valid
4880 * @fires valid
4881 */
4882 OO.ui.TagMultiselectWidget.prototype.toggleValid = function ( valid ) {
4883 valid = valid === undefined ? !this.valid : !!valid;
4884
4885 if ( this.valid !== valid ) {
4886 this.valid = valid;
4887
4888 this.setFlags( { invalid: !this.valid } );
4889
4890 this.emit( 'valid', this.valid );
4891 }
4892 };
4893
4894 /**
4895 * Get the current valid state of the widget
4896 *
4897 * @return {boolean} Widget is valid
4898 */
4899 OO.ui.TagMultiselectWidget.prototype.isValid = function () {
4900 return this.valid;
4901 };
4902
4903 /**
4904 * PopupTagMultiselectWidget is a {@link OO.ui.TagMultiselectWidget OO.ui.TagMultiselectWidget} intended
4905 * to use a popup. The popup can be configured to have a default input to insert values into the widget.
4906 *
4907 * @example
4908 * // Example: A basic PopupTagMultiselectWidget.
4909 * var widget = new OO.ui.PopupTagMultiselectWidget();
4910 * $( 'body' ).append( widget.$element );
4911 *
4912 * // Example: A PopupTagMultiselectWidget with an external popup.
4913 * var popupInput = new OO.ui.TextInputWidget(),
4914 * widget = new OO.ui.PopupTagMultiselectWidget( {
4915 * popupInput: popupInput,
4916 * popup: {
4917 * $content: popupInput.$element
4918 * }
4919 * } );
4920 * $( 'body' ).append( widget.$element );
4921 *
4922 * @class
4923 * @extends OO.ui.TagMultiselectWidget
4924 * @mixins OO.ui.mixin.PopupElement
4925 *
4926 * @param {Object} config Configuration object
4927 * @cfg {jQuery} [$overlay] An overlay for the popup.
4928 * See <https://www.mediawiki.org/wiki/OOUI/Concepts#Overlays>.
4929 * @cfg {Object} [popup] Configuration options for the popup
4930 * @cfg {OO.ui.InputWidget} [popupInput] An input widget inside the popup that will be
4931 * focused when the popup is opened and will be used as replacement for the
4932 * general input in the widget.
4933 * @deprecated
4934 */
4935 OO.ui.PopupTagMultiselectWidget = function OoUiPopupTagMultiselectWidget( config ) {
4936 var defaultInput,
4937 defaultConfig = { popup: {} };
4938
4939 config = config || {};
4940
4941 // Parent constructor
4942 OO.ui.PopupTagMultiselectWidget.parent.call( this, $.extend( { inputPosition: 'none' }, config ) );
4943
4944 this.$overlay = ( config.$overlay === true ? OO.ui.getDefaultOverlay() : config.$overlay ) || this.$element;
4945
4946 if ( !config.popup ) {
4947 // For the default base implementation, we give a popup
4948 // with an input widget inside it. For any other use cases
4949 // the popup needs to be populated externally and the
4950 // event handled to add tags separately and manually
4951 defaultInput = new OO.ui.TextInputWidget();
4952
4953 defaultConfig.popupInput = defaultInput;
4954 defaultConfig.popup.$content = defaultInput.$element;
4955 defaultConfig.popup.padded = true;
4956
4957 this.$element.addClass( 'oo-ui-popupTagMultiselectWidget-defaultPopup' );
4958 }
4959
4960 // Add overlay, and add that to the autoCloseIgnore
4961 defaultConfig.popup.$overlay = this.$overlay;
4962 defaultConfig.popup.$autoCloseIgnore = this.hasInput ?
4963 this.input.$element.add( this.$overlay ) : this.$overlay;
4964
4965 // Allow extending any of the above
4966 config = $.extend( defaultConfig, config );
4967
4968 // Mixin constructors
4969 OO.ui.mixin.PopupElement.call( this, config );
4970
4971 if ( this.hasInput ) {
4972 this.input.$input.on( 'focus', this.popup.toggle.bind( this.popup, true ) );
4973 }
4974
4975 // Configuration options
4976 this.popupInput = config.popupInput;
4977 if ( this.popupInput ) {
4978 this.popupInput.connect( this, {
4979 enter: 'onPopupInputEnter'
4980 } );
4981 }
4982
4983 // Events
4984 this.on( 'resize', this.popup.updateDimensions.bind( this.popup ) );
4985 this.popup.connect( this, { toggle: 'onPopupToggle' } );
4986 this.$tabIndexed
4987 .on( 'focus', this.onFocus.bind( this ) );
4988
4989 // Initialize
4990 this.$element
4991 .append( this.popup.$element )
4992 .addClass( 'oo-ui-popupTagMultiselectWidget' );
4993
4994 // Deprecation warning
4995 OO.ui.warnDeprecation( 'PopupTagMultiselectWidget: Deprecated widget. Use MenuTagMultiselectWidget instead. See T208821.' );
4996 };
4997
4998 /* Initialization */
4999
5000 OO.inheritClass( OO.ui.PopupTagMultiselectWidget, OO.ui.TagMultiselectWidget );
5001 OO.mixinClass( OO.ui.PopupTagMultiselectWidget, OO.ui.mixin.PopupElement );
5002
5003 /* Methods */
5004
5005 /**
5006 * Focus event handler.
5007 *
5008 * @private
5009 */
5010 OO.ui.PopupTagMultiselectWidget.prototype.onFocus = function () {
5011 this.popup.toggle( true );
5012 };
5013
5014 /**
5015 * Respond to popup toggle event
5016 *
5017 * @param {boolean} isVisible Popup is visible
5018 */
5019 OO.ui.PopupTagMultiselectWidget.prototype.onPopupToggle = function ( isVisible ) {
5020 if ( isVisible && this.popupInput ) {
5021 this.popupInput.focus();
5022 }
5023 };
5024
5025 /**
5026 * Respond to popup input enter event
5027 */
5028 OO.ui.PopupTagMultiselectWidget.prototype.onPopupInputEnter = function () {
5029 if ( this.popupInput ) {
5030 this.addTagByPopupValue( this.popupInput.getValue() );
5031 this.popupInput.setValue( '' );
5032 }
5033 };
5034
5035 /**
5036 * @inheritdoc
5037 */
5038 OO.ui.PopupTagMultiselectWidget.prototype.onTagSelect = function ( item ) {
5039 if ( this.popupInput && this.allowEditTags ) {
5040 this.popupInput.setValue( item.getData() );
5041 this.removeItems( [ item ] );
5042
5043 this.popup.toggle( true );
5044 this.popupInput.focus();
5045 } else {
5046 // Parent
5047 OO.ui.PopupTagMultiselectWidget.parent.prototype.onTagSelect.call( this, item );
5048 }
5049 };
5050
5051 /**
5052 * Add a tag by the popup value.
5053 * Whatever is responsible for setting the value in the popup should call
5054 * this method to add a tag, or use the regular methods like #addTag or
5055 * #setValue directly.
5056 *
5057 * @param {string} data The value of item
5058 * @param {string} [label] The label of the tag. If not given, the data is used.
5059 */
5060 OO.ui.PopupTagMultiselectWidget.prototype.addTagByPopupValue = function ( data, label ) {
5061 this.addTag( data, label );
5062 };
5063
5064 /**
5065 * MenuTagMultiselectWidget is a {@link OO.ui.TagMultiselectWidget OO.ui.TagMultiselectWidget} intended
5066 * to use a menu of selectable options.
5067 *
5068 * @example
5069 * // Example: A basic MenuTagMultiselectWidget.
5070 * var widget = new OO.ui.MenuTagMultiselectWidget( {
5071 * inputPosition: 'outline',
5072 * options: [
5073 * { data: 'option1', label: 'Option 1', icon: 'tag' },
5074 * { data: 'option2', label: 'Option 2' },
5075 * { data: 'option3', label: 'Option 3' },
5076 * ],
5077 * selected: [ 'option1', 'option2' ]
5078 * } );
5079 * $( 'body' ).append( widget.$element );
5080 *
5081 * @class
5082 * @extends OO.ui.TagMultiselectWidget
5083 *
5084 * @constructor
5085 * @param {Object} [config] Configuration object
5086 * @cfg {boolean} [clearInputOnChoose=true] Clear the text input value when a menu option is chosen
5087 * @cfg {Object} [menu] Configuration object for the menu widget
5088 * @cfg {jQuery} [$overlay] An overlay for the menu.
5089 * See <https://www.mediawiki.org/wiki/OOUI/Concepts#Overlays>.
5090 * @cfg {Object[]} [options=[]] Array of menu options in the format `{ data: …, label: … }`
5091 */
5092 OO.ui.MenuTagMultiselectWidget = function OoUiMenuTagMultiselectWidget( config ) {
5093 config = config || {};
5094
5095 // Parent constructor
5096 OO.ui.MenuTagMultiselectWidget.parent.call( this, config );
5097
5098 this.$overlay = ( config.$overlay === true ? OO.ui.getDefaultOverlay() : config.$overlay ) || this.$element;
5099 this.clearInputOnChoose = config.clearInputOnChoose === undefined || !!config.clearInputOnChoose;
5100 this.menu = this.createMenuWidget( $.extend( {
5101 widget: this,
5102 input: this.hasInput ? this.input : null,
5103 $input: this.hasInput ? this.input.$input : null,
5104 filterFromInput: !!this.hasInput,
5105 $autoCloseIgnore: this.hasInput ?
5106 this.input.$element : $( [] ),
5107 $floatableContainer: this.hasInput && this.inputPosition === 'outline' ?
5108 this.input.$element : this.$element,
5109 $overlay: this.$overlay,
5110 disabled: this.isDisabled()
5111 }, config.menu ) );
5112 this.addOptions( config.options || [] );
5113
5114 // Events
5115 this.menu.connect( this, {
5116 choose: 'onMenuChoose',
5117 toggle: 'onMenuToggle'
5118 } );
5119 if ( this.hasInput ) {
5120 this.input.connect( this, { change: 'onInputChange' } );
5121 }
5122 this.connect( this, { resize: 'onResize' } );
5123
5124 // Initialization
5125 this.$overlay
5126 .append( this.menu.$element );
5127 this.$element
5128 .addClass( 'oo-ui-menuTagMultiselectWidget' );
5129 // TagMultiselectWidget already does this, but it doesn't work right because this.menu is not yet
5130 // set up while the parent constructor runs, and #getAllowedValues rejects everything.
5131 if ( config.selected ) {
5132 this.setValue( config.selected );
5133 }
5134 };
5135
5136 /* Initialization */
5137
5138 OO.inheritClass( OO.ui.MenuTagMultiselectWidget, OO.ui.TagMultiselectWidget );
5139
5140 /* Methods */
5141
5142 /**
5143 * Respond to resize event
5144 */
5145 OO.ui.MenuTagMultiselectWidget.prototype.onResize = function () {
5146 // Reposition the menu
5147 this.menu.position();
5148 };
5149
5150 /**
5151 * @inheritdoc
5152 */
5153 OO.ui.MenuTagMultiselectWidget.prototype.onInputFocus = function () {
5154 // Parent method
5155 OO.ui.MenuTagMultiselectWidget.parent.prototype.onInputFocus.call( this );
5156
5157 this.menu.toggle( true );
5158 };
5159
5160 /**
5161 * @inheritdoc
5162 */
5163 OO.ui.MenuTagMultiselectWidget.prototype.onInputBlur = function () {
5164 // Parent method
5165 OO.ui.MenuTagMultiselectWidget.parent.prototype.onInputBlur.call( this );
5166
5167 this.menu.toggle( false );
5168 };
5169
5170 /**
5171 * Respond to input change event
5172 */
5173 OO.ui.MenuTagMultiselectWidget.prototype.onInputChange = function () {
5174 this.menu.toggle( true );
5175 this.initializeMenuSelection();
5176 };
5177
5178 /**
5179 * Respond to menu choose event
5180 *
5181 * @param {OO.ui.OptionWidget} menuItem Chosen menu item
5182 */
5183 OO.ui.MenuTagMultiselectWidget.prototype.onMenuChoose = function ( menuItem ) {
5184 if ( this.hasInput && this.clearInputOnChoose ) {
5185 this.input.setValue( '' );
5186 }
5187 // Add tag
5188 this.addTag( menuItem.getData(), menuItem.getLabel() );
5189 };
5190
5191 /**
5192 * Respond to menu toggle event. Reset item highlights on hide.
5193 *
5194 * @param {boolean} isVisible The menu is visible
5195 */
5196 OO.ui.MenuTagMultiselectWidget.prototype.onMenuToggle = function ( isVisible ) {
5197 if ( !isVisible ) {
5198 this.menu.selectItem( null );
5199 this.menu.highlightItem( null );
5200 } else {
5201 this.initializeMenuSelection();
5202 }
5203 };
5204
5205 /**
5206 * @inheritdoc
5207 */
5208 OO.ui.MenuTagMultiselectWidget.prototype.onTagSelect = function ( tagItem ) {
5209 var menuItem = this.menu.findItemFromData( tagItem.getData() );
5210 if ( !this.allowArbitrary ) {
5211 // Override the base behavior from TagMultiselectWidget; the base behavior
5212 // in TagMultiselectWidget is to remove the tag to edit it in the input,
5213 // but in our case, we want to utilize the menu selection behavior, and
5214 // definitely not remove the item.
5215
5216 // If there is an input that is used for filtering, erase the value so we don't filter
5217 if ( this.hasInput && this.menu.filterFromInput ) {
5218 this.input.setValue( '' );
5219 }
5220
5221 // Select the menu item
5222 this.menu.selectItem( menuItem );
5223
5224 this.focus();
5225 } else {
5226 // Use the default
5227 OO.ui.MenuTagMultiselectWidget.parent.prototype.onTagSelect.call( this, tagItem );
5228 }
5229 };
5230
5231 /**
5232 * @inheritdoc
5233 */
5234 OO.ui.MenuTagMultiselectWidget.prototype.setDisabled = function ( isDisabled ) {
5235 // Parent method
5236 OO.ui.MenuTagMultiselectWidget.parent.prototype.setDisabled.call( this, isDisabled );
5237
5238 if ( this.menu ) {
5239 // Protect against calling setDisabled() before the menu was initialized
5240 this.menu.setDisabled( isDisabled );
5241 }
5242 };
5243
5244 /**
5245 * Highlight the first selectable item in the menu, if configured.
5246 *
5247 * @private
5248 * @chainable
5249 */
5250 OO.ui.MenuTagMultiselectWidget.prototype.initializeMenuSelection = function () {
5251 if ( !this.menu.findSelectedItem() ) {
5252 this.menu.highlightItem(
5253 this.allowArbitrary ?
5254 null :
5255 this.menu.findFirstSelectableItem()
5256 );
5257 }
5258 };
5259
5260 /**
5261 * @inheritdoc
5262 */
5263 OO.ui.MenuTagMultiselectWidget.prototype.addTagFromInput = function () {
5264 var val = this.input.getValue(),
5265 // Look for a highlighted item first
5266 // Then look for the element that fits the data
5267 item = this.menu.findHighlightedItem() || this.menu.findItemFromData( val ),
5268 data = item ? item.getData() : val,
5269 isValid = this.isAllowedData( data );
5270
5271 // Override the parent method so we add from the menu
5272 // rather than directly from the input
5273
5274 if ( !val ) {
5275 return;
5276 }
5277
5278 if ( isValid || this.allowDisplayInvalidTags ) {
5279 this.clearInput();
5280 if ( item ) {
5281 this.addTag( data, item.getLabel() );
5282 } else {
5283 this.addTag( val );
5284 }
5285 }
5286 };
5287
5288 /**
5289 * Return the visible items in the menu. This is mainly used for when
5290 * the menu is filtering results.
5291 *
5292 * @return {OO.ui.MenuOptionWidget[]} Visible results
5293 */
5294 OO.ui.MenuTagMultiselectWidget.prototype.getMenuVisibleItems = function () {
5295 return this.menu.getItems().filter( function ( menuItem ) {
5296 return menuItem.isVisible();
5297 } );
5298 };
5299
5300 /**
5301 * Create the menu for this widget. This is in a separate method so that
5302 * child classes can override this without polluting the constructor with
5303 * unnecessary extra objects that will be overidden.
5304 *
5305 * @param {Object} menuConfig Configuration options
5306 * @return {OO.ui.MenuSelectWidget} Menu widget
5307 */
5308 OO.ui.MenuTagMultiselectWidget.prototype.createMenuWidget = function ( menuConfig ) {
5309 return new OO.ui.MenuSelectWidget( menuConfig );
5310 };
5311
5312 /**
5313 * Add options to the menu
5314 *
5315 * @param {Object[]} menuOptions Object defining options
5316 */
5317 OO.ui.MenuTagMultiselectWidget.prototype.addOptions = function ( menuOptions ) {
5318 var widget = this,
5319 items = menuOptions.map( function ( obj ) {
5320 return widget.createMenuOptionWidget( obj.data, obj.label, obj.icon );
5321 } );
5322
5323 this.menu.addItems( items );
5324 };
5325
5326 /**
5327 * Create a menu option widget.
5328 *
5329 * @param {string} data Item data
5330 * @param {string} [label] Item label
5331 * @param {string} [icon] Symbolic icon name
5332 * @return {OO.ui.OptionWidget} Option widget
5333 */
5334 OO.ui.MenuTagMultiselectWidget.prototype.createMenuOptionWidget = function ( data, label, icon ) {
5335 return new OO.ui.MenuOptionWidget( {
5336 data: data,
5337 label: label || data,
5338 icon: icon
5339 } );
5340 };
5341
5342 /**
5343 * Get the menu
5344 *
5345 * @return {OO.ui.MenuSelectWidget} Menu
5346 */
5347 OO.ui.MenuTagMultiselectWidget.prototype.getMenu = function () {
5348 return this.menu;
5349 };
5350
5351 /**
5352 * Get the allowed values list
5353 *
5354 * @return {string[]} Allowed data values
5355 */
5356 OO.ui.MenuTagMultiselectWidget.prototype.getAllowedValues = function () {
5357 var menuDatas = [];
5358 if ( this.menu ) {
5359 // If the parent constructor is calling us, we're not ready yet, this.menu is not set up.
5360 menuDatas = this.menu.getItems().map( function ( menuItem ) {
5361 return menuItem.getData();
5362 } );
5363 }
5364 return this.allowedValues.concat( menuDatas );
5365 };
5366
5367 /**
5368 * SelectFileWidgets allow for selecting files, using the HTML5 File API. These
5369 * widgets can be configured with {@link OO.ui.mixin.IconElement icons} and {@link
5370 * OO.ui.mixin.IndicatorElement indicators}.
5371 * Please see the [OOUI documentation on MediaWiki] [1] for more information and examples.
5372 *
5373 * @example
5374 * // Example of a file select widget
5375 * var selectFile = new OO.ui.SelectFileWidget();
5376 * $( 'body' ).append( selectFile.$element );
5377 *
5378 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets
5379 *
5380 * @class
5381 * @extends OO.ui.Widget
5382 * @mixins OO.ui.mixin.IconElement
5383 * @mixins OO.ui.mixin.IndicatorElement
5384 * @mixins OO.ui.mixin.PendingElement
5385 * @mixins OO.ui.mixin.LabelElement
5386 *
5387 * @constructor
5388 * @param {Object} [config] Configuration options
5389 * @cfg {string[]|null} [accept=null] MIME types to accept. null accepts all types.
5390 * @cfg {string} [placeholder] Text to display when no file is selected.
5391 * @cfg {string} [notsupported] Text to display when file support is missing in the browser.
5392 * @cfg {boolean} [droppable=true] Whether to accept files by drag and drop.
5393 * @cfg {boolean} [showDropTarget=false] Whether to show a drop target. Requires droppable to be true.
5394 * @cfg {number} [thumbnailSizeLimit=20] File size limit in MiB above which to not try and show a
5395 * preview (for performance)
5396 */
5397 OO.ui.SelectFileWidget = function OoUiSelectFileWidget( config ) {
5398 var dragHandler;
5399
5400 // Configuration initialization
5401 config = $.extend( {
5402 accept: null,
5403 placeholder: OO.ui.msg( 'ooui-selectfile-placeholder' ),
5404 notsupported: OO.ui.msg( 'ooui-selectfile-not-supported' ),
5405 droppable: true,
5406 showDropTarget: false,
5407 thumbnailSizeLimit: 20
5408 }, config );
5409
5410 // Parent constructor
5411 OO.ui.SelectFileWidget.parent.call( this, config );
5412
5413 // Mixin constructors
5414 OO.ui.mixin.IconElement.call( this, config );
5415 OO.ui.mixin.IndicatorElement.call( this, config );
5416 OO.ui.mixin.PendingElement.call( this, $.extend( {}, config, { $pending: this.$info } ) );
5417 OO.ui.mixin.LabelElement.call( this, config );
5418
5419 // Properties
5420 this.$info = $( '<span>' );
5421 this.showDropTarget = config.showDropTarget;
5422 this.thumbnailSizeLimit = config.thumbnailSizeLimit;
5423 this.isSupported = this.constructor.static.isSupported();
5424 this.currentFile = null;
5425 if ( Array.isArray( config.accept ) ) {
5426 this.accept = config.accept;
5427 } else {
5428 this.accept = null;
5429 }
5430 this.placeholder = config.placeholder;
5431 this.notsupported = config.notsupported;
5432 this.onFileSelectedHandler = this.onFileSelected.bind( this );
5433
5434 this.selectButton = new OO.ui.ButtonWidget( {
5435 $element: $( '<label>' ),
5436 classes: [ 'oo-ui-selectFileWidget-selectButton' ],
5437 label: OO.ui.msg( 'ooui-selectfile-button-select' ),
5438 disabled: this.disabled || !this.isSupported
5439 } );
5440
5441 this.clearButton = new OO.ui.ButtonWidget( {
5442 classes: [ 'oo-ui-selectFileWidget-clearButton' ],
5443 framed: false,
5444 icon: 'close',
5445 disabled: this.disabled
5446 } );
5447
5448 // Events
5449 this.selectButton.$button.on( {
5450 keypress: this.onKeyPress.bind( this )
5451 } );
5452 this.clearButton.connect( this, {
5453 click: 'onClearClick'
5454 } );
5455 if ( config.droppable ) {
5456 dragHandler = this.onDragEnterOrOver.bind( this );
5457 this.$element.on( {
5458 dragenter: dragHandler,
5459 dragover: dragHandler,
5460 dragleave: this.onDragLeave.bind( this ),
5461 drop: this.onDrop.bind( this )
5462 } );
5463 }
5464
5465 // Initialization
5466 this.addInput();
5467 this.$label.addClass( 'oo-ui-selectFileWidget-label' );
5468 this.$info
5469 .addClass( 'oo-ui-selectFileWidget-info' )
5470 .append( this.$icon, this.$label, this.clearButton.$element, this.$indicator );
5471
5472 if ( config.droppable && config.showDropTarget ) {
5473 this.selectButton.setIcon( 'upload' );
5474 this.$thumbnail = $( '<div>' ).addClass( 'oo-ui-selectFileWidget-thumbnail' );
5475 this.setPendingElement( this.$thumbnail );
5476 this.$element
5477 .addClass( 'oo-ui-selectFileWidget-dropTarget oo-ui-selectFileWidget' )
5478 .on( {
5479 click: this.onDropTargetClick.bind( this )
5480 } )
5481 .append(
5482 this.$thumbnail,
5483 this.$info,
5484 this.selectButton.$element,
5485 $( '<span>' )
5486 .addClass( 'oo-ui-selectFileWidget-dropLabel' )
5487 .text( OO.ui.msg( 'ooui-selectfile-dragdrop-placeholder' ) )
5488 );
5489 } else {
5490 this.$element
5491 .addClass( 'oo-ui-selectFileWidget' )
5492 .append( this.$info, this.selectButton.$element );
5493 }
5494 this.updateUI();
5495 };
5496
5497 /* Setup */
5498
5499 OO.inheritClass( OO.ui.SelectFileWidget, OO.ui.Widget );
5500 OO.mixinClass( OO.ui.SelectFileWidget, OO.ui.mixin.IconElement );
5501 OO.mixinClass( OO.ui.SelectFileWidget, OO.ui.mixin.IndicatorElement );
5502 OO.mixinClass( OO.ui.SelectFileWidget, OO.ui.mixin.PendingElement );
5503 OO.mixinClass( OO.ui.SelectFileWidget, OO.ui.mixin.LabelElement );
5504
5505 /* Static Properties */
5506
5507 /**
5508 * Check if this widget is supported
5509 *
5510 * @static
5511 * @return {boolean}
5512 */
5513 OO.ui.SelectFileWidget.static.isSupported = function () {
5514 var $input;
5515 if ( OO.ui.SelectFileWidget.static.isSupportedCache === null ) {
5516 $input = $( '<input>' ).attr( 'type', 'file' );
5517 OO.ui.SelectFileWidget.static.isSupportedCache = $input[ 0 ].files !== undefined;
5518 }
5519 return OO.ui.SelectFileWidget.static.isSupportedCache;
5520 };
5521
5522 OO.ui.SelectFileWidget.static.isSupportedCache = null;
5523
5524 /* Events */
5525
5526 /**
5527 * @event change
5528 *
5529 * A change event is emitted when the on/off state of the toggle changes.
5530 *
5531 * @param {File|null} value New value
5532 */
5533
5534 /* Methods */
5535
5536 /**
5537 * Get the current value of the field
5538 *
5539 * @return {File|null}
5540 */
5541 OO.ui.SelectFileWidget.prototype.getValue = function () {
5542 return this.currentFile;
5543 };
5544
5545 /**
5546 * Set the current value of the field
5547 *
5548 * @param {File|null} file File to select
5549 */
5550 OO.ui.SelectFileWidget.prototype.setValue = function ( file ) {
5551 if ( this.currentFile !== file ) {
5552 this.currentFile = file;
5553 this.updateUI();
5554 this.emit( 'change', this.currentFile );
5555 }
5556 };
5557
5558 /**
5559 * Focus the widget.
5560 *
5561 * Focusses the select file button.
5562 *
5563 * @chainable
5564 * @return {OO.ui.Widget} The widget, for chaining
5565 */
5566 OO.ui.SelectFileWidget.prototype.focus = function () {
5567 this.selectButton.focus();
5568 return this;
5569 };
5570
5571 /**
5572 * Blur the widget.
5573 *
5574 * @chainable
5575 * @return {OO.ui.Widget} The widget, for chaining
5576 */
5577 OO.ui.SelectFileWidget.prototype.blur = function () {
5578 this.selectButton.blur();
5579 return this;
5580 };
5581
5582 /**
5583 * @inheritdoc
5584 */
5585 OO.ui.SelectFileWidget.prototype.simulateLabelClick = function () {
5586 this.focus();
5587 };
5588
5589 /**
5590 * Update the user interface when a file is selected or unselected
5591 *
5592 * @protected
5593 */
5594 OO.ui.SelectFileWidget.prototype.updateUI = function () {
5595 var $label;
5596 if ( !this.isSupported ) {
5597 this.$element.addClass( 'oo-ui-selectFileWidget-notsupported' );
5598 this.$element.removeClass( 'oo-ui-selectFileWidget-empty' );
5599 this.setLabel( this.notsupported );
5600 } else {
5601 this.$element.addClass( 'oo-ui-selectFileWidget-supported' );
5602 if ( this.currentFile ) {
5603 this.$element.removeClass( 'oo-ui-selectFileWidget-empty' );
5604 $label = $( [] );
5605 $label = $label.add(
5606 $( '<span>' )
5607 .addClass( 'oo-ui-selectFileWidget-fileName' )
5608 .text( this.currentFile.name )
5609 );
5610 this.setLabel( $label );
5611
5612 if ( this.showDropTarget ) {
5613 this.pushPending();
5614 this.loadAndGetImageUrl().done( function ( url ) {
5615 this.$thumbnail.css( 'background-image', 'url( ' + url + ' )' );
5616 }.bind( this ) ).fail( function () {
5617 this.$thumbnail.append(
5618 new OO.ui.IconWidget( {
5619 icon: 'attachment',
5620 classes: [ 'oo-ui-selectFileWidget-noThumbnail-icon' ]
5621 } ).$element
5622 );
5623 }.bind( this ) ).always( function () {
5624 this.popPending();
5625 }.bind( this ) );
5626 this.$element.off( 'click' );
5627 }
5628 } else {
5629 if ( this.showDropTarget ) {
5630 this.$element.off( 'click' );
5631 this.$element.on( {
5632 click: this.onDropTargetClick.bind( this )
5633 } );
5634 this.$thumbnail
5635 .empty()
5636 .css( 'background-image', '' );
5637 }
5638 this.$element.addClass( 'oo-ui-selectFileWidget-empty' );
5639 this.setLabel( this.placeholder );
5640 }
5641 }
5642 };
5643
5644 /**
5645 * If the selected file is an image, get its URL and load it.
5646 *
5647 * @return {jQuery.Promise} Promise resolves with the image URL after it has loaded
5648 */
5649 OO.ui.SelectFileWidget.prototype.loadAndGetImageUrl = function () {
5650 var deferred = $.Deferred(),
5651 file = this.currentFile,
5652 reader = new FileReader();
5653
5654 if (
5655 file &&
5656 ( OO.getProp( file, 'type' ) || '' ).indexOf( 'image/' ) === 0 &&
5657 file.size < this.thumbnailSizeLimit * 1024 * 1024
5658 ) {
5659 reader.onload = function ( event ) {
5660 var img = document.createElement( 'img' );
5661 img.addEventListener( 'load', function () {
5662 if (
5663 img.naturalWidth === 0 ||
5664 img.naturalHeight === 0 ||
5665 img.complete === false
5666 ) {
5667 deferred.reject();
5668 } else {
5669 deferred.resolve( event.target.result );
5670 }
5671 } );
5672 img.src = event.target.result;
5673 };
5674 reader.readAsDataURL( file );
5675 } else {
5676 deferred.reject();
5677 }
5678
5679 return deferred.promise();
5680 };
5681
5682 /**
5683 * Add the input to the widget
5684 *
5685 * @private
5686 */
5687 OO.ui.SelectFileWidget.prototype.addInput = function () {
5688 if ( this.$input ) {
5689 this.$input.remove();
5690 }
5691
5692 if ( !this.isSupported ) {
5693 this.$input = null;
5694 return;
5695 }
5696
5697 this.$input = $( '<input>' ).attr( 'type', 'file' );
5698 this.$input.on( 'change', this.onFileSelectedHandler );
5699 this.$input.on( 'click', function ( e ) {
5700 // Prevents dropTarget to get clicked which calls
5701 // a click on this input
5702 e.stopPropagation();
5703 } );
5704 this.$input.attr( {
5705 tabindex: -1
5706 } );
5707 if ( this.accept ) {
5708 this.$input.attr( 'accept', this.accept.join( ', ' ) );
5709 }
5710 this.selectButton.$button.append( this.$input );
5711 };
5712
5713 /**
5714 * Determine if we should accept this file
5715 *
5716 * @private
5717 * @param {string} mimeType File MIME type
5718 * @return {boolean}
5719 */
5720 OO.ui.SelectFileWidget.prototype.isAllowedType = function ( mimeType ) {
5721 var i, mimeTest;
5722
5723 if ( !this.accept || !mimeType ) {
5724 return true;
5725 }
5726
5727 for ( i = 0; i < this.accept.length; i++ ) {
5728 mimeTest = this.accept[ i ];
5729 if ( mimeTest === mimeType ) {
5730 return true;
5731 } else if ( mimeTest.substr( -2 ) === '/*' ) {
5732 mimeTest = mimeTest.substr( 0, mimeTest.length - 1 );
5733 if ( mimeType.substr( 0, mimeTest.length ) === mimeTest ) {
5734 return true;
5735 }
5736 }
5737 }
5738
5739 return false;
5740 };
5741
5742 /**
5743 * Handle file selection from the input
5744 *
5745 * @private
5746 * @param {jQuery.Event} e
5747 */
5748 OO.ui.SelectFileWidget.prototype.onFileSelected = function ( e ) {
5749 var file = OO.getProp( e.target, 'files', 0 ) || null;
5750
5751 if ( file && !this.isAllowedType( file.type ) ) {
5752 file = null;
5753 }
5754
5755 this.setValue( file );
5756 this.addInput();
5757 };
5758
5759 /**
5760 * Handle clear button click events.
5761 *
5762 * @private
5763 * @return {undefined/boolean} False to prevent default if event is handled
5764 */
5765 OO.ui.SelectFileWidget.prototype.onClearClick = function () {
5766 this.setValue( null );
5767 return false;
5768 };
5769
5770 /**
5771 * Handle key press events.
5772 *
5773 * @private
5774 * @param {jQuery.Event} e Key press event
5775 * @return {undefined/boolean} False to prevent default if event is handled
5776 */
5777 OO.ui.SelectFileWidget.prototype.onKeyPress = function ( e ) {
5778 if ( this.isSupported && !this.isDisabled() && this.$input &&
5779 ( e.which === OO.ui.Keys.SPACE || e.which === OO.ui.Keys.ENTER )
5780 ) {
5781 this.$input.click();
5782 return false;
5783 }
5784 };
5785
5786 /**
5787 * Handle drop target click events.
5788 *
5789 * @private
5790 * @param {jQuery.Event} e Key press event
5791 * @return {undefined/boolean} False to prevent default if event is handled
5792 */
5793 OO.ui.SelectFileWidget.prototype.onDropTargetClick = function () {
5794 if ( this.isSupported && !this.isDisabled() && this.$input ) {
5795 this.$input.click();
5796 return false;
5797 }
5798 };
5799
5800 /**
5801 * Handle drag enter and over events
5802 *
5803 * @private
5804 * @param {jQuery.Event} e Drag event
5805 * @return {undefined/boolean} False to prevent default if event is handled
5806 */
5807 OO.ui.SelectFileWidget.prototype.onDragEnterOrOver = function ( e ) {
5808 var itemOrFile,
5809 droppableFile = false,
5810 dt = e.originalEvent.dataTransfer;
5811
5812 e.preventDefault();
5813 e.stopPropagation();
5814
5815 if ( this.isDisabled() || !this.isSupported ) {
5816 this.$element.removeClass( 'oo-ui-selectFileWidget-canDrop' );
5817 dt.dropEffect = 'none';
5818 return false;
5819 }
5820
5821 // DataTransferItem and File both have a type property, but in Chrome files
5822 // have no information at this point.
5823 itemOrFile = OO.getProp( dt, 'items', 0 ) || OO.getProp( dt, 'files', 0 );
5824 if ( itemOrFile ) {
5825 if ( this.isAllowedType( itemOrFile.type ) ) {
5826 droppableFile = true;
5827 }
5828 // dt.types is Array-like, but not an Array
5829 } else if ( Array.prototype.indexOf.call( OO.getProp( dt, 'types' ) || [], 'Files' ) !== -1 ) {
5830 // File information is not available at this point for security so just assume
5831 // it is acceptable for now.
5832 // https://bugzilla.mozilla.org/show_bug.cgi?id=640534
5833 droppableFile = true;
5834 }
5835
5836 this.$element.toggleClass( 'oo-ui-selectFileWidget-canDrop', droppableFile );
5837 if ( !droppableFile ) {
5838 dt.dropEffect = 'none';
5839 }
5840
5841 return false;
5842 };
5843
5844 /**
5845 * Handle drag leave events
5846 *
5847 * @private
5848 * @param {jQuery.Event} e Drag event
5849 */
5850 OO.ui.SelectFileWidget.prototype.onDragLeave = function () {
5851 this.$element.removeClass( 'oo-ui-selectFileWidget-canDrop' );
5852 };
5853
5854 /**
5855 * Handle drop events
5856 *
5857 * @private
5858 * @param {jQuery.Event} e Drop event
5859 * @return {undefined/boolean} False to prevent default if event is handled
5860 */
5861 OO.ui.SelectFileWidget.prototype.onDrop = function ( e ) {
5862 var file = null,
5863 dt = e.originalEvent.dataTransfer;
5864
5865 e.preventDefault();
5866 e.stopPropagation();
5867 this.$element.removeClass( 'oo-ui-selectFileWidget-canDrop' );
5868
5869 if ( this.isDisabled() || !this.isSupported ) {
5870 return false;
5871 }
5872
5873 file = OO.getProp( dt, 'files', 0 );
5874 if ( file && !this.isAllowedType( file.type ) ) {
5875 file = null;
5876 }
5877 if ( file ) {
5878 this.setValue( file );
5879 }
5880
5881 return false;
5882 };
5883
5884 /**
5885 * @inheritdoc
5886 */
5887 OO.ui.SelectFileWidget.prototype.setDisabled = function ( disabled ) {
5888 OO.ui.SelectFileWidget.parent.prototype.setDisabled.call( this, disabled );
5889 if ( this.selectButton ) {
5890 this.selectButton.setDisabled( disabled );
5891 }
5892 if ( this.clearButton ) {
5893 this.clearButton.setDisabled( disabled );
5894 }
5895 return this;
5896 };
5897
5898 /**
5899 * SearchWidgets combine a {@link OO.ui.TextInputWidget text input field}, where users can type a search query,
5900 * and a menu of search results, which is displayed beneath the query
5901 * field. Unlike {@link OO.ui.mixin.LookupElement lookup menus}, search result menus are always visible to the user.
5902 * Users can choose an item from the menu or type a query into the text field to search for a matching result item.
5903 * In general, search widgets are used inside a separate {@link OO.ui.Dialog dialog} window.
5904 *
5905 * Each time the query is changed, the search result menu is cleared and repopulated. Please see
5906 * the [OOUI demos][1] for an example.
5907 *
5908 * [1]: https://doc.wikimedia.org/oojs-ui/master/demos/#SearchInputWidget-type-search
5909 *
5910 * @class
5911 * @extends OO.ui.Widget
5912 *
5913 * @constructor
5914 * @param {Object} [config] Configuration options
5915 * @cfg {string|jQuery} [placeholder] Placeholder text for query input
5916 * @cfg {string} [value] Initial query value
5917 */
5918 OO.ui.SearchWidget = function OoUiSearchWidget( config ) {
5919 // Configuration initialization
5920 config = config || {};
5921
5922 // Parent constructor
5923 OO.ui.SearchWidget.parent.call( this, config );
5924
5925 // Properties
5926 this.query = new OO.ui.TextInputWidget( {
5927 icon: 'search',
5928 placeholder: config.placeholder,
5929 value: config.value
5930 } );
5931 this.results = new OO.ui.SelectWidget();
5932 this.$query = $( '<div>' );
5933 this.$results = $( '<div>' );
5934
5935 // Events
5936 this.query.connect( this, {
5937 change: 'onQueryChange',
5938 enter: 'onQueryEnter'
5939 } );
5940 this.query.$input.on( 'keydown', this.onQueryKeydown.bind( this ) );
5941
5942 // Initialization
5943 this.$query
5944 .addClass( 'oo-ui-searchWidget-query' )
5945 .append( this.query.$element );
5946 this.$results
5947 .addClass( 'oo-ui-searchWidget-results' )
5948 .append( this.results.$element );
5949 this.$element
5950 .addClass( 'oo-ui-searchWidget' )
5951 .append( this.$results, this.$query );
5952 };
5953
5954 /* Setup */
5955
5956 OO.inheritClass( OO.ui.SearchWidget, OO.ui.Widget );
5957
5958 /* Methods */
5959
5960 /**
5961 * Handle query key down events.
5962 *
5963 * @private
5964 * @param {jQuery.Event} e Key down event
5965 */
5966 OO.ui.SearchWidget.prototype.onQueryKeydown = function ( e ) {
5967 var highlightedItem, nextItem,
5968 dir = e.which === OO.ui.Keys.DOWN ? 1 : ( e.which === OO.ui.Keys.UP ? -1 : 0 );
5969
5970 if ( dir ) {
5971 highlightedItem = this.results.findHighlightedItem();
5972 if ( !highlightedItem ) {
5973 highlightedItem = this.results.findSelectedItem();
5974 }
5975 nextItem = this.results.findRelativeSelectableItem( highlightedItem, dir );
5976 this.results.highlightItem( nextItem );
5977 nextItem.scrollElementIntoView();
5978 }
5979 };
5980
5981 /**
5982 * Handle select widget select events.
5983 *
5984 * Clears existing results. Subclasses should repopulate items according to new query.
5985 *
5986 * @private
5987 * @param {string} value New value
5988 */
5989 OO.ui.SearchWidget.prototype.onQueryChange = function () {
5990 // Reset
5991 this.results.clearItems();
5992 };
5993
5994 /**
5995 * Handle select widget enter key events.
5996 *
5997 * Chooses highlighted item.
5998 *
5999 * @private
6000 * @param {string} value New value
6001 */
6002 OO.ui.SearchWidget.prototype.onQueryEnter = function () {
6003 var highlightedItem = this.results.findHighlightedItem();
6004 if ( highlightedItem ) {
6005 this.results.chooseItem( highlightedItem );
6006 }
6007 };
6008
6009 /**
6010 * Get the query input.
6011 *
6012 * @return {OO.ui.TextInputWidget} Query input
6013 */
6014 OO.ui.SearchWidget.prototype.getQuery = function () {
6015 return this.query;
6016 };
6017
6018 /**
6019 * Get the search results menu.
6020 *
6021 * @return {OO.ui.SelectWidget} Menu of search results
6022 */
6023 OO.ui.SearchWidget.prototype.getResults = function () {
6024 return this.results;
6025 };
6026
6027 }( OO ) );
6028
6029 //# sourceMappingURL=oojs-ui-widgets.js.map.json