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