Merge "Add support for PHP7 random_bytes in favor of mcrypt_create_iv"
[lhc/web/wiklou.git] / resources / lib / oojs-ui / oojs-ui-widgets.js
1 /*!
2 * OOjs UI v0.19.5
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-03-07T22:57:01Z
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, config );
3647 $tabFocus = $( '<span>' );
3648 OO.ui.mixin.TabIndexedElement.call( this, $.extend( {}, config, { $tabIndexed: $tabFocus } ) );
3649 } else {
3650 this.popup = null;
3651 $tabFocus = null;
3652 OO.ui.mixin.TabIndexedElement.call( this, $.extend( {}, config, { $tabIndexed: this.$input } ) );
3653 }
3654 OO.ui.mixin.IndicatorElement.call( this, config );
3655 OO.ui.mixin.IconElement.call( this, config );
3656
3657 // Properties
3658 this.$content = $( '<div>' );
3659 this.allowArbitrary = config.allowArbitrary;
3660 this.allowDuplicates = config.allowDuplicates;
3661 this.$overlay = config.$overlay;
3662 this.menu = new OO.ui.FloatingMenuSelectWidget( $.extend(
3663 {
3664 widget: this,
3665 $input: this.$input,
3666 $container: this.$element,
3667 filterFromInput: true,
3668 disabled: this.isDisabled()
3669 },
3670 config.menu
3671 ) );
3672
3673 // Events
3674 if ( this.popup ) {
3675 $tabFocus.on( {
3676 focus: this.focus.bind( this )
3677 } );
3678 this.popup.$element.on( 'focusout', this.onPopupFocusOut.bind( this ) );
3679 if ( this.popup.$autoCloseIgnore ) {
3680 this.popup.$autoCloseIgnore.on( 'focusout', this.onPopupFocusOut.bind( this ) );
3681 }
3682 this.popup.connect( this, {
3683 toggle: function ( visible ) {
3684 $tabFocus.toggle( !visible );
3685 }
3686 } );
3687 } else {
3688 this.$input.on( {
3689 focus: this.onInputFocus.bind( this ),
3690 blur: this.onInputBlur.bind( this ),
3691 'propertychange change click mouseup keydown keyup input cut paste select focus':
3692 OO.ui.debounce( this.updateInputSize.bind( this ) ),
3693 keydown: this.onKeyDown.bind( this ),
3694 keypress: this.onKeyPress.bind( this )
3695 } );
3696 }
3697 this.menu.connect( this, {
3698 choose: 'onMenuChoose',
3699 toggle: 'onMenuToggle',
3700 add: 'onMenuItemsChange',
3701 remove: 'onMenuItemsChange'
3702 } );
3703 this.$handle.on( {
3704 mousedown: this.onMouseDown.bind( this )
3705 } );
3706
3707 // Initialization
3708 if ( this.$input ) {
3709 this.$input.prop( 'disabled', this.isDisabled() );
3710 this.$input.attr( {
3711 role: 'combobox',
3712 'aria-autocomplete': 'list'
3713 } );
3714 }
3715 if ( config.data ) {
3716 this.setItemsFromData( config.data );
3717 }
3718 this.$content.addClass( 'oo-ui-capsuleMultiselectWidget-content' )
3719 .append( this.$group );
3720 this.$group.addClass( 'oo-ui-capsuleMultiselectWidget-group' );
3721 this.$handle.addClass( 'oo-ui-capsuleMultiselectWidget-handle' )
3722 .append( this.$indicator, this.$icon, this.$content );
3723 this.$element.addClass( 'oo-ui-capsuleMultiselectWidget' )
3724 .append( this.$handle );
3725 if ( this.popup ) {
3726 this.popup.$element.addClass( 'oo-ui-capsuleMultiselectWidget-popup' );
3727 this.$content.append( $tabFocus );
3728 this.$overlay.append( this.popup.$element );
3729 } else {
3730 this.$content.append( this.$input );
3731 this.$overlay.append( this.menu.$element );
3732 }
3733 if ( $tabFocus ) {
3734 $tabFocus.addClass( 'oo-ui-capsuleMultiselectWidget-focusTrap' );
3735 }
3736
3737 // Input size needs to be calculated after everything else is rendered
3738 setTimeout( function () {
3739 if ( this.$input ) {
3740 this.updateInputSize();
3741 }
3742 }.bind( this ) );
3743
3744 this.onMenuItemsChange();
3745 };
3746
3747 /* Setup */
3748
3749 OO.inheritClass( OO.ui.CapsuleMultiselectWidget, OO.ui.Widget );
3750 OO.mixinClass( OO.ui.CapsuleMultiselectWidget, OO.ui.mixin.GroupElement );
3751 OO.mixinClass( OO.ui.CapsuleMultiselectWidget, OO.ui.mixin.PopupElement );
3752 OO.mixinClass( OO.ui.CapsuleMultiselectWidget, OO.ui.mixin.TabIndexedElement );
3753 OO.mixinClass( OO.ui.CapsuleMultiselectWidget, OO.ui.mixin.IndicatorElement );
3754 OO.mixinClass( OO.ui.CapsuleMultiselectWidget, OO.ui.mixin.IconElement );
3755
3756 /* Static Properties */
3757
3758 OO.ui.CapsuleMultiselectWidget.static.supportsSimpleLabel = true;
3759
3760 /* Events */
3761
3762 /**
3763 * @event change
3764 *
3765 * A change event is emitted when the set of selected items changes.
3766 *
3767 * @param {Mixed[]} datas Data of the now-selected items
3768 */
3769
3770 /**
3771 * @event resize
3772 *
3773 * A resize event is emitted when the widget's dimensions change to accomodate newly added items or
3774 * current user input.
3775 */
3776
3777 /* Methods */
3778
3779 /**
3780 * Construct a OO.ui.CapsuleItemWidget (or a subclass thereof) from given label and data.
3781 * May return `null` if the given label and data are not valid.
3782 *
3783 * @protected
3784 * @param {Mixed} data Custom data of any type.
3785 * @param {string} label The label text.
3786 * @return {OO.ui.CapsuleItemWidget|null}
3787 */
3788 OO.ui.CapsuleMultiselectWidget.prototype.createItemWidget = function ( data, label ) {
3789 if ( label === '' ) {
3790 return null;
3791 }
3792 return new OO.ui.CapsuleItemWidget( { data: data, label: label } );
3793 };
3794
3795 /**
3796 * Get the widget's input's id, or generate one, if it has an input.
3797 *
3798 * @return {string}
3799 */
3800 OO.ui.CapsuleMultiselectWidget.prototype.getInputId = function () {
3801 var id;
3802 if ( !this.$input ) {
3803 return false;
3804 }
3805
3806 id = this.$input.attr( 'id' );
3807 if ( id === undefined ) {
3808 id = OO.ui.generateElementId();
3809 this.$input.attr( 'id', id );
3810 }
3811
3812 return id;
3813 };
3814
3815 /**
3816 * Get the data of the items in the capsule
3817 *
3818 * @return {Mixed[]}
3819 */
3820 OO.ui.CapsuleMultiselectWidget.prototype.getItemsData = function () {
3821 return this.getItems().map( function ( item ) {
3822 return item.data;
3823 } );
3824 };
3825
3826 /**
3827 * Set the items in the capsule by providing data
3828 *
3829 * @chainable
3830 * @param {Mixed[]} datas
3831 * @return {OO.ui.CapsuleMultiselectWidget}
3832 */
3833 OO.ui.CapsuleMultiselectWidget.prototype.setItemsFromData = function ( datas ) {
3834 var widget = this,
3835 menu = this.menu,
3836 items = this.getItems();
3837
3838 $.each( datas, function ( i, data ) {
3839 var j, label,
3840 item = menu.getItemFromData( data );
3841
3842 if ( item ) {
3843 label = item.label;
3844 } else if ( widget.allowArbitrary ) {
3845 label = String( data );
3846 } else {
3847 return;
3848 }
3849
3850 item = null;
3851 for ( j = 0; j < items.length; j++ ) {
3852 if ( items[ j ].data === data && items[ j ].label === label ) {
3853 item = items[ j ];
3854 items.splice( j, 1 );
3855 break;
3856 }
3857 }
3858 if ( !item ) {
3859 item = widget.createItemWidget( data, label );
3860 }
3861 if ( item ) {
3862 widget.addItems( [ item ], i );
3863 }
3864 } );
3865
3866 if ( items.length ) {
3867 widget.removeItems( items );
3868 }
3869
3870 return this;
3871 };
3872
3873 /**
3874 * Add items to the capsule by providing their data
3875 *
3876 * @chainable
3877 * @param {Mixed[]} datas
3878 * @return {OO.ui.CapsuleMultiselectWidget}
3879 */
3880 OO.ui.CapsuleMultiselectWidget.prototype.addItemsFromData = function ( datas ) {
3881 var widget = this,
3882 menu = this.menu,
3883 items = [];
3884
3885 $.each( datas, function ( i, data ) {
3886 var item;
3887
3888 if ( !widget.getItemFromData( data ) || widget.allowDuplicates ) {
3889 item = menu.getItemFromData( data );
3890 if ( item ) {
3891 item = widget.createItemWidget( data, item.label );
3892 } else if ( widget.allowArbitrary ) {
3893 item = widget.createItemWidget( data, String( data ) );
3894 }
3895 if ( item ) {
3896 items.push( item );
3897 }
3898 }
3899 } );
3900
3901 if ( items.length ) {
3902 this.addItems( items );
3903 }
3904
3905 return this;
3906 };
3907
3908 /**
3909 * Add items to the capsule by providing a label
3910 *
3911 * @param {string} label
3912 * @return {boolean} Whether the item was added or not
3913 */
3914 OO.ui.CapsuleMultiselectWidget.prototype.addItemFromLabel = function ( label ) {
3915 var item, items;
3916 item = this.menu.getItemFromLabel( label, true );
3917 if ( item ) {
3918 this.addItemsFromData( [ item.data ] );
3919 return true;
3920 } else if ( this.allowArbitrary ) {
3921 items = this.getItems();
3922 this.addItemsFromData( [ label ] );
3923 return !OO.compare( this.getItems(), items );
3924 }
3925 return false;
3926 };
3927
3928 /**
3929 * Remove items by data
3930 *
3931 * @chainable
3932 * @param {Mixed[]} datas
3933 * @return {OO.ui.CapsuleMultiselectWidget}
3934 */
3935 OO.ui.CapsuleMultiselectWidget.prototype.removeItemsFromData = function ( datas ) {
3936 var widget = this,
3937 items = [];
3938
3939 $.each( datas, function ( i, data ) {
3940 var item = widget.getItemFromData( data );
3941 if ( item ) {
3942 items.push( item );
3943 }
3944 } );
3945
3946 if ( items.length ) {
3947 this.removeItems( items );
3948 }
3949
3950 return this;
3951 };
3952
3953 /**
3954 * @inheritdoc
3955 */
3956 OO.ui.CapsuleMultiselectWidget.prototype.addItems = function ( items ) {
3957 var same, i, l,
3958 oldItems = this.items.slice();
3959
3960 OO.ui.mixin.GroupElement.prototype.addItems.call( this, items );
3961
3962 if ( this.items.length !== oldItems.length ) {
3963 same = false;
3964 } else {
3965 same = true;
3966 for ( i = 0, l = oldItems.length; same && i < l; i++ ) {
3967 same = same && this.items[ i ] === oldItems[ i ];
3968 }
3969 }
3970 if ( !same ) {
3971 this.emit( 'change', this.getItemsData() );
3972 this.updateIfHeightChanged();
3973 }
3974
3975 return this;
3976 };
3977
3978 /**
3979 * Removes the item from the list and copies its label to `this.$input`.
3980 *
3981 * @param {Object} item
3982 */
3983 OO.ui.CapsuleMultiselectWidget.prototype.editItem = function ( item ) {
3984 this.addItemFromLabel( this.$input.val() );
3985 this.clearInput();
3986 this.$input.val( item.label );
3987 this.updateInputSize();
3988 this.focus();
3989 this.menu.updateItemVisibility(); // Hack, we shouldn't be calling this method directly
3990 this.removeItems( [ item ] );
3991 };
3992
3993 /**
3994 * @inheritdoc
3995 */
3996 OO.ui.CapsuleMultiselectWidget.prototype.removeItems = function ( items ) {
3997 var same, i, l,
3998 oldItems = this.items.slice();
3999
4000 OO.ui.mixin.GroupElement.prototype.removeItems.call( this, items );
4001
4002 if ( this.items.length !== oldItems.length ) {
4003 same = false;
4004 } else {
4005 same = true;
4006 for ( i = 0, l = oldItems.length; same && i < l; i++ ) {
4007 same = same && this.items[ i ] === oldItems[ i ];
4008 }
4009 }
4010 if ( !same ) {
4011 this.emit( 'change', this.getItemsData() );
4012 this.updateIfHeightChanged();
4013 }
4014
4015 return this;
4016 };
4017
4018 /**
4019 * @inheritdoc
4020 */
4021 OO.ui.CapsuleMultiselectWidget.prototype.clearItems = function () {
4022 if ( this.items.length ) {
4023 OO.ui.mixin.GroupElement.prototype.clearItems.call( this );
4024 this.emit( 'change', this.getItemsData() );
4025 this.updateIfHeightChanged();
4026 }
4027 return this;
4028 };
4029
4030 /**
4031 * Given an item, returns the item after it. If its the last item,
4032 * returns `this.$input`. If no item is passed, returns the very first
4033 * item.
4034 *
4035 * @param {OO.ui.CapsuleItemWidget} [item]
4036 * @return {OO.ui.CapsuleItemWidget|jQuery|boolean}
4037 */
4038 OO.ui.CapsuleMultiselectWidget.prototype.getNextItem = function ( item ) {
4039 var itemIndex;
4040
4041 if ( item === undefined ) {
4042 return this.items[ 0 ];
4043 }
4044
4045 itemIndex = this.items.indexOf( item );
4046 if ( itemIndex < 0 ) { // Item not in list
4047 return false;
4048 } else if ( itemIndex === this.items.length - 1 ) { // Last item
4049 return this.$input;
4050 } else {
4051 return this.items[ itemIndex + 1 ];
4052 }
4053 };
4054
4055 /**
4056 * Given an item, returns the item before it. If its the first item,
4057 * returns `this.$input`. If no item is passed, returns the very last
4058 * item.
4059 *
4060 * @param {OO.ui.CapsuleItemWidget} [item]
4061 * @return {OO.ui.CapsuleItemWidget|jQuery|boolean}
4062 */
4063 OO.ui.CapsuleMultiselectWidget.prototype.getPreviousItem = function ( item ) {
4064 var itemIndex;
4065
4066 if ( item === undefined ) {
4067 return this.items[ this.items.length - 1 ];
4068 }
4069
4070 itemIndex = this.items.indexOf( item );
4071 if ( itemIndex < 0 ) { // Item not in list
4072 return false;
4073 } else if ( itemIndex === 0 ) { // First item
4074 return this.$input;
4075 } else {
4076 return this.items[ itemIndex - 1 ];
4077 }
4078 };
4079
4080 /**
4081 * Get the capsule widget's menu.
4082 *
4083 * @return {OO.ui.MenuSelectWidget} Menu widget
4084 */
4085 OO.ui.CapsuleMultiselectWidget.prototype.getMenu = function () {
4086 return this.menu;
4087 };
4088
4089 /**
4090 * Handle focus events
4091 *
4092 * @private
4093 * @param {jQuery.Event} event
4094 */
4095 OO.ui.CapsuleMultiselectWidget.prototype.onInputFocus = function () {
4096 if ( !this.isDisabled() ) {
4097 this.menu.toggle( true );
4098 }
4099 };
4100
4101 /**
4102 * Handle blur events
4103 *
4104 * @private
4105 * @param {jQuery.Event} event
4106 */
4107 OO.ui.CapsuleMultiselectWidget.prototype.onInputBlur = function () {
4108 this.addItemFromLabel( this.$input.val() );
4109 this.clearInput();
4110 };
4111
4112 /**
4113 * Handles popup focus out events.
4114 *
4115 * @private
4116 * @param {jQuery.Event} e Focus out event
4117 */
4118 OO.ui.CapsuleMultiselectWidget.prototype.onPopupFocusOut = function () {
4119 var widget = this.popup;
4120
4121 setTimeout( function () {
4122 if (
4123 widget.isVisible() &&
4124 !OO.ui.contains( widget.$element.add( widget.$autoCloseIgnore ).get(), document.activeElement, true )
4125 ) {
4126 widget.toggle( false );
4127 }
4128 } );
4129 };
4130
4131 /**
4132 * Handle mouse down events.
4133 *
4134 * @private
4135 * @param {jQuery.Event} e Mouse down event
4136 */
4137 OO.ui.CapsuleMultiselectWidget.prototype.onMouseDown = function ( e ) {
4138 if ( e.which === OO.ui.MouseButtons.LEFT ) {
4139 this.focus();
4140 return false;
4141 } else {
4142 this.updateInputSize();
4143 }
4144 };
4145
4146 /**
4147 * Handle key press events.
4148 *
4149 * @private
4150 * @param {jQuery.Event} e Key press event
4151 */
4152 OO.ui.CapsuleMultiselectWidget.prototype.onKeyPress = function ( e ) {
4153 if ( !this.isDisabled() ) {
4154 if ( e.which === OO.ui.Keys.ESCAPE ) {
4155 this.clearInput();
4156 return false;
4157 }
4158
4159 if ( !this.popup ) {
4160 this.menu.toggle( true );
4161 if ( e.which === OO.ui.Keys.ENTER ) {
4162 if ( this.addItemFromLabel( this.$input.val() ) ) {
4163 this.clearInput();
4164 }
4165 return false;
4166 }
4167
4168 // Make sure the input gets resized.
4169 setTimeout( this.updateInputSize.bind( this ), 0 );
4170 }
4171 }
4172 };
4173
4174 /**
4175 * Handle key down events.
4176 *
4177 * @private
4178 * @param {jQuery.Event} e Key down event
4179 */
4180 OO.ui.CapsuleMultiselectWidget.prototype.onKeyDown = function ( e ) {
4181 if (
4182 !this.isDisabled() &&
4183 this.$input.val() === '' &&
4184 this.items.length
4185 ) {
4186 // 'keypress' event is not triggered for Backspace
4187 if ( e.keyCode === OO.ui.Keys.BACKSPACE ) {
4188 if ( e.metaKey || e.ctrlKey ) {
4189 this.removeItems( this.items.slice( -1 ) );
4190 } else {
4191 this.editItem( this.items[ this.items.length - 1 ] );
4192 }
4193 return false;
4194 } else if ( e.keyCode === OO.ui.Keys.LEFT ) {
4195 this.getPreviousItem().focus();
4196 } else if ( e.keyCode === OO.ui.Keys.RIGHT ) {
4197 this.getNextItem().focus();
4198 }
4199 }
4200 };
4201
4202 /**
4203 * Update the dimensions of the text input field to encompass all available area.
4204 *
4205 * @private
4206 * @param {jQuery.Event} e Event of some sort
4207 */
4208 OO.ui.CapsuleMultiselectWidget.prototype.updateInputSize = function () {
4209 var $lastItem, direction, contentWidth, currentWidth, bestWidth;
4210 if ( this.$input && !this.isDisabled() ) {
4211 this.$input.css( 'width', '1em' );
4212 $lastItem = this.$group.children().last();
4213 direction = OO.ui.Element.static.getDir( this.$handle );
4214
4215 // Get the width of the input with the placeholder text as
4216 // the value and save it so that we don't keep recalculating
4217 if (
4218 this.contentWidthWithPlaceholder === undefined &&
4219 this.$input.val() === '' &&
4220 this.$input.attr( 'placeholder' ) !== undefined
4221 ) {
4222 this.$input.val( this.$input.attr( 'placeholder' ) );
4223 this.contentWidthWithPlaceholder = this.$input[ 0 ].scrollWidth;
4224 this.$input.val( '' );
4225
4226 }
4227
4228 // Always keep the input wide enough for the placeholder text
4229 contentWidth = Math.max(
4230 this.$input[ 0 ].scrollWidth,
4231 // undefined arguments in Math.max lead to NaN
4232 ( this.contentWidthWithPlaceholder === undefined ) ?
4233 0 : this.contentWidthWithPlaceholder
4234 );
4235 currentWidth = this.$input.width();
4236
4237 if ( contentWidth < currentWidth ) {
4238 // All is fine, don't perform expensive calculations
4239 return;
4240 }
4241
4242 if ( $lastItem.length === 0 ) {
4243 bestWidth = this.$content.innerWidth();
4244 } else {
4245 bestWidth = direction === 'ltr' ?
4246 this.$content.innerWidth() - $lastItem.position().left - $lastItem.outerWidth() :
4247 $lastItem.position().left;
4248 }
4249
4250 // Some safety margin for sanity, because I *really* don't feel like finding out where the few
4251 // pixels this is off by are coming from.
4252 bestWidth -= 10;
4253 if ( contentWidth > bestWidth ) {
4254 // This will result in the input getting shifted to the next line
4255 bestWidth = this.$content.innerWidth() - 10;
4256 }
4257 this.$input.width( Math.floor( bestWidth ) );
4258 this.updateIfHeightChanged();
4259 }
4260 };
4261
4262 /**
4263 * Determine if widget height changed, and if so, update menu position and emit 'resize' event.
4264 *
4265 * @private
4266 */
4267 OO.ui.CapsuleMultiselectWidget.prototype.updateIfHeightChanged = function () {
4268 var height = this.$element.height();
4269 if ( height !== this.height ) {
4270 this.height = height;
4271 this.menu.position();
4272 if ( this.popup ) {
4273 this.popup.updateDimensions();
4274 }
4275 this.emit( 'resize' );
4276 }
4277 };
4278
4279 /**
4280 * Handle menu choose events.
4281 *
4282 * @private
4283 * @param {OO.ui.OptionWidget} item Chosen item
4284 */
4285 OO.ui.CapsuleMultiselectWidget.prototype.onMenuChoose = function ( item ) {
4286 if ( item && item.isVisible() ) {
4287 this.addItemsFromData( [ item.getData() ] );
4288 this.clearInput();
4289 }
4290 };
4291
4292 /**
4293 * Handle menu toggle events.
4294 *
4295 * @private
4296 * @param {boolean} isVisible Menu toggle event
4297 */
4298 OO.ui.CapsuleMultiselectWidget.prototype.onMenuToggle = function ( isVisible ) {
4299 this.$element.toggleClass( 'oo-ui-capsuleMultiselectWidget-open', isVisible );
4300 };
4301
4302 /**
4303 * Handle menu item change events.
4304 *
4305 * @private
4306 */
4307 OO.ui.CapsuleMultiselectWidget.prototype.onMenuItemsChange = function () {
4308 this.setItemsFromData( this.getItemsData() );
4309 this.$element.toggleClass( 'oo-ui-capsuleMultiselectWidget-empty', this.menu.isEmpty() );
4310 };
4311
4312 /**
4313 * Clear the input field
4314 *
4315 * @private
4316 */
4317 OO.ui.CapsuleMultiselectWidget.prototype.clearInput = function () {
4318 if ( this.$input ) {
4319 this.$input.val( '' );
4320 this.updateInputSize();
4321 }
4322 if ( this.popup ) {
4323 this.popup.toggle( false );
4324 }
4325 this.menu.toggle( false );
4326 this.menu.selectItem();
4327 this.menu.highlightItem();
4328 };
4329
4330 /**
4331 * @inheritdoc
4332 */
4333 OO.ui.CapsuleMultiselectWidget.prototype.setDisabled = function ( disabled ) {
4334 var i, len;
4335
4336 // Parent method
4337 OO.ui.CapsuleMultiselectWidget.parent.prototype.setDisabled.call( this, disabled );
4338
4339 if ( this.$input ) {
4340 this.$input.prop( 'disabled', this.isDisabled() );
4341 }
4342 if ( this.menu ) {
4343 this.menu.setDisabled( this.isDisabled() );
4344 }
4345 if ( this.popup ) {
4346 this.popup.setDisabled( this.isDisabled() );
4347 }
4348
4349 if ( this.items ) {
4350 for ( i = 0, len = this.items.length; i < len; i++ ) {
4351 this.items[ i ].updateDisabled();
4352 }
4353 }
4354
4355 return this;
4356 };
4357
4358 /**
4359 * Focus the widget
4360 *
4361 * @chainable
4362 * @return {OO.ui.CapsuleMultiselectWidget}
4363 */
4364 OO.ui.CapsuleMultiselectWidget.prototype.focus = function () {
4365 if ( !this.isDisabled() ) {
4366 if ( this.popup ) {
4367 this.popup.setSize( this.$handle.outerWidth() );
4368 this.popup.toggle( true );
4369 OO.ui.findFocusable( this.popup.$element ).focus();
4370 } else {
4371 this.updateInputSize();
4372 this.menu.toggle( true );
4373 this.$input.focus();
4374 }
4375 }
4376 return this;
4377 };
4378
4379 /**
4380 * The old name for the CapsuleMultiselectWidget widget, provided for backwards-compatibility.
4381 *
4382 * @class
4383 * @extends OO.ui.CapsuleMultiselectWidget
4384 *
4385 * @constructor
4386 * @deprecated since 0.17.3; use OO.ui.CapsuleMultiselectWidget instead
4387 */
4388 OO.ui.CapsuleMultiSelectWidget = function OoUiCapsuleMultiSelectWidget() {
4389 OO.ui.warnDeprecation( 'CapsuleMultiSelectWidget is deprecated. Use the CapsuleMultiselectWidget instead.' );
4390 // Parent constructor
4391 OO.ui.CapsuleMultiSelectWidget.parent.apply( this, arguments );
4392 };
4393
4394 OO.inheritClass( OO.ui.CapsuleMultiSelectWidget, OO.ui.CapsuleMultiselectWidget );
4395
4396 /**
4397 * SelectFileWidgets allow for selecting files, using the HTML5 File API. These
4398 * widgets can be configured with {@link OO.ui.mixin.IconElement icons} and {@link
4399 * OO.ui.mixin.IndicatorElement indicators}.
4400 * Please see the [OOjs UI documentation on MediaWiki] [1] for more information and examples.
4401 *
4402 * @example
4403 * // Example of a file select widget
4404 * var selectFile = new OO.ui.SelectFileWidget();
4405 * $( 'body' ).append( selectFile.$element );
4406 *
4407 * [1]: https://www.mediawiki.org/wiki/OOjs_UI/Widgets
4408 *
4409 * @class
4410 * @extends OO.ui.Widget
4411 * @mixins OO.ui.mixin.IconElement
4412 * @mixins OO.ui.mixin.IndicatorElement
4413 * @mixins OO.ui.mixin.PendingElement
4414 * @mixins OO.ui.mixin.LabelElement
4415 *
4416 * @constructor
4417 * @param {Object} [config] Configuration options
4418 * @cfg {string[]|null} [accept=null] MIME types to accept. null accepts all types.
4419 * @cfg {string} [placeholder] Text to display when no file is selected.
4420 * @cfg {string} [notsupported] Text to display when file support is missing in the browser.
4421 * @cfg {boolean} [droppable=true] Whether to accept files by drag and drop.
4422 * @cfg {boolean} [showDropTarget=false] Whether to show a drop target. Requires droppable to be true.
4423 * @cfg {number} [thumbnailSizeLimit=20] File size limit in MiB above which to not try and show a
4424 * preview (for performance)
4425 */
4426 OO.ui.SelectFileWidget = function OoUiSelectFileWidget( config ) {
4427 var dragHandler;
4428
4429 // Configuration initialization
4430 config = $.extend( {
4431 accept: null,
4432 placeholder: OO.ui.msg( 'ooui-selectfile-placeholder' ),
4433 notsupported: OO.ui.msg( 'ooui-selectfile-not-supported' ),
4434 droppable: true,
4435 showDropTarget: false,
4436 thumbnailSizeLimit: 20
4437 }, config );
4438
4439 // Parent constructor
4440 OO.ui.SelectFileWidget.parent.call( this, config );
4441
4442 // Mixin constructors
4443 OO.ui.mixin.IconElement.call( this, config );
4444 OO.ui.mixin.IndicatorElement.call( this, config );
4445 OO.ui.mixin.PendingElement.call( this, $.extend( {}, config, { $pending: this.$info } ) );
4446 OO.ui.mixin.LabelElement.call( this, config );
4447
4448 // Properties
4449 this.$info = $( '<span>' );
4450 this.showDropTarget = config.showDropTarget;
4451 this.thumbnailSizeLimit = config.thumbnailSizeLimit;
4452 this.isSupported = this.constructor.static.isSupported();
4453 this.currentFile = null;
4454 if ( Array.isArray( config.accept ) ) {
4455 this.accept = config.accept;
4456 } else {
4457 this.accept = null;
4458 }
4459 this.placeholder = config.placeholder;
4460 this.notsupported = config.notsupported;
4461 this.onFileSelectedHandler = this.onFileSelected.bind( this );
4462
4463 this.selectButton = new OO.ui.ButtonWidget( {
4464 classes: [ 'oo-ui-selectFileWidget-selectButton' ],
4465 label: OO.ui.msg( 'ooui-selectfile-button-select' ),
4466 disabled: this.disabled || !this.isSupported
4467 } );
4468
4469 this.clearButton = new OO.ui.ButtonWidget( {
4470 classes: [ 'oo-ui-selectFileWidget-clearButton' ],
4471 framed: false,
4472 icon: 'close',
4473 disabled: this.disabled
4474 } );
4475
4476 // Events
4477 this.selectButton.$button.on( {
4478 keypress: this.onKeyPress.bind( this )
4479 } );
4480 this.clearButton.connect( this, {
4481 click: 'onClearClick'
4482 } );
4483 if ( config.droppable ) {
4484 dragHandler = this.onDragEnterOrOver.bind( this );
4485 this.$element.on( {
4486 dragenter: dragHandler,
4487 dragover: dragHandler,
4488 dragleave: this.onDragLeave.bind( this ),
4489 drop: this.onDrop.bind( this )
4490 } );
4491 }
4492
4493 // Initialization
4494 this.addInput();
4495 this.$label.addClass( 'oo-ui-selectFileWidget-label' );
4496 this.$info
4497 .addClass( 'oo-ui-selectFileWidget-info' )
4498 .append( this.$icon, this.$label, this.clearButton.$element, this.$indicator );
4499
4500 if ( config.droppable && config.showDropTarget ) {
4501 this.selectButton.setIcon( 'upload' );
4502 this.$thumbnail = $( '<div>' ).addClass( 'oo-ui-selectFileWidget-thumbnail' );
4503 this.setPendingElement( this.$thumbnail );
4504 this.$element
4505 .addClass( 'oo-ui-selectFileWidget-dropTarget oo-ui-selectFileWidget' )
4506 .on( {
4507 click: this.onDropTargetClick.bind( this )
4508 } )
4509 .append(
4510 this.$thumbnail,
4511 this.$info,
4512 this.selectButton.$element,
4513 $( '<span>' )
4514 .addClass( 'oo-ui-selectFileWidget-dropLabel' )
4515 .text( OO.ui.msg( 'ooui-selectfile-dragdrop-placeholder' ) )
4516 );
4517 } else {
4518 this.$element
4519 .addClass( 'oo-ui-selectFileWidget' )
4520 .append( this.$info, this.selectButton.$element );
4521 }
4522 this.updateUI();
4523 };
4524
4525 /* Setup */
4526
4527 OO.inheritClass( OO.ui.SelectFileWidget, OO.ui.Widget );
4528 OO.mixinClass( OO.ui.SelectFileWidget, OO.ui.mixin.IconElement );
4529 OO.mixinClass( OO.ui.SelectFileWidget, OO.ui.mixin.IndicatorElement );
4530 OO.mixinClass( OO.ui.SelectFileWidget, OO.ui.mixin.PendingElement );
4531 OO.mixinClass( OO.ui.SelectFileWidget, OO.ui.mixin.LabelElement );
4532
4533 /* Static Properties */
4534
4535 /**
4536 * Check if this widget is supported
4537 *
4538 * @static
4539 * @return {boolean}
4540 */
4541 OO.ui.SelectFileWidget.static.isSupported = function () {
4542 var $input;
4543 if ( OO.ui.SelectFileWidget.static.isSupportedCache === null ) {
4544 $input = $( '<input>' ).attr( 'type', 'file' );
4545 OO.ui.SelectFileWidget.static.isSupportedCache = $input[ 0 ].files !== undefined;
4546 }
4547 return OO.ui.SelectFileWidget.static.isSupportedCache;
4548 };
4549
4550 OO.ui.SelectFileWidget.static.isSupportedCache = null;
4551
4552 /* Events */
4553
4554 /**
4555 * @event change
4556 *
4557 * A change event is emitted when the on/off state of the toggle changes.
4558 *
4559 * @param {File|null} value New value
4560 */
4561
4562 /* Methods */
4563
4564 /**
4565 * Get the current value of the field
4566 *
4567 * @return {File|null}
4568 */
4569 OO.ui.SelectFileWidget.prototype.getValue = function () {
4570 return this.currentFile;
4571 };
4572
4573 /**
4574 * Set the current value of the field
4575 *
4576 * @param {File|null} file File to select
4577 */
4578 OO.ui.SelectFileWidget.prototype.setValue = function ( file ) {
4579 if ( this.currentFile !== file ) {
4580 this.currentFile = file;
4581 this.updateUI();
4582 this.emit( 'change', this.currentFile );
4583 }
4584 };
4585
4586 /**
4587 * Focus the widget.
4588 *
4589 * Focusses the select file button.
4590 *
4591 * @chainable
4592 */
4593 OO.ui.SelectFileWidget.prototype.focus = function () {
4594 this.selectButton.$button[ 0 ].focus();
4595 return this;
4596 };
4597
4598 /**
4599 * Update the user interface when a file is selected or unselected
4600 *
4601 * @protected
4602 */
4603 OO.ui.SelectFileWidget.prototype.updateUI = function () {
4604 var $label;
4605 if ( !this.isSupported ) {
4606 this.$element.addClass( 'oo-ui-selectFileWidget-notsupported' );
4607 this.$element.removeClass( 'oo-ui-selectFileWidget-empty' );
4608 this.setLabel( this.notsupported );
4609 } else {
4610 this.$element.addClass( 'oo-ui-selectFileWidget-supported' );
4611 if ( this.currentFile ) {
4612 this.$element.removeClass( 'oo-ui-selectFileWidget-empty' );
4613 $label = $( [] );
4614 $label = $label.add(
4615 $( '<span>' )
4616 .addClass( 'oo-ui-selectFileWidget-fileName' )
4617 .text( this.currentFile.name )
4618 );
4619 this.setLabel( $label );
4620
4621 if ( this.showDropTarget ) {
4622 this.pushPending();
4623 this.loadAndGetImageUrl().done( function ( url ) {
4624 this.$thumbnail.css( 'background-image', 'url( ' + url + ' )' );
4625 }.bind( this ) ).fail( function () {
4626 this.$thumbnail.append(
4627 new OO.ui.IconWidget( {
4628 icon: 'attachment',
4629 classes: [ 'oo-ui-selectFileWidget-noThumbnail-icon' ]
4630 } ).$element
4631 );
4632 }.bind( this ) ).always( function () {
4633 this.popPending();
4634 }.bind( this ) );
4635 this.$element.off( 'click' );
4636 }
4637 } else {
4638 if ( this.showDropTarget ) {
4639 this.$element.off( 'click' );
4640 this.$element.on( {
4641 click: this.onDropTargetClick.bind( this )
4642 } );
4643 this.$thumbnail
4644 .empty()
4645 .css( 'background-image', '' );
4646 }
4647 this.$element.addClass( 'oo-ui-selectFileWidget-empty' );
4648 this.setLabel( this.placeholder );
4649 }
4650 }
4651 };
4652
4653 /**
4654 * If the selected file is an image, get its URL and load it.
4655 *
4656 * @return {jQuery.Promise} Promise resolves with the image URL after it has loaded
4657 */
4658 OO.ui.SelectFileWidget.prototype.loadAndGetImageUrl = function () {
4659 var deferred = $.Deferred(),
4660 file = this.currentFile,
4661 reader = new FileReader();
4662
4663 if (
4664 file &&
4665 ( OO.getProp( file, 'type' ) || '' ).indexOf( 'image/' ) === 0 &&
4666 file.size < this.thumbnailSizeLimit * 1024 * 1024
4667 ) {
4668 reader.onload = function ( event ) {
4669 var img = document.createElement( 'img' );
4670 img.addEventListener( 'load', function () {
4671 if (
4672 img.naturalWidth === 0 ||
4673 img.naturalHeight === 0 ||
4674 img.complete === false
4675 ) {
4676 deferred.reject();
4677 } else {
4678 deferred.resolve( event.target.result );
4679 }
4680 } );
4681 img.src = event.target.result;
4682 };
4683 reader.readAsDataURL( file );
4684 } else {
4685 deferred.reject();
4686 }
4687
4688 return deferred.promise();
4689 };
4690
4691 /**
4692 * Add the input to the widget
4693 *
4694 * @private
4695 */
4696 OO.ui.SelectFileWidget.prototype.addInput = function () {
4697 if ( this.$input ) {
4698 this.$input.remove();
4699 }
4700
4701 if ( !this.isSupported ) {
4702 this.$input = null;
4703 return;
4704 }
4705
4706 this.$input = $( '<input>' ).attr( 'type', 'file' );
4707 this.$input.on( 'change', this.onFileSelectedHandler );
4708 this.$input.on( 'click', function ( e ) {
4709 // Prevents dropTarget to get clicked which calls
4710 // a click on this input
4711 e.stopPropagation();
4712 } );
4713 this.$input.attr( {
4714 tabindex: -1
4715 } );
4716 if ( this.accept ) {
4717 this.$input.attr( 'accept', this.accept.join( ', ' ) );
4718 }
4719 this.selectButton.$button.append( this.$input );
4720 };
4721
4722 /**
4723 * Determine if we should accept this file
4724 *
4725 * @private
4726 * @param {string} mimeType File MIME type
4727 * @return {boolean}
4728 */
4729 OO.ui.SelectFileWidget.prototype.isAllowedType = function ( mimeType ) {
4730 var i, mimeTest;
4731
4732 if ( !this.accept || !mimeType ) {
4733 return true;
4734 }
4735
4736 for ( i = 0; i < this.accept.length; i++ ) {
4737 mimeTest = this.accept[ i ];
4738 if ( mimeTest === mimeType ) {
4739 return true;
4740 } else if ( mimeTest.substr( -2 ) === '/*' ) {
4741 mimeTest = mimeTest.substr( 0, mimeTest.length - 1 );
4742 if ( mimeType.substr( 0, mimeTest.length ) === mimeTest ) {
4743 return true;
4744 }
4745 }
4746 }
4747
4748 return false;
4749 };
4750
4751 /**
4752 * Handle file selection from the input
4753 *
4754 * @private
4755 * @param {jQuery.Event} e
4756 */
4757 OO.ui.SelectFileWidget.prototype.onFileSelected = function ( e ) {
4758 var file = OO.getProp( e.target, 'files', 0 ) || null;
4759
4760 if ( file && !this.isAllowedType( file.type ) ) {
4761 file = null;
4762 }
4763
4764 this.setValue( file );
4765 this.addInput();
4766 };
4767
4768 /**
4769 * Handle clear button click events.
4770 *
4771 * @private
4772 */
4773 OO.ui.SelectFileWidget.prototype.onClearClick = function () {
4774 this.setValue( null );
4775 return false;
4776 };
4777
4778 /**
4779 * Handle key press events.
4780 *
4781 * @private
4782 * @param {jQuery.Event} e Key press event
4783 */
4784 OO.ui.SelectFileWidget.prototype.onKeyPress = function ( e ) {
4785 if ( this.isSupported && !this.isDisabled() && this.$input &&
4786 ( e.which === OO.ui.Keys.SPACE || e.which === OO.ui.Keys.ENTER )
4787 ) {
4788 this.$input.click();
4789 return false;
4790 }
4791 };
4792
4793 /**
4794 * Handle drop target click events.
4795 *
4796 * @private
4797 * @param {jQuery.Event} e Key press event
4798 */
4799 OO.ui.SelectFileWidget.prototype.onDropTargetClick = function () {
4800 if ( this.isSupported && !this.isDisabled() && this.$input ) {
4801 this.$input.click();
4802 return false;
4803 }
4804 };
4805
4806 /**
4807 * Handle drag enter and over events
4808 *
4809 * @private
4810 * @param {jQuery.Event} e Drag event
4811 */
4812 OO.ui.SelectFileWidget.prototype.onDragEnterOrOver = function ( e ) {
4813 var itemOrFile,
4814 droppableFile = false,
4815 dt = e.originalEvent.dataTransfer;
4816
4817 e.preventDefault();
4818 e.stopPropagation();
4819
4820 if ( this.isDisabled() || !this.isSupported ) {
4821 this.$element.removeClass( 'oo-ui-selectFileWidget-canDrop' );
4822 dt.dropEffect = 'none';
4823 return false;
4824 }
4825
4826 // DataTransferItem and File both have a type property, but in Chrome files
4827 // have no information at this point.
4828 itemOrFile = OO.getProp( dt, 'items', 0 ) || OO.getProp( dt, 'files', 0 );
4829 if ( itemOrFile ) {
4830 if ( this.isAllowedType( itemOrFile.type ) ) {
4831 droppableFile = true;
4832 }
4833 // dt.types is Array-like, but not an Array
4834 } else if ( Array.prototype.indexOf.call( OO.getProp( dt, 'types' ) || [], 'Files' ) !== -1 ) {
4835 // File information is not available at this point for security so just assume
4836 // it is acceptable for now.
4837 // https://bugzilla.mozilla.org/show_bug.cgi?id=640534
4838 droppableFile = true;
4839 }
4840
4841 this.$element.toggleClass( 'oo-ui-selectFileWidget-canDrop', droppableFile );
4842 if ( !droppableFile ) {
4843 dt.dropEffect = 'none';
4844 }
4845
4846 return false;
4847 };
4848
4849 /**
4850 * Handle drag leave events
4851 *
4852 * @private
4853 * @param {jQuery.Event} e Drag event
4854 */
4855 OO.ui.SelectFileWidget.prototype.onDragLeave = function () {
4856 this.$element.removeClass( 'oo-ui-selectFileWidget-canDrop' );
4857 };
4858
4859 /**
4860 * Handle drop events
4861 *
4862 * @private
4863 * @param {jQuery.Event} e Drop event
4864 */
4865 OO.ui.SelectFileWidget.prototype.onDrop = function ( e ) {
4866 var file = null,
4867 dt = e.originalEvent.dataTransfer;
4868
4869 e.preventDefault();
4870 e.stopPropagation();
4871 this.$element.removeClass( 'oo-ui-selectFileWidget-canDrop' );
4872
4873 if ( this.isDisabled() || !this.isSupported ) {
4874 return false;
4875 }
4876
4877 file = OO.getProp( dt, 'files', 0 );
4878 if ( file && !this.isAllowedType( file.type ) ) {
4879 file = null;
4880 }
4881 if ( file ) {
4882 this.setValue( file );
4883 }
4884
4885 return false;
4886 };
4887
4888 /**
4889 * @inheritdoc
4890 */
4891 OO.ui.SelectFileWidget.prototype.setDisabled = function ( disabled ) {
4892 OO.ui.SelectFileWidget.parent.prototype.setDisabled.call( this, disabled );
4893 if ( this.selectButton ) {
4894 this.selectButton.setDisabled( disabled );
4895 }
4896 if ( this.clearButton ) {
4897 this.clearButton.setDisabled( disabled );
4898 }
4899 return this;
4900 };
4901
4902 /**
4903 * SearchWidgets combine a {@link OO.ui.TextInputWidget text input field}, where users can type a search query,
4904 * and a menu of search results, which is displayed beneath the query
4905 * field. Unlike {@link OO.ui.mixin.LookupElement lookup menus}, search result menus are always visible to the user.
4906 * Users can choose an item from the menu or type a query into the text field to search for a matching result item.
4907 * In general, search widgets are used inside a separate {@link OO.ui.Dialog dialog} window.
4908 *
4909 * Each time the query is changed, the search result menu is cleared and repopulated. Please see
4910 * the [OOjs UI demos][1] for an example.
4911 *
4912 * [1]: https://tools.wmflabs.org/oojs-ui/oojs-ui/demos/#dialogs-mediawiki-vector-ltr
4913 *
4914 * @class
4915 * @extends OO.ui.Widget
4916 *
4917 * @constructor
4918 * @param {Object} [config] Configuration options
4919 * @cfg {string|jQuery} [placeholder] Placeholder text for query input
4920 * @cfg {string} [value] Initial query value
4921 */
4922 OO.ui.SearchWidget = function OoUiSearchWidget( config ) {
4923 // Configuration initialization
4924 config = config || {};
4925
4926 // Parent constructor
4927 OO.ui.SearchWidget.parent.call( this, config );
4928
4929 // Properties
4930 this.query = new OO.ui.TextInputWidget( {
4931 icon: 'search',
4932 placeholder: config.placeholder,
4933 value: config.value
4934 } );
4935 this.results = new OO.ui.SelectWidget();
4936 this.$query = $( '<div>' );
4937 this.$results = $( '<div>' );
4938
4939 // Events
4940 this.query.connect( this, {
4941 change: 'onQueryChange',
4942 enter: 'onQueryEnter'
4943 } );
4944 this.query.$input.on( 'keydown', this.onQueryKeydown.bind( this ) );
4945
4946 // Initialization
4947 this.$query
4948 .addClass( 'oo-ui-searchWidget-query' )
4949 .append( this.query.$element );
4950 this.$results
4951 .addClass( 'oo-ui-searchWidget-results' )
4952 .append( this.results.$element );
4953 this.$element
4954 .addClass( 'oo-ui-searchWidget' )
4955 .append( this.$results, this.$query );
4956 };
4957
4958 /* Setup */
4959
4960 OO.inheritClass( OO.ui.SearchWidget, OO.ui.Widget );
4961
4962 /* Methods */
4963
4964 /**
4965 * Handle query key down events.
4966 *
4967 * @private
4968 * @param {jQuery.Event} e Key down event
4969 */
4970 OO.ui.SearchWidget.prototype.onQueryKeydown = function ( e ) {
4971 var highlightedItem, nextItem,
4972 dir = e.which === OO.ui.Keys.DOWN ? 1 : ( e.which === OO.ui.Keys.UP ? -1 : 0 );
4973
4974 if ( dir ) {
4975 highlightedItem = this.results.getHighlightedItem();
4976 if ( !highlightedItem ) {
4977 highlightedItem = this.results.getSelectedItem();
4978 }
4979 nextItem = this.results.getRelativeSelectableItem( highlightedItem, dir );
4980 this.results.highlightItem( nextItem );
4981 nextItem.scrollElementIntoView();
4982 }
4983 };
4984
4985 /**
4986 * Handle select widget select events.
4987 *
4988 * Clears existing results. Subclasses should repopulate items according to new query.
4989 *
4990 * @private
4991 * @param {string} value New value
4992 */
4993 OO.ui.SearchWidget.prototype.onQueryChange = function () {
4994 // Reset
4995 this.results.clearItems();
4996 };
4997
4998 /**
4999 * Handle select widget enter key events.
5000 *
5001 * Chooses highlighted item.
5002 *
5003 * @private
5004 * @param {string} value New value
5005 */
5006 OO.ui.SearchWidget.prototype.onQueryEnter = function () {
5007 var highlightedItem = this.results.getHighlightedItem();
5008 if ( highlightedItem ) {
5009 this.results.chooseItem( highlightedItem );
5010 }
5011 };
5012
5013 /**
5014 * Get the query input.
5015 *
5016 * @return {OO.ui.TextInputWidget} Query input
5017 */
5018 OO.ui.SearchWidget.prototype.getQuery = function () {
5019 return this.query;
5020 };
5021
5022 /**
5023 * Get the search results menu.
5024 *
5025 * @return {OO.ui.SelectWidget} Menu of search results
5026 */
5027 OO.ui.SearchWidget.prototype.getResults = function () {
5028 return this.results;
5029 };
5030
5031 /**
5032 * NumberInputWidgets combine a {@link OO.ui.TextInputWidget text input} (where a value
5033 * can be entered manually) and two {@link OO.ui.ButtonWidget button widgets}
5034 * (to adjust the value in increments) to allow the user to enter a number.
5035 *
5036 * @example
5037 * // Example: A NumberInputWidget.
5038 * var numberInput = new OO.ui.NumberInputWidget( {
5039 * label: 'NumberInputWidget',
5040 * input: { value: 5 },
5041 * min: 1,
5042 * max: 10
5043 * } );
5044 * $( 'body' ).append( numberInput.$element );
5045 *
5046 * @class
5047 * @extends OO.ui.Widget
5048 *
5049 * @constructor
5050 * @param {Object} [config] Configuration options
5051 * @cfg {Object} [input] Configuration options to pass to the {@link OO.ui.TextInputWidget text input widget}.
5052 * @cfg {Object} [minusButton] Configuration options to pass to the {@link OO.ui.ButtonWidget decrementing button widget}.
5053 * @cfg {Object} [plusButton] Configuration options to pass to the {@link OO.ui.ButtonWidget incrementing button widget}.
5054 * @cfg {boolean} [isInteger=false] Whether the field accepts only integer values.
5055 * @cfg {number} [min=-Infinity] Minimum allowed value
5056 * @cfg {number} [max=Infinity] Maximum allowed value
5057 * @cfg {number} [step=1] Delta when using the buttons or up/down arrow keys
5058 * @cfg {number|null} [pageStep] Delta when using the page-up/page-down keys. Defaults to 10 times #step.
5059 * @cfg {boolean} [showButtons=true] Whether to show the plus and minus buttons.
5060 */
5061 OO.ui.NumberInputWidget = function OoUiNumberInputWidget( config ) {
5062 // Configuration initialization
5063 config = $.extend( {
5064 isInteger: false,
5065 min: -Infinity,
5066 max: Infinity,
5067 step: 1,
5068 pageStep: null,
5069 showButtons: true
5070 }, config );
5071
5072 // Parent constructor
5073 OO.ui.NumberInputWidget.parent.call( this, config );
5074
5075 // Properties
5076 this.input = new OO.ui.TextInputWidget( $.extend(
5077 {
5078 disabled: this.isDisabled(),
5079 type: 'number'
5080 },
5081 config.input
5082 ) );
5083 if ( config.showButtons ) {
5084 this.minusButton = new OO.ui.ButtonWidget( $.extend(
5085 {
5086 disabled: this.isDisabled(),
5087 tabIndex: -1,
5088 classes: [ 'oo-ui-numberInputWidget-minusButton' ],
5089 label: '−'
5090 },
5091 config.minusButton
5092 ) );
5093 this.plusButton = new OO.ui.ButtonWidget( $.extend(
5094 {
5095 disabled: this.isDisabled(),
5096 tabIndex: -1,
5097 classes: [ 'oo-ui-numberInputWidget-plusButton' ],
5098 label: '+'
5099 },
5100 config.plusButton
5101 ) );
5102 }
5103
5104 // Events
5105 this.input.connect( this, {
5106 change: this.emit.bind( this, 'change' ),
5107 enter: this.emit.bind( this, 'enter' )
5108 } );
5109 this.input.$input.on( {
5110 keydown: this.onKeyDown.bind( this ),
5111 'wheel mousewheel DOMMouseScroll': this.onWheel.bind( this )
5112 } );
5113 if ( config.showButtons ) {
5114 this.plusButton.connect( this, {
5115 click: [ 'onButtonClick', +1 ]
5116 } );
5117 this.minusButton.connect( this, {
5118 click: [ 'onButtonClick', -1 ]
5119 } );
5120 }
5121
5122 // Initialization
5123 this.setIsInteger( !!config.isInteger );
5124 this.setRange( config.min, config.max );
5125 this.setStep( config.step, config.pageStep );
5126
5127 this.$field = $( '<div>' ).addClass( 'oo-ui-numberInputWidget-field' )
5128 .append( this.input.$element );
5129 this.$element.addClass( 'oo-ui-numberInputWidget' ).append( this.$field );
5130 if ( config.showButtons ) {
5131 this.$field
5132 .prepend( this.minusButton.$element )
5133 .append( this.plusButton.$element );
5134 this.$element.addClass( 'oo-ui-numberInputWidget-buttoned' );
5135 }
5136 this.input.setValidation( this.validateNumber.bind( this ) );
5137 };
5138
5139 /* Setup */
5140
5141 OO.inheritClass( OO.ui.NumberInputWidget, OO.ui.Widget );
5142
5143 /* Events */
5144
5145 /**
5146 * A `change` event is emitted when the value of the input changes.
5147 *
5148 * @event change
5149 */
5150
5151 /**
5152 * An `enter` event is emitted when the user presses 'enter' inside the text box.
5153 *
5154 * @event enter
5155 */
5156
5157 /* Methods */
5158
5159 /**
5160 * Set whether only integers are allowed
5161 *
5162 * @param {boolean} flag
5163 */
5164 OO.ui.NumberInputWidget.prototype.setIsInteger = function ( flag ) {
5165 this.isInteger = !!flag;
5166 this.input.setValidityFlag();
5167 };
5168
5169 /**
5170 * Get whether only integers are allowed
5171 *
5172 * @return {boolean} Flag value
5173 */
5174 OO.ui.NumberInputWidget.prototype.getIsInteger = function () {
5175 return this.isInteger;
5176 };
5177
5178 /**
5179 * Set the range of allowed values
5180 *
5181 * @param {number} min Minimum allowed value
5182 * @param {number} max Maximum allowed value
5183 */
5184 OO.ui.NumberInputWidget.prototype.setRange = function ( min, max ) {
5185 if ( min > max ) {
5186 throw new Error( 'Minimum (' + min + ') must not be greater than maximum (' + max + ')' );
5187 }
5188 this.min = min;
5189 this.max = max;
5190 this.input.setValidityFlag();
5191 };
5192
5193 /**
5194 * Get the current range
5195 *
5196 * @return {number[]} Minimum and maximum values
5197 */
5198 OO.ui.NumberInputWidget.prototype.getRange = function () {
5199 return [ this.min, this.max ];
5200 };
5201
5202 /**
5203 * Set the stepping deltas
5204 *
5205 * @param {number} step Normal step
5206 * @param {number|null} pageStep Page step. If null, 10 * step will be used.
5207 */
5208 OO.ui.NumberInputWidget.prototype.setStep = function ( step, pageStep ) {
5209 if ( step <= 0 ) {
5210 throw new Error( 'Step value must be positive' );
5211 }
5212 if ( pageStep === null ) {
5213 pageStep = step * 10;
5214 } else if ( pageStep <= 0 ) {
5215 throw new Error( 'Page step value must be positive' );
5216 }
5217 this.step = step;
5218 this.pageStep = pageStep;
5219 };
5220
5221 /**
5222 * Get the current stepping values
5223 *
5224 * @return {number[]} Step and page step
5225 */
5226 OO.ui.NumberInputWidget.prototype.getStep = function () {
5227 return [ this.step, this.pageStep ];
5228 };
5229
5230 /**
5231 * Get the current value of the widget
5232 *
5233 * @return {string}
5234 */
5235 OO.ui.NumberInputWidget.prototype.getValue = function () {
5236 return this.input.getValue();
5237 };
5238
5239 /**
5240 * Get the current value of the widget as a number
5241 *
5242 * @return {number} May be NaN, or an invalid number
5243 */
5244 OO.ui.NumberInputWidget.prototype.getNumericValue = function () {
5245 return +this.input.getValue();
5246 };
5247
5248 /**
5249 * Set the value of the widget
5250 *
5251 * @param {string} value Invalid values are allowed
5252 */
5253 OO.ui.NumberInputWidget.prototype.setValue = function ( value ) {
5254 this.input.setValue( value );
5255 };
5256
5257 /**
5258 * Adjust the value of the widget
5259 *
5260 * @param {number} delta Adjustment amount
5261 */
5262 OO.ui.NumberInputWidget.prototype.adjustValue = function ( delta ) {
5263 var n, v = this.getNumericValue();
5264
5265 delta = +delta;
5266 if ( isNaN( delta ) || !isFinite( delta ) ) {
5267 throw new Error( 'Delta must be a finite number' );
5268 }
5269
5270 if ( isNaN( v ) ) {
5271 n = 0;
5272 } else {
5273 n = v + delta;
5274 n = Math.max( Math.min( n, this.max ), this.min );
5275 if ( this.isInteger ) {
5276 n = Math.round( n );
5277 }
5278 }
5279
5280 if ( n !== v ) {
5281 this.setValue( n );
5282 }
5283 };
5284
5285 /**
5286 * Validate input
5287 *
5288 * @private
5289 * @param {string} value Field value
5290 * @return {boolean}
5291 */
5292 OO.ui.NumberInputWidget.prototype.validateNumber = function ( value ) {
5293 var n = +value;
5294 if ( isNaN( n ) || !isFinite( n ) ) {
5295 return false;
5296 }
5297
5298 if ( this.isInteger && Math.floor( n ) !== n ) {
5299 return false;
5300 }
5301
5302 if ( n < this.min || n > this.max ) {
5303 return false;
5304 }
5305
5306 return true;
5307 };
5308
5309 /**
5310 * Handle mouse click events.
5311 *
5312 * @private
5313 * @param {number} dir +1 or -1
5314 */
5315 OO.ui.NumberInputWidget.prototype.onButtonClick = function ( dir ) {
5316 this.adjustValue( dir * this.step );
5317 };
5318
5319 /**
5320 * Handle mouse wheel events.
5321 *
5322 * @private
5323 * @param {jQuery.Event} event
5324 */
5325 OO.ui.NumberInputWidget.prototype.onWheel = function ( event ) {
5326 var delta = 0;
5327
5328 if ( !this.isDisabled() && this.input.$input.is( ':focus' ) ) {
5329 // Standard 'wheel' event
5330 if ( event.originalEvent.deltaMode !== undefined ) {
5331 this.sawWheelEvent = true;
5332 }
5333 if ( event.originalEvent.deltaY ) {
5334 delta = -event.originalEvent.deltaY;
5335 } else if ( event.originalEvent.deltaX ) {
5336 delta = event.originalEvent.deltaX;
5337 }
5338
5339 // Non-standard events
5340 if ( !this.sawWheelEvent ) {
5341 if ( event.originalEvent.wheelDeltaX ) {
5342 delta = -event.originalEvent.wheelDeltaX;
5343 } else if ( event.originalEvent.wheelDeltaY ) {
5344 delta = event.originalEvent.wheelDeltaY;
5345 } else if ( event.originalEvent.wheelDelta ) {
5346 delta = event.originalEvent.wheelDelta;
5347 } else if ( event.originalEvent.detail ) {
5348 delta = -event.originalEvent.detail;
5349 }
5350 }
5351
5352 if ( delta ) {
5353 delta = delta < 0 ? -1 : 1;
5354 this.adjustValue( delta * this.step );
5355 }
5356
5357 return false;
5358 }
5359 };
5360
5361 /**
5362 * Handle key down events.
5363 *
5364 * @private
5365 * @param {jQuery.Event} e Key down event
5366 */
5367 OO.ui.NumberInputWidget.prototype.onKeyDown = function ( e ) {
5368 if ( !this.isDisabled() ) {
5369 switch ( e.which ) {
5370 case OO.ui.Keys.UP:
5371 this.adjustValue( this.step );
5372 return false;
5373 case OO.ui.Keys.DOWN:
5374 this.adjustValue( -this.step );
5375 return false;
5376 case OO.ui.Keys.PAGEUP:
5377 this.adjustValue( this.pageStep );
5378 return false;
5379 case OO.ui.Keys.PAGEDOWN:
5380 this.adjustValue( -this.pageStep );
5381 return false;
5382 }
5383 }
5384 };
5385
5386 /**
5387 * @inheritdoc
5388 */
5389 OO.ui.NumberInputWidget.prototype.setDisabled = function ( disabled ) {
5390 // Parent method
5391 OO.ui.NumberInputWidget.parent.prototype.setDisabled.call( this, disabled );
5392
5393 if ( this.input ) {
5394 this.input.setDisabled( this.isDisabled() );
5395 }
5396 if ( this.minusButton ) {
5397 this.minusButton.setDisabled( this.isDisabled() );
5398 }
5399 if ( this.plusButton ) {
5400 this.plusButton.setDisabled( this.isDisabled() );
5401 }
5402
5403 return this;
5404 };
5405
5406 }( OO ) );