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