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 ) );