4 * CalendarWidget displays a calendar that can be used to select a date. It
5 * uses {@link mw.widgets.datetime.DateTimeFormatter DateTimeFormatter} to get the details of
8 * This widget is mainly intended to be used as a popup from a
9 * {@link mw.widgets.datetime.DateTimeInputWidget DateTimeInputWidget}, but may also be used
13 * @extends OO.ui.Widget
14 * @mixins OO.ui.mixin.TabIndexedElement
17 * @param {Object} [config] Configuration options
18 * @cfg {Object|mw.widgets.datetime.DateTimeFormatter} [formatter={}] Configuration options for
19 * mw.widgets.datetime.ProlepticGregorianDateTimeFormatter, or an mw.widgets.datetime.DateTimeFormatter
21 * @cfg {OO.ui.Widget|null} [widget=null] Widget associated with the calendar.
22 * Specifying this configures the calendar to be used as a popup from the
23 * specified widget (e.g. absolute positioning, automatic hiding when clicked
25 * @cfg {Date|null} [min=null] Minimum allowed date
26 * @cfg {Date|null} [max=null] Maximum allowed date
27 * @cfg {Date} [focusedDate] Initially focused date.
28 * @cfg {Date|Date[]|null} [selected=null] Selected date(s).
30 mw
.widgets
.datetime
.CalendarWidget
= function MwWidgetsDatetimeCalendarWidget( config
) {
31 var $colgroup
, $headTR
, headings
, i
;
33 // Configuration initialization
37 focusedDate
: new Date(),
43 mw
.widgets
.datetime
.CalendarWidget
[ 'super' ].call( this, config
);
46 OO
.ui
.mixin
.TabIndexedElement
.call( this, $.extend( {}, config
, { $tabIndexed
: this.$element
} ) );
49 if ( config
.min
instanceof Date
&& config
.min
.getTime() >= -62167219200000 ) {
50 this.min
= config
.min
;
52 this.min
= new Date( -62167219200000 ); // 0000-01-01T00:00:00.000Z
54 if ( config
.max
instanceof Date
&& config
.max
.getTime() <= 253402300799999 ) {
55 this.max
= config
.max
;
57 this.max
= new Date( 253402300799999 ); // 9999-12-31T12:59:59.999Z
60 if ( config
.focusedDate
instanceof Date
) {
61 this.focusedDate
= config
.focusedDate
;
63 this.focusedDate
= new Date();
68 if ( config
.formatter
instanceof mw
.widgets
.datetime
.DateTimeFormatter
) {
69 this.formatter
= config
.formatter
;
70 } else if ( $.isPlainObject( config
.formatter
) ) {
71 this.formatter
= new mw
.widgets
.datetime
.ProlepticGregorianDateTimeFormatter( config
.formatter
);
73 throw new Error( '"formatter" must be an mw.widgets.datetime.DateTimeFormatter or a plain object' );
76 this.calendarData
= null;
78 this.widget
= config
.widget
;
79 this.$widget
= config
.widget
? config
.widget
.$element
: null;
80 this.onDocumentMouseDownHandler
= this.onDocumentMouseDown
.bind( this );
82 this.$head
= $( '<div>' );
83 this.$header
= $( '<span>' );
84 this.$table
= $( '<table>' );
86 this.colNullable
= [];
88 this.$tableBody
= $( '<tbody>' );
96 keydown
: this.onKeyDown
.bind( this )
98 this.formatter
.connect( this, {
99 local
: 'onLocalChange'
101 if ( this.$widget
) {
102 this.checkFocusHandler
= this.checkFocus
.bind( this );
104 focusout
: this.onFocusOut
.bind( this )
107 focusout
: this.onFocusOut
.bind( this )
113 .addClass( 'mw-widgets-datetime-calendarWidget-heading' )
115 new OO
.ui
.ButtonWidget( {
118 classes
: [ 'mw-widgets-datetime-calendarWidget-previous' ],
120 } ).connect( this, { click
: 'onPrevClick' } ).$element
,
121 new OO
.ui
.ButtonWidget( {
124 classes
: [ 'mw-widgets-datetime-calendarWidget-next' ],
126 } ).connect( this, { click
: 'onNextClick' } ).$element
,
129 $colgroup
= $( '<colgroup>' );
130 $headTR
= $( '<tr>' );
132 .addClass( 'mw-widgets-datetime-calendarWidget-grid' )
134 .append( $( '<thead>' ).append( $headTR
) )
135 .append( this.$tableBody
);
137 headings
= this.formatter
.getCalendarHeadings();
138 for ( i
= 0; i
< headings
.length
; i
++ ) {
139 this.cols
[ i
] = $( '<col>' );
140 this.headings
[ i
] = $( '<th>' );
141 this.colNullable
[ i
] = headings
[ i
] === null;
142 if ( headings
[ i
] !== null ) {
143 this.headings
[ i
].text( headings
[ i
] );
144 this.minWidth
= Math
.max( this.minWidth
, headings
[ i
].length
);
147 $colgroup
.append( this.cols
[ i
] );
148 $headTR
.append( this.headings
[ i
] );
151 this.setSelected( config
.selected
);
153 .addClass( 'mw-widgets-datetime-calendarWidget' )
154 .append( this.$head
, this.$table
);
157 this.$element
.addClass( 'mw-widgets-datetime-calendarWidget-dependent' );
159 // Initially hidden - using #toggle may cause errors if subclasses override toggle with methods
160 // that reference properties not initialized at that time of parent class construction
161 // TODO: Find a better way to handle post-constructor setup
162 this.visible
= false;
163 this.$element
.addClass( 'oo-ui-element-hidden' );
171 OO
.inheritClass( mw
.widgets
.datetime
.CalendarWidget
, OO
.ui
.Widget
);
172 OO
.mixinClass( mw
.widgets
.datetime
.CalendarWidget
, OO
.ui
.mixin
.TabIndexedElement
);
177 * A `change` event is emitted when the selected dates change
183 * A `focusChange` event is emitted when the focused date changes
189 * A `page` event is emitted when the current "month" changes
197 * Return the current selected dates
201 mw
.widgets
.datetime
.CalendarWidget
.prototype.getSelected = function () {
202 return this.selected
;
205 // eslint-disable-next-line valid-jsdoc
207 * Set the selected dates
209 * @param {Date|Date[]|null} dates
213 mw
.widgets
.datetime
.CalendarWidget
.prototype.setSelected = function ( dates
) {
214 var i
, changed
= false;
216 if ( dates
instanceof Date
) {
218 } else if ( Array
.isArray( dates
) ) {
219 dates
= dates
.filter( function ( dt
) {
220 return dt
instanceof Date
;
227 if ( this.selected
.length
!== dates
.length
) {
230 for ( i
= 0; i
< dates
.length
; i
++ ) {
231 if ( dates
[ i
].getTime() !== this.selected
[ i
].getTime() ) {
239 this.selected
= dates
;
240 this.emit( 'change', dates
);
248 * Return the currently-focused date
252 mw
.widgets
.datetime
.CalendarWidget
.prototype.getFocusedDate = function () {
253 return this.focusedDate
;
256 // eslint-disable-next-line valid-jsdoc
258 * Set the currently-focused date
264 mw
.widgets
.datetime
.CalendarWidget
.prototype.setFocusedDate = function ( date
) {
265 var changePage
= false,
268 if ( this.focusedDate
.getTime() === date
.getTime() ) {
272 if ( !this.formatter
.sameCalendarGrid( this.focusedDate
, date
) ) {
276 !this.formatter
.timePartIsEqual( this.focusedDate
, date
) ||
277 !this.formatter
.datePartIsEqual( this.focusedDate
, date
)
282 this.focusedDate
= date
;
283 this.emit( 'focusChanged', this.focusedDate
);
285 this.emit( 'page', date
);
298 * @param {Date} date Date to adjust
299 * @param {string} component Component: 'month', 'week', or 'day'
300 * @param {number} delta Integer, usually -1 or 1
301 * @param {boolean} [enforceRange=true] Whether to enforce this.min and this.max
304 mw
.widgets
.datetime
.CalendarWidget
.prototype.adjustDate = function ( date
, component
, delta
) {
306 data
= this.calendarData
;
312 switch ( component
) {
314 newDate
= this.formatter
.adjustComponent( date
, data
.monthComponent
, delta
, 'overflow' );
318 if ( data
.weekComponent
=== undefined ) {
319 newDate
= this.formatter
.adjustComponent(
320 date
, data
.dayComponent
, delta
* this.daysPerWeek
, 'overflow' );
322 newDate
= this.formatter
.adjustComponent( date
, data
.weekComponent
, delta
, 'overflow' );
327 newDate
= this.formatter
.adjustComponent( date
, data
.dayComponent
, delta
, 'overflow' );
331 throw new Error( 'Unknown component' );
334 while ( newDate
< this.min
) {
335 newDate
= this.formatter
.adjustComponent( newDate
, data
.dayComponent
, 1, 'overflow' );
337 while ( newDate
> this.max
) {
338 newDate
= this.formatter
.adjustComponent( newDate
, data
.dayComponent
, -1, 'overflow' );
345 * Update the user interface
349 mw
.widgets
.datetime
.CalendarWidget
.prototype.updateUI = function () {
350 var r
, c
, row
, day
, k
, $cell
,
351 width
= this.minWidth
,
353 focusedDate
= this.getFocusedDate(),
354 selected
= this.getSelected(),
355 datePartIsEqual
= this.formatter
.datePartIsEqual
.bind( this.formatter
),
356 isSelected = function ( dt
) {
357 return datePartIsEqual( this, dt
);
360 this.calendarData
= this.formatter
.getCalendarData( focusedDate
);
362 this.$header
.text( this.calendarData
.header
);
364 for ( c
= 0; c
< this.colNullable
.length
; c
++ ) {
365 nullCols
[ c
] = this.colNullable
[ c
];
366 if ( nullCols
[ c
] ) {
367 for ( r
= 0; r
< this.calendarData
.rows
.length
; r
++ ) {
368 if ( this.calendarData
.rows
[ r
][ c
] ) {
369 nullCols
[ c
] = false;
376 this.$tableBody
.children().detach();
377 for ( r
= 0; r
< this.calendarData
.rows
.length
; r
++ ) {
378 if ( !this.rows
[ r
] ) {
379 this.rows
[ r
] = $( '<tr>' );
381 this.rows
[ r
].children().detach();
383 this.$tableBody
.append( this.rows
[ r
] );
384 row
= this.calendarData
.rows
[ r
];
385 for ( c
= 0; c
< row
.length
; c
++ ) {
387 if ( day
=== null ) {
388 k
= 'empty-' + r
+ '-' + c
;
389 if ( !this.buttons
[ k
] ) {
390 this.buttons
[ k
] = $( '<td>' );
392 $cell
= this.buttons
[ k
];
393 $cell
.toggleClass( 'oo-ui-element-hidden', nullCols
[ c
] );
395 k
= ( day
.extra
? day
.extra
: '' ) + day
.display
;
396 width
= Math
.max( width
, day
.display
.length
);
397 if ( !this.buttons
[ k
] ) {
398 this.buttons
[ k
] = new OO
.ui
.ButtonWidget( {
399 $element
: $( '<td>' ),
401 'mw-widgets-datetime-calendarWidget-cell',
402 day
.extra
? 'mw-widgets-datetime-calendarWidget-extra' : ''
408 this.buttons
[ k
].connect( this, { click
: [ 'onDayClick', this.buttons
[ k
] ] } );
412 .setDisabled( day
.date
< this.min
|| day
.date
> this.max
);
413 $cell
= this.buttons
[ k
].$element
;
414 $cell
.toggleClass( 'mw-widgets-datetime-calendarWidget-focused',
415 this.formatter
.datePartIsEqual( focusedDate
, day
.date
) );
416 $cell
.toggleClass( 'mw-widgets-datetime-calendarWidget-selected',
417 selected
.some( isSelected
, day
.date
) );
419 this.rows
[ r
].append( $cell
);
423 for ( c
= 0; c
< this.cols
.length
; c
++ ) {
424 if ( nullCols
[ c
] ) {
425 this.cols
[ c
].width( 0 );
427 this.cols
[ c
].width( width
+ 'em' );
429 this.cols
[ c
].toggleClass( 'oo-ui-element-hidden', nullCols
[ c
] );
430 this.headings
[ c
].toggleClass( 'oo-ui-element-hidden', nullCols
[ c
] );
435 * Handles formatter 'local' flag changing
439 mw
.widgets
.datetime
.CalendarWidget
.prototype.onLocalChange = function () {
440 if ( this.formatter
.localChangesDatePart( this.getFocusedDate() ) ) {
441 this.emit( 'page', this.getFocusedDate() );
448 * Handles previous button click
452 mw
.widgets
.datetime
.CalendarWidget
.prototype.onPrevClick = function () {
453 this.setFocusedDate( this.adjustDate( this.getFocusedDate(), 'month', -1 ) );
454 if ( !this.$widget
|| OO
.ui
.contains( this.$element
[ 0 ], document
.activeElement
, true ) ) {
455 this.$element
.focus();
460 * Handles next button click
464 mw
.widgets
.datetime
.CalendarWidget
.prototype.onNextClick = function () {
465 this.setFocusedDate( this.adjustDate( this.getFocusedDate(), 'month', 1 ) );
466 if ( !this.$widget
|| OO
.ui
.contains( this.$element
[ 0 ], document
.activeElement
, true ) ) {
467 this.$element
.focus();
472 * Handles day button click
475 * @param {OO.ui.ButtonWidget} $button
477 mw
.widgets
.datetime
.CalendarWidget
.prototype.onDayClick = function ( $button
) {
478 this.setFocusedDate( $button
.getData() );
479 this.setSelected( [ $button
.getData() ] );
480 if ( !this.$widget
|| OO
.ui
.contains( this.$element
[ 0 ], document
.activeElement
, true ) ) {
481 this.$element
.focus();
486 * Handles document mouse down events.
489 * @param {jQuery.Event} e Mouse down event
491 mw
.widgets
.datetime
.CalendarWidget
.prototype.onDocumentMouseDown = function ( e
) {
493 !OO
.ui
.contains( this.$element
[ 0 ], e
.target
, true ) &&
494 !OO
.ui
.contains( this.$widget
[ 0 ], e
.target
, true )
496 this.toggle( false );
501 * Handles key presses.
504 * @param {jQuery.Event} e Key down event
505 * @return {boolean} False to cancel the default event
507 mw
.widgets
.datetime
.CalendarWidget
.prototype.onKeyDown = function ( e
) {
508 var focusedDate
= this.getFocusedDate();
510 if ( !this.isDisabled() ) {
512 case OO
.ui
.Keys
.ENTER
:
513 case OO
.ui
.Keys
.SPACE
:
514 this.setSelected( [ focusedDate
] );
517 case OO
.ui
.Keys
.LEFT
:
518 this.setFocusedDate( this.adjustDate( focusedDate
, 'day', -1 ) );
521 case OO
.ui
.Keys
.RIGHT
:
522 this.setFocusedDate( this.adjustDate( focusedDate
, 'day', 1 ) );
526 this.setFocusedDate( this.adjustDate( focusedDate
, 'week', -1 ) );
529 case OO
.ui
.Keys
.DOWN
:
530 this.setFocusedDate( this.adjustDate( focusedDate
, 'week', 1 ) );
533 case OO
.ui
.Keys
.PAGEUP
:
534 this.setFocusedDate( this.adjustDate( focusedDate
, 'month', -1 ) );
537 case OO
.ui
.Keys
.PAGEDOWN
:
538 this.setFocusedDate( this.adjustDate( focusedDate
, 'month', 1 ) );
545 * Handles focusout events in dependent mode
549 mw
.widgets
.datetime
.CalendarWidget
.prototype.onFocusOut = function () {
550 setTimeout( this.checkFocusHandler
);
554 * When we or our widget lost focus, check if the calendar should be hidden.
558 mw
.widgets
.datetime
.CalendarWidget
.prototype.checkFocus = function () {
559 var containers
= [ this.$element
[ 0 ], this.$widget
[ 0 ] ],
560 activeElement
= document
.activeElement
;
562 if ( !activeElement
|| !OO
.ui
.contains( containers
, activeElement
, true ) ) {
563 this.toggle( false );
570 mw
.widgets
.datetime
.CalendarWidget
.prototype.toggle = function ( visible
) {
573 visible
= ( visible
=== undefined ? !this.visible
: !!visible
);
574 change
= visible
!== this.isVisible();
577 mw
.widgets
.datetime
.CalendarWidget
[ 'super' ].prototype.toggle
.call( this, visible
);
582 if ( this.$widget
) {
583 this.getElementDocument().addEventListener(
584 'mousedown', this.onDocumentMouseDownHandler
, true
589 this.getElementDocument().removeEventListener(
590 'mousedown', this.onDocumentMouseDownHandler
, true