Merge "Add checkDependencies.php"
[lhc/web/wiklou.git] / resources / lib / ooui / oojs-ui-widgets.js
1 /*!
2 * OOUI v0.32.0
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-05-29T00:38:42Z
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 * TabSelectWidget is a list that contains {@link OO.ui.TabOptionWidget tab options}
3804 *
3805 * **Currently, this class is only used by {@link OO.ui.IndexLayout index layouts}.**
3806 *
3807 * @class
3808 * @extends OO.ui.SelectWidget
3809 * @mixins OO.ui.mixin.TabIndexedElement
3810 *
3811 * @constructor
3812 * @param {Object} [config] Configuration options
3813 * @cfg {boolean} [framed=true] Use framed tabs
3814 */
3815 OO.ui.TabSelectWidget = function OoUiTabSelectWidget( config ) {
3816 // Parent constructor
3817 OO.ui.TabSelectWidget.parent.call( this, config );
3818
3819 // Mixin constructors
3820 OO.ui.mixin.TabIndexedElement.call( this, config );
3821
3822 // Events
3823 this.$element.on( {
3824 focus: this.bindDocumentKeyDownListener.bind( this ),
3825 blur: this.unbindDocumentKeyDownListener.bind( this )
3826 } );
3827
3828 // Initialization
3829 this.$element
3830 .addClass( 'oo-ui-tabSelectWidget' )
3831 .attr( 'role', 'tablist' );
3832
3833 this.toggleFramed( config.framed === undefined || config.framed );
3834
3835 if ( OO.ui.isMobile() ) {
3836 this.$element.addClass( 'oo-ui-tabSelectWidget-mobile' );
3837 }
3838 };
3839
3840 /* Setup */
3841
3842 OO.inheritClass( OO.ui.TabSelectWidget, OO.ui.SelectWidget );
3843 OO.mixinClass( OO.ui.TabSelectWidget, OO.ui.mixin.TabIndexedElement );
3844
3845 /* Methods */
3846
3847 /**
3848 * Check if tabs are framed.
3849 *
3850 * @return {boolean} Tabs are framed
3851 */
3852 OO.ui.TabSelectWidget.prototype.isFramed = function () {
3853 return this.framed;
3854 };
3855
3856 /**
3857 * Render the tabs with or without frames.
3858 *
3859 * @param {boolean} [framed] Make tabs framed, omit to toggle
3860 * @chainable
3861 * @return {OO.ui.Element} The element, for chaining
3862 */
3863 OO.ui.TabSelectWidget.prototype.toggleFramed = function ( framed ) {
3864 framed = framed === undefined ? !this.framed : !!framed;
3865 if ( framed !== this.framed ) {
3866 this.framed = framed;
3867 this.$element
3868 .toggleClass( 'oo-ui-tabSelectWidget-frameless', !framed )
3869 .toggleClass( 'oo-ui-tabSelectWidget-framed', framed );
3870 }
3871
3872 return this;
3873 };
3874
3875 /**
3876 * TagItemWidgets are used within a {@link OO.ui.TagMultiselectWidget
3877 * TagMultiselectWidget} to display the selected items.
3878 *
3879 * @class
3880 * @extends OO.ui.Widget
3881 * @mixins OO.ui.mixin.ItemWidget
3882 * @mixins OO.ui.mixin.LabelElement
3883 * @mixins OO.ui.mixin.FlaggedElement
3884 * @mixins OO.ui.mixin.TabIndexedElement
3885 * @mixins OO.ui.mixin.DraggableElement
3886 *
3887 * @constructor
3888 * @param {Object} [config] Configuration object
3889 * @cfg {boolean} [valid=true] Item is valid
3890 * @cfg {boolean} [fixed] Item is fixed. This means the item is
3891 * always included in the values and cannot be removed.
3892 */
3893 OO.ui.TagItemWidget = function OoUiTagItemWidget( config ) {
3894 config = config || {};
3895
3896 // Parent constructor
3897 OO.ui.TagItemWidget.parent.call( this, config );
3898
3899 // Mixin constructors
3900 OO.ui.mixin.ItemWidget.call( this );
3901 OO.ui.mixin.LabelElement.call( this, config );
3902 OO.ui.mixin.FlaggedElement.call( this, config );
3903 OO.ui.mixin.TabIndexedElement.call( this, config );
3904 OO.ui.mixin.DraggableElement.call( this, config );
3905
3906 this.valid = config.valid === undefined ? true : !!config.valid;
3907 this.fixed = !!config.fixed;
3908
3909 this.closeButton = new OO.ui.ButtonWidget( {
3910 framed: false,
3911 icon: 'close',
3912 tabIndex: -1,
3913 title: OO.ui.msg( 'ooui-item-remove' )
3914 } );
3915 this.closeButton.setDisabled( this.isDisabled() );
3916
3917 // Events
3918 this.closeButton.connect( this, {
3919 click: 'remove'
3920 } );
3921 this.$element
3922 .on( 'click', this.select.bind( this ) )
3923 .on( 'keydown', this.onKeyDown.bind( this ) )
3924 // Prevent propagation of mousedown; the tag item "lives" in the
3925 // clickable area of the TagMultiselectWidget, which listens to
3926 // mousedown to open the menu or popup. We want to prevent that
3927 // for clicks specifically on the tag itself, so the actions taken
3928 // are more deliberate. When the tag is clicked, it will emit the
3929 // selection event (similar to how #OO.ui.MultioptionWidget emits 'change')
3930 // and can be handled separately.
3931 .on( 'mousedown', function ( e ) { e.stopPropagation(); } );
3932
3933 // Initialization
3934 this.$element
3935 .addClass( 'oo-ui-tagItemWidget' )
3936 .append( this.$label, this.closeButton.$element );
3937 };
3938
3939 /* Initialization */
3940
3941 OO.inheritClass( OO.ui.TagItemWidget, OO.ui.Widget );
3942 OO.mixinClass( OO.ui.TagItemWidget, OO.ui.mixin.ItemWidget );
3943 OO.mixinClass( OO.ui.TagItemWidget, OO.ui.mixin.LabelElement );
3944 OO.mixinClass( OO.ui.TagItemWidget, OO.ui.mixin.FlaggedElement );
3945 OO.mixinClass( OO.ui.TagItemWidget, OO.ui.mixin.TabIndexedElement );
3946 OO.mixinClass( OO.ui.TagItemWidget, OO.ui.mixin.DraggableElement );
3947
3948 /* Events */
3949
3950 /**
3951 * @event remove
3952 *
3953 * A remove action was performed on the item
3954 */
3955
3956 /**
3957 * @event navigate
3958 * @param {string} direction Direction of the movement, forward or backwards
3959 *
3960 * A navigate action was performed on the item
3961 */
3962
3963 /**
3964 * @event select
3965 *
3966 * The tag widget was selected. This can occur when the widget
3967 * is either clicked or enter was pressed on it.
3968 */
3969
3970 /**
3971 * @event valid
3972 * @param {boolean} isValid Item is valid
3973 *
3974 * Item validity has changed
3975 */
3976
3977 /**
3978 * @event fixed
3979 * @param {boolean} isFixed Item is fixed
3980 *
3981 * Item fixed state has changed
3982 */
3983
3984 /* Methods */
3985
3986 /**
3987 * Set this item as fixed, meaning it cannot be removed
3988 *
3989 * @param {string} [state] Item is fixed
3990 * @fires fixed
3991 * @return {OO.ui.Widget} The widget, for chaining
3992 */
3993 OO.ui.TagItemWidget.prototype.setFixed = function ( state ) {
3994 state = state === undefined ? !this.fixed : !!state;
3995
3996 if ( this.fixed !== state ) {
3997 this.fixed = state;
3998 if ( this.closeButton ) {
3999 this.closeButton.toggle( !this.fixed );
4000 }
4001
4002 if ( !this.fixed && this.elementGroup && !this.elementGroup.isDraggable() ) {
4003 // Only enable the state of the item if the
4004 // entire group is draggable
4005 this.toggleDraggable( !this.fixed );
4006 }
4007 this.$element.toggleClass( 'oo-ui-tagItemWidget-fixed', this.fixed );
4008
4009 this.emit( 'fixed', this.isFixed() );
4010 }
4011 return this;
4012 };
4013
4014 /**
4015 * Check whether the item is fixed
4016 * @return {boolean}
4017 */
4018 OO.ui.TagItemWidget.prototype.isFixed = function () {
4019 return this.fixed;
4020 };
4021
4022 /**
4023 * @inheritdoc
4024 */
4025 OO.ui.TagItemWidget.prototype.setDisabled = function ( state ) {
4026 if ( state && this.elementGroup && !this.elementGroup.isDisabled() ) {
4027 OO.ui.warnDeprecation( 'TagItemWidget#setDisabled: Disabling individual items is deprecated and will result in inconsistent behavior. Use #setFixed instead. See T193571.' );
4028 }
4029 // Parent method
4030 OO.ui.TagItemWidget.parent.prototype.setDisabled.call( this, state );
4031 if (
4032 !state &&
4033 // Verify we have a group, and that the widget is ready
4034 this.toggleDraggable && this.elementGroup &&
4035 !this.isFixed() &&
4036 !this.elementGroup.isDraggable()
4037 ) {
4038 // Only enable the draggable state of the item if the
4039 // entire group is draggable to begin with, and if the
4040 // item is not fixed
4041 this.toggleDraggable( !state );
4042 }
4043
4044 if ( this.closeButton ) {
4045 this.closeButton.setDisabled( state );
4046 }
4047
4048 return this;
4049 };
4050
4051 /**
4052 * Handle removal of the item
4053 *
4054 * This is mainly for extensibility concerns, so other children
4055 * of this class can change the behavior if they need to. This
4056 * is called by both clicking the 'remove' button but also
4057 * on keypress, which is harder to override if needed.
4058 *
4059 * @fires remove
4060 */
4061 OO.ui.TagItemWidget.prototype.remove = function () {
4062 if ( !this.isDisabled() && !this.isFixed() ) {
4063 this.emit( 'remove' );
4064 }
4065 };
4066
4067 /**
4068 * Handle a keydown event on the widget
4069 *
4070 * @fires navigate
4071 * @fires remove
4072 * @param {jQuery.Event} e Key down event
4073 * @return {boolean|undefined} false to stop the operation
4074 */
4075 OO.ui.TagItemWidget.prototype.onKeyDown = function ( e ) {
4076 var movement;
4077
4078 if (
4079 !this.isDisabled() &&
4080 !this.isFixed() &&
4081 ( e.keyCode === OO.ui.Keys.BACKSPACE || e.keyCode === OO.ui.Keys.DELETE )
4082 ) {
4083 this.remove();
4084 return false;
4085 } else if ( e.keyCode === OO.ui.Keys.ENTER ) {
4086 this.select();
4087 return false;
4088 } else if (
4089 e.keyCode === OO.ui.Keys.LEFT ||
4090 e.keyCode === OO.ui.Keys.RIGHT
4091 ) {
4092 if ( OO.ui.Element.static.getDir( this.$element ) === 'rtl' ) {
4093 movement = {
4094 left: 'forwards',
4095 right: 'backwards'
4096 };
4097 } else {
4098 movement = {
4099 left: 'backwards',
4100 right: 'forwards'
4101 };
4102 }
4103
4104 this.emit(
4105 'navigate',
4106 e.keyCode === OO.ui.Keys.LEFT ?
4107 movement.left : movement.right
4108 );
4109 return false;
4110 }
4111 };
4112
4113 /**
4114 * Select this item
4115 *
4116 * @fires select
4117 */
4118 OO.ui.TagItemWidget.prototype.select = function () {
4119 if ( !this.isDisabled() ) {
4120 this.emit( 'select' );
4121 }
4122 };
4123
4124 /**
4125 * Set the valid state of this item
4126 *
4127 * @param {boolean} [valid] Item is valid
4128 * @fires valid
4129 */
4130 OO.ui.TagItemWidget.prototype.toggleValid = function ( valid ) {
4131 valid = valid === undefined ? !this.valid : !!valid;
4132
4133 if ( this.valid !== valid ) {
4134 this.valid = valid;
4135
4136 this.setFlags( { invalid: !this.valid } );
4137
4138 this.emit( 'valid', this.valid );
4139 }
4140 };
4141
4142 /**
4143 * Check whether the item is valid
4144 *
4145 * @return {boolean} Item is valid
4146 */
4147 OO.ui.TagItemWidget.prototype.isValid = function () {
4148 return this.valid;
4149 };
4150
4151 /**
4152 * A basic tag multiselect widget, similar in concept to
4153 * {@link OO.ui.ComboBoxInputWidget combo box widget} that allows the user to add multiple values
4154 * that are displayed in a tag area.
4155 *
4156 * This widget is a base widget; see {@link OO.ui.MenuTagMultiselectWidget MenuTagMultiselectWidget}
4157 * and {@link OO.ui.PopupTagMultiselectWidget PopupTagMultiselectWidget} for the implementations
4158 * that use a menu and a popup respectively.
4159 *
4160 * @example
4161 * // A TagMultiselectWidget.
4162 * var widget = new OO.ui.TagMultiselectWidget( {
4163 * inputPosition: 'outline',
4164 * allowedValues: [ 'Option 1', 'Option 2', 'Option 3' ],
4165 * selected: [ 'Option 1' ]
4166 * } );
4167 * $( document.body ).append( widget.$element );
4168 *
4169 * @class
4170 * @extends OO.ui.Widget
4171 * @mixins OO.ui.mixin.GroupWidget
4172 * @mixins OO.ui.mixin.DraggableGroupElement
4173 * @mixins OO.ui.mixin.IndicatorElement
4174 * @mixins OO.ui.mixin.IconElement
4175 * @mixins OO.ui.mixin.TabIndexedElement
4176 * @mixins OO.ui.mixin.FlaggedElement
4177 * @mixins OO.ui.mixin.TitledElement
4178 *
4179 * @constructor
4180 * @param {Object} config Configuration object
4181 * @cfg {Object} [input] Configuration options for the input widget
4182 * @cfg {OO.ui.InputWidget} [inputWidget] An optional input widget. If given, it will
4183 * replace the input widget used in the TagMultiselectWidget. If not given,
4184 * TagMultiselectWidget creates its own.
4185 * @cfg {boolean} [inputPosition='inline'] Position of the input. Options are:
4186 * - inline: The input is invisible, but exists inside the tag list, so
4187 * the user types into the tag groups to add tags.
4188 * - outline: The input is underneath the tag area.
4189 * - none: No input supplied
4190 * @cfg {boolean} [allowEditTags=true] Allow editing of the tags by clicking them
4191 * @cfg {boolean} [allowArbitrary=false] Allow data items to be added even if
4192 * not present in the menu.
4193 * @cfg {Object[]} [allowedValues] An array representing the allowed items
4194 * by their datas.
4195 * @cfg {boolean} [allowDuplicates=false] Allow duplicate items to be added
4196 * @cfg {boolean} [allowDisplayInvalidTags=false] Allow the display of
4197 * invalid tags. These tags will display with an invalid state, and
4198 * the widget as a whole will have an invalid state if any invalid tags
4199 * are present.
4200 * @cfg {number} [tagLimit] An optional limit on the number of selected options.
4201 * If 'tagLimit' is set and is reached, the input is disabled, not allowing any
4202 * additions. If 'tagLimit' is unset or is 0, an unlimited number of items can be
4203 * added.
4204 * @cfg {boolean} [allowReordering=true] Allow reordering of the items
4205 * @cfg {Object[]|String[]} [selected] A set of selected tags. If given,
4206 * these will appear in the tag list on initialization, as long as they
4207 * pass the validity tests.
4208 */
4209 OO.ui.TagMultiselectWidget = function OoUiTagMultiselectWidget( config ) {
4210 var inputEvents,
4211 rAF = window.requestAnimationFrame || setTimeout,
4212 widget = this,
4213 $tabFocus = $( '<span>' ).addClass( 'oo-ui-tagMultiselectWidget-focusTrap' );
4214
4215 config = config || {};
4216
4217 // Parent constructor
4218 OO.ui.TagMultiselectWidget.parent.call( this, config );
4219
4220 // Mixin constructors
4221 OO.ui.mixin.GroupWidget.call( this, config );
4222 OO.ui.mixin.IndicatorElement.call( this, config );
4223 OO.ui.mixin.IconElement.call( this, config );
4224 OO.ui.mixin.TabIndexedElement.call( this, config );
4225 OO.ui.mixin.FlaggedElement.call( this, config );
4226 OO.ui.mixin.DraggableGroupElement.call( this, config );
4227 OO.ui.mixin.TitledElement.call( this, config );
4228
4229 this.toggleDraggable(
4230 config.allowReordering === undefined ?
4231 true : !!config.allowReordering
4232 );
4233
4234 this.inputPosition =
4235 this.constructor.static.allowedInputPositions.indexOf( config.inputPosition ) > -1 ?
4236 config.inputPosition : 'inline';
4237 this.allowEditTags = config.allowEditTags === undefined ? true : !!config.allowEditTags;
4238 this.allowArbitrary = !!config.allowArbitrary;
4239 this.allowDuplicates = !!config.allowDuplicates;
4240 this.allowedValues = config.allowedValues || [];
4241 this.allowDisplayInvalidTags = config.allowDisplayInvalidTags;
4242 this.hasInput = this.inputPosition !== 'none';
4243 this.tagLimit = config.tagLimit;
4244 this.height = null;
4245 this.valid = true;
4246
4247 this.$content = $( '<div>' ).addClass( 'oo-ui-tagMultiselectWidget-content' );
4248 this.$handle = $( '<div>' )
4249 .addClass( 'oo-ui-tagMultiselectWidget-handle' )
4250 .append(
4251 this.$indicator,
4252 this.$icon,
4253 this.$content
4254 .append(
4255 this.$group.addClass( 'oo-ui-tagMultiselectWidget-group' )
4256 )
4257 );
4258
4259 // Events
4260 this.aggregate( {
4261 remove: 'itemRemove',
4262 navigate: 'itemNavigate',
4263 select: 'itemSelect',
4264 fixed: 'itemFixed'
4265 } );
4266 this.connect( this, {
4267 itemRemove: 'onTagRemove',
4268 itemSelect: 'onTagSelect',
4269 itemFixed: 'onTagFixed',
4270 itemNavigate: 'onTagNavigate',
4271 change: 'onChangeTags'
4272 } );
4273 this.$handle.on( {
4274 mousedown: this.onMouseDown.bind( this )
4275 } );
4276
4277 // Initialize
4278 this.$element
4279 .addClass( 'oo-ui-tagMultiselectWidget' )
4280 .append( this.$handle );
4281
4282 if ( this.hasInput ) {
4283 if ( config.inputWidget ) {
4284 this.input = config.inputWidget;
4285 } else {
4286 this.input = new OO.ui.TextInputWidget( $.extend( {
4287 placeholder: config.placeholder,
4288 classes: [ 'oo-ui-tagMultiselectWidget-input' ]
4289 }, config.input ) );
4290 }
4291 this.input.setDisabled( this.isDisabled() );
4292
4293 inputEvents = {
4294 focus: this.onInputFocus.bind( this ),
4295 blur: this.onInputBlur.bind( this ),
4296 'propertychange change click mouseup keydown keyup input cut paste select focus':
4297 OO.ui.debounce( this.updateInputSize.bind( this ) ),
4298 keydown: this.onInputKeyDown.bind( this ),
4299 keypress: this.onInputKeyPress.bind( this )
4300 };
4301
4302 this.input.$input.on( inputEvents );
4303 this.inputPlaceholder = this.input.$input.attr( 'placeholder' );
4304
4305 if ( this.inputPosition === 'outline' ) {
4306 // Override max-height for the input widget
4307 // in the case the widget is outline so it can
4308 // stretch all the way if the widget is wide
4309 this.input.$element.css( 'max-width', 'inherit' );
4310 this.$element
4311 .addClass( 'oo-ui-tagMultiselectWidget-outlined' )
4312 .append( this.input.$element );
4313 } else {
4314 this.$element.addClass( 'oo-ui-tagMultiselectWidget-inlined' );
4315 // HACK: When the widget is using 'inline' input, the
4316 // behavior needs to only use the $input itself
4317 // so we style and size it accordingly (otherwise
4318 // the styling and sizing can get very convoluted
4319 // when the wrapping divs and other elements)
4320 // We are taking advantage of still being able to
4321 // call the widget itself for operations like
4322 // .getValue() and setDisabled() and .focus() but
4323 // having only the $input attached to the DOM
4324 this.$content.append( this.input.$input );
4325 }
4326 } else {
4327 this.$content.append( $tabFocus );
4328 }
4329
4330 this.setTabIndexedElement(
4331 this.hasInput ?
4332 this.input.$input :
4333 $tabFocus
4334 );
4335
4336 if ( config.selected ) {
4337 this.setValue( config.selected );
4338 }
4339
4340 // HACK: Input size needs to be calculated after everything
4341 // else is rendered
4342 rAF( function () {
4343 if ( widget.hasInput ) {
4344 widget.updateInputSize();
4345 }
4346 } );
4347 };
4348
4349 /* Initialization */
4350
4351 OO.inheritClass( OO.ui.TagMultiselectWidget, OO.ui.Widget );
4352 OO.mixinClass( OO.ui.TagMultiselectWidget, OO.ui.mixin.GroupWidget );
4353 OO.mixinClass( OO.ui.TagMultiselectWidget, OO.ui.mixin.DraggableGroupElement );
4354 OO.mixinClass( OO.ui.TagMultiselectWidget, OO.ui.mixin.IndicatorElement );
4355 OO.mixinClass( OO.ui.TagMultiselectWidget, OO.ui.mixin.IconElement );
4356 OO.mixinClass( OO.ui.TagMultiselectWidget, OO.ui.mixin.TabIndexedElement );
4357 OO.mixinClass( OO.ui.TagMultiselectWidget, OO.ui.mixin.FlaggedElement );
4358 OO.mixinClass( OO.ui.TagMultiselectWidget, OO.ui.mixin.TitledElement );
4359
4360 /* Static properties */
4361
4362 /**
4363 * Allowed input positions.
4364 * - inline: The input is inside the tag list
4365 * - outline: The input is under the tag list
4366 * - none: There is no input
4367 *
4368 * @property {Array}
4369 */
4370 OO.ui.TagMultiselectWidget.static.allowedInputPositions = [ 'inline', 'outline', 'none' ];
4371
4372 /* Methods */
4373
4374 /**
4375 * Handle mouse down events.
4376 *
4377 * @private
4378 * @param {jQuery.Event} e Mouse down event
4379 * @return {boolean} False to prevent defaults
4380 */
4381 OO.ui.TagMultiselectWidget.prototype.onMouseDown = function ( e ) {
4382 if (
4383 !this.isDisabled() &&
4384 ( !this.hasInput || e.target !== this.input.$input[ 0 ] ) &&
4385 e.which === OO.ui.MouseButtons.LEFT
4386 ) {
4387 this.focus();
4388 return false;
4389 }
4390 };
4391
4392 /**
4393 * Handle key press events.
4394 *
4395 * @private
4396 * @param {jQuery.Event} e Key press event
4397 * @return {boolean} Whether to prevent defaults
4398 */
4399 OO.ui.TagMultiselectWidget.prototype.onInputKeyPress = function ( e ) {
4400 var stopOrContinue,
4401 withMetaKey = e.metaKey || e.ctrlKey;
4402
4403 if ( !this.isDisabled() ) {
4404 if ( e.which === OO.ui.Keys.ENTER ) {
4405 stopOrContinue = this.doInputEnter( e, withMetaKey );
4406 }
4407
4408 // Make sure the input gets resized.
4409 setTimeout( this.updateInputSize.bind( this ), 0 );
4410 return stopOrContinue;
4411 }
4412 };
4413
4414 /**
4415 * Handle key down events.
4416 *
4417 * @private
4418 * @param {jQuery.Event} e Key down event
4419 * @return {boolean}
4420 */
4421 OO.ui.TagMultiselectWidget.prototype.onInputKeyDown = function ( e ) {
4422 var movement, direction,
4423 widget = this,
4424 withMetaKey = e.metaKey || e.ctrlKey,
4425 isMovementInsideInput = function ( direction ) {
4426 var inputRange = widget.input.getRange(),
4427 inputValue = widget.hasInput && widget.input.getValue();
4428
4429 if ( direction === 'forwards' && inputRange.to > inputValue.length - 1 ) {
4430 return false;
4431 }
4432
4433 if ( direction === 'backwards' && inputRange.from <= 0 ) {
4434 return false;
4435 }
4436
4437 return true;
4438 };
4439
4440 if ( !this.isDisabled() ) {
4441 // 'keypress' event is not triggered for Backspace key
4442 if ( e.keyCode === OO.ui.Keys.BACKSPACE ) {
4443 return this.doInputBackspace( e, withMetaKey );
4444 } else if ( e.keyCode === OO.ui.Keys.ESCAPE ) {
4445 return this.doInputEscape( e );
4446 } else if (
4447 e.keyCode === OO.ui.Keys.LEFT ||
4448 e.keyCode === OO.ui.Keys.RIGHT
4449 ) {
4450 if ( OO.ui.Element.static.getDir( this.$element ) === 'rtl' ) {
4451 movement = {
4452 left: 'forwards',
4453 right: 'backwards'
4454 };
4455 } else {
4456 movement = {
4457 left: 'backwards',
4458 right: 'forwards'
4459 };
4460 }
4461 direction = e.keyCode === OO.ui.Keys.LEFT ?
4462 movement.left : movement.right;
4463
4464 if ( !this.hasInput || !isMovementInsideInput( direction ) ) {
4465 return this.doInputArrow( e, direction, withMetaKey );
4466 }
4467 }
4468 }
4469 };
4470
4471 /**
4472 * Respond to input focus event
4473 */
4474 OO.ui.TagMultiselectWidget.prototype.onInputFocus = function () {
4475 this.$element.addClass( 'oo-ui-tagMultiselectWidget-focus' );
4476 // Reset validity
4477 this.toggleValid( true );
4478 };
4479
4480 /**
4481 * Respond to input blur event
4482 */
4483 OO.ui.TagMultiselectWidget.prototype.onInputBlur = function () {
4484 this.$element.removeClass( 'oo-ui-tagMultiselectWidget-focus' );
4485
4486 // Set the widget as invalid if there's text in the input
4487 this.addTagFromInput();
4488 this.toggleValid( this.checkValidity() && ( !this.hasInput || !this.input.getValue() ) );
4489 };
4490
4491 /**
4492 * Perform an action after the Enter key on the input
4493 *
4494 * @param {jQuery.Event} e Event data
4495 * @param {boolean} [withMetaKey] Whether this key was pressed with
4496 * a meta key like Control
4497 * @return {boolean} Whether to prevent defaults
4498 */
4499 OO.ui.TagMultiselectWidget.prototype.doInputEnter = function () {
4500 this.addTagFromInput();
4501 return false;
4502 };
4503
4504 /**
4505 * Perform an action responding to the Enter key on the input
4506 *
4507 * @param {jQuery.Event} e Event data
4508 * @param {boolean} [withMetaKey] Whether this key was pressed with
4509 * a meta key like Control
4510 * @return {boolean} Whether to prevent defaults
4511 */
4512 OO.ui.TagMultiselectWidget.prototype.doInputBackspace = function ( e, withMetaKey ) {
4513 var items, item;
4514
4515 if (
4516 this.inputPosition === 'inline' &&
4517 this.input.getValue() === '' &&
4518 !this.isEmpty()
4519 ) {
4520 // Delete the last item
4521 items = this.getItems();
4522 item = items[ items.length - 1 ];
4523
4524 if ( !item.isDisabled() && !item.isFixed() ) {
4525 this.removeItems( [ item ] );
4526 // If Ctrl/Cmd was pressed, delete item entirely.
4527 // Otherwise put it into the text field for editing.
4528 if ( !withMetaKey ) {
4529 this.input.setValue( item.getLabel() );
4530 }
4531 }
4532
4533 return false;
4534 }
4535 };
4536
4537 /**
4538 * Perform an action after the Escape key on the input
4539 *
4540 * @param {jQuery.Event} e Event data
4541 */
4542 OO.ui.TagMultiselectWidget.prototype.doInputEscape = function () {
4543 this.clearInput();
4544 };
4545
4546 /**
4547 * Perform an action after the Left/Right arrow key on the input, select the previous
4548 * item from the input.
4549 * See #getPreviousItem
4550 *
4551 * @param {jQuery.Event} e Event data
4552 * @param {string} direction Direction of the movement; forwards or backwards
4553 * @param {boolean} [withMetaKey] Whether this key was pressed with
4554 * a meta key like Control
4555 */
4556 OO.ui.TagMultiselectWidget.prototype.doInputArrow = function ( e, direction ) {
4557 if (
4558 this.inputPosition === 'inline' &&
4559 !this.isEmpty() &&
4560 direction === 'backwards'
4561 ) {
4562 // Get previous item
4563 this.getPreviousItem().focus();
4564 }
4565 };
4566
4567 /**
4568 * Respond to item select event
4569 *
4570 * @param {OO.ui.TagItemWidget} item Selected item
4571 */
4572 OO.ui.TagMultiselectWidget.prototype.onTagSelect = function ( item ) {
4573 if ( this.hasInput && this.allowEditTags && !item.isFixed() ) {
4574 if ( this.input.getValue() ) {
4575 this.addTagFromInput();
4576 }
4577 // 1. Get the label of the tag into the input
4578 this.input.setValue( item.getLabel() );
4579 // 2. Remove the tag
4580 this.removeItems( [ item ] );
4581 // 3. Focus the input
4582 this.focus();
4583 }
4584 };
4585
4586 /**
4587 * Respond to item fixed state change
4588 *
4589 * @param {OO.ui.TagItemWidget} item Selected item
4590 */
4591 OO.ui.TagMultiselectWidget.prototype.onTagFixed = function ( item ) {
4592 var i,
4593 items = this.getItems();
4594
4595 // Move item to the end of the static items
4596 for ( i = 0; i < items.length; i++ ) {
4597 if ( items[ i ] !== item && !items[ i ].isFixed() ) {
4598 break;
4599 }
4600 }
4601 this.addItems( item, i );
4602 };
4603 /**
4604 * Respond to change event, where items were added, removed, or cleared.
4605 */
4606 OO.ui.TagMultiselectWidget.prototype.onChangeTags = function () {
4607 var isUnderLimit = this.isUnderLimit();
4608
4609 // Reset validity
4610 this.toggleValid(
4611 this.checkValidity() &&
4612 !( this.hasInput && this.input.getValue() )
4613 );
4614
4615 if ( this.hasInput ) {
4616 this.updateInputSize();
4617 if ( !isUnderLimit ) {
4618 // Clear the input
4619 this.input.setValue( '' );
4620 }
4621 if ( this.inputPosition === 'outline' ) {
4622 // Show/clear the placeholder and enable/disable the input
4623 // based on whether we are/aren't under the specified limit
4624 this.input.$input.attr( 'placeholder', isUnderLimit ? this.inputPlaceholder : '' );
4625 this.input.setDisabled( !isUnderLimit );
4626 } else {
4627 // Show/hide the input
4628 this.input.$input.toggleClass( 'oo-ui-element-hidden', !isUnderLimit );
4629 }
4630 }
4631 this.updateIfHeightChanged();
4632 };
4633
4634 /**
4635 * @inheritdoc
4636 */
4637 OO.ui.TagMultiselectWidget.prototype.setDisabled = function ( isDisabled ) {
4638 // Parent method
4639 OO.ui.TagMultiselectWidget.parent.prototype.setDisabled.call( this, isDisabled );
4640
4641 if ( this.hasInput && this.input ) {
4642 if ( !isDisabled ) {
4643 this.updateInputSize();
4644 }
4645 this.input.setDisabled( !!isDisabled && !this.isUnderLimit() );
4646 }
4647
4648 if ( this.items ) {
4649 this.getItems().forEach( function ( item ) {
4650 item.setDisabled( !!isDisabled );
4651 } );
4652 }
4653 };
4654
4655 /**
4656 * Respond to tag remove event
4657 * @param {OO.ui.TagItemWidget} item Removed tag
4658 */
4659 OO.ui.TagMultiselectWidget.prototype.onTagRemove = function ( item ) {
4660 this.removeTagByData( item.getData() );
4661 };
4662
4663 /**
4664 * Respond to navigate event on the tag
4665 *
4666 * @param {OO.ui.TagItemWidget} item Removed tag
4667 * @param {string} direction Direction of movement; 'forwards' or 'backwards'
4668 */
4669 OO.ui.TagMultiselectWidget.prototype.onTagNavigate = function ( item, direction ) {
4670 var firstItem = this.getItems()[ 0 ];
4671
4672 if ( direction === 'forwards' ) {
4673 this.getNextItem( item ).focus();
4674 } else if ( !this.inputPosition === 'inline' || item !== firstItem ) {
4675 // If the widget has an inline input, we want to stop at the starting edge
4676 // of the tags
4677 this.getPreviousItem( item ).focus();
4678 }
4679 };
4680
4681 /**
4682 * Add tag from input value
4683 */
4684 OO.ui.TagMultiselectWidget.prototype.addTagFromInput = function () {
4685 var val = this.input.getValue(),
4686 isValid = this.isAllowedData( val );
4687
4688 if ( !val ) {
4689 return;
4690 }
4691
4692 if ( isValid || this.allowDisplayInvalidTags ) {
4693 this.clearInput();
4694 this.addTag( val );
4695 }
4696 };
4697
4698 /**
4699 * Clear the input
4700 */
4701 OO.ui.TagMultiselectWidget.prototype.clearInput = function () {
4702 this.input.setValue( '' );
4703 };
4704
4705 /**
4706 * Check whether the given value is a duplicate of an existing
4707 * tag already in the list.
4708 *
4709 * @param {string|Object} data Requested value
4710 * @return {boolean} Value is duplicate
4711 */
4712 OO.ui.TagMultiselectWidget.prototype.isDuplicateData = function ( data ) {
4713 return !!this.findItemFromData( data );
4714 };
4715
4716 /**
4717 * Check whether a given value is allowed to be added
4718 *
4719 * @param {string|Object} data Requested value
4720 * @return {boolean} Value is allowed
4721 */
4722 OO.ui.TagMultiselectWidget.prototype.isAllowedData = function ( data ) {
4723 if (
4724 !this.allowDuplicates &&
4725 this.isDuplicateData( data )
4726 ) {
4727 return false;
4728 }
4729
4730 if ( this.allowArbitrary ) {
4731 return true;
4732 }
4733
4734 // Check with allowed values
4735 if (
4736 this.getAllowedValues().some( function ( value ) {
4737 return data === value;
4738 } )
4739 ) {
4740 return true;
4741 }
4742
4743 return false;
4744 };
4745
4746 /**
4747 * Get the allowed values list
4748 *
4749 * @return {string[]} Allowed data values
4750 */
4751 OO.ui.TagMultiselectWidget.prototype.getAllowedValues = function () {
4752 return this.allowedValues;
4753 };
4754
4755 /**
4756 * Add a value to the allowed values list
4757 *
4758 * @param {string} value Allowed data value
4759 */
4760 OO.ui.TagMultiselectWidget.prototype.addAllowedValue = function ( value ) {
4761 if ( this.allowedValues.indexOf( value ) === -1 ) {
4762 this.allowedValues.push( value );
4763 }
4764 };
4765
4766 /**
4767 * Get the datas of the currently selected items
4768 *
4769 * @return {string[]|Object[]} Datas of currently selected items
4770 */
4771 OO.ui.TagMultiselectWidget.prototype.getValue = function () {
4772 return this.getItems()
4773 .filter( function ( item ) {
4774 return item.isValid();
4775 } )
4776 .map( function ( item ) {
4777 return item.getData();
4778 } );
4779 };
4780
4781 /**
4782 * Set the value of this widget by datas.
4783 *
4784 * @param {string|string[]|Object|Object[]} valueObject An object representing the data
4785 * and label of the value. If the widget allows arbitrary values,
4786 * the items will be added as-is. Otherwise, the data value will
4787 * be checked against allowedValues.
4788 * This object must contain at least a data key. Example:
4789 * { data: 'foo', label: 'Foo item' }
4790 * For multiple items, use an array of objects. For example:
4791 * [
4792 * { data: 'foo', label: 'Foo item' },
4793 * { data: 'bar', label: 'Bar item' }
4794 * ]
4795 * Value can also be added with plaintext array, for example:
4796 * [ 'foo', 'bar', 'bla' ] or a single string, like 'foo'
4797 */
4798 OO.ui.TagMultiselectWidget.prototype.setValue = function ( valueObject ) {
4799 valueObject = Array.isArray( valueObject ) ? valueObject : [ valueObject ];
4800
4801 this.clearItems();
4802 valueObject.forEach( function ( obj ) {
4803 if ( typeof obj === 'string' ) {
4804 this.addTag( obj );
4805 } else {
4806 this.addTag( obj.data, obj.label );
4807 }
4808 }.bind( this ) );
4809 };
4810
4811 /**
4812 * Add tag to the display area
4813 *
4814 * @param {string|Object} data Tag data
4815 * @param {string} [label] Tag label. If no label is provided, the
4816 * stringified version of the data will be used instead.
4817 * @return {boolean} Item was added successfully
4818 */
4819 OO.ui.TagMultiselectWidget.prototype.addTag = function ( data, label ) {
4820 var newItemWidget,
4821 isValid = this.isAllowedData( data );
4822
4823 if ( this.isUnderLimit() && ( isValid || this.allowDisplayInvalidTags ) ) {
4824 newItemWidget = this.createTagItemWidget( data, label );
4825 newItemWidget.toggleValid( isValid );
4826 this.addItems( [ newItemWidget ] );
4827 return true;
4828 }
4829
4830 return false;
4831 };
4832
4833 /**
4834 * Check whether the number of current tags is within the limit.
4835 *
4836 * @return {boolean} True if current tag count is within the limit or
4837 * if 'tagLimit' is not set
4838 */
4839 OO.ui.TagMultiselectWidget.prototype.isUnderLimit = function () {
4840 return !this.tagLimit ||
4841 this.getItemCount() < this.tagLimit;
4842 };
4843
4844 /**
4845 * Remove tag by its data property.
4846 *
4847 * @param {string|Object} data Tag data
4848 */
4849 OO.ui.TagMultiselectWidget.prototype.removeTagByData = function ( data ) {
4850 var item = this.findItemFromData( data );
4851
4852 this.removeItems( [ item ] );
4853 };
4854
4855 /**
4856 * Construct a OO.ui.TagItemWidget (or a subclass thereof) from given label and data.
4857 *
4858 * @protected
4859 * @param {string} data Item data
4860 * @param {string} label The label text.
4861 * @return {OO.ui.TagItemWidget}
4862 */
4863 OO.ui.TagMultiselectWidget.prototype.createTagItemWidget = function ( data, label ) {
4864 label = label || data;
4865
4866 return new OO.ui.TagItemWidget( { data: data, label: label } );
4867 };
4868
4869 /**
4870 * Given an item, returns the item after it. If the item is already the
4871 * last item, return `this.input`. If no item is passed, returns the
4872 * very first item.
4873 *
4874 * @protected
4875 * @param {OO.ui.TagItemWidget} [item] Tag item
4876 * @return {OO.ui.Widget} The next widget available.
4877 */
4878 OO.ui.TagMultiselectWidget.prototype.getNextItem = function ( item ) {
4879 var itemIndex = this.items.indexOf( item );
4880
4881 if ( item === undefined || itemIndex === -1 ) {
4882 return this.items[ 0 ];
4883 }
4884
4885 if ( itemIndex === this.items.length - 1 ) { // Last item
4886 if ( this.hasInput ) {
4887 return this.input;
4888 } else {
4889 // Return first item
4890 return this.items[ 0 ];
4891 }
4892 } else {
4893 return this.items[ itemIndex + 1 ];
4894 }
4895 };
4896
4897 /**
4898 * Given an item, returns the item before it. If the item is already the
4899 * first item, return `this.input`. If no item is passed, returns the
4900 * very last item.
4901 *
4902 * @protected
4903 * @param {OO.ui.TagItemWidget} [item] Tag item
4904 * @return {OO.ui.Widget} The previous widget available.
4905 */
4906 OO.ui.TagMultiselectWidget.prototype.getPreviousItem = function ( item ) {
4907 var itemIndex = this.items.indexOf( item );
4908
4909 if ( item === undefined || itemIndex === -1 ) {
4910 return this.items[ this.items.length - 1 ];
4911 }
4912
4913 if ( itemIndex === 0 ) {
4914 if ( this.hasInput ) {
4915 return this.input;
4916 } else {
4917 // Return the last item
4918 return this.items[ this.items.length - 1 ];
4919 }
4920 } else {
4921 return this.items[ itemIndex - 1 ];
4922 }
4923 };
4924
4925 /**
4926 * Update the dimensions of the text input field to encompass all available area.
4927 * This is especially relevant for when the input is at the edge of a line
4928 * and should get smaller. The usual operation (as an inline-block with min-width)
4929 * does not work in that case, pushing the input downwards to the next line.
4930 *
4931 * @private
4932 */
4933 OO.ui.TagMultiselectWidget.prototype.updateInputSize = function () {
4934 var $lastItem, direction, contentWidth, currentWidth, bestWidth;
4935 if ( this.inputPosition === 'inline' && !this.isDisabled() ) {
4936 if ( this.input.$input[ 0 ].scrollWidth === 0 ) {
4937 // Input appears to be attached but not visible.
4938 // Don't attempt to adjust its size, because our measurements
4939 // are going to fail anyway.
4940 return;
4941 }
4942 this.input.$input.css( 'width', '1em' );
4943 $lastItem = this.$group.children().last();
4944 direction = OO.ui.Element.static.getDir( this.$handle );
4945
4946 // Get the width of the input with the placeholder text as
4947 // the value and save it so that we don't keep recalculating
4948 if (
4949 this.contentWidthWithPlaceholder === undefined &&
4950 this.input.getValue() === '' &&
4951 this.input.$input.attr( 'placeholder' ) !== undefined
4952 ) {
4953 this.input.setValue( this.input.$input.attr( 'placeholder' ) );
4954 this.contentWidthWithPlaceholder = this.input.$input[ 0 ].scrollWidth;
4955 this.input.setValue( '' );
4956
4957 }
4958
4959 // Always keep the input wide enough for the placeholder text
4960 contentWidth = Math.max(
4961 this.input.$input[ 0 ].scrollWidth,
4962 // undefined arguments in Math.max lead to NaN
4963 ( this.contentWidthWithPlaceholder === undefined ) ?
4964 0 : this.contentWidthWithPlaceholder
4965 );
4966 currentWidth = this.input.$input.width();
4967
4968 if ( contentWidth < currentWidth ) {
4969 this.updateIfHeightChanged();
4970 // All is fine, don't perform expensive calculations
4971 return;
4972 }
4973
4974 if ( $lastItem.length === 0 ) {
4975 bestWidth = this.$content.innerWidth();
4976 } else {
4977 bestWidth = direction === 'ltr' ?
4978 this.$content.innerWidth() - $lastItem.position().left - $lastItem.outerWidth() :
4979 $lastItem.position().left;
4980 }
4981
4982 // Some safety margin for sanity, because I *really* don't feel like finding out where the
4983 // few pixels this is off by are coming from.
4984 bestWidth -= 13;
4985 if ( contentWidth > bestWidth ) {
4986 // This will result in the input getting shifted to the next line
4987 bestWidth = this.$content.innerWidth() - 13;
4988 }
4989 this.input.$input.width( Math.floor( bestWidth ) );
4990 this.updateIfHeightChanged();
4991 } else {
4992 this.updateIfHeightChanged();
4993 }
4994 };
4995
4996 /**
4997 * Determine if widget height changed, and if so,
4998 * emit the resize event. This is useful for when there are either
4999 * menus or popups attached to the bottom of the widget, to allow
5000 * them to change their positioning in case the widget moved down
5001 * or up.
5002 *
5003 * @private
5004 */
5005 OO.ui.TagMultiselectWidget.prototype.updateIfHeightChanged = function () {
5006 var height = this.$element.height();
5007 if ( height !== this.height ) {
5008 this.height = height;
5009 this.emit( 'resize' );
5010 }
5011 };
5012
5013 /**
5014 * Check whether all items in the widget are valid
5015 *
5016 * @return {boolean} Widget is valid
5017 */
5018 OO.ui.TagMultiselectWidget.prototype.checkValidity = function () {
5019 return this.getItems().every( function ( item ) {
5020 return item.isValid();
5021 } );
5022 };
5023
5024 /**
5025 * Set the valid state of this item
5026 *
5027 * @param {boolean} [valid] Item is valid
5028 * @fires valid
5029 */
5030 OO.ui.TagMultiselectWidget.prototype.toggleValid = function ( valid ) {
5031 valid = valid === undefined ? !this.valid : !!valid;
5032
5033 if ( this.valid !== valid ) {
5034 this.valid = valid;
5035
5036 this.setFlags( { invalid: !this.valid } );
5037
5038 this.emit( 'valid', this.valid );
5039 }
5040 };
5041
5042 /**
5043 * Get the current valid state of the widget
5044 *
5045 * @return {boolean} Widget is valid
5046 */
5047 OO.ui.TagMultiselectWidget.prototype.isValid = function () {
5048 return this.valid;
5049 };
5050
5051 /**
5052 * PopupTagMultiselectWidget is a {@link OO.ui.TagMultiselectWidget OO.ui.TagMultiselectWidget}
5053 * intended to use a popup. The popup can be configured to have a default input to insert values
5054 * into the widget.
5055 *
5056 * @example
5057 * // A PopupTagMultiselectWidget.
5058 * var widget = new OO.ui.PopupTagMultiselectWidget();
5059 * $( document.body ).append( widget.$element );
5060 *
5061 * // Example: A PopupTagMultiselectWidget with an external popup.
5062 * var popupInput = new OO.ui.TextInputWidget(),
5063 * widget = new OO.ui.PopupTagMultiselectWidget( {
5064 * popupInput: popupInput,
5065 * popup: {
5066 * $content: popupInput.$element
5067 * }
5068 * } );
5069 * $( document.body ).append( widget.$element );
5070 *
5071 * @class
5072 * @extends OO.ui.TagMultiselectWidget
5073 * @mixins OO.ui.mixin.PopupElement
5074 *
5075 * @param {Object} config Configuration object
5076 * @cfg {jQuery} [$overlay] An overlay for the popup.
5077 * See <https://www.mediawiki.org/wiki/OOUI/Concepts#Overlays>.
5078 * @cfg {Object} [popup] Configuration options for the popup
5079 * @cfg {OO.ui.InputWidget} [popupInput] An input widget inside the popup that will be
5080 * focused when the popup is opened and will be used as replacement for the
5081 * general input in the widget.
5082 * @deprecated
5083 */
5084 OO.ui.PopupTagMultiselectWidget = function OoUiPopupTagMultiselectWidget( config ) {
5085 var defaultInput,
5086 defaultConfig = { popup: {} };
5087
5088 config = config || {};
5089
5090 // Parent constructor
5091 OO.ui.PopupTagMultiselectWidget.parent.call( this, $.extend( {
5092 inputPosition: 'none'
5093 }, config ) );
5094
5095 this.$overlay = ( config.$overlay === true ?
5096 OO.ui.getDefaultOverlay() : config.$overlay ) || this.$element;
5097
5098 if ( !config.popup ) {
5099 // For the default base implementation, we give a popup
5100 // with an input widget inside it. For any other use cases
5101 // the popup needs to be populated externally and the
5102 // event handled to add tags separately and manually
5103 defaultInput = new OO.ui.TextInputWidget();
5104
5105 defaultConfig.popupInput = defaultInput;
5106 defaultConfig.popup.$content = defaultInput.$element;
5107 defaultConfig.popup.padded = true;
5108
5109 this.$element.addClass( 'oo-ui-popupTagMultiselectWidget-defaultPopup' );
5110 }
5111
5112 // Add overlay, and add that to the autoCloseIgnore
5113 defaultConfig.popup.$overlay = this.$overlay;
5114 defaultConfig.popup.$autoCloseIgnore = this.hasInput ?
5115 this.input.$element.add( this.$overlay ) : this.$overlay;
5116
5117 // Allow extending any of the above
5118 config = $.extend( defaultConfig, config );
5119
5120 // Mixin constructors
5121 OO.ui.mixin.PopupElement.call( this, config );
5122
5123 if ( this.hasInput ) {
5124 this.input.$input.on( 'focus', this.popup.toggle.bind( this.popup, true ) );
5125 }
5126
5127 // Configuration options
5128 this.popupInput = config.popupInput;
5129 if ( this.popupInput ) {
5130 this.popupInput.connect( this, {
5131 enter: 'onPopupInputEnter'
5132 } );
5133 }
5134
5135 // Events
5136 this.on( 'resize', this.popup.updateDimensions.bind( this.popup ) );
5137 this.popup.connect( this, {
5138 toggle: 'onPopupToggle'
5139 } );
5140 this.$tabIndexed.on( 'focus', this.onFocus.bind( this ) );
5141
5142 // Initialize
5143 this.$element
5144 .append( this.popup.$element )
5145 .addClass( 'oo-ui-popupTagMultiselectWidget' );
5146
5147 // Deprecation warning
5148 OO.ui.warnDeprecation( 'PopupTagMultiselectWidget: Deprecated widget. Use MenuTagMultiselectWidget instead. See T208821.' );
5149 };
5150
5151 /* Initialization */
5152
5153 OO.inheritClass( OO.ui.PopupTagMultiselectWidget, OO.ui.TagMultiselectWidget );
5154 OO.mixinClass( OO.ui.PopupTagMultiselectWidget, OO.ui.mixin.PopupElement );
5155
5156 /* Methods */
5157
5158 /**
5159 * Focus event handler.
5160 *
5161 * @private
5162 */
5163 OO.ui.PopupTagMultiselectWidget.prototype.onFocus = function () {
5164 this.popup.toggle( true );
5165 };
5166
5167 /**
5168 * Respond to popup toggle event
5169 *
5170 * @param {boolean} isVisible Popup is visible
5171 */
5172 OO.ui.PopupTagMultiselectWidget.prototype.onPopupToggle = function ( isVisible ) {
5173 if ( isVisible && this.popupInput ) {
5174 this.popupInput.focus();
5175 }
5176 };
5177
5178 /**
5179 * Respond to popup input enter event
5180 */
5181 OO.ui.PopupTagMultiselectWidget.prototype.onPopupInputEnter = function () {
5182 if ( this.popupInput ) {
5183 this.addTagByPopupValue( this.popupInput.getValue() );
5184 this.popupInput.setValue( '' );
5185 }
5186 };
5187
5188 /**
5189 * @inheritdoc
5190 */
5191 OO.ui.PopupTagMultiselectWidget.prototype.onTagSelect = function ( item ) {
5192 if ( this.popupInput && this.allowEditTags ) {
5193 this.popupInput.setValue( item.getData() );
5194 this.removeItems( [ item ] );
5195
5196 this.popup.toggle( true );
5197 this.popupInput.focus();
5198 } else {
5199 // Parent
5200 OO.ui.PopupTagMultiselectWidget.parent.prototype.onTagSelect.call( this, item );
5201 }
5202 };
5203
5204 /**
5205 * Add a tag by the popup value.
5206 * Whatever is responsible for setting the value in the popup should call
5207 * this method to add a tag, or use the regular methods like #addTag or
5208 * #setValue directly.
5209 *
5210 * @param {string} data The value of item
5211 * @param {string} [label] The label of the tag. If not given, the data is used.
5212 */
5213 OO.ui.PopupTagMultiselectWidget.prototype.addTagByPopupValue = function ( data, label ) {
5214 this.addTag( data, label );
5215 };
5216
5217 /**
5218 * MenuTagMultiselectWidget is a {@link OO.ui.TagMultiselectWidget OO.ui.TagMultiselectWidget}
5219 * intended to use a menu of selectable options.
5220 *
5221 * @example
5222 * // A basic MenuTagMultiselectWidget.
5223 * var widget = new OO.ui.MenuTagMultiselectWidget( {
5224 * inputPosition: 'outline',
5225 * options: [
5226 * { data: 'option1', label: 'Option 1', icon: 'tag' },
5227 * { data: 'option2', label: 'Option 2' },
5228 * { data: 'option3', label: 'Option 3' },
5229 * ],
5230 * selected: [ 'option1', 'option2' ]
5231 * } );
5232 * $( document.body ).append( widget.$element );
5233 *
5234 * @class
5235 * @extends OO.ui.TagMultiselectWidget
5236 *
5237 * @constructor
5238 * @param {Object} [config] Configuration object
5239 * @cfg {boolean} [clearInputOnChoose=true] Clear the text input value when a menu option is chosen
5240 * @cfg {Object} [menu] Configuration object for the menu widget
5241 * @cfg {jQuery} [$overlay] An overlay for the menu.
5242 * See <https://www.mediawiki.org/wiki/OOUI/Concepts#Overlays>.
5243 * @cfg {Object[]} [options=[]] Array of menu options in the format `{ data: …, label: … }`
5244 */
5245 OO.ui.MenuTagMultiselectWidget = function OoUiMenuTagMultiselectWidget( config ) {
5246 var $autoCloseIgnore = $( [] );
5247 config = config || {};
5248
5249 // Parent constructor
5250 OO.ui.MenuTagMultiselectWidget.parent.call( this, config );
5251
5252 $autoCloseIgnore = $autoCloseIgnore.add( this.$group );
5253 if ( this.hasInput ) {
5254 $autoCloseIgnore = $autoCloseIgnore.add( this.input.$element );
5255 }
5256
5257 this.$overlay = ( config.$overlay === true ?
5258 OO.ui.getDefaultOverlay() : config.$overlay ) || this.$element;
5259 this.clearInputOnChoose = config.clearInputOnChoose === undefined ||
5260 !!config.clearInputOnChoose;
5261 this.menu = this.createMenuWidget( $.extend( {
5262 widget: this,
5263 hideOnChoose: false,
5264 input: this.hasInput ? this.input : null,
5265 $input: this.hasInput ? this.input.$input : null,
5266 filterFromInput: !!this.hasInput,
5267 highlightOnFilter: !this.allowArbitrary,
5268 multiselect: true,
5269 $autoCloseIgnore: $autoCloseIgnore,
5270 $floatableContainer: this.hasInput && this.inputPosition === 'outline' ?
5271 this.input.$element : this.$element,
5272 $overlay: this.$overlay,
5273 disabled: this.isDisabled()
5274 }, config.menu ) );
5275 this.addOptions( config.options || [] );
5276
5277 // Events
5278 this.menu.connect( this, {
5279 choose: 'onMenuChoose',
5280 toggle: 'onMenuToggle'
5281 } );
5282 if ( this.hasInput ) {
5283 this.input.connect( this, {
5284 change: 'onInputChange'
5285 } );
5286 }
5287 this.connect( this, {
5288 resize: 'onResize'
5289 } );
5290
5291 // Initialization
5292 this.$overlay.append( this.menu.$element );
5293 this.$element.addClass( 'oo-ui-menuTagMultiselectWidget' );
5294 // Remove MenuSelectWidget's generic focus owner ARIA attribute
5295 // TODO: Should this widget have a `role` that is compatible with this attribute?
5296 this.menu.$focusOwner.removeAttr( 'aria-expanded' );
5297 // TagMultiselectWidget already does this, but it doesn't work right because this.menu is
5298 // not yet set up while the parent constructor runs, and #getAllowedValues rejects everything.
5299 if ( config.selected ) {
5300 this.setValue( config.selected );
5301 }
5302 };
5303
5304 /* Initialization */
5305
5306 OO.inheritClass( OO.ui.MenuTagMultiselectWidget, OO.ui.TagMultiselectWidget );
5307
5308 /* Methods */
5309
5310 /**
5311 * Respond to resize event
5312 */
5313 OO.ui.MenuTagMultiselectWidget.prototype.onResize = function () {
5314 // Reposition the menu
5315 this.menu.position();
5316 };
5317
5318 /**
5319 * @inheritdoc
5320 */
5321 OO.ui.MenuTagMultiselectWidget.prototype.onInputFocus = function () {
5322 // Parent method
5323 OO.ui.MenuTagMultiselectWidget.parent.prototype.onInputFocus.call( this );
5324
5325 this.menu.toggle( true );
5326 };
5327
5328 /**
5329 * Respond to input change event
5330 */
5331 OO.ui.MenuTagMultiselectWidget.prototype.onInputChange = function () {
5332 this.menu.toggle( true );
5333 };
5334
5335 /**
5336 * Respond to menu choose event, which is intentional by the user.
5337 *
5338 * @param {OO.ui.OptionWidget} menuItem Selected menu items
5339 * @param {boolean} selected Item is selected
5340 */
5341 OO.ui.MenuTagMultiselectWidget.prototype.onMenuChoose = function ( menuItem, selected ) {
5342 if ( this.hasInput && this.clearInputOnChoose ) {
5343 this.input.setValue( '' );
5344 }
5345
5346 if ( selected && !this.findItemFromData( menuItem.getData() ) ) {
5347 // The menu item is selected, add it to the tags
5348 this.addTag( menuItem.getData(), menuItem.getLabel() );
5349 } else {
5350 // The menu item was unselected, remove the tag
5351 this.removeTagByData( menuItem.getData() );
5352 }
5353 };
5354
5355 /**
5356 * Respond to menu toggle event. Reset item highlights on hide.
5357 *
5358 * @param {boolean} isVisible The menu is visible
5359 */
5360 OO.ui.MenuTagMultiselectWidget.prototype.onMenuToggle = function ( isVisible ) {
5361 if ( !isVisible ) {
5362 this.menu.highlightItem( null );
5363 this.menu.scrollToTop();
5364 }
5365 setTimeout( function () {
5366 // Remove MenuSelectWidget's generic focus owner ARIA attribute
5367 // TODO: Should this widget have a `role` that is compatible with this attribute?
5368 this.menu.$focusOwner.removeAttr( 'aria-expanded' );
5369 }.bind( this ) );
5370 };
5371
5372 /**
5373 * @inheritdoc
5374 */
5375 OO.ui.MenuTagMultiselectWidget.prototype.onTagSelect = function ( tagItem ) {
5376 var menuItem = this.menu.findItemFromData( tagItem.getData() );
5377 if ( !this.allowArbitrary ) {
5378 // Override the base behavior from TagMultiselectWidget; the base behavior
5379 // in TagMultiselectWidget is to remove the tag to edit it in the input,
5380 // but in our case, we want to utilize the menu selection behavior, and
5381 // definitely not remove the item.
5382
5383 // If there is an input that is used for filtering, erase the value so we don't filter
5384 if ( this.hasInput && this.menu.filterFromInput ) {
5385 this.input.setValue( '' );
5386 }
5387
5388 this.focus();
5389
5390 // Highlight the menu item
5391 this.menu.highlightItem( menuItem );
5392 this.menu.scrollItemIntoView( menuItem );
5393
5394 } else {
5395 // Use the default
5396 OO.ui.MenuTagMultiselectWidget.parent.prototype.onTagSelect.call( this, tagItem );
5397 }
5398 };
5399
5400 /**
5401 * @inheritdoc
5402 */
5403 OO.ui.MenuTagMultiselectWidget.prototype.removeItems = function ( items ) {
5404 var widget = this;
5405
5406 // Parent
5407 OO.ui.MenuTagMultiselectWidget.parent.prototype.removeItems.call( this, items );
5408
5409 items.forEach( function ( tagItem ) {
5410 var menuItem = widget.menu.findItemFromData( tagItem.getData() );
5411 if ( menuItem ) {
5412 // Synchronize the menu selection - unselect the removed tag
5413 widget.menu.unselectItem( menuItem );
5414 }
5415 } );
5416 };
5417
5418 /**
5419 * @inheritdoc
5420 */
5421 OO.ui.MenuTagMultiselectWidget.prototype.setValue = function ( valueObject ) {
5422 valueObject = Array.isArray( valueObject ) ? valueObject : [ valueObject ];
5423
5424 // We override this method from the parent, to make sure we are adding proper
5425 // menu items, and are accounting for cases where we have this widget with
5426 // a menu but also 'allowArbitrary'
5427 if ( !this.menu ) {
5428 return;
5429 }
5430
5431 this.clearItems();
5432 valueObject.forEach( function ( obj ) {
5433 var data, label, menuItem;
5434
5435 if ( typeof obj === 'string' ) {
5436 data = label = obj;
5437 } else {
5438 data = obj.data;
5439 label = obj.label;
5440 }
5441
5442 // Check if the item is in the menu
5443 menuItem = this.menu.getItemFromLabel( label ) || this.menu.findItemFromData( data );
5444 if ( menuItem ) {
5445 // Menu item found, add the menu item
5446 this.addTag( menuItem.getData(), menuItem.getLabel() );
5447 // Make sure that item is also selected
5448 this.menu.selectItem( menuItem );
5449 } else if ( this.allowArbitrary ) {
5450 // If the item isn't in the menu, only add it if we
5451 // allow for arbitrary values
5452 this.addTag( data, label );
5453 }
5454 }.bind( this ) );
5455 };
5456
5457 /**
5458 * @inheritdoc
5459 */
5460 OO.ui.MenuTagMultiselectWidget.prototype.setDisabled = function ( isDisabled ) {
5461 // Parent method
5462 OO.ui.MenuTagMultiselectWidget.parent.prototype.setDisabled.call( this, isDisabled );
5463
5464 if ( this.menu ) {
5465 // Protect against calling setDisabled() before the menu was initialized
5466 this.menu.setDisabled( isDisabled );
5467 }
5468 };
5469
5470 /**
5471 * Highlight the first selectable item in the menu, if configured.
5472 *
5473 * @private
5474 * @chainable
5475 */
5476 OO.ui.MenuTagMultiselectWidget.prototype.initializeMenuSelection = function () {
5477 var highlightedItem;
5478 this.menu.highlightItem(
5479 this.allowArbitrary ?
5480 null :
5481 this.menu.findFirstSelectableItem()
5482 );
5483
5484 highlightedItem = this.menu.findHighlightedItem();
5485 // Scroll to the highlighted item, if it exists
5486 if ( highlightedItem ) {
5487 this.menu.scrollItemIntoView( highlightedItem );
5488 }
5489 };
5490
5491 /**
5492 * @inheritdoc
5493 */
5494 OO.ui.MenuTagMultiselectWidget.prototype.addTagFromInput = function () {
5495 var val = this.input.getValue(),
5496 // Look for a highlighted item first
5497 // Then look for the element that fits the data
5498 item = this.menu.findHighlightedItem() || this.menu.findItemFromData( val ),
5499 data = item ? item.getData() : val,
5500 isValid = this.isAllowedData( data );
5501
5502 // Override the parent method so we add from the menu
5503 // rather than directly from the input
5504
5505 if ( !val ) {
5506 return;
5507 }
5508
5509 if ( isValid || this.allowDisplayInvalidTags ) {
5510 this.clearInput();
5511 if ( item ) {
5512 this.addTag( data, item.getLabel() );
5513 } else {
5514 this.addTag( val );
5515 }
5516 }
5517 };
5518
5519 /**
5520 * Return the visible items in the menu. This is mainly used for when
5521 * the menu is filtering results.
5522 *
5523 * @return {OO.ui.MenuOptionWidget[]} Visible results
5524 */
5525 OO.ui.MenuTagMultiselectWidget.prototype.getMenuVisibleItems = function () {
5526 return this.menu.getItems().filter( function ( menuItem ) {
5527 return menuItem.isVisible();
5528 } );
5529 };
5530
5531 /**
5532 * Create the menu for this widget. This is in a separate method so that
5533 * child classes can override this without polluting the constructor with
5534 * unnecessary extra objects that will be overidden.
5535 *
5536 * @param {Object} menuConfig Configuration options
5537 * @return {OO.ui.MenuSelectWidget} Menu widget
5538 */
5539 OO.ui.MenuTagMultiselectWidget.prototype.createMenuWidget = function ( menuConfig ) {
5540 return new OO.ui.MenuSelectWidget( menuConfig );
5541 };
5542
5543 /**
5544 * Add options to the menu
5545 *
5546 * @param {Object[]} menuOptions Object defining options
5547 */
5548 OO.ui.MenuTagMultiselectWidget.prototype.addOptions = function ( menuOptions ) {
5549 var widget = this,
5550 items = menuOptions.map( function ( obj ) {
5551 return widget.createMenuOptionWidget( obj.data, obj.label, obj.icon );
5552 } );
5553
5554 this.menu.addItems( items );
5555 };
5556
5557 /**
5558 * Create a menu option widget.
5559 *
5560 * @param {string} data Item data
5561 * @param {string} [label] Item label
5562 * @param {string} [icon] Symbolic icon name
5563 * @return {OO.ui.OptionWidget} Option widget
5564 */
5565 OO.ui.MenuTagMultiselectWidget.prototype.createMenuOptionWidget = function ( data, label, icon ) {
5566 return new OO.ui.MenuOptionWidget( {
5567 data: data,
5568 label: label || data,
5569 icon: icon
5570 } );
5571 };
5572
5573 /**
5574 * Get the menu
5575 *
5576 * @return {OO.ui.MenuSelectWidget} Menu
5577 */
5578 OO.ui.MenuTagMultiselectWidget.prototype.getMenu = function () {
5579 return this.menu;
5580 };
5581
5582 /**
5583 * Get the allowed values list
5584 *
5585 * @return {string[]} Allowed data values
5586 */
5587 OO.ui.MenuTagMultiselectWidget.prototype.getAllowedValues = function () {
5588 var menuDatas = [];
5589 if ( this.menu ) {
5590 // If the parent constructor is calling us, we're not ready yet, this.menu is not set up.
5591 menuDatas = this.menu.getItems().map( function ( menuItem ) {
5592 return menuItem.getData();
5593 } );
5594 }
5595 return this.allowedValues.concat( menuDatas );
5596 };
5597
5598 /**
5599 * SelectFileWidgets allow for selecting files, using the HTML5 File API. These
5600 * widgets can be configured with {@link OO.ui.mixin.IconElement icons}, {@link
5601 * OO.ui.mixin.IndicatorElement indicators} and {@link OO.ui.mixin.TitledElement titles}.
5602 * Please see the [OOUI documentation on MediaWiki] [1] for more information and examples.
5603 *
5604 * Although SelectFileWidget inherits from SelectFileInputWidget, it does not
5605 * behave as an InputWidget, and can't be used in HTML forms.
5606 *
5607 * @example
5608 * // A file select widget.
5609 * var selectFile = new OO.ui.SelectFileWidget();
5610 * $( document.body ).append( selectFile.$element );
5611 *
5612 * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets
5613 *
5614 * @class
5615 * @extends OO.ui.SelectFileInputWidget
5616 * @mixins OO.ui.mixin.PendingElement
5617 *
5618 * @constructor
5619 * @param {Object} [config] Configuration options
5620 * @cfg {string} [notsupported] Text to display when file support is missing in the browser.
5621 * @cfg {boolean} [droppable=true] Whether to accept files by drag and drop.
5622 * @cfg {boolean} [buttonOnly=false] Show only the select file button, no info field. Requires
5623 * showDropTarget to be false.
5624 * @cfg {boolean} [showDropTarget=false] Whether to show a drop target. Requires droppable to be
5625 * true. Not yet supported in multiple file mode.
5626 * @cfg {number} [thumbnailSizeLimit=20] File size limit in MiB above which to not try and show a
5627 * preview (for performance).
5628 */
5629 OO.ui.SelectFileWidget = function OoUiSelectFileWidget( config ) {
5630 var dragHandler, droppable,
5631 isSupported = this.constructor.static.isSupported();
5632
5633 // Configuration initialization
5634 config = $.extend( {
5635 notsupported: OO.ui.msg( 'ooui-selectfile-not-supported' ),
5636 droppable: true,
5637 buttonOnly: false,
5638 showDropTarget: false,
5639 thumbnailSizeLimit: 20
5640 }, config );
5641
5642 if ( !isSupported ) {
5643 config.disabled = true;
5644 }
5645
5646 // Parent constructor
5647 OO.ui.SelectFileWidget.parent.call( this, config );
5648
5649 // Mixin constructors
5650 OO.ui.mixin.PendingElement.call( this );
5651
5652 if ( !isSupported ) {
5653 this.info.setValue( config.notsupported );
5654 }
5655
5656 // Properties
5657 droppable = config.droppable && isSupported;
5658 // TODO: Support drop target when multiple is set
5659 this.showDropTarget = droppable && config.showDropTarget && !this.multiple;
5660 this.thumbnailSizeLimit = config.thumbnailSizeLimit;
5661
5662 // Events
5663 if ( droppable ) {
5664 dragHandler = this.onDragEnterOrOver.bind( this );
5665 this.$element.on( {
5666 dragenter: dragHandler,
5667 dragover: dragHandler,
5668 dragleave: this.onDragLeave.bind( this ),
5669 drop: this.onDrop.bind( this )
5670 } );
5671 }
5672
5673 // Initialization
5674 if ( this.showDropTarget ) {
5675 this.selectButton.setIcon( 'upload' );
5676 this.$thumbnail = $( '<div>' ).addClass( 'oo-ui-selectFileWidget-thumbnail' );
5677 this.setPendingElement( this.$thumbnail );
5678 this.$element
5679 .addClass( 'oo-ui-selectFileWidget-dropTarget' )
5680 .on( {
5681 click: this.onDropTargetClick.bind( this )
5682 } )
5683 .append(
5684 this.$thumbnail,
5685 this.info.$element,
5686 this.selectButton.$element,
5687 $( '<span>' )
5688 .addClass( 'oo-ui-selectFileWidget-dropLabel' )
5689 .text( OO.ui.msg( 'ooui-selectfile-dragdrop-placeholder' ) )
5690 );
5691 this.fieldLayout.$element.remove();
5692 } else if ( config.buttonOnly ) {
5693 this.$element.append( this.selectButton.$element );
5694 this.fieldLayout.$element.remove();
5695 }
5696
5697 this.$input
5698 .on( 'click', function ( e ) {
5699 // Prevents dropTarget to get clicked which calls
5700 // a click on this input
5701 e.stopPropagation();
5702 } );
5703
5704 this.$element.addClass( 'oo-ui-selectFileWidget' );
5705
5706 this.updateUI();
5707 };
5708
5709 /* Setup */
5710
5711 OO.inheritClass( OO.ui.SelectFileWidget, OO.ui.SelectFileInputWidget );
5712 OO.mixinClass( OO.ui.SelectFileWidget, OO.ui.mixin.PendingElement );
5713
5714 /* Static Properties */
5715
5716 /**
5717 * Check if this widget is supported
5718 *
5719 * @static
5720 * @return {boolean}
5721 */
5722 OO.ui.SelectFileWidget.static.isSupported = function () {
5723 var $input;
5724 if ( OO.ui.SelectFileWidget.static.isSupportedCache === null ) {
5725 $input = $( '<input>' ).attr( 'type', 'file' );
5726 OO.ui.SelectFileWidget.static.isSupportedCache = $input[ 0 ].files !== undefined;
5727 }
5728 return OO.ui.SelectFileWidget.static.isSupportedCache;
5729 };
5730
5731 OO.ui.SelectFileWidget.static.isSupportedCache = null;
5732
5733 /* Events */
5734
5735 /**
5736 * @event change
5737 *
5738 * A change event is emitted when the on/off state of the toggle changes.
5739 *
5740 * @param {File|null} value New value
5741 */
5742
5743 /* Methods */
5744
5745 /**
5746 * Get the current value of the field
5747 *
5748 * For single file widgets returns a File or null.
5749 * For multiple file widgets returns a list of Files.
5750 *
5751 * @return {File|File[]|null}
5752 */
5753 OO.ui.SelectFileWidget.prototype.getValue = function () {
5754 return this.multiple ? this.currentFiles : this.currentFiles[ 0 ];
5755 };
5756
5757 /**
5758 * Set the current value of the field
5759 *
5760 * @param {File[]|null} files Files to select
5761 */
5762 OO.ui.SelectFileWidget.prototype.setValue = function ( files ) {
5763 if ( files && !this.multiple ) {
5764 files = files.slice( 0, 1 );
5765 }
5766
5767 function comparableFile( file ) {
5768 // Use extend to convert to plain objects so they can be compared.
5769 return $.extend( {}, file );
5770 }
5771
5772 if ( !OO.compare(
5773 files && files.map( comparableFile ),
5774 this.currentFiles && this.currentFiles.map( comparableFile )
5775 ) ) {
5776 this.currentFiles = files || [];
5777 this.emit( 'change', this.currentFiles );
5778 }
5779 };
5780
5781 /**
5782 * @inheritdoc
5783 */
5784 OO.ui.SelectFileWidget.prototype.getFilename = function () {
5785 return this.currentFiles.map( function ( file ) {
5786 return file.name;
5787 } ).join( ', ' );
5788 };
5789
5790 /**
5791 * Disable InputWidget#onEdit listener, onFileSelected is used instead.
5792 * @inheritdoc
5793 */
5794 OO.ui.SelectFileWidget.prototype.onEdit = function () {};
5795
5796 /**
5797 * @inheritdoc
5798 */
5799 OO.ui.SelectFileWidget.prototype.updateUI = function () {
5800 // Too early, or not supported
5801 if ( !this.selectButton || !this.constructor.static.isSupported() ) {
5802 return;
5803 }
5804
5805 // Parent method
5806 OO.ui.SelectFileWidget.super.prototype.updateUI.call( this );
5807
5808 if ( this.currentFiles.length ) {
5809 this.$element.removeClass( 'oo-ui-selectFileInputWidget-empty' );
5810
5811 if ( this.showDropTarget ) {
5812 this.pushPending();
5813 this.loadAndGetImageUrl( this.currentFiles[ 0 ] ).done( function ( url ) {
5814 this.$thumbnail.css( 'background-image', 'url( ' + url + ' )' );
5815 }.bind( this ) ).fail( function () {
5816 this.$thumbnail.append(
5817 new OO.ui.IconWidget( {
5818 icon: 'attachment',
5819 classes: [ 'oo-ui-selectFileWidget-noThumbnail-icon' ]
5820 } ).$element
5821 );
5822 }.bind( this ) ).always( function () {
5823 this.popPending();
5824 }.bind( this ) );
5825 this.$element.off( 'click' );
5826 }
5827 } else {
5828 if ( this.showDropTarget ) {
5829 this.$element.off( 'click' );
5830 this.$element.on( {
5831 click: this.onDropTargetClick.bind( this )
5832 } );
5833 this.$thumbnail
5834 .empty()
5835 .css( 'background-image', '' );
5836 }
5837 this.$element.addClass( 'oo-ui-selectFileInputWidget-empty' );
5838 }
5839 };
5840
5841 /**
5842 * If the selected file is an image, get its URL and load it.
5843 *
5844 * @param {File} file File
5845 * @return {jQuery.Promise} Promise resolves with the image URL after it has loaded
5846 */
5847 OO.ui.SelectFileWidget.prototype.loadAndGetImageUrl = function ( file ) {
5848 var deferred = $.Deferred(),
5849 reader = new FileReader();
5850
5851 if (
5852 ( OO.getProp( file, 'type' ) || '' ).indexOf( 'image/' ) === 0 &&
5853 file.size < this.thumbnailSizeLimit * 1024 * 1024
5854 ) {
5855 reader.onload = function ( event ) {
5856 var img = document.createElement( 'img' );
5857 img.addEventListener( 'load', function () {
5858 if (
5859 img.naturalWidth === 0 ||
5860 img.naturalHeight === 0 ||
5861 img.complete === false
5862 ) {
5863 deferred.reject();
5864 } else {
5865 deferred.resolve( event.target.result );
5866 }
5867 } );
5868 img.src = event.target.result;
5869 };
5870 reader.readAsDataURL( file );
5871 } else {
5872 deferred.reject();
5873 }
5874
5875 return deferred.promise();
5876 };
5877
5878 /**
5879 * @inheritdoc
5880 */
5881 OO.ui.SelectFileWidget.prototype.onFileSelected = function ( e ) {
5882 var files;
5883
5884 if ( this.inputClearing ) {
5885 return;
5886 }
5887
5888 files = this.filterFiles( e.target.files || [] );
5889
5890 // After a file is selected clear the native widget to avoid confusion
5891 this.inputClearing = true;
5892 this.$input[ 0 ].value = '';
5893 this.inputClearing = false;
5894
5895 this.setValue( files );
5896 };
5897
5898 /**
5899 * Handle drop target click events.
5900 *
5901 * @private
5902 * @param {jQuery.Event} e Key press event
5903 * @return {undefined|boolean} False to prevent default if event is handled
5904 */
5905 OO.ui.SelectFileWidget.prototype.onDropTargetClick = function () {
5906 if ( !this.isDisabled() && this.$input ) {
5907 this.$input.trigger( 'click' );
5908 return false;
5909 }
5910 };
5911
5912 /**
5913 * Handle drag enter and over events
5914 *
5915 * @private
5916 * @param {jQuery.Event} e Drag event
5917 * @return {undefined|boolean} False to prevent default if event is handled
5918 */
5919 OO.ui.SelectFileWidget.prototype.onDragEnterOrOver = function ( e ) {
5920 var itemsOrFiles,
5921 hasDroppableFile = false,
5922 dt = e.originalEvent.dataTransfer;
5923
5924 e.preventDefault();
5925 e.stopPropagation();
5926
5927 if ( this.isDisabled() ) {
5928 this.$element.removeClass( 'oo-ui-selectFileWidget-canDrop' );
5929 dt.dropEffect = 'none';
5930 return false;
5931 }
5932
5933 // DataTransferItem and File both have a type property, but in Chrome files
5934 // have no information at this point.
5935 itemsOrFiles = dt.items || dt.files;
5936 if ( itemsOrFiles && itemsOrFiles.length ) {
5937 if ( this.filterFiles( itemsOrFiles ).length ) {
5938 hasDroppableFile = true;
5939 }
5940 // dt.types is Array-like, but not an Array
5941 } else if ( Array.prototype.indexOf.call( OO.getProp( dt, 'types' ) || [], 'Files' ) !== -1 ) {
5942 // File information is not available at this point for security so just assume
5943 // it is acceptable for now.
5944 // https://bugzilla.mozilla.org/show_bug.cgi?id=640534
5945 hasDroppableFile = true;
5946 }
5947
5948 this.$element.toggleClass( 'oo-ui-selectFileWidget-canDrop', hasDroppableFile );
5949 if ( !hasDroppableFile ) {
5950 dt.dropEffect = 'none';
5951 }
5952
5953 return false;
5954 };
5955
5956 /**
5957 * Handle drag leave events
5958 *
5959 * @private
5960 * @param {jQuery.Event} e Drag event
5961 */
5962 OO.ui.SelectFileWidget.prototype.onDragLeave = function () {
5963 this.$element.removeClass( 'oo-ui-selectFileWidget-canDrop' );
5964 };
5965
5966 /**
5967 * Handle drop events
5968 *
5969 * @private
5970 * @param {jQuery.Event} e Drop event
5971 * @return {undefined|boolean} False to prevent default if event is handled
5972 */
5973 OO.ui.SelectFileWidget.prototype.onDrop = function ( e ) {
5974 var files,
5975 dt = e.originalEvent.dataTransfer;
5976
5977 e.preventDefault();
5978 e.stopPropagation();
5979 this.$element.removeClass( 'oo-ui-selectFileWidget-canDrop' );
5980
5981 if ( this.isDisabled() ) {
5982 return false;
5983 }
5984
5985 files = this.filterFiles( dt.files || [] );
5986 this.setValue( files );
5987
5988 return false;
5989 };
5990
5991 /**
5992 * @inheritdoc
5993 */
5994 OO.ui.SelectFileWidget.prototype.setDisabled = function ( disabled ) {
5995 disabled = disabled || !this.constructor.static.isSupported();
5996
5997 // Parent method
5998 OO.ui.SelectFileWidget.parent.prototype.setDisabled.call( this, disabled );
5999 };
6000
6001 /**
6002 * SearchWidgets combine a {@link OO.ui.TextInputWidget text input field},
6003 * where users can type a search query, and a menu of search results,
6004 * which is displayed beneath the query field.
6005 * Unlike {@link OO.ui.mixin.LookupElement lookup menus}, search result menus are always visible
6006 * to the user. Users can choose an item from the menu or type a query into the text field to
6007 * search for a matching result item.
6008 * In general, search widgets are used inside a separate {@link OO.ui.Dialog dialog} window.
6009 *
6010 * Each time the query is changed, the search result menu is cleared and repopulated. Please see
6011 * the [OOUI demos][1] for an example.
6012 *
6013 * [1]: https://doc.wikimedia.org/oojs-ui/master/demos/#SearchInputWidget-type-search
6014 *
6015 * @class
6016 * @extends OO.ui.Widget
6017 *
6018 * @constructor
6019 * @param {Object} [config] Configuration options
6020 * @cfg {string|jQuery} [placeholder] Placeholder text for query input
6021 * @cfg {string} [value] Initial query value
6022 */
6023 OO.ui.SearchWidget = function OoUiSearchWidget( config ) {
6024 // Configuration initialization
6025 config = config || {};
6026
6027 // Parent constructor
6028 OO.ui.SearchWidget.parent.call( this, config );
6029
6030 // Properties
6031 this.query = new OO.ui.TextInputWidget( {
6032 icon: 'search',
6033 placeholder: config.placeholder,
6034 value: config.value
6035 } );
6036 this.results = new OO.ui.SelectWidget();
6037 this.$query = $( '<div>' );
6038 this.$results = $( '<div>' );
6039
6040 // Events
6041 this.query.connect( this, {
6042 change: 'onQueryChange',
6043 enter: 'onQueryEnter'
6044 } );
6045 this.query.$input.on( 'keydown', this.onQueryKeydown.bind( this ) );
6046
6047 // Initialization
6048 this.$query
6049 .addClass( 'oo-ui-searchWidget-query' )
6050 .append( this.query.$element );
6051 this.$results
6052 .addClass( 'oo-ui-searchWidget-results' )
6053 .append( this.results.$element );
6054 this.$element
6055 .addClass( 'oo-ui-searchWidget' )
6056 .append( this.$results, this.$query );
6057 };
6058
6059 /* Setup */
6060
6061 OO.inheritClass( OO.ui.SearchWidget, OO.ui.Widget );
6062
6063 /* Methods */
6064
6065 /**
6066 * Handle query key down events.
6067 *
6068 * @private
6069 * @param {jQuery.Event} e Key down event
6070 */
6071 OO.ui.SearchWidget.prototype.onQueryKeydown = function ( e ) {
6072 var highlightedItem, nextItem,
6073 dir = e.which === OO.ui.Keys.DOWN ? 1 : ( e.which === OO.ui.Keys.UP ? -1 : 0 );
6074
6075 if ( dir ) {
6076 highlightedItem = this.results.findHighlightedItem();
6077 if ( !highlightedItem ) {
6078 highlightedItem = this.results.findSelectedItem();
6079 }
6080 nextItem = this.results.findRelativeSelectableItem( highlightedItem, dir );
6081 this.results.highlightItem( nextItem );
6082 nextItem.scrollElementIntoView();
6083 }
6084 };
6085
6086 /**
6087 * Handle select widget select events.
6088 *
6089 * Clears existing results. Subclasses should repopulate items according to new query.
6090 *
6091 * @private
6092 * @param {string} value New value
6093 */
6094 OO.ui.SearchWidget.prototype.onQueryChange = function () {
6095 // Reset
6096 this.results.clearItems();
6097 };
6098
6099 /**
6100 * Handle select widget enter key events.
6101 *
6102 * Chooses highlighted item.
6103 *
6104 * @private
6105 * @param {string} value New value
6106 */
6107 OO.ui.SearchWidget.prototype.onQueryEnter = function () {
6108 var highlightedItem = this.results.findHighlightedItem();
6109 if ( highlightedItem ) {
6110 this.results.chooseItem( highlightedItem );
6111 }
6112 };
6113
6114 /**
6115 * Get the query input.
6116 *
6117 * @return {OO.ui.TextInputWidget} Query input
6118 */
6119 OO.ui.SearchWidget.prototype.getQuery = function () {
6120 return this.query;
6121 };
6122
6123 /**
6124 * Get the search results menu.
6125 *
6126 * @return {OO.ui.SelectWidget} Menu of search results
6127 */
6128 OO.ui.SearchWidget.prototype.getResults = function () {
6129 return this.results;
6130 };
6131
6132 }( OO ) );
6133
6134 //# sourceMappingURL=oojs-ui-widgets.js.map.json