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