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