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