2 * MediaWiki Widgets – CalendarWidget class.
4 * @copyright 2011-2015 MediaWiki Widgets Team and others; see AUTHORS.txt
5 * @license The MIT License (MIT); see LICENSE.txt
11 * @classdesc Calendar widget.
13 * You will most likely want to use {@link mw.widgets.DateInputWidget} instead of CalendarWidget directly.
16 * @extends OO.ui.Widget
17 * @mixes OO.ui.mixin.TabIndexedElement
18 * @mixes OO.ui.mixin.FloatableElement
19 * @mixes OO.ui.mixin.ClippableElement
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
34 mw.widgets.CalendarWidget = function MWWCalendarWidget( config ) {
35 // Config initialization
36 config = config || {};
39 mw.widgets.CalendarWidget.super.call( this, config );
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;
53 this.lazyInitOnToggle = !!config.lazyInitOnToggle;
54 this.precision = config.precision || 'day';
55 this.duoDecade = config.duoDecade || 'prev';
56 // Currently selected date (day or month)
58 // Current UI state (date and precision we're displaying right now)
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' );
69 focus: this.onFocus.bind( this ),
70 mousedown: this.onClick.bind( this ),
71 keydown: this.onKeyDown.bind( this )
76 .addClass( 'mw-widget-calendarWidget' )
77 .append( this.$header, this.$bodyOuterWrapper.append( this.$bodyWrapper.append( this.$body ) ) );
78 if ( !this.lazyInitOnToggle ) {
79 this.buildHeaderButtons();
81 this.setDate( config.date !== undefined ? config.date : null );
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 );
94 * A change event is emitted when the chosen date changes.
96 * @event mw.widgets.CalendarWidget.change
97 * @param {string} date Day or month date, in the format 'YYYY-MM-DD' or 'YYYY-MM'
100 /* Static properties */
103 * Positions to flip to if there isn't room in the container for the
104 * menu in a specific direction.
106 * @name mw.widgets.CalendarWidget.flippedPositions
107 * @type {Object.<string,string>}
109 mw.widgets.CalendarWidget.static.flippedPositions = {
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.
123 * @return {string} Format
125 mw.widgets.CalendarWidget.prototype.getDateFormat = function () {
133 * Get the date precision this calendar uses, 'day' or 'month'.
136 * @return {string} Precision, 'day' or 'month'
138 mw.widgets.CalendarWidget.prototype.getPrecision = function () {
139 return this.precision;
143 * Get list of possible display layers.
146 * @return {string[]} Layers
148 mw.widgets.CalendarWidget.prototype.getDisplayLayers = function () {
149 return [ 'month', 'year', 'duodecade' ].slice( this.precision === 'month' ? 1 : 0 );
153 * Update the calendar.
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.
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
169 this.displayLayer === this.previousDisplayLayer &&
170 this.date === this.previousDate &&
171 this.previousMoment &&
172 this.previousMoment.isSame( this.moment, this.precision === 'month' ? 'month' : 'day' )
178 if ( fade === 'auto' ) {
179 if ( !this.previousMoment ) {
181 } else if ( this.previousMoment.isBefore( this.moment, this.precision === 'month' ? 'month' : 'day' ) ) {
183 } else if ( this.previousMoment.isAfter( this.moment, this.precision === 'month' ? 'month' : 'day' ) ) {
191 if ( this.$oldBody ) {
192 this.$oldBody.remove();
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 ) {
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
219 for ( let w = 0; w < 7; w++ ) {
222 .addClass( 'mw-widget-calendarWidget-day-heading' )
223 .text( currentDay.format( 'dd' ) )
225 currentDay.add( 1, 'day' );
227 currentDay.subtract( 7, 'days' );
229 // Actual calendar month. Always displays 6 weeks, for consistency (months can span 4 to 6
231 for ( let i = 0; i < 42; i++ ) {
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() )
243 currentDay.add( 1, 'day' );
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++ ) {
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() )
262 currentMonth.add( 1, 'month' );
264 // Shuffle the array to display months in columns rather than rows:
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 ]
282 this.labelButton.setLabel( null );
283 this.labelButton.toggle( false );
284 this.upButton.toggle( false );
285 // eslint-disable-next-line no-case-declarations
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 } );
292 for ( let y = 0; y < 20; y++ ) {
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() )
300 currentYear.add( 1, 'year' );
305 this.$body.append( ...items );
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
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 );
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 );
342 this.$oldBody.replaceWith( this.$body );
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 ) );
353 * Construct and display buttons to navigate the calendar.
357 mw.widgets.CalendarWidget.prototype.buildHeaderButtons = function () {
358 this.labelButton = new OO.ui.ButtonWidget( {
362 classes: [ 'mw-widget-calendarWidget-labelButton' ]
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( {
370 classes: [ 'mw-widget-calendarWidget-upButton' ]
372 this.prevButton = new OO.ui.ButtonWidget( {
376 classes: [ 'mw-widget-calendarWidget-prevButton' ]
378 this.nextButton = new OO.ui.ButtonWidget( {
382 classes: [ 'mw-widget-calendarWidget-nextButton' ]
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' } );
391 this.prevButton.$element,
392 this.nextButton.$element,
393 this.labelButton.$element,
394 this.upButton.$element
399 * Handle click events on the "up" button, switching to less precise view.
403 mw.widgets.CalendarWidget.prototype.onUpButtonClick = function () {
405 layers = this.getDisplayLayers(),
406 currentLayer = layers.indexOf( this.displayLayer );
407 if ( currentLayer !== layers.length - 1 ) {
409 this.displayLayer = layers[ currentLayer + 1 ];
410 this.updateUI( 'up' );
417 * Handle click events on the "previous" button, switching to previous pane.
421 mw.widgets.CalendarWidget.prototype.onPrevButtonClick = function () {
422 switch ( this.displayLayer ) {
424 this.moment.subtract( 1, 'month' );
427 this.moment.subtract( 1, 'year' );
430 this.moment.subtract( 20, 'years' );
433 this.updateUI( 'previous' );
437 * Handle click events on the "next" button, switching to next pane.
441 mw.widgets.CalendarWidget.prototype.onNextButtonClick = function () {
442 switch ( this.displayLayer ) {
444 this.moment.add( 1, 'month' );
447 this.moment.add( 1, 'year' );
450 this.moment.add( 20, 'years' );
453 this.updateUI( 'next' );
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
462 * @param {jQuery.Event} e Click event
464 mw.widgets.CalendarWidget.prototype.onBodyClick = function ( e ) {
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' ) );
472 if ( $target.data( 'month' ) !== undefined ) {
473 this.moment.month( $target.data( 'month' ) );
475 if ( $target.data( 'date' ) !== undefined ) {
476 this.moment.date( $target.data( 'date' ) );
478 if ( currentLayer === 0 ) {
479 this.setDateFromMoment();
480 this.updateUI( 'auto' );
483 this.displayLayer = layers[ currentLayer - 1 ];
484 this.updateUI( 'down' );
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
495 mw.widgets.CalendarWidget.prototype.setDate = function ( date ) {
496 const mom = date !== null ? moment( date, this.getDateFormat() ) : moment();
497 if ( mom.isValid() ) {
499 if ( date !== null ) {
500 this.setDateFromMoment();
501 } else if ( this.date !== null ) {
503 this.emit( 'change', this.date );
505 this.displayLayer = this.getDisplayLayers()[ 0 ];
511 * Set the date that is shown in the calendar, but not the selected date.
513 * @param {Object} mom Moment object
515 mw.widgets.CalendarWidget.prototype.setMoment = function ( mom ) {
516 if ( mom.isValid() ) {
523 * Reset the user interface of this widget to reflect selected date.
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 ];
532 * Set the date from moment object.
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 ) {
542 this.emit( 'change', this.date );
547 * Get current date, in the format 'YYYY-MM-DD' or 'YYYY-MM', depending on precision. Digits will
550 * @return {string|null} Date string
552 mw.widgets.CalendarWidget.prototype.getDate = function () {
557 * Handle focus events.
561 mw.widgets.CalendarWidget.prototype.onFocus = function () {
562 this.displayLayer = this.getDisplayLayers()[ 0 ];
563 this.updateUI( 'down' );
567 * Handle mouse click events.
570 * @param {jQuery.Event} e Mouse click event
571 * @return {boolean|undefined} False to cancel the default event
573 mw.widgets.CalendarWidget.prototype.onClick = function ( e ) {
574 if ( !this.isDisabled() && e.which === 1 ) {
575 // Prevent unintended focussing
581 * Handle key down events.
584 * @param {jQuery.Event} e Key down event
585 * @return {boolean|undefined} False to cancel the default event
587 mw.widgets.CalendarWidget.prototype.onKeyDown = function ( e ) {
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;
595 if ( !this.isDisabled() ) {
597 case prevDirectionKey:
598 this.moment.subtract( 1, this.precision === 'month' ? 'month' : 'day' );
600 case nextDirectionKey:
601 this.moment.add( 1, this.precision === 'month' ? 'month' : 'day' );
604 this.moment.subtract( 1, this.precision === 'month' ? 'month' : 'week' );
606 case OO.ui.Keys.DOWN:
607 this.moment.add( 1, this.precision === 'month' ? 'month' : 'week' );
609 case OO.ui.Keys.PAGEUP:
610 this.moment.subtract( 1, this.precision === 'month' ? 'year' : 'month' );
612 case OO.ui.Keys.PAGEDOWN:
613 this.moment.add( 1, this.precision === 'month' ? 'year' : 'month' );
621 this.displayLayer = this.getDisplayLayers()[ 0 ];
622 this.setDateFromMoment();
623 this.updateUI( 'auto' );
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();
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 );
649 mw.widgets.CalendarWidget.super.prototype.toggle.call( this, visible );
652 this.togglePositioning( visible && !!this.$floatableContainer );
653 this.toggleClipping( visible );
655 // Flipping implementation derived from MenuSelectWidget
657 ( this.isClippedVertically() || this.isFloatableOutOfView() ) &&
658 this.originalVerticalPosition !== 'center'
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 ]
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 );
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