mw.widgets.DateInputWidget, CalendarWidget: Lazy-initialize calendar when first shown
[lhc/web/wiklou.git] / resources / src / mediawiki.widgets / mw.widgets.CalendarWidget.js
1 /*!
2 * MediaWiki Widgets – CalendarWidget class.
3 *
4 * @copyright 2011-2015 MediaWiki Widgets Team and others; see AUTHORS.txt
5 * @license The MIT License (MIT); see LICENSE.txt
6 */
7 /*global moment */
8 ( function ( $, mw ) {
9
10 /**
11 * Creates an mw.widgets.CalendarWidget object.
12 *
13 * You will most likely want to use mw.widgets.DateInputWidget instead of CalendarWidget directly.
14 *
15 * @class
16 * @extends OO.ui.Widget
17 * @mixins OO.ui.mixin.TabIndexedElement
18 * @mixins OO.ui.mixin.FloatableElement
19 *
20 * @constructor
21 * @param {Object} [config] Configuration options
22 * @cfg {boolean} [lazyInitOnToggle=false] Don't build most of the interface until
23 * `.toggle( true )` is called. Meant to be used when the calendar is not immediately visible.
24 * @cfg {string} [precision='day'] Date precision to use, 'day' or 'month'
25 * @cfg {string|null} [date=null] Day or month date (depending on `precision`), in the format
26 * 'YYYY-MM-DD' or 'YYYY-MM'. When null, the calendar will show today's date, but not select
27 * it.
28 */
29 mw.widgets.CalendarWidget = function MWWCalendarWidget( config ) {
30 // Config initialization
31 config = config || {};
32
33 // Parent constructor
34 mw.widgets.CalendarWidget.parent.call( this, config );
35
36 // Mixin constructors
37 OO.ui.mixin.TabIndexedElement.call( this, $.extend( {}, config, { $tabIndexed: this.$element } ) );
38 OO.ui.mixin.FloatableElement.call( this, config );
39
40 // Properties
41 this.lazyInitOnToggle = !!config.lazyInitOnToggle;
42 this.precision = config.precision || 'day';
43 // Currently selected date (day or month)
44 this.date = null;
45 // Current UI state (date and precision we're displaying right now)
46 this.moment = null;
47 this.displayLayer = this.getDisplayLayers()[ 0 ]; // 'month', 'year', 'duodecade'
48
49 this.$header = $( '<div>' ).addClass( 'mw-widget-calendarWidget-header' );
50 this.$bodyOuterWrapper = $( '<div>' ).addClass( 'mw-widget-calendarWidget-body-outer-wrapper' );
51 this.$bodyWrapper = $( '<div>' ).addClass( 'mw-widget-calendarWidget-body-wrapper' );
52 this.$body = $( '<div>' ).addClass( 'mw-widget-calendarWidget-body' );
53 this.labelButton = new OO.ui.ButtonWidget( {
54 tabIndex: -1,
55 label: '',
56 framed: false,
57 classes: [ 'mw-widget-calendarWidget-labelButton' ]
58 } );
59 this.upButton = new OO.ui.ButtonWidget( {
60 tabIndex: -1,
61 framed: false,
62 icon: 'collapse',
63 classes: [ 'mw-widget-calendarWidget-upButton' ]
64 } );
65 this.prevButton = new OO.ui.ButtonWidget( {
66 tabIndex: -1,
67 framed: false,
68 icon: 'previous',
69 classes: [ 'mw-widget-calendarWidget-prevButton' ]
70 } );
71 this.nextButton = new OO.ui.ButtonWidget( {
72 tabIndex: -1,
73 framed: false,
74 icon: 'next',
75 classes: [ 'mw-widget-calendarWidget-nextButton' ]
76 } );
77
78 // Events
79 this.labelButton.connect( this, { click: 'onUpButtonClick' } );
80 this.upButton.connect( this, { click: 'onUpButtonClick' } );
81 this.prevButton.connect( this, { click: 'onPrevButtonClick' } );
82 this.nextButton.connect( this, { click: 'onNextButtonClick' } );
83 this.$element.on( {
84 focus: this.onFocus.bind( this ),
85 mousedown: this.onClick.bind( this ),
86 keydown: this.onKeyDown.bind( this )
87 } );
88
89 // Initialization
90 this.$element
91 .addClass( 'mw-widget-calendarWidget' )
92 .append( this.$header, this.$bodyOuterWrapper.append( this.$bodyWrapper.append( this.$body ) ) );
93 this.$header.append(
94 this.prevButton.$element,
95 this.nextButton.$element,
96 this.upButton.$element,
97 this.labelButton.$element
98 );
99 this.setDate( config.date !== undefined ? config.date : null );
100 };
101
102 /* Inheritance */
103
104 OO.inheritClass( mw.widgets.CalendarWidget, OO.ui.Widget );
105 OO.mixinClass( mw.widgets.CalendarWidget, OO.ui.mixin.TabIndexedElement );
106 OO.mixinClass( mw.widgets.CalendarWidget, OO.ui.mixin.FloatableElement );
107
108 /* Events */
109
110 /**
111 * @event change
112 *
113 * A change event is emitted when the chosen date changes.
114 *
115 * @param {string} date Day or month date, in the format 'YYYY-MM-DD' or 'YYYY-MM'
116 */
117
118 /* Methods */
119
120 /**
121 * Get the date format ('YYYY-MM-DD' or 'YYYY-MM', depending on precision), which is used
122 * internally and for dates accepted by #setDate and returned by #getDate.
123 *
124 * @private
125 * @return {string} Format
126 */
127 mw.widgets.CalendarWidget.prototype.getDateFormat = function () {
128 return {
129 day: 'YYYY-MM-DD',
130 month: 'YYYY-MM'
131 }[ this.precision ];
132 };
133
134 /**
135 * Get the date precision this calendar uses, 'day' or 'month'.
136 *
137 * @private
138 * @return {string} Precision, 'day' or 'month'
139 */
140 mw.widgets.CalendarWidget.prototype.getPrecision = function () {
141 return this.precision;
142 };
143
144 /**
145 * Get list of possible display layers.
146 *
147 * @private
148 * @return {string[]} Layers
149 */
150 mw.widgets.CalendarWidget.prototype.getDisplayLayers = function () {
151 return [ 'month', 'year', 'duodecade' ].slice( this.precision === 'month' ? 1 : 0 );
152 };
153
154 /**
155 * Update the calendar.
156 *
157 * @private
158 * @param {string|null} [fade=null] Direction in which to fade out current calendar contents,
159 * 'previous', 'next', 'up' or 'down'; or 'auto', which has the same result as 'previous' or
160 * 'next' depending on whether the current date is later or earlier than the previous.
161 */
162 mw.widgets.CalendarWidget.prototype.updateUI = function ( fade ) {
163 var items, today, selected, currentMonth, currentYear, currentDay, i, needsFade,
164 $bodyWrapper = this.$bodyWrapper;
165
166 if ( this.lazyInitOnToggle ) {
167 // We're being called from the constructor and not being shown yet, do nothing
168 return;
169 }
170
171 if (
172 this.displayLayer === this.previousDisplayLayer &&
173 this.date === this.previousDate &&
174 this.previousMoment &&
175 this.previousMoment.isSame( this.moment, this.precision === 'month' ? 'month' : 'day' )
176 ) {
177 // Already displayed
178 return;
179 }
180
181 if ( fade === 'auto' ) {
182 if ( !this.previousMoment ) {
183 fade = null;
184 } else if ( this.previousMoment.isBefore( this.moment, this.precision === 'month' ? 'month' : 'day' ) ) {
185 fade = 'next';
186 } else if ( this.previousMoment.isAfter( this.moment, this.precision === 'month' ? 'month' : 'day' ) ) {
187 fade = 'previous';
188 } else {
189 fade = null;
190 }
191 }
192
193 items = [];
194 if ( this.$oldBody ) {
195 this.$oldBody.remove();
196 }
197 this.$oldBody = this.$body.addClass( 'mw-widget-calendarWidget-old-body' );
198 // Clone without children
199 this.$body = $( this.$body[ 0 ].cloneNode( false ) )
200 .removeClass( 'mw-widget-calendarWidget-old-body' )
201 .toggleClass( 'mw-widget-calendarWidget-body-month', this.displayLayer === 'month' )
202 .toggleClass( 'mw-widget-calendarWidget-body-year', this.displayLayer === 'year' )
203 .toggleClass( 'mw-widget-calendarWidget-body-duodecade', this.displayLayer === 'duodecade' );
204
205 today = moment();
206 selected = moment( this.getDate(), this.getDateFormat() );
207
208 switch ( this.displayLayer ) {
209 case 'month':
210 this.labelButton.setLabel( this.moment.format( 'MMMM YYYY' ) );
211 this.upButton.toggle( true );
212
213 // First week displayed is the first week spanned by the month, unless it begins on Monday, in
214 // which case first week displayed is the previous week. This makes the calendar "balanced"
215 // and also neatly handles 28-day February sometimes spanning only 4 weeks.
216 currentDay = moment( this.moment ).startOf( 'month' ).subtract( 1, 'day' ).startOf( 'week' );
217
218 // Day-of-week labels. Localisation-independent: works with weeks starting on Saturday, Sunday
219 // or Monday.
220 for ( i = 0; i < 7; i++ ) {
221 items.push(
222 $( '<div>' )
223 .addClass( 'mw-widget-calendarWidget-day-heading' )
224 .text( currentDay.format( 'dd' ) )
225 );
226 currentDay.add( 1, 'day' );
227 }
228 currentDay.subtract( 7, 'days' );
229
230 // Actual calendar month. Always displays 6 weeks, for consistency (months can span 4 to 6
231 // weeks).
232 for ( i = 0; i < 42; i++ ) {
233 items.push(
234 $( '<div>' )
235 .addClass( 'mw-widget-calendarWidget-item mw-widget-calendarWidget-day' )
236 .toggleClass( 'mw-widget-calendarWidget-day-additional', !currentDay.isSame( this.moment, 'month' ) )
237 .toggleClass( 'mw-widget-calendarWidget-day-today', currentDay.isSame( today, 'day' ) )
238 .toggleClass( 'mw-widget-calendarWidget-item-selected', currentDay.isSame( selected, 'day' ) )
239 .text( currentDay.format( 'D' ) )
240 .data( 'date', currentDay.date() )
241 .data( 'month', currentDay.month() )
242 .data( 'year', currentDay.year() )
243 );
244 currentDay.add( 1, 'day' );
245 }
246 break;
247
248 case 'year':
249 this.labelButton.setLabel( this.moment.format( 'YYYY' ) );
250 this.upButton.toggle( true );
251
252 currentMonth = moment( this.moment ).startOf( 'year' );
253 for ( i = 0; i < 12; i++ ) {
254 items.push(
255 $( '<div>' )
256 .addClass( 'mw-widget-calendarWidget-item mw-widget-calendarWidget-month' )
257 .toggleClass( 'mw-widget-calendarWidget-item-selected', currentMonth.isSame( selected, 'month' ) )
258 .text( currentMonth.format( 'MMMM' ) )
259 .data( 'month', currentMonth.month() )
260 );
261 currentMonth.add( 1, 'month' );
262 }
263 // Shuffle the array to display months in columns rather than rows.
264 items = [
265 items[ 0 ], items[ 6 ], // | January | July |
266 items[ 1 ], items[ 7 ], // | February | August |
267 items[ 2 ], items[ 8 ], // | March | September |
268 items[ 3 ], items[ 9 ], // | April | October |
269 items[ 4 ], items[ 10 ], // | May | November |
270 items[ 5 ], items[ 11 ] // | June | December |
271 ];
272 break;
273
274 case 'duodecade':
275 this.labelButton.setLabel( null );
276 this.upButton.toggle( false );
277
278 currentYear = moment( { year: Math.floor( this.moment.year() / 20 ) * 20 } );
279 for ( i = 0; i < 20; i++ ) {
280 items.push(
281 $( '<div>' )
282 .addClass( 'mw-widget-calendarWidget-item mw-widget-calendarWidget-year' )
283 .toggleClass( 'mw-widget-calendarWidget-item-selected', currentYear.isSame( selected, 'year' ) )
284 .text( currentYear.format( 'YYYY' ) )
285 .data( 'year', currentYear.year() )
286 );
287 currentYear.add( 1, 'year' );
288 }
289 break;
290 }
291
292 this.$body.append.apply( this.$body, items );
293
294 $bodyWrapper
295 .removeClass( 'mw-widget-calendarWidget-body-wrapper-fade-up' )
296 .removeClass( 'mw-widget-calendarWidget-body-wrapper-fade-down' )
297 .removeClass( 'mw-widget-calendarWidget-body-wrapper-fade-previous' )
298 .removeClass( 'mw-widget-calendarWidget-body-wrapper-fade-next' );
299
300 needsFade = this.previousDisplayLayer !== this.displayLayer;
301 if ( this.displayLayer === 'month' ) {
302 needsFade = needsFade || !this.moment.isSame( this.previousMoment, 'month' );
303 } else if ( this.displayLayer === 'year' ) {
304 needsFade = needsFade || !this.moment.isSame( this.previousMoment, 'year' );
305 } else if ( this.displayLayer === 'duodecade' ) {
306 needsFade = needsFade || (
307 Math.floor( this.moment.year() / 20 ) * 20 !==
308 Math.floor( this.previousMoment.year() / 20 ) * 20
309 );
310 }
311
312 if ( fade && needsFade ) {
313 this.$oldBody.find( '.mw-widget-calendarWidget-item-selected' )
314 .removeClass( 'mw-widget-calendarWidget-item-selected' );
315 if ( fade === 'previous' || fade === 'up' ) {
316 this.$body.insertBefore( this.$oldBody );
317 } else if ( fade === 'next' || fade === 'down' ) {
318 this.$body.insertAfter( this.$oldBody );
319 }
320 setTimeout( function () {
321 $bodyWrapper.addClass( 'mw-widget-calendarWidget-body-wrapper-fade-' + fade );
322 }.bind( this ), 0 );
323 } else {
324 this.$oldBody.replaceWith( this.$body );
325 }
326
327 this.previousMoment = moment( this.moment );
328 this.previousDisplayLayer = this.displayLayer;
329 this.previousDate = this.date;
330
331 this.$body.on( 'click', this.onBodyClick.bind( this ) );
332 };
333
334 /**
335 * Handle click events on the "up" button, switching to less precise view.
336 *
337 * @private
338 */
339 mw.widgets.CalendarWidget.prototype.onUpButtonClick = function () {
340 var
341 layers = this.getDisplayLayers(),
342 currentLayer = layers.indexOf( this.displayLayer );
343 if ( currentLayer !== layers.length - 1 ) {
344 // One layer up
345 this.displayLayer = layers[ currentLayer + 1 ];
346 this.updateUI( 'up' );
347 } else {
348 this.updateUI();
349 }
350 };
351
352 /**
353 * Handle click events on the "previous" button, switching to previous pane.
354 *
355 * @private
356 */
357 mw.widgets.CalendarWidget.prototype.onPrevButtonClick = function () {
358 switch ( this.displayLayer ) {
359 case 'month':
360 this.moment.subtract( 1, 'month' );
361 break;
362 case 'year':
363 this.moment.subtract( 1, 'year' );
364 break;
365 case 'duodecade':
366 this.moment.subtract( 20, 'years' );
367 break;
368 }
369 this.updateUI( 'previous' );
370 };
371
372 /**
373 * Handle click events on the "next" button, switching to next pane.
374 *
375 * @private
376 */
377 mw.widgets.CalendarWidget.prototype.onNextButtonClick = function () {
378 switch ( this.displayLayer ) {
379 case 'month':
380 this.moment.add( 1, 'month' );
381 break;
382 case 'year':
383 this.moment.add( 1, 'year' );
384 break;
385 case 'duodecade':
386 this.moment.add( 20, 'years' );
387 break;
388 }
389 this.updateUI( 'next' );
390 };
391
392 /**
393 * Handle click events anywhere in the body of the widget, which contains the matrix of days,
394 * months or years to choose. Maybe change the pane or switch to more precise view, depending on
395 * what gets clicked.
396 *
397 * @private
398 */
399 mw.widgets.CalendarWidget.prototype.onBodyClick = function ( e ) {
400 var
401 $target = $( e.target ),
402 layers = this.getDisplayLayers(),
403 currentLayer = layers.indexOf( this.displayLayer );
404 if ( $target.data( 'year' ) !== undefined ) {
405 this.moment.year( $target.data( 'year' ) );
406 }
407 if ( $target.data( 'month' ) !== undefined ) {
408 this.moment.month( $target.data( 'month' ) );
409 }
410 if ( $target.data( 'date' ) !== undefined ) {
411 this.moment.date( $target.data( 'date' ) );
412 }
413 if ( currentLayer === 0 ) {
414 this.setDateFromMoment();
415 this.updateUI( 'auto' );
416 } else {
417 // One layer down
418 this.displayLayer = layers[ currentLayer - 1 ];
419 this.updateUI( 'down' );
420 }
421 };
422
423 /**
424 * Set the date.
425 *
426 * @param {string|null} [date=null] Day or month date, in the format 'YYYY-MM-DD' or 'YYYY-MM'.
427 * When null, the calendar will show today's date, but not select it. When invalid, the date
428 * is not changed.
429 */
430 mw.widgets.CalendarWidget.prototype.setDate = function ( date ) {
431 var mom = date !== null ? moment( date, this.getDateFormat() ) : moment();
432 if ( mom.isValid() ) {
433 this.moment = mom;
434 if ( date !== null ) {
435 this.setDateFromMoment();
436 } else if ( this.date !== null ) {
437 this.date = null;
438 this.emit( 'change', this.date );
439 }
440 this.displayLayer = this.getDisplayLayers()[ 0 ];
441 this.updateUI();
442 }
443 };
444
445 /**
446 * Reset the user interface of this widget to reflect selected date.
447 */
448 mw.widgets.CalendarWidget.prototype.resetUI = function () {
449 this.moment = this.getDate() !== null ? moment( this.getDate(), this.getDateFormat() ) : moment();
450 this.displayLayer = this.getDisplayLayers()[ 0 ];
451 this.updateUI();
452 };
453
454 /**
455 * Set the date from moment object.
456 *
457 * @private
458 */
459 mw.widgets.CalendarWidget.prototype.setDateFromMoment = function () {
460 // Switch to English locale to avoid number formatting. We want the internal value to be
461 // '2015-07-24' and not '٢٠١٥-٠٧-٢٤' even if the UI language is Arabic.
462 var newDate = moment( this.moment ).locale( 'en' ).format( this.getDateFormat() );
463 if ( this.date !== newDate ) {
464 this.date = newDate;
465 this.emit( 'change', this.date );
466 }
467 };
468
469 /**
470 * Get current date, in the format 'YYYY-MM-DD' or 'YYYY-MM', depending on precision. Digits will
471 * not be localised.
472 *
473 * @return {string|null} Date string
474 */
475 mw.widgets.CalendarWidget.prototype.getDate = function () {
476 return this.date;
477 };
478
479 /**
480 * Handle focus events.
481 *
482 * @private
483 */
484 mw.widgets.CalendarWidget.prototype.onFocus = function () {
485 this.displayLayer = this.getDisplayLayers()[ 0 ];
486 this.updateUI( 'down' );
487 };
488
489 /**
490 * Handle mouse click events.
491 *
492 * @private
493 * @param {jQuery.Event} e Mouse click event
494 */
495 mw.widgets.CalendarWidget.prototype.onClick = function ( e ) {
496 if ( !this.isDisabled() && e.which === 1 ) {
497 // Prevent unintended focussing
498 return false;
499 }
500 };
501
502 /**
503 * Handle key down events.
504 *
505 * @private
506 * @param {jQuery.Event} e Key down event
507 */
508 mw.widgets.CalendarWidget.prototype.onKeyDown = function ( e ) {
509 var
510 /*jshint -W024*/
511 dir = OO.ui.Element.static.getDir( this.$element ),
512 /*jshint +W024*/
513 nextDirectionKey = dir === 'ltr' ? OO.ui.Keys.RIGHT : OO.ui.Keys.LEFT,
514 prevDirectionKey = dir === 'ltr' ? OO.ui.Keys.LEFT : OO.ui.Keys.RIGHT,
515 changed = true;
516
517 if ( !this.isDisabled() ) {
518 switch ( e.which ) {
519 case prevDirectionKey:
520 this.moment.subtract( 1, this.precision === 'month' ? 'month' : 'day' );
521 break;
522 case nextDirectionKey:
523 this.moment.add( 1, this.precision === 'month' ? 'month' : 'day' );
524 break;
525 case OO.ui.Keys.UP:
526 this.moment.subtract( 1, this.precision === 'month' ? 'month' : 'week' );
527 break;
528 case OO.ui.Keys.DOWN:
529 this.moment.add( 1, this.precision === 'month' ? 'month' : 'week' );
530 break;
531 case OO.ui.Keys.PAGEUP:
532 this.moment.subtract( 1, this.precision === 'month' ? 'year' : 'month' );
533 break;
534 case OO.ui.Keys.PAGEDOWN:
535 this.moment.add( 1, this.precision === 'month' ? 'year' : 'month' );
536 break;
537 default:
538 changed = false;
539 break;
540 }
541
542 if ( changed ) {
543 this.displayLayer = this.getDisplayLayers()[ 0 ];
544 this.setDateFromMoment();
545 this.updateUI( 'auto' );
546 return false;
547 }
548 }
549 };
550
551 /**
552 * @inheritdoc
553 */
554 mw.widgets.CalendarWidget.prototype.toggle = function ( visible ) {
555 if ( this.lazyInitOnToggle && visible ) {
556 this.lazyInitOnToggle = false;
557 this.updateUI();
558 }
559
560 // Parent method
561 mw.widgets.CalendarWidget.parent.prototype.toggle.call( this, visible );
562
563 if ( this.$floatableContainer ) {
564 this.togglePositioning( this.isVisible() );
565 }
566
567 return this;
568 };
569
570 }( jQuery, mediaWiki ) );