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