Add datetime input widget
[mediawiki.git] / resources / src / mediawiki.widgets.datetime / CalendarWidget.js
blob31b1cd5b4426e56545cdd6eed531650589e06880
1 ( function ( $, mw ) {
3         /**
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
6          * the calendar.
7          *
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
10          * standalone.
11          *
12          * @class
13          * @extends OO.ui.Widget
14          * @mixins OO.ui.mixin.TabIndexedElement
15          *
16          * @constructor
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
20          *  instance to use.
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
24          *  outside).
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).
29          */
30         mw.widgets.datetime.CalendarWidget = function MwWidgetsDatetimeCalendarWidget( config ) {
31                 var $colgroup, $headTR, headings, i;
33                 // Configuration initialization
34                 config = $.extend( {
35                         min: null,
36                         max: null,
37                         focusedDate: new Date(),
38                         selected: null,
39                         formatter: {}
40                 }, config );
42                 // Parent constructor
43                 mw.widgets.datetime.CalendarWidget[ 'super' ].call( this, config );
45                 // Mixin constructors
46                 OO.ui.mixin.TabIndexedElement.call( this, $.extend( {}, config, { $tabIndexed: this.$element } ) );
48                 // Properties
49                 if ( config.min instanceof Date && config.min.getTime() >= -62167219200000 ) {
50                         this.min = config.min;
51                 } else {
52                         this.min = new Date( -62167219200000 ); // 0000-01-01T00:00:00.000Z
53                 }
54                 if ( config.max instanceof Date && config.max.getTime() <= 253402300799999 ) {
55                         this.max = config.max;
56                 } else {
57                         this.max = new Date( 253402300799999 ); // 9999-12-31T12:59:59.999Z
58                 }
60                 if ( config.focusedDate instanceof Date ) {
61                         this.focusedDate = config.focusedDate;
62                 } else {
63                         this.focusedDate = new Date();
64                 }
66                 this.selected = [];
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 );
72                 } else {
73                         throw new Error( '"formatter" must be an mw.widgets.datetime.DateTimeFormatter or a plain object' );
74                 }
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>' );
85                 this.cols = [];
86                 this.colNullable = [];
87                 this.headings = [];
88                 this.$tableBody = $( '<tbody>' );
89                 this.rows = [];
90                 this.buttons = {};
91                 this.minWidth = 1;
92                 this.daysPerWeek = 0;
94                 // Events
95                 this.$element.on( {
96                         keydown: this.onKeyDown.bind( this )
97                 } );
98                 this.formatter.connect( this, {
99                         local: 'onLocalChange'
100                 } );
101                 if ( this.$widget ) {
102                         this.checkFocusHandler = this.checkFocus.bind( this );
103                         this.$element.on( {
104                                 focusout: this.onFocusOut.bind( this )
105                         } );
106                         this.$widget.on( {
107                                 focusout: this.onFocusOut.bind( this )
108                         } );
109                 }
111                 // Initialization
112                 this.$head
113                         .addClass( 'mw-widgets-datetime-calendarWidget-heading' )
114                         .append(
115                                 new OO.ui.ButtonWidget( {
116                                         icon: 'previous',
117                                         framed: false,
118                                         classes: [ 'mw-widgets-datetime-calendarWidget-previous' ],
119                                         tabIndex: -1
120                                 } ).connect( this, { click: 'onPrevClick' } ).$element,
121                                 new OO.ui.ButtonWidget( {
122                                         icon: 'next',
123                                         framed: false,
124                                         classes: [ 'mw-widgets-datetime-calendarWidget-next' ],
125                                         tabIndex: -1
126                                 } ).connect( this, { click: 'onNextClick' } ).$element,
127                                 this.$header
128                         );
129                 $colgroup = $( '<colgroup>' );
130                 $headTR = $( '<tr>' );
131                 this.$table
132                         .addClass( 'mw-widgets-datetime-calendarWidget-grid' )
133                         .append( $colgroup )
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 );
145                                 this.daysPerWeek++;
146                         }
147                         $colgroup.append( this.cols[ i ] );
148                         $headTR.append( this.headings[ i ] );
149                 }
151                 this.setSelected( config.selected );
152                 this.$element
153                         .addClass( 'mw-widgets-datetime-calendarWidget' )
154                         .append( this.$head, this.$table );
156                 if ( this.widget ) {
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' );
164                 } else {
165                         this.updateUI();
166                 }
167         };
169         /* Setup */
171         OO.inheritClass( mw.widgets.datetime.CalendarWidget, OO.ui.Widget );
172         OO.mixinClass( mw.widgets.datetime.CalendarWidget, OO.ui.mixin.TabIndexedElement );
174         /* Events */
176         /**
177          * A `change` event is emitted when the selected dates change
178          *
179          * @event change
180          */
182         /**
183          * A `focusChange` event is emitted when the focused date changes
184          *
185          * @event focusChange
186          */
188         /**
189          * A `page` event is emitted when the current "month" changes
190          *
191          * @event page
192          */
194         /* Methods */
196         /**
197          * Return the current selected dates
198          *
199          * @return {Date[]}
200          */
201         mw.widgets.datetime.CalendarWidget.prototype.getSelected = function () {
202                 return this.selected;
203         };
205         /**
206          * Set the selected dates
207          *
208          * @param {Date|Date[]|null} dates
209          * @fires change
210          * @chainable
211          */
212         mw.widgets.datetime.CalendarWidget.prototype.setSelected = function ( dates ) {
213                 var i, changed = false;
215                 if ( dates instanceof Date ) {
216                         dates = [ dates ];
217                 } else if ( Array.isArray( dates ) ) {
218                         dates = $.grep( dates, function ( dt ) { return dt instanceof Date; } );
219                         dates.sort();
220                 } else {
221                         dates = [];
222                 }
224                 if ( this.selected.length !== dates.length ) {
225                         changed = true;
226                 } else {
227                         for ( i = 0; i < dates.length; i++ ) {
228                                 if ( dates[ i ].getTime() !== this.selected[ i ].getTime() ) {
229                                         changed = true;
230                                         break;
231                                 }
232                         }
233                 }
235                 if ( changed ) {
236                         this.selected = dates;
237                         this.emit( 'change', dates );
238                         this.updateUI();
239                 }
241                 return this;
242         };
244         /**
245          * Return the currently-focused date
246          *
247          * @return {Date}
248          */
249         mw.widgets.datetime.CalendarWidget.prototype.getFocusedDate = function () {
250                 return this.focusedDate;
251         };
253         /**
254          * Set the currently-focused date
255          *
256          * @param {Date} date
257          * @fires page
258          * @chainable
259          */
260         mw.widgets.datetime.CalendarWidget.prototype.setFocusedDate = function ( date ) {
261                 var changePage = false,
262                         updateUI = false;
264                 if ( this.focusedDate.getTime() === date.getTime() ) {
265                         return this;
266                 }
268                 if ( !this.formatter.sameCalendarGrid( this.focusedDate, date ) ) {
269                         changePage = true;
270                         updateUI = true;
271                 } else if (
272                         !this.formatter.timePartIsEqual( this.focusedDate, date ) ||
273                         !this.formatter.datePartIsEqual( this.focusedDate, date )
274                 ) {
275                         updateUI = true;
276                 }
278                 this.focusedDate = date;
279                 this.emit( 'focusChanged', this.focusedDate );
280                 if ( changePage ) {
281                         this.emit( 'page', date );
282                 }
283                 if ( updateUI ) {
284                         this.updateUI();
285                 }
287                 return this;
288         };
290         /**
291          * Adjust a date
292          *
293          * @protected
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
298          * @return {Date}
299          */
300         mw.widgets.datetime.CalendarWidget.prototype.adjustDate = function ( date, component, delta ) {
301                 var newDate,
302                         data = this.calendarData;
304                 if ( !data ) {
305                         return date;
306                 }
308                 switch ( component ) {
309                         case 'month':
310                                 newDate = this.formatter.adjustComponent( date, data.monthComponent, delta, 'overflow' );
311                                 break;
313                         case 'week':
314                                 if ( data.weekComponent === undefined ) {
315                                         newDate = this.formatter.adjustComponent(
316                                                 date, data.dayComponent, delta * this.daysPerWeek, 'overflow' );
317                                 } else {
318                                         newDate = this.formatter.adjustComponent( date, data.weekComponent, delta, 'overflow' );
319                                 }
320                                 break;
322                         case 'day':
323                                 newDate = this.formatter.adjustComponent( date, data.dayComponent, delta, 'overflow' );
324                                 break;
326                         default:
327                                 throw new Error( 'Unknown component' );
328                 }
330                 while ( newDate < this.min ) {
331                         newDate = this.formatter.adjustComponent( newDate, data.dayComponent, 1, 'overflow' );
332                 }
333                 while ( newDate > this.max ) {
334                         newDate = this.formatter.adjustComponent( newDate, data.dayComponent, -1, 'overflow' );
335                 }
337                 return newDate;
338         };
340         /**
341          * Update the user interface
342          *
343          * @protected
344          */
345         mw.widgets.datetime.CalendarWidget.prototype.updateUI = function () {
346                 var r, c, row, day, k, $cell,
347                         width = this.minWidth,
348                         nullCols = [],
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 );
354                         };
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;
366                                                 break;
367                                         }
368                                 }
369                         }
370                 }
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>' );
376                         } else {
377                                 this.rows[ r ].children().detach();
378                         }
379                         this.$tableBody.append( this.rows[ r ] );
380                         row = this.calendarData.rows[ r ];
381                         for ( c = 0; c < row.length; c++ ) {
382                                 day = row[ c ];
383                                 if ( day === null ) {
384                                         k = 'empty-' + r + '-' + c;
385                                         if ( !this.buttons[ k ] ) {
386                                                 this.buttons[ k ] = $( '<td>' );
387                                         }
388                                         $cell = this.buttons[ k ];
389                                         $cell.toggleClass( 'oo-ui-element-hidden', nullCols[ c ] );
390                                 } else {
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>' ),
396                                                         classes: [
397                                                                 'mw-widgets-datetime-calendarWidget-cell',
398                                                                 day.extra ? 'mw-widgets-datetime-calendarWidget-extra' : ''
399                                                         ],
400                                                         framed: true,
401                                                         label: day.display,
402                                                         tabIndex: -1
403                                                 } );
404                                                 this.buttons[ k ].connect( this, { click: [ 'onDayClick', this.buttons[ k ] ] } );
405                                         }
406                                         this.buttons[ k ]
407                                                 .setData( day.date )
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 ) );
414                                 }
415                                 this.rows[ r ].append( $cell );
416                         }
417                 }
419                 for ( c = 0; c < this.cols.length; c++ ) {
420                         if ( nullCols[ c ] ) {
421                                 this.cols[ c ].width( 0 );
422                         } else {
423                                 this.cols[ c ].width( width + 'em' );
424                         }
425                         this.cols[ c ].toggleClass( 'oo-ui-element-hidden', nullCols[ c ] );
426                         this.headings[ c ].toggleClass( 'oo-ui-element-hidden', nullCols[ c ] );
427                 }
428         };
430         /**
431          * Handles formatter 'local' flag changing
432          *
433          * @protected
434          */
435         mw.widgets.datetime.CalendarWidget.prototype.onLocalChange = function () {
436                 if ( this.formatter.localChangesDatePart( this.getFocusedDate() ) ) {
437                         this.emit( 'page', this.getFocusedDate() );
438                 }
440                 this.updateUI();
441         };
443         /**
444          * Handles previous button click
445          *
446          * @protected
447          */
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();
452                 }
453         };
455         /**
456          * Handles next button click
457          *
458          * @protected
459          */
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();
464                 }
465         };
467         /**
468          * Handles day button click
469          *
470          * @protected
471          * @param {OO.ui.ButtonWidget} $button
472          */
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();
478                 }
479         };
481         /**
482          * Handles document mouse down events.
483          *
484          * @protected
485          * @param {jQuery.Event} e Mouse down event
486          */
487         mw.widgets.datetime.CalendarWidget.prototype.onDocumentMouseDown = function ( e ) {
488                 if ( this.$widget &&
489                         !OO.ui.contains( this.$element[ 0 ], e.target, true ) &&
490                         !OO.ui.contains( this.$widget[ 0 ], e.target, true )
491                 ) {
492                         this.toggle( false );
493                 }
494         };
496         /**
497          * Handles key presses.
498          *
499          * @protected
500          * @param {jQuery.Event} e Key down event
501          */
502         mw.widgets.datetime.CalendarWidget.prototype.onKeyDown = function ( e ) {
503                 var focusedDate = this.getFocusedDate();
505                 if ( !this.isDisabled() ) {
506                         switch ( e.which ) {
507                                 case OO.ui.Keys.ENTER:
508                                 case OO.ui.Keys.SPACE:
509                                         this.setSelected( [ focusedDate ] );
510                                         return false;
512                                 case OO.ui.Keys.LEFT:
513                                         this.setFocusedDate( this.adjustDate( focusedDate, 'day', -1 ) );
514                                         return false;
516                                 case OO.ui.Keys.RIGHT:
517                                         this.setFocusedDate( this.adjustDate( focusedDate, 'day', 1 ) );
518                                         return false;
520                                 case OO.ui.Keys.UP:
521                                         this.setFocusedDate( this.adjustDate( focusedDate, 'week', -1 ) );
522                                         return false;
524                                 case OO.ui.Keys.DOWN:
525                                         this.setFocusedDate( this.adjustDate( focusedDate, 'week', 1 ) );
526                                         return false;
528                                 case OO.ui.Keys.PAGEUP:
529                                         this.setFocusedDate( this.adjustDate( focusedDate, 'month', -1 ) );
530                                         return false;
532                                 case OO.ui.Keys.PAGEDOWN:
533                                         this.setFocusedDate( this.adjustDate( focusedDate, 'month', 1 ) );
534                                         return false;
535                         }
536                 }
537         };
539         /**
540          * Handles focusout events in dependent mode
541          *
542          * @private
543          */
544         mw.widgets.datetime.CalendarWidget.prototype.onFocusOut = function () {
545                 setTimeout( this.checkFocusHandler );
546         };
548         /**
549          * When we or our widget lost focus, check if the calendar should be hidden.
550          *
551          * @private
552          */
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 );
559                 }
560         };
562         /**
563          * @inheritdoc
564          */
565         mw.widgets.datetime.CalendarWidget.prototype.toggle = function ( visible ) {
566                 var change;
568                 visible = ( visible === undefined ? !this.visible : !!visible );
569                 change = visible !== this.isVisible();
571                 // Parent method
572                 mw.widgets.datetime.CalendarWidget[ 'super' ].prototype.toggle.call( this, visible );
574                 if ( change ) {
575                         if ( visible ) {
576                                 // Auto-hide
577                                 if ( this.$widget ) {
578                                         this.getElementDocument().addEventListener(
579                                                 'mousedown', this.onDocumentMouseDownHandler, true
580                                         );
581                                 }
582                                 this.updateUI();
583                         } else {
584                                 this.getElementDocument().removeEventListener(
585                                         'mousedown', this.onDocumentMouseDownHandler, true
586                                 );
587                         }
588                 }
590                 return this;
591         };
593 }( jQuery, mediaWiki ) );