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
;
206 * Set the selected dates
208 * @param {Date|Date[]|null} dates
212 mw
.widgets
.datetime
.CalendarWidget
.prototype.setSelected = function ( dates
) {
213 var i
, changed
= false;
215 if ( dates
instanceof Date
) {
217 } else if ( Array
.isArray( dates
) ) {
218 dates
= $.grep( dates
, function ( dt
) { return dt
instanceof Date
; } );
224 if ( this.selected
.length
!== dates
.length
) {
227 for ( i
= 0; i
< dates
.length
; i
++ ) {
228 if ( dates
[ i
].getTime() !== this.selected
[ i
].getTime() ) {
236 this.selected
= dates
;
237 this.emit( 'change', dates
);
245 * Return the currently-focused date
249 mw
.widgets
.datetime
.CalendarWidget
.prototype.getFocusedDate = function () {
250 return this.focusedDate
;
254 * Set the currently-focused date
260 mw
.widgets
.datetime
.CalendarWidget
.prototype.setFocusedDate = function ( date
) {
261 var changePage
= false,
264 if ( this.focusedDate
.getTime() === date
.getTime() ) {
268 if ( !this.formatter
.sameCalendarGrid( this.focusedDate
, date
) ) {
272 !this.formatter
.timePartIsEqual( this.focusedDate
, date
) ||
273 !this.formatter
.datePartIsEqual( this.focusedDate
, date
)
278 this.focusedDate
= date
;
279 this.emit( 'focusChanged', this.focusedDate
);
281 this.emit( 'page', date
);
294 * @param {Date} date Date to adjust
295 * @param {string} component Component: 'month', 'week', or 'day'
296 * @param {number} delta Integer, usually -1 or 1
297 * @param {boolean} [enforceRange=true] Whether to enforce this.min and this.max
300 mw
.widgets
.datetime
.CalendarWidget
.prototype.adjustDate = function ( date
, component
, delta
) {
302 data
= this.calendarData
;
308 switch ( component
) {
310 newDate
= this.formatter
.adjustComponent( date
, data
.monthComponent
, delta
, 'overflow' );
314 if ( data
.weekComponent
=== undefined ) {
315 newDate
= this.formatter
.adjustComponent(
316 date
, data
.dayComponent
, delta
* this.daysPerWeek
, 'overflow' );
318 newDate
= this.formatter
.adjustComponent( date
, data
.weekComponent
, delta
, 'overflow' );
323 newDate
= this.formatter
.adjustComponent( date
, data
.dayComponent
, delta
, 'overflow' );
327 throw new Error( 'Unknown component' );
330 while ( newDate
< this.min
) {
331 newDate
= this.formatter
.adjustComponent( newDate
, data
.dayComponent
, 1, 'overflow' );
333 while ( newDate
> this.max
) {
334 newDate
= this.formatter
.adjustComponent( newDate
, data
.dayComponent
, -1, 'overflow' );
341 * Update the user interface
345 mw
.widgets
.datetime
.CalendarWidget
.prototype.updateUI = function () {
346 var r
, c
, row
, day
, k
, $cell
,
347 width
= this.minWidth
,
349 focusedDate
= this.getFocusedDate(),
350 selected
= this.getSelected(),
351 datePartIsEqual
= this.formatter
.datePartIsEqual
.bind( this.formatter
),
352 isSelected = function ( dt
) {
353 return datePartIsEqual( this, dt
);
356 this.calendarData
= this.formatter
.getCalendarData( focusedDate
);
358 this.$header
.text( this.calendarData
.header
);
360 for ( c
= 0; c
< this.colNullable
.length
; c
++ ) {
361 nullCols
[ c
] = this.colNullable
[ c
];
362 if ( nullCols
[ c
] ) {
363 for ( r
= 0; r
< this.calendarData
.rows
.length
; r
++ ) {
364 if ( this.calendarData
.rows
[ r
][ c
] ) {
365 nullCols
[ c
] = false;
372 this.$tableBody
.children().detach();
373 for ( r
= 0; r
< this.calendarData
.rows
.length
; r
++ ) {
374 if ( !this.rows
[ r
] ) {
375 this.rows
[ r
] = $( '<tr>' );
377 this.rows
[ r
].children().detach();
379 this.$tableBody
.append( this.rows
[ r
] );
380 row
= this.calendarData
.rows
[ r
];
381 for ( c
= 0; c
< row
.length
; c
++ ) {
383 if ( day
=== null ) {
384 k
= 'empty-' + r
+ '-' + c
;
385 if ( !this.buttons
[ k
] ) {
386 this.buttons
[ k
] = $( '<td>' );
388 $cell
= this.buttons
[ k
];
389 $cell
.toggleClass( 'oo-ui-element-hidden', nullCols
[ c
] );
391 k
= ( day
.extra
? day
.extra
: '' ) + day
.display
;
392 width
= Math
.max( width
, day
.display
.length
);
393 if ( !this.buttons
[ k
] ) {
394 this.buttons
[ k
] = new OO
.ui
.ButtonWidget( {
395 $element
: $( '<td>' ),
397 'mw-widgets-datetime-calendarWidget-cell',
398 day
.extra
? 'mw-widgets-datetime-calendarWidget-extra' : ''
404 this.buttons
[ k
].connect( this, { click
: [ 'onDayClick', this.buttons
[ k
] ] } );
408 .setDisabled( day
.date
< this.min
|| day
.date
> this.max
);
409 $cell
= this.buttons
[ k
].$element
;
410 $cell
.toggleClass( 'mw-widgets-datetime-calendarWidget-focused',
411 this.formatter
.datePartIsEqual( focusedDate
, day
.date
) );
412 $cell
.toggleClass( 'mw-widgets-datetime-calendarWidget-selected',
413 selected
.some( isSelected
, day
.date
) );
415 this.rows
[ r
].append( $cell
);
419 for ( c
= 0; c
< this.cols
.length
; c
++ ) {
420 if ( nullCols
[ c
] ) {
421 this.cols
[ c
].width( 0 );
423 this.cols
[ c
].width( width
+ 'em' );
425 this.cols
[ c
].toggleClass( 'oo-ui-element-hidden', nullCols
[ c
] );
426 this.headings
[ c
].toggleClass( 'oo-ui-element-hidden', nullCols
[ c
] );
431 * Handles formatter 'local' flag changing
435 mw
.widgets
.datetime
.CalendarWidget
.prototype.onLocalChange = function () {
436 if ( this.formatter
.localChangesDatePart( this.getFocusedDate() ) ) {
437 this.emit( 'page', this.getFocusedDate() );
444 * Handles previous button click
448 mw
.widgets
.datetime
.CalendarWidget
.prototype.onPrevClick = function () {
449 this.setFocusedDate( this.adjustDate( this.getFocusedDate(), 'month', -1 ) );
450 if ( !this.$widget
|| OO
.ui
.contains( this.$element
[ 0 ], document
.activeElement
, true ) ) {
451 this.$element
.focus();
456 * Handles next button click
460 mw
.widgets
.datetime
.CalendarWidget
.prototype.onNextClick = function () {
461 this.setFocusedDate( this.adjustDate( this.getFocusedDate(), 'month', 1 ) );
462 if ( !this.$widget
|| OO
.ui
.contains( this.$element
[ 0 ], document
.activeElement
, true ) ) {
463 this.$element
.focus();
468 * Handles day button click
471 * @param {OO.ui.ButtonWidget} $button
473 mw
.widgets
.datetime
.CalendarWidget
.prototype.onDayClick = function ( $button
) {
474 this.setFocusedDate( $button
.getData() );
475 this.setSelected( [ $button
.getData() ] );
476 if ( !this.$widget
|| OO
.ui
.contains( this.$element
[ 0 ], document
.activeElement
, true ) ) {
477 this.$element
.focus();
482 * Handles document mouse down events.
485 * @param {jQuery.Event} e Mouse down event
487 mw
.widgets
.datetime
.CalendarWidget
.prototype.onDocumentMouseDown = function ( e
) {
489 !OO
.ui
.contains( this.$element
[ 0 ], e
.target
, true ) &&
490 !OO
.ui
.contains( this.$widget
[ 0 ], e
.target
, true )
492 this.toggle( false );
497 * Handles key presses.
500 * @param {jQuery.Event} e Key down event
502 mw
.widgets
.datetime
.CalendarWidget
.prototype.onKeyDown = function ( e
) {
503 var focusedDate
= this.getFocusedDate();
505 if ( !this.isDisabled() ) {
507 case OO
.ui
.Keys
.ENTER
:
508 case OO
.ui
.Keys
.SPACE
:
509 this.setSelected( [ focusedDate
] );
512 case OO
.ui
.Keys
.LEFT
:
513 this.setFocusedDate( this.adjustDate( focusedDate
, 'day', -1 ) );
516 case OO
.ui
.Keys
.RIGHT
:
517 this.setFocusedDate( this.adjustDate( focusedDate
, 'day', 1 ) );
521 this.setFocusedDate( this.adjustDate( focusedDate
, 'week', -1 ) );
524 case OO
.ui
.Keys
.DOWN
:
525 this.setFocusedDate( this.adjustDate( focusedDate
, 'week', 1 ) );
528 case OO
.ui
.Keys
.PAGEUP
:
529 this.setFocusedDate( this.adjustDate( focusedDate
, 'month', -1 ) );
532 case OO
.ui
.Keys
.PAGEDOWN
:
533 this.setFocusedDate( this.adjustDate( focusedDate
, 'month', 1 ) );
540 * Handles focusout events in dependent mode
544 mw
.widgets
.datetime
.CalendarWidget
.prototype.onFocusOut = function () {
545 setTimeout( this.checkFocusHandler
);
549 * When we or our widget lost focus, check if the calendar should be hidden.
553 mw
.widgets
.datetime
.CalendarWidget
.prototype.checkFocus = function () {
554 var containers
= [ this.$element
[ 0 ], this.$widget
[ 0 ] ],
555 activeElement
= document
.activeElement
;
557 if ( !activeElement
|| !OO
.ui
.contains( containers
, activeElement
, true ) ) {
558 this.toggle( false );
565 mw
.widgets
.datetime
.CalendarWidget
.prototype.toggle = function ( visible
) {
568 visible
= ( visible
=== undefined ? !this.visible
: !!visible
);
569 change
= visible
!== this.isVisible();
572 mw
.widgets
.datetime
.CalendarWidget
[ 'super' ].prototype.toggle
.call( this, visible
);
577 if ( this.$widget
) {
578 this.getElementDocument().addEventListener(
579 'mousedown', this.onDocumentMouseDownHandler
, true
584 this.getElementDocument().removeEventListener(
585 'mousedown', this.onDocumentMouseDownHandler
, true
593 }( jQuery
, mediaWiki
) );