Localisation updates from https://translatewiki.net.
[mediawiki.git] / resources / src / mediawiki.widgets / mw.widgets.CalendarWidget.js
blob038b4be9b79c2c638696fbb3a881af88a2055ab1
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 () {
10         /**
11          * @classdesc Calendar widget.
12          *
13          * You will most likely want to use {@link mw.widgets.DateInputWidget} instead of CalendarWidget directly.
14          *
15          * @class
16          * @extends OO.ui.Widget
17          * @mixes OO.ui.mixin.TabIndexedElement
18          * @mixes OO.ui.mixin.FloatableElement
19          * @mixes OO.ui.mixin.ClippableElement
20          *
21          * @constructor
22          * @description Creates an mw.widgets.CalendarWidget object.
23          * @param {Object} [config] Configuration options
24          * @param {boolean} [config.lazyInitOnToggle=false] Don't build most of the interface until
25          *     `.toggle( true )` is called. Meant to be used when the calendar is not immediately visible.
26          * @param {string} [config.precision='day'] Date precision to use, 'day' or 'month'
27          * @param {string|null} [config.duoDecade='prev'] Alignment of years to display in picker, use 'prev' or 'next'
28          *     'prev' is previous and current decades
29          *     'next' is current and next decades
30          * @param {string|null} [config.date=null] Day or month date (depending on `precision`), in the format
31          *     'YYYY-MM-DD' or 'YYYY-MM'. When null, the calendar will show today's date, but not select
32          *     it.
33          */
34         mw.widgets.CalendarWidget = function MWWCalendarWidget( config ) {
35                 // Config initialization
36                 config = config || {};
38                 // Parent constructor
39                 mw.widgets.CalendarWidget.super.call( this, config );
41                 // Mixin constructors
42                 OO.ui.mixin.TabIndexedElement.call( this, Object.assign( {}, config, { $tabIndexed: this.$element } ) );
43                 OO.ui.mixin.ClippableElement.call( this, Object.assign( { $clippable: this.$element }, config ) );
44                 OO.ui.mixin.FloatableElement.call( this, config );
46                 // Flipping implementation derived from MenuSelectWidget
47                 // Initial vertical positions other than 'center' will result in
48                 // the menu being flipped if there is not enough space in the container.
49                 // Store the original position so we know what to reset to.
50                 this.originalVerticalPosition = this.verticalPosition;
52                 // Properties
53                 this.lazyInitOnToggle = !!config.lazyInitOnToggle;
54                 this.precision = config.precision || 'day';
55                 this.duoDecade = config.duoDecade || 'prev';
56                 // Currently selected date (day or month)
57                 this.date = null;
58                 // Current UI state (date and precision we're displaying right now)
59                 this.moment = null;
60                 this.displayLayer = this.getDisplayLayers()[ 0 ]; // 'month', 'year', 'duodecade'
62                 this.$header = $( '<div>' ).addClass( 'mw-widget-calendarWidget-header' );
63                 this.$bodyOuterWrapper = $( '<div>' ).addClass( 'mw-widget-calendarWidget-body-outer-wrapper' );
64                 this.$bodyWrapper = $( '<div>' ).addClass( 'mw-widget-calendarWidget-body-wrapper' );
65                 this.$body = $( '<div>' ).addClass( 'mw-widget-calendarWidget-body' );
67                 // Events
68                 this.$element.on( {
69                         focus: this.onFocus.bind( this ),
70                         mousedown: this.onClick.bind( this ),
71                         keydown: this.onKeyDown.bind( this )
72                 } );
74                 // Initialization
75                 this.$element
76                         .addClass( 'mw-widget-calendarWidget' )
77                         .append( this.$header, this.$bodyOuterWrapper.append( this.$bodyWrapper.append( this.$body ) ) );
78                 if ( !this.lazyInitOnToggle ) {
79                         this.buildHeaderButtons();
80                 }
81                 this.setDate( config.date !== undefined ? config.date : null );
82         };
84         /* Inheritance */
86         OO.inheritClass( mw.widgets.CalendarWidget, OO.ui.Widget );
87         OO.mixinClass( mw.widgets.CalendarWidget, OO.ui.mixin.TabIndexedElement );
88         OO.mixinClass( mw.widgets.CalendarWidget, OO.ui.mixin.FloatableElement );
89         OO.mixinClass( mw.widgets.CalendarWidget, OO.ui.mixin.ClippableElement );
91         /* Events */
93         /**
94          * A change event is emitted when the chosen date changes.
95          *
96          * @event mw.widgets.CalendarWidget.change
97          * @param {string} date Day or month date, in the format 'YYYY-MM-DD' or 'YYYY-MM'
98          */
100         /* Static properties */
102         /**
103          * Positions to flip to if there isn't room in the container for the
104          * menu in a specific direction.
105          *
106          * @name mw.widgets.CalendarWidget.flippedPositions
107          * @type {Object.<string,string>}
108          */
109         mw.widgets.CalendarWidget.static.flippedPositions = {
110                 below: 'above',
111                 above: 'below',
112                 top: 'bottom',
113                 bottom: 'top'
114         };
116         /* Methods */
118         /**
119          * Get the date format ('YYYY-MM-DD' or 'YYYY-MM', depending on precision), which is used
120          * internally and for dates accepted by #setDate and returned by #getDate.
121          *
122          * @private
123          * @return {string} Format
124          */
125         mw.widgets.CalendarWidget.prototype.getDateFormat = function () {
126                 return {
127                         day: 'YYYY-MM-DD',
128                         month: 'YYYY-MM'
129                 }[ this.precision ];
130         };
132         /**
133          * Get the date precision this calendar uses, 'day' or 'month'.
134          *
135          * @private
136          * @return {string} Precision, 'day' or 'month'
137          */
138         mw.widgets.CalendarWidget.prototype.getPrecision = function () {
139                 return this.precision;
140         };
142         /**
143          * Get list of possible display layers.
144          *
145          * @private
146          * @return {string[]} Layers
147          */
148         mw.widgets.CalendarWidget.prototype.getDisplayLayers = function () {
149                 return [ 'month', 'year', 'duodecade' ].slice( this.precision === 'month' ? 1 : 0 );
150         };
152         /**
153          * Update the calendar.
154          *
155          * @private
156          * @param {string|null} [fade=null] Direction in which to fade out current calendar contents,
157          *     'previous', 'next', 'up' or 'down'; or 'auto', which has the same result as 'previous' or
158          *     'next' depending on whether the current date is later or earlier than the previous.
159          */
160         mw.widgets.CalendarWidget.prototype.updateUI = function ( fade ) {
161                 const $bodyWrapper = this.$bodyWrapper;
163                 if ( this.lazyInitOnToggle ) {
164                         // We're being called from the constructor and not being shown yet, do nothing
165                         return;
166                 }
168                 if (
169                         this.displayLayer === this.previousDisplayLayer &&
170                         this.date === this.previousDate &&
171                         this.previousMoment &&
172                         this.previousMoment.isSame( this.moment, this.precision === 'month' ? 'month' : 'day' )
173                 ) {
174                         // Already displayed
175                         return;
176                 }
178                 if ( fade === 'auto' ) {
179                         if ( !this.previousMoment ) {
180                                 fade = null;
181                         } else if ( this.previousMoment.isBefore( this.moment, this.precision === 'month' ? 'month' : 'day' ) ) {
182                                 fade = 'next';
183                         } else if ( this.previousMoment.isAfter( this.moment, this.precision === 'month' ? 'month' : 'day' ) ) {
184                                 fade = 'previous';
185                         } else {
186                                 fade = null;
187                         }
188                 }
190                 let items = [];
191                 if ( this.$oldBody ) {
192                         this.$oldBody.remove();
193                 }
194                 this.$oldBody = this.$body.addClass( 'mw-widget-calendarWidget-old-body' );
195                 // Clone without children
196                 this.$body = $( this.$body[ 0 ].cloneNode( false ) )
197                         .removeClass( 'mw-widget-calendarWidget-old-body' )
198                         .toggleClass( 'mw-widget-calendarWidget-body-month', this.displayLayer === 'month' )
199                         .toggleClass( 'mw-widget-calendarWidget-body-year', this.displayLayer === 'year' )
200                         .toggleClass( 'mw-widget-calendarWidget-body-duodecade', this.displayLayer === 'duodecade' );
202                 const today = moment();
203                 const selected = moment( this.getDate(), this.getDateFormat() );
205                 switch ( this.displayLayer ) {
206                         case 'month':
207                                 this.labelButton.setLabel( this.moment.format( 'MMMM YYYY' ) );
208                                 this.labelButton.toggle( true );
209                                 this.upButton.toggle( true );
211                                 // First week displayed is the first week spanned by the month, unless it begins on Monday, in
212                                 // which case first week displayed is the previous week. This makes the calendar "balanced"
213                                 // and also neatly handles 28-day February sometimes spanning only 4 weeks.
214                                 // eslint-disable-next-line no-case-declarations
215                                 const currentDay = moment( this.moment ).startOf( 'month' ).subtract( 1, 'day' ).startOf( 'week' );
217                                 // Day-of-week labels. Localisation-independent: works with weeks starting on Saturday, Sunday
218                                 // or Monday.
219                                 for ( let w = 0; w < 7; w++ ) {
220                                         items.push(
221                                                 $( '<div>' )
222                                                         .addClass( 'mw-widget-calendarWidget-day-heading' )
223                                                         .text( currentDay.format( 'dd' ) )
224                                         );
225                                         currentDay.add( 1, 'day' );
226                                 }
227                                 currentDay.subtract( 7, 'days' );
229                                 // Actual calendar month. Always displays 6 weeks, for consistency (months can span 4 to 6
230                                 // weeks).
231                                 for ( let i = 0; i < 42; i++ ) {
232                                         items.push(
233                                                 $( '<div>' )
234                                                         .addClass( 'mw-widget-calendarWidget-item mw-widget-calendarWidget-day' )
235                                                         .toggleClass( 'mw-widget-calendarWidget-day-additional', !currentDay.isSame( this.moment, 'month' ) )
236                                                         .toggleClass( 'mw-widget-calendarWidget-day-today', currentDay.isSame( today, 'day' ) )
237                                                         .toggleClass( 'mw-widget-calendarWidget-item-selected', currentDay.isSame( selected, 'day' ) )
238                                                         .text( currentDay.format( 'D' ) )
239                                                         .data( 'date', currentDay.date() )
240                                                         .data( 'month', currentDay.month() )
241                                                         .data( 'year', currentDay.year() )
242                                         );
243                                         currentDay.add( 1, 'day' );
244                                 }
245                                 break;
247                         case 'year':
248                                 this.labelButton.setLabel( this.moment.format( 'YYYY' ) );
249                                 this.labelButton.toggle( true );
250                                 this.upButton.toggle( true );
252                                 // eslint-disable-next-line no-case-declarations
253                                 const currentMonth = moment( this.moment ).startOf( 'year' );
254                                 for ( let m = 0; m < 12; m++ ) {
255                                         items.push(
256                                                 $( '<div>' )
257                                                         .addClass( 'mw-widget-calendarWidget-item mw-widget-calendarWidget-month' )
258                                                         .toggleClass( 'mw-widget-calendarWidget-item-selected', currentMonth.isSame( selected, 'month' ) )
259                                                         .text( currentMonth.format( 'MMMM' ) )
260                                                         .data( 'month', currentMonth.month() )
261                                         );
262                                         currentMonth.add( 1, 'month' );
263                                 }
264                                 // Shuffle the array to display months in columns rather than rows:
265                                 // | Jan | Jul |
266                                 // | Feb | Aug |
267                                 // | Mar | Sep |
268                                 // | Apr | Oct |
269                                 // | May | Nov |
270                                 // | Jun | Dec |
271                                 items = [
272                                         items[ 0 ], items[ 6 ],
273                                         items[ 1 ], items[ 7 ],
274                                         items[ 2 ], items[ 8 ],
275                                         items[ 3 ], items[ 9 ],
276                                         items[ 4 ], items[ 10 ],
277                                         items[ 5 ], items[ 11 ]
278                                 ];
279                                 break;
281                         case 'duodecade':
282                                 this.labelButton.setLabel( null );
283                                 this.labelButton.toggle( false );
284                                 this.upButton.toggle( false );
285                                 // eslint-disable-next-line no-case-declarations
286                                 let currentYear;
287                                 if ( this.duoDecade === 'prev' ) {
288                                         currentYear = moment( { year: Math.floor( ( this.moment.year() - 10 ) / 10 ) * 10 } );
289                                 } else if ( this.duoDecade === 'next' ) {
290                                         currentYear = moment( { year: Math.floor( this.moment.year() / 10 ) * 10 } );
291                                 }
292                                 for ( let y = 0; y < 20; y++ ) {
293                                         items.push(
294                                                 $( '<div>' )
295                                                         .addClass( 'mw-widget-calendarWidget-item mw-widget-calendarWidget-year' )
296                                                         .toggleClass( 'mw-widget-calendarWidget-item-selected', currentYear.isSame( selected, 'year' ) )
297                                                         .text( currentYear.format( 'YYYY' ) )
298                                                         .data( 'year', currentYear.year() )
299                                         );
300                                         currentYear.add( 1, 'year' );
301                                 }
302                                 break;
303                 }
305                 this.$body.append( ...items );
307                 $bodyWrapper
308                         .removeClass( 'mw-widget-calendarWidget-body-wrapper-fade-up' )
309                         .removeClass( 'mw-widget-calendarWidget-body-wrapper-fade-down' )
310                         .removeClass( 'mw-widget-calendarWidget-body-wrapper-fade-previous' )
311                         .removeClass( 'mw-widget-calendarWidget-body-wrapper-fade-next' );
313                 let needsFade = this.previousDisplayLayer !== this.displayLayer;
314                 if ( this.displayLayer === 'month' ) {
315                         needsFade = needsFade || !this.moment.isSame( this.previousMoment, 'month' );
316                 } else if ( this.displayLayer === 'year' ) {
317                         needsFade = needsFade || !this.moment.isSame( this.previousMoment, 'year' );
318                 } else if ( this.displayLayer === 'duodecade' ) {
319                         needsFade = needsFade || (
320                                 Math.floor( this.moment.year() / 20 ) * 20 !==
321                                         Math.floor( this.previousMoment.year() / 20 ) * 20
322                         );
323                 }
325                 if ( fade && needsFade ) {
326                         this.$oldBody.find( '.mw-widget-calendarWidget-item-selected' )
327                                 .removeClass( 'mw-widget-calendarWidget-item-selected' );
328                         if ( fade === 'previous' || fade === 'up' ) {
329                                 this.$body.insertBefore( this.$oldBody );
330                         } else if ( fade === 'next' || fade === 'down' ) {
331                                 this.$body.insertAfter( this.$oldBody );
332                         }
333                         setTimeout( () => {
334                                 // The following classes are used here:
335                                 // * mw-widget-calendarWidget-body-wrapper-fade-up
336                                 // * mw-widget-calendarWidget-body-wrapper-fade-down
337                                 // * mw-widget-calendarWidget-body-wrapper-fade-previous
338                                 // * mw-widget-calendarWidget-body-wrapper-fade-next
339                                 $bodyWrapper.addClass( 'mw-widget-calendarWidget-body-wrapper-fade-' + fade );
340                         } );
341                 } else {
342                         this.$oldBody.replaceWith( this.$body );
343                 }
345                 this.previousMoment = moment( this.moment );
346                 this.previousDisplayLayer = this.displayLayer;
347                 this.previousDate = this.date;
349                 this.$body.on( 'click', this.onBodyClick.bind( this ) );
350         };
352         /**
353          * Construct and display buttons to navigate the calendar.
354          *
355          * @private
356          */
357         mw.widgets.CalendarWidget.prototype.buildHeaderButtons = function () {
358                 this.labelButton = new OO.ui.ButtonWidget( {
359                         tabIndex: -1,
360                         label: '',
361                         framed: false,
362                         classes: [ 'mw-widget-calendarWidget-labelButton' ]
363                 } );
364                 // FIXME This button is actually not clickable because labelButton covers it,
365                 // should it just be a plain icon?
366                 this.upButton = new OO.ui.ButtonWidget( {
367                         tabIndex: -1,
368                         framed: false,
369                         icon: 'collapse',
370                         classes: [ 'mw-widget-calendarWidget-upButton' ]
371                 } );
372                 this.prevButton = new OO.ui.ButtonWidget( {
373                         tabIndex: -1,
374                         framed: false,
375                         icon: 'previous',
376                         classes: [ 'mw-widget-calendarWidget-prevButton' ]
377                 } );
378                 this.nextButton = new OO.ui.ButtonWidget( {
379                         tabIndex: -1,
380                         framed: false,
381                         icon: 'next',
382                         classes: [ 'mw-widget-calendarWidget-nextButton' ]
383                 } );
385                 this.labelButton.connect( this, { click: 'onUpButtonClick' } );
386                 this.upButton.connect( this, { click: 'onUpButtonClick' } );
387                 this.prevButton.connect( this, { click: 'onPrevButtonClick' } );
388                 this.nextButton.connect( this, { click: 'onNextButtonClick' } );
390                 this.$header.append(
391                         this.prevButton.$element,
392                         this.nextButton.$element,
393                         this.labelButton.$element,
394                         this.upButton.$element
395                 );
396         };
398         /**
399          * Handle click events on the "up" button, switching to less precise view.
400          *
401          * @private
402          */
403         mw.widgets.CalendarWidget.prototype.onUpButtonClick = function () {
404                 const
405                         layers = this.getDisplayLayers(),
406                         currentLayer = layers.indexOf( this.displayLayer );
407                 if ( currentLayer !== layers.length - 1 ) {
408                         // One layer up
409                         this.displayLayer = layers[ currentLayer + 1 ];
410                         this.updateUI( 'up' );
411                 } else {
412                         this.updateUI();
413                 }
414         };
416         /**
417          * Handle click events on the "previous" button, switching to previous pane.
418          *
419          * @private
420          */
421         mw.widgets.CalendarWidget.prototype.onPrevButtonClick = function () {
422                 switch ( this.displayLayer ) {
423                         case 'month':
424                                 this.moment.subtract( 1, 'month' );
425                                 break;
426                         case 'year':
427                                 this.moment.subtract( 1, 'year' );
428                                 break;
429                         case 'duodecade':
430                                 this.moment.subtract( 20, 'years' );
431                                 break;
432                 }
433                 this.updateUI( 'previous' );
434         };
436         /**
437          * Handle click events on the "next" button, switching to next pane.
438          *
439          * @private
440          */
441         mw.widgets.CalendarWidget.prototype.onNextButtonClick = function () {
442                 switch ( this.displayLayer ) {
443                         case 'month':
444                                 this.moment.add( 1, 'month' );
445                                 break;
446                         case 'year':
447                                 this.moment.add( 1, 'year' );
448                                 break;
449                         case 'duodecade':
450                                 this.moment.add( 20, 'years' );
451                                 break;
452                 }
453                 this.updateUI( 'next' );
454         };
456         /**
457          * Handle click events anywhere in the body of the widget, which contains the matrix of days,
458          * months or years to choose. Maybe change the pane or switch to more precise view, depending on
459          * what gets clicked.
460          *
461          * @private
462          * @param {jQuery.Event} e Click event
463          */
464         mw.widgets.CalendarWidget.prototype.onBodyClick = function ( e ) {
465                 const
466                         $target = $( e.target ),
467                         layers = this.getDisplayLayers(),
468                         currentLayer = layers.indexOf( this.displayLayer );
469                 if ( $target.data( 'year' ) !== undefined ) {
470                         this.moment.year( $target.data( 'year' ) );
471                 }
472                 if ( $target.data( 'month' ) !== undefined ) {
473                         this.moment.month( $target.data( 'month' ) );
474                 }
475                 if ( $target.data( 'date' ) !== undefined ) {
476                         this.moment.date( $target.data( 'date' ) );
477                 }
478                 if ( currentLayer === 0 ) {
479                         this.setDateFromMoment();
480                         this.updateUI( 'auto' );
481                 } else {
482                         // One layer down
483                         this.displayLayer = layers[ currentLayer - 1 ];
484                         this.updateUI( 'down' );
485                 }
486         };
488         /**
489          * Set the date.
490          *
491          * @param {string|null} [date=null] Day or month date, in the format 'YYYY-MM-DD' or 'YYYY-MM'.
492          *     When null, the calendar will show today's date, but not select it. When invalid, the date
493          *     is not changed.
494          */
495         mw.widgets.CalendarWidget.prototype.setDate = function ( date ) {
496                 const mom = date !== null ? moment( date, this.getDateFormat() ) : moment();
497                 if ( mom.isValid() ) {
498                         this.moment = mom;
499                         if ( date !== null ) {
500                                 this.setDateFromMoment();
501                         } else if ( this.date !== null ) {
502                                 this.date = null;
503                                 this.emit( 'change', this.date );
504                         }
505                         this.displayLayer = this.getDisplayLayers()[ 0 ];
506                         this.updateUI();
507                 }
508         };
510         /**
511          * Set the date that is shown in the calendar, but not the selected date.
512          *
513          * @param {Object} mom Moment object
514          */
515         mw.widgets.CalendarWidget.prototype.setMoment = function ( mom ) {
516                 if ( mom.isValid() ) {
517                         this.moment = mom;
518                         this.updateUI();
519                 }
520         };
522         /**
523          * Reset the user interface of this widget to reflect selected date.
524          */
525         mw.widgets.CalendarWidget.prototype.resetUI = function () {
526                 this.moment = this.getDate() !== null ? moment( this.getDate(), this.getDateFormat() ) : moment();
527                 this.displayLayer = this.getDisplayLayers()[ 0 ];
528                 this.updateUI();
529         };
531         /**
532          * Set the date from moment object.
533          *
534          * @private
535          */
536         mw.widgets.CalendarWidget.prototype.setDateFromMoment = function () {
537                 // Switch to English locale to avoid number formatting. We want the internal value to be
538                 // '2015-07-24' and not '٢٠١٥-٠٧-٢٤' even if the UI language is Arabic.
539                 const newDate = moment( this.moment ).locale( 'en' ).format( this.getDateFormat() );
540                 if ( this.date !== newDate ) {
541                         this.date = newDate;
542                         this.emit( 'change', this.date );
543                 }
544         };
546         /**
547          * Get current date, in the format 'YYYY-MM-DD' or 'YYYY-MM', depending on precision. Digits will
548          * not be localised.
549          *
550          * @return {string|null} Date string
551          */
552         mw.widgets.CalendarWidget.prototype.getDate = function () {
553                 return this.date;
554         };
556         /**
557          * Handle focus events.
558          *
559          * @private
560          */
561         mw.widgets.CalendarWidget.prototype.onFocus = function () {
562                 this.displayLayer = this.getDisplayLayers()[ 0 ];
563                 this.updateUI( 'down' );
564         };
566         /**
567          * Handle mouse click events.
568          *
569          * @private
570          * @param {jQuery.Event} e Mouse click event
571          * @return {boolean|undefined} False to cancel the default event
572          */
573         mw.widgets.CalendarWidget.prototype.onClick = function ( e ) {
574                 if ( !this.isDisabled() && e.which === 1 ) {
575                         // Prevent unintended focussing
576                         return false;
577                 }
578         };
580         /**
581          * Handle key down events.
582          *
583          * @private
584          * @param {jQuery.Event} e Key down event
585          * @return {boolean|undefined} False to cancel the default event
586          */
587         mw.widgets.CalendarWidget.prototype.onKeyDown = function ( e ) {
588                 const
589                         dir = OO.ui.Element.static.getDir( this.$element ),
590                         nextDirectionKey = dir === 'ltr' ? OO.ui.Keys.RIGHT : OO.ui.Keys.LEFT,
591                         prevDirectionKey = dir === 'ltr' ? OO.ui.Keys.LEFT : OO.ui.Keys.RIGHT;
593                 let changed = true;
595                 if ( !this.isDisabled() ) {
596                         switch ( e.which ) {
597                                 case prevDirectionKey:
598                                         this.moment.subtract( 1, this.precision === 'month' ? 'month' : 'day' );
599                                         break;
600                                 case nextDirectionKey:
601                                         this.moment.add( 1, this.precision === 'month' ? 'month' : 'day' );
602                                         break;
603                                 case OO.ui.Keys.UP:
604                                         this.moment.subtract( 1, this.precision === 'month' ? 'month' : 'week' );
605                                         break;
606                                 case OO.ui.Keys.DOWN:
607                                         this.moment.add( 1, this.precision === 'month' ? 'month' : 'week' );
608                                         break;
609                                 case OO.ui.Keys.PAGEUP:
610                                         this.moment.subtract( 1, this.precision === 'month' ? 'year' : 'month' );
611                                         break;
612                                 case OO.ui.Keys.PAGEDOWN:
613                                         this.moment.add( 1, this.precision === 'month' ? 'year' : 'month' );
614                                         break;
615                                 default:
616                                         changed = false;
617                                         break;
618                         }
620                         if ( changed ) {
621                                 this.displayLayer = this.getDisplayLayers()[ 0 ];
622                                 this.setDateFromMoment();
623                                 this.updateUI( 'auto' );
624                                 return false;
625                         }
626                 }
627         };
629         /**
630          * @inheritdoc
631          */
632         mw.widgets.CalendarWidget.prototype.toggle = function ( visible ) {
633                 visible = visible === undefined ? !this.visible : !!visible;
634                 const change = visible !== this.isVisible();
635                 if ( this.lazyInitOnToggle && visible ) {
636                         this.lazyInitOnToggle = false;
637                         this.buildHeaderButtons();
638                         this.updateUI();
639                 }
641                 // Flipping implementation derived from MenuSelectWidget
642                 if ( change && visible ) {
643                         // Reset position before showing the popup again. It's possible we no longer need to flip
644                         // (e.g. if the user scrolled).
645                         this.setVerticalPosition( this.originalVerticalPosition );
646                 }
648                 // Parent method
649                 mw.widgets.CalendarWidget.super.prototype.toggle.call( this, visible );
651                 if ( change ) {
652                         this.togglePositioning( visible && !!this.$floatableContainer );
653                         this.toggleClipping( visible );
655                         // Flipping implementation derived from MenuSelectWidget
656                         if (
657                                 ( this.isClippedVertically() || this.isFloatableOutOfView() ) &&
658                                 this.originalVerticalPosition !== 'center'
659                         ) {
660                                 // If opening the menu in one direction causes it to be clipped, flip it
661                                 const originalHeight = this.$element.height();
662                                 this.setVerticalPosition(
663                                         this.constructor.static.flippedPositions[ this.originalVerticalPosition ]
664                                 );
665                                 if ( this.isClippedVertically() || this.isFloatableOutOfView() ) {
666                                         // If flipping also causes it to be clipped, open in whichever direction
667                                         // we have more space
668                                         const flippedHeight = this.$element.height();
669                                         if ( originalHeight > flippedHeight ) {
670                                                 this.setVerticalPosition( this.originalVerticalPosition );
671                                         }
672                                 }
673                         }
674                         // Note that we do not flip the menu's opening direction if the clipping changes
675                         // later (e.g. after the user scrolls), that seems like it would be annoying
676                 }
678                 return this;
679         };
681 }() );