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