Avoid pointless use of isset() in LBFactoryMulti()
[mediawiki.git] / resources / src / mediawiki.widgets.datetime / CalendarWidget.js
blob54a5a850bcd1c955c444c06da770802a2e24758d
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         // eslint-disable-next-line valid-jsdoc
206         /**
207          * Set the selected dates
208          *
209          * @param {Date|Date[]|null} dates
210          * @fires change
211          * @chainable
212          */
213         mw.widgets.datetime.CalendarWidget.prototype.setSelected = function ( dates ) {
214                 var i, changed = false;
216                 if ( dates instanceof Date ) {
217                         dates = [ dates ];
218                 } else if ( Array.isArray( dates ) ) {
219                         dates = $.grep( dates, function ( dt ) { return dt instanceof Date; } );
220                         dates.sort();
221                 } else {
222                         dates = [];
223                 }
225                 if ( this.selected.length !== dates.length ) {
226                         changed = true;
227                 } else {
228                         for ( i = 0; i < dates.length; i++ ) {
229                                 if ( dates[ i ].getTime() !== this.selected[ i ].getTime() ) {
230                                         changed = true;
231                                         break;
232                                 }
233                         }
234                 }
236                 if ( changed ) {
237                         this.selected = dates;
238                         this.emit( 'change', dates );
239                         this.updateUI();
240                 }
242                 return this;
243         };
245         /**
246          * Return the currently-focused date
247          *
248          * @return {Date}
249          */
250         mw.widgets.datetime.CalendarWidget.prototype.getFocusedDate = function () {
251                 return this.focusedDate;
252         };
254         // eslint-disable-next-line valid-jsdoc
255         /**
256          * Set the currently-focused date
257          *
258          * @param {Date} date
259          * @fires page
260          * @chainable
261          */
262         mw.widgets.datetime.CalendarWidget.prototype.setFocusedDate = function ( date ) {
263                 var changePage = false,
264                         updateUI = false;
266                 if ( this.focusedDate.getTime() === date.getTime() ) {
267                         return this;
268                 }
270                 if ( !this.formatter.sameCalendarGrid( this.focusedDate, date ) ) {
271                         changePage = true;
272                         updateUI = true;
273                 } else if (
274                         !this.formatter.timePartIsEqual( this.focusedDate, date ) ||
275                         !this.formatter.datePartIsEqual( this.focusedDate, date )
276                 ) {
277                         updateUI = true;
278                 }
280                 this.focusedDate = date;
281                 this.emit( 'focusChanged', this.focusedDate );
282                 if ( changePage ) {
283                         this.emit( 'page', date );
284                 }
285                 if ( updateUI ) {
286                         this.updateUI();
287                 }
289                 return this;
290         };
292         /**
293          * Adjust a date
294          *
295          * @protected
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
300          * @return {Date}
301          */
302         mw.widgets.datetime.CalendarWidget.prototype.adjustDate = function ( date, component, delta ) {
303                 var newDate,
304                         data = this.calendarData;
306                 if ( !data ) {
307                         return date;
308                 }
310                 switch ( component ) {
311                         case 'month':
312                                 newDate = this.formatter.adjustComponent( date, data.monthComponent, delta, 'overflow' );
313                                 break;
315                         case 'week':
316                                 if ( data.weekComponent === undefined ) {
317                                         newDate = this.formatter.adjustComponent(
318                                                 date, data.dayComponent, delta * this.daysPerWeek, 'overflow' );
319                                 } else {
320                                         newDate = this.formatter.adjustComponent( date, data.weekComponent, delta, 'overflow' );
321                                 }
322                                 break;
324                         case 'day':
325                                 newDate = this.formatter.adjustComponent( date, data.dayComponent, delta, 'overflow' );
326                                 break;
328                         default:
329                                 throw new Error( 'Unknown component' );
330                 }
332                 while ( newDate < this.min ) {
333                         newDate = this.formatter.adjustComponent( newDate, data.dayComponent, 1, 'overflow' );
334                 }
335                 while ( newDate > this.max ) {
336                         newDate = this.formatter.adjustComponent( newDate, data.dayComponent, -1, 'overflow' );
337                 }
339                 return newDate;
340         };
342         /**
343          * Update the user interface
344          *
345          * @protected
346          */
347         mw.widgets.datetime.CalendarWidget.prototype.updateUI = function () {
348                 var r, c, row, day, k, $cell,
349                         width = this.minWidth,
350                         nullCols = [],
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 );
356                         };
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;
368                                                 break;
369                                         }
370                                 }
371                         }
372                 }
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>' );
378                         } else {
379                                 this.rows[ r ].children().detach();
380                         }
381                         this.$tableBody.append( this.rows[ r ] );
382                         row = this.calendarData.rows[ r ];
383                         for ( c = 0; c < row.length; c++ ) {
384                                 day = row[ c ];
385                                 if ( day === null ) {
386                                         k = 'empty-' + r + '-' + c;
387                                         if ( !this.buttons[ k ] ) {
388                                                 this.buttons[ k ] = $( '<td>' );
389                                         }
390                                         $cell = this.buttons[ k ];
391                                         $cell.toggleClass( 'oo-ui-element-hidden', nullCols[ c ] );
392                                 } else {
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>' ),
398                                                         classes: [
399                                                                 'mw-widgets-datetime-calendarWidget-cell',
400                                                                 day.extra ? 'mw-widgets-datetime-calendarWidget-extra' : ''
401                                                         ],
402                                                         framed: true,
403                                                         label: day.display,
404                                                         tabIndex: -1
405                                                 } );
406                                                 this.buttons[ k ].connect( this, { click: [ 'onDayClick', this.buttons[ k ] ] } );
407                                         }
408                                         this.buttons[ k ]
409                                                 .setData( day.date )
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 ) );
416                                 }
417                                 this.rows[ r ].append( $cell );
418                         }
419                 }
421                 for ( c = 0; c < this.cols.length; c++ ) {
422                         if ( nullCols[ c ] ) {
423                                 this.cols[ c ].width( 0 );
424                         } else {
425                                 this.cols[ c ].width( width + 'em' );
426                         }
427                         this.cols[ c ].toggleClass( 'oo-ui-element-hidden', nullCols[ c ] );
428                         this.headings[ c ].toggleClass( 'oo-ui-element-hidden', nullCols[ c ] );
429                 }
430         };
432         /**
433          * Handles formatter 'local' flag changing
434          *
435          * @protected
436          */
437         mw.widgets.datetime.CalendarWidget.prototype.onLocalChange = function () {
438                 if ( this.formatter.localChangesDatePart( this.getFocusedDate() ) ) {
439                         this.emit( 'page', this.getFocusedDate() );
440                 }
442                 this.updateUI();
443         };
445         /**
446          * Handles previous button click
447          *
448          * @protected
449          */
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();
454                 }
455         };
457         /**
458          * Handles next button click
459          *
460          * @protected
461          */
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();
466                 }
467         };
469         /**
470          * Handles day button click
471          *
472          * @protected
473          * @param {OO.ui.ButtonWidget} $button
474          */
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();
480                 }
481         };
483         /**
484          * Handles document mouse down events.
485          *
486          * @protected
487          * @param {jQuery.Event} e Mouse down event
488          */
489         mw.widgets.datetime.CalendarWidget.prototype.onDocumentMouseDown = function ( e ) {
490                 if ( this.$widget &&
491                         !OO.ui.contains( this.$element[ 0 ], e.target, true ) &&
492                         !OO.ui.contains( this.$widget[ 0 ], e.target, true )
493                 ) {
494                         this.toggle( false );
495                 }
496         };
498         /**
499          * Handles key presses.
500          *
501          * @protected
502          * @param {jQuery.Event} e Key down event
503          * @return {boolean} False to cancel the default event
504          */
505         mw.widgets.datetime.CalendarWidget.prototype.onKeyDown = function ( e ) {
506                 var focusedDate = this.getFocusedDate();
508                 if ( !this.isDisabled() ) {
509                         switch ( e.which ) {
510                                 case OO.ui.Keys.ENTER:
511                                 case OO.ui.Keys.SPACE:
512                                         this.setSelected( [ focusedDate ] );
513                                         return false;
515                                 case OO.ui.Keys.LEFT:
516                                         this.setFocusedDate( this.adjustDate( focusedDate, 'day', -1 ) );
517                                         return false;
519                                 case OO.ui.Keys.RIGHT:
520                                         this.setFocusedDate( this.adjustDate( focusedDate, 'day', 1 ) );
521                                         return false;
523                                 case OO.ui.Keys.UP:
524                                         this.setFocusedDate( this.adjustDate( focusedDate, 'week', -1 ) );
525                                         return false;
527                                 case OO.ui.Keys.DOWN:
528                                         this.setFocusedDate( this.adjustDate( focusedDate, 'week', 1 ) );
529                                         return false;
531                                 case OO.ui.Keys.PAGEUP:
532                                         this.setFocusedDate( this.adjustDate( focusedDate, 'month', -1 ) );
533                                         return false;
535                                 case OO.ui.Keys.PAGEDOWN:
536                                         this.setFocusedDate( this.adjustDate( focusedDate, 'month', 1 ) );
537                                         return false;
538                         }
539                 }
540         };
542         /**
543          * Handles focusout events in dependent mode
544          *
545          * @private
546          */
547         mw.widgets.datetime.CalendarWidget.prototype.onFocusOut = function () {
548                 setTimeout( this.checkFocusHandler );
549         };
551         /**
552          * When we or our widget lost focus, check if the calendar should be hidden.
553          *
554          * @private
555          */
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 );
562                 }
563         };
565         /**
566          * @inheritdoc
567          */
568         mw.widgets.datetime.CalendarWidget.prototype.toggle = function ( visible ) {
569                 var change;
571                 visible = ( visible === undefined ? !this.visible : !!visible );
572                 change = visible !== this.isVisible();
574                 // Parent method
575                 mw.widgets.datetime.CalendarWidget[ 'super' ].prototype.toggle.call( this, visible );
577                 if ( change ) {
578                         if ( visible ) {
579                                 // Auto-hide
580                                 if ( this.$widget ) {
581                                         this.getElementDocument().addEventListener(
582                                                 'mousedown', this.onDocumentMouseDownHandler, true
583                                         );
584                                 }
585                                 this.updateUI();
586                         } else {
587                                 this.getElementDocument().removeEventListener(
588                                         'mousedown', this.onDocumentMouseDownHandler, true
589                                 );
590                         }
591                 }
593                 return this;
594         };
596 }( jQuery, mediaWiki ) );