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