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
= $.grep( dates
, function ( dt
) { return dt
instanceof Date
; } );
225 if ( this.selected
.length
!== dates
.length
) {
228 for ( i
= 0; i
< dates
.length
; i
++ ) {
229 if ( dates
[ i
].getTime() !== this.selected
[ i
].getTime() ) {
237 this.selected
= dates
;
238 this.emit( 'change', dates
);
246 * Return the currently-focused date
250 mw
.widgets
.datetime
.CalendarWidget
.prototype.getFocusedDate = function () {
251 return this.focusedDate
;
254 // eslint-disable-next-line valid-jsdoc
256 * Set the currently-focused date
262 mw
.widgets
.datetime
.CalendarWidget
.prototype.setFocusedDate = function ( date
) {
263 var changePage
= false,
266 if ( this.focusedDate
.getTime() === date
.getTime() ) {
270 if ( !this.formatter
.sameCalendarGrid( this.focusedDate
, date
) ) {
274 !this.formatter
.timePartIsEqual( this.focusedDate
, date
) ||
275 !this.formatter
.datePartIsEqual( this.focusedDate
, date
)
280 this.focusedDate
= date
;
281 this.emit( 'focusChanged', this.focusedDate
);
283 this.emit( 'page', date
);
296 * @param {Date} date Date to adjust
297 * @param {string} component Component: 'month', 'week', or 'day'
298 * @param {number} delta Integer, usually -1 or 1
299 * @param {boolean} [enforceRange=true] Whether to enforce this.min and this.max
302 mw
.widgets
.datetime
.CalendarWidget
.prototype.adjustDate = function ( date
, component
, delta
) {
304 data
= this.calendarData
;
310 switch ( component
) {
312 newDate
= this.formatter
.adjustComponent( date
, data
.monthComponent
, delta
, 'overflow' );
316 if ( data
.weekComponent
=== undefined ) {
317 newDate
= this.formatter
.adjustComponent(
318 date
, data
.dayComponent
, delta
* this.daysPerWeek
, 'overflow' );
320 newDate
= this.formatter
.adjustComponent( date
, data
.weekComponent
, delta
, 'overflow' );
325 newDate
= this.formatter
.adjustComponent( date
, data
.dayComponent
, delta
, 'overflow' );
329 throw new Error( 'Unknown component' );
332 while ( newDate
< this.min
) {
333 newDate
= this.formatter
.adjustComponent( newDate
, data
.dayComponent
, 1, 'overflow' );
335 while ( newDate
> this.max
) {
336 newDate
= this.formatter
.adjustComponent( newDate
, data
.dayComponent
, -1, 'overflow' );
343 * Update the user interface
347 mw
.widgets
.datetime
.CalendarWidget
.prototype.updateUI = function () {
348 var r
, c
, row
, day
, k
, $cell
,
349 width
= this.minWidth
,
351 focusedDate
= this.getFocusedDate(),
352 selected
= this.getSelected(),
353 datePartIsEqual
= this.formatter
.datePartIsEqual
.bind( this.formatter
),
354 isSelected = function ( dt
) {
355 return datePartIsEqual( this, dt
);
358 this.calendarData
= this.formatter
.getCalendarData( focusedDate
);
360 this.$header
.text( this.calendarData
.header
);
362 for ( c
= 0; c
< this.colNullable
.length
; c
++ ) {
363 nullCols
[ c
] = this.colNullable
[ c
];
364 if ( nullCols
[ c
] ) {
365 for ( r
= 0; r
< this.calendarData
.rows
.length
; r
++ ) {
366 if ( this.calendarData
.rows
[ r
][ c
] ) {
367 nullCols
[ c
] = false;
374 this.$tableBody
.children().detach();
375 for ( r
= 0; r
< this.calendarData
.rows
.length
; r
++ ) {
376 if ( !this.rows
[ r
] ) {
377 this.rows
[ r
] = $( '<tr>' );
379 this.rows
[ r
].children().detach();
381 this.$tableBody
.append( this.rows
[ r
] );
382 row
= this.calendarData
.rows
[ r
];
383 for ( c
= 0; c
< row
.length
; c
++ ) {
385 if ( day
=== null ) {
386 k
= 'empty-' + r
+ '-' + c
;
387 if ( !this.buttons
[ k
] ) {
388 this.buttons
[ k
] = $( '<td>' );
390 $cell
= this.buttons
[ k
];
391 $cell
.toggleClass( 'oo-ui-element-hidden', nullCols
[ c
] );
393 k
= ( day
.extra
? day
.extra
: '' ) + day
.display
;
394 width
= Math
.max( width
, day
.display
.length
);
395 if ( !this.buttons
[ k
] ) {
396 this.buttons
[ k
] = new OO
.ui
.ButtonWidget( {
397 $element
: $( '<td>' ),
399 'mw-widgets-datetime-calendarWidget-cell',
400 day
.extra
? 'mw-widgets-datetime-calendarWidget-extra' : ''
406 this.buttons
[ k
].connect( this, { click
: [ 'onDayClick', this.buttons
[ k
] ] } );
410 .setDisabled( day
.date
< this.min
|| day
.date
> this.max
);
411 $cell
= this.buttons
[ k
].$element
;
412 $cell
.toggleClass( 'mw-widgets-datetime-calendarWidget-focused',
413 this.formatter
.datePartIsEqual( focusedDate
, day
.date
) );
414 $cell
.toggleClass( 'mw-widgets-datetime-calendarWidget-selected',
415 selected
.some( isSelected
, day
.date
) );
417 this.rows
[ r
].append( $cell
);
421 for ( c
= 0; c
< this.cols
.length
; c
++ ) {
422 if ( nullCols
[ c
] ) {
423 this.cols
[ c
].width( 0 );
425 this.cols
[ c
].width( width
+ 'em' );
427 this.cols
[ c
].toggleClass( 'oo-ui-element-hidden', nullCols
[ c
] );
428 this.headings
[ c
].toggleClass( 'oo-ui-element-hidden', nullCols
[ c
] );
433 * Handles formatter 'local' flag changing
437 mw
.widgets
.datetime
.CalendarWidget
.prototype.onLocalChange = function () {
438 if ( this.formatter
.localChangesDatePart( this.getFocusedDate() ) ) {
439 this.emit( 'page', this.getFocusedDate() );
446 * Handles previous button click
450 mw
.widgets
.datetime
.CalendarWidget
.prototype.onPrevClick = function () {
451 this.setFocusedDate( this.adjustDate( this.getFocusedDate(), 'month', -1 ) );
452 if ( !this.$widget
|| OO
.ui
.contains( this.$element
[ 0 ], document
.activeElement
, true ) ) {
453 this.$element
.focus();
458 * Handles next button click
462 mw
.widgets
.datetime
.CalendarWidget
.prototype.onNextClick = function () {
463 this.setFocusedDate( this.adjustDate( this.getFocusedDate(), 'month', 1 ) );
464 if ( !this.$widget
|| OO
.ui
.contains( this.$element
[ 0 ], document
.activeElement
, true ) ) {
465 this.$element
.focus();
470 * Handles day button click
473 * @param {OO.ui.ButtonWidget} $button
475 mw
.widgets
.datetime
.CalendarWidget
.prototype.onDayClick = function ( $button
) {
476 this.setFocusedDate( $button
.getData() );
477 this.setSelected( [ $button
.getData() ] );
478 if ( !this.$widget
|| OO
.ui
.contains( this.$element
[ 0 ], document
.activeElement
, true ) ) {
479 this.$element
.focus();
484 * Handles document mouse down events.
487 * @param {jQuery.Event} e Mouse down event
489 mw
.widgets
.datetime
.CalendarWidget
.prototype.onDocumentMouseDown = function ( e
) {
491 !OO
.ui
.contains( this.$element
[ 0 ], e
.target
, true ) &&
492 !OO
.ui
.contains( this.$widget
[ 0 ], e
.target
, true )
494 this.toggle( false );
499 * Handles key presses.
502 * @param {jQuery.Event} e Key down event
503 * @return {boolean} False to cancel the default event
505 mw
.widgets
.datetime
.CalendarWidget
.prototype.onKeyDown = function ( e
) {
506 var focusedDate
= this.getFocusedDate();
508 if ( !this.isDisabled() ) {
510 case OO
.ui
.Keys
.ENTER
:
511 case OO
.ui
.Keys
.SPACE
:
512 this.setSelected( [ focusedDate
] );
515 case OO
.ui
.Keys
.LEFT
:
516 this.setFocusedDate( this.adjustDate( focusedDate
, 'day', -1 ) );
519 case OO
.ui
.Keys
.RIGHT
:
520 this.setFocusedDate( this.adjustDate( focusedDate
, 'day', 1 ) );
524 this.setFocusedDate( this.adjustDate( focusedDate
, 'week', -1 ) );
527 case OO
.ui
.Keys
.DOWN
:
528 this.setFocusedDate( this.adjustDate( focusedDate
, 'week', 1 ) );
531 case OO
.ui
.Keys
.PAGEUP
:
532 this.setFocusedDate( this.adjustDate( focusedDate
, 'month', -1 ) );
535 case OO
.ui
.Keys
.PAGEDOWN
:
536 this.setFocusedDate( this.adjustDate( focusedDate
, 'month', 1 ) );
543 * Handles focusout events in dependent mode
547 mw
.widgets
.datetime
.CalendarWidget
.prototype.onFocusOut = function () {
548 setTimeout( this.checkFocusHandler
);
552 * When we or our widget lost focus, check if the calendar should be hidden.
556 mw
.widgets
.datetime
.CalendarWidget
.prototype.checkFocus = function () {
557 var containers
= [ this.$element
[ 0 ], this.$widget
[ 0 ] ],
558 activeElement
= document
.activeElement
;
560 if ( !activeElement
|| !OO
.ui
.contains( containers
, activeElement
, true ) ) {
561 this.toggle( false );
568 mw
.widgets
.datetime
.CalendarWidget
.prototype.toggle = function ( visible
) {
571 visible
= ( visible
=== undefined ? !this.visible
: !!visible
);
572 change
= visible
!== this.isVisible();
575 mw
.widgets
.datetime
.CalendarWidget
[ 'super' ].prototype.toggle
.call( this, visible
);
580 if ( this.$widget
) {
581 this.getElementDocument().addEventListener(
582 'mousedown', this.onDocumentMouseDownHandler
, true
587 this.getElementDocument().removeEventListener(
588 'mousedown', this.onDocumentMouseDownHandler
, true
596 }( jQuery
, mediaWiki
) );