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