Merge "Add editing own JSON to editmyoptions grant"
[lhc/web/wiklou.git] / resources / src / mediawiki.widgets.datetime / CalendarWidget.js
1 ( function () {
2
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;
32
33 // Configuration initialization
34 config = $.extend( {
35 min: null,
36 max: null,
37 focusedDate: new Date(),
38 selected: null,
39 formatter: {}
40 }, config );
41
42 // Parent constructor
43 mw.widgets.datetime.CalendarWidget.super.call( this, config );
44
45 // Mixin constructors
46 OO.ui.mixin.TabIndexedElement.call( this, $.extend( {}, config, { $tabIndexed: this.$element } ) );
47
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 }
59
60 if ( config.focusedDate instanceof Date ) {
61 this.focusedDate = config.focusedDate;
62 } else {
63 this.focusedDate = new Date();
64 }
65
66 this.selected = [];
67
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 }
75
76 this.calendarData = null;
77
78 this.widget = config.widget;
79 this.$widget = config.widget ? config.widget.$element : null;
80 this.onDocumentMouseDownHandler = this.onDocumentMouseDown.bind( this );
81
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;
93
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 }
110
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 );
136
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 }
150
151 this.setSelected( config.selected );
152 this.$element
153 .addClass( 'mw-widgets-datetime-calendarWidget' )
154 .append( this.$head, this.$table );
155
156 if ( this.widget ) {
157 this.$element.addClass( 'mw-widgets-datetime-calendarWidget-dependent' );
158
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 };
168
169 /* Setup */
170
171 OO.inheritClass( mw.widgets.datetime.CalendarWidget, OO.ui.Widget );
172 OO.mixinClass( mw.widgets.datetime.CalendarWidget, OO.ui.mixin.TabIndexedElement );
173
174 /* Events */
175
176 /**
177 * A `change` event is emitted when the selected dates change
178 *
179 * @event change
180 */
181
182 /**
183 * A `focusChange` event is emitted when the focused date changes
184 *
185 * @event focusChange
186 */
187
188 /**
189 * A `page` event is emitted when the current "month" changes
190 *
191 * @event page
192 */
193
194 /* Methods */
195
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 };
204
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;
215
216 if ( dates instanceof Date ) {
217 dates = [ dates ];
218 } else if ( Array.isArray( dates ) ) {
219 dates = dates.filter( function ( dt ) {
220 return dt instanceof Date;
221 } );
222 dates.sort();
223 } else {
224 dates = [];
225 }
226
227 if ( this.selected.length !== dates.length ) {
228 changed = true;
229 } else {
230 for ( i = 0; i < dates.length; i++ ) {
231 if ( dates[ i ].getTime() !== this.selected[ i ].getTime() ) {
232 changed = true;
233 break;
234 }
235 }
236 }
237
238 if ( changed ) {
239 this.selected = dates;
240 this.emit( 'change', dates );
241 this.updateUI();
242 }
243
244 return this;
245 };
246
247 /**
248 * Return the currently-focused date
249 *
250 * @return {Date}
251 */
252 mw.widgets.datetime.CalendarWidget.prototype.getFocusedDate = function () {
253 return this.focusedDate;
254 };
255
256 // eslint-disable-next-line valid-jsdoc
257 /**
258 * Set the currently-focused date
259 *
260 * @param {Date} date
261 * @fires page
262 * @chainable
263 */
264 mw.widgets.datetime.CalendarWidget.prototype.setFocusedDate = function ( date ) {
265 var changePage = false,
266 updateUI = false;
267
268 if ( this.focusedDate.getTime() === date.getTime() ) {
269 return this;
270 }
271
272 if ( !this.formatter.sameCalendarGrid( this.focusedDate, date ) ) {
273 changePage = true;
274 updateUI = true;
275 } else if (
276 !this.formatter.timePartIsEqual( this.focusedDate, date ) ||
277 !this.formatter.datePartIsEqual( this.focusedDate, date )
278 ) {
279 updateUI = true;
280 }
281
282 this.focusedDate = date;
283 this.emit( 'focusChanged', this.focusedDate );
284 if ( changePage ) {
285 this.emit( 'page', date );
286 }
287 if ( updateUI ) {
288 this.updateUI();
289 }
290
291 return this;
292 };
293
294 /**
295 * Adjust a date
296 *
297 * @protected
298 * @param {Date} date Date to adjust
299 * @param {string} component Component: 'month', 'week', or 'day'
300 * @param {number} delta Integer, usually -1 or 1
301 * @param {boolean} [enforceRange=true] Whether to enforce this.min and this.max
302 * @return {Date}
303 */
304 mw.widgets.datetime.CalendarWidget.prototype.adjustDate = function ( date, component, delta ) {
305 var newDate,
306 data = this.calendarData;
307
308 if ( !data ) {
309 return date;
310 }
311
312 switch ( component ) {
313 case 'month':
314 newDate = this.formatter.adjustComponent( date, data.monthComponent, delta, 'overflow' );
315 break;
316
317 case 'week':
318 if ( data.weekComponent === undefined ) {
319 newDate = this.formatter.adjustComponent(
320 date, data.dayComponent, delta * this.daysPerWeek, 'overflow' );
321 } else {
322 newDate = this.formatter.adjustComponent( date, data.weekComponent, delta, 'overflow' );
323 }
324 break;
325
326 case 'day':
327 newDate = this.formatter.adjustComponent( date, data.dayComponent, delta, 'overflow' );
328 break;
329
330 default:
331 throw new Error( 'Unknown component' );
332 }
333
334 while ( newDate < this.min ) {
335 newDate = this.formatter.adjustComponent( newDate, data.dayComponent, 1, 'overflow' );
336 }
337 while ( newDate > this.max ) {
338 newDate = this.formatter.adjustComponent( newDate, data.dayComponent, -1, 'overflow' );
339 }
340
341 return newDate;
342 };
343
344 /**
345 * Update the user interface
346 *
347 * @protected
348 */
349 mw.widgets.datetime.CalendarWidget.prototype.updateUI = function () {
350 var r, c, row, day, k, $cell,
351 width = this.minWidth,
352 nullCols = [],
353 focusedDate = this.getFocusedDate(),
354 selected = this.getSelected(),
355 datePartIsEqual = this.formatter.datePartIsEqual.bind( this.formatter ),
356 isSelected = function ( dt ) {
357 return datePartIsEqual( this, dt );
358 };
359
360 this.calendarData = this.formatter.getCalendarData( focusedDate );
361
362 this.$header.text( this.calendarData.header );
363
364 for ( c = 0; c < this.colNullable.length; c++ ) {
365 nullCols[ c ] = this.colNullable[ c ];
366 if ( nullCols[ c ] ) {
367 for ( r = 0; r < this.calendarData.rows.length; r++ ) {
368 if ( this.calendarData.rows[ r ][ c ] ) {
369 nullCols[ c ] = false;
370 break;
371 }
372 }
373 }
374 }
375
376 this.$tableBody.children().detach();
377 for ( r = 0; r < this.calendarData.rows.length; r++ ) {
378 if ( !this.rows[ r ] ) {
379 this.rows[ r ] = $( '<tr>' );
380 } else {
381 this.rows[ r ].children().detach();
382 }
383 this.$tableBody.append( this.rows[ r ] );
384 row = this.calendarData.rows[ r ];
385 for ( c = 0; c < row.length; c++ ) {
386 day = row[ c ];
387 if ( day === null ) {
388 k = 'empty-' + r + '-' + c;
389 if ( !this.buttons[ k ] ) {
390 this.buttons[ k ] = $( '<td>' );
391 }
392 $cell = this.buttons[ k ];
393 $cell.toggleClass( 'oo-ui-element-hidden', nullCols[ c ] );
394 } else {
395 k = ( day.extra ? day.extra : '' ) + day.display;
396 width = Math.max( width, day.display.length );
397 if ( !this.buttons[ k ] ) {
398 this.buttons[ k ] = new OO.ui.ButtonWidget( {
399 $element: $( '<td>' ),
400 classes: [
401 'mw-widgets-datetime-calendarWidget-cell',
402 day.extra ? 'mw-widgets-datetime-calendarWidget-extra' : ''
403 ],
404 framed: true,
405 label: day.display,
406 tabIndex: -1
407 } );
408 this.buttons[ k ].connect( this, { click: [ 'onDayClick', this.buttons[ k ] ] } );
409 }
410 this.buttons[ k ]
411 .setData( day.date )
412 .setDisabled( day.date < this.min || day.date > this.max );
413 $cell = this.buttons[ k ].$element;
414 $cell.toggleClass( 'mw-widgets-datetime-calendarWidget-focused',
415 this.formatter.datePartIsEqual( focusedDate, day.date ) );
416 $cell.toggleClass( 'mw-widgets-datetime-calendarWidget-selected',
417 selected.some( isSelected, day.date ) );
418 }
419 this.rows[ r ].append( $cell );
420 }
421 }
422
423 for ( c = 0; c < this.cols.length; c++ ) {
424 if ( nullCols[ c ] ) {
425 this.cols[ c ].width( 0 );
426 } else {
427 this.cols[ c ].width( width + 'em' );
428 }
429 this.cols[ c ].toggleClass( 'oo-ui-element-hidden', nullCols[ c ] );
430 this.headings[ c ].toggleClass( 'oo-ui-element-hidden', nullCols[ c ] );
431 }
432 };
433
434 /**
435 * Handles formatter 'local' flag changing
436 *
437 * @protected
438 */
439 mw.widgets.datetime.CalendarWidget.prototype.onLocalChange = function () {
440 if ( this.formatter.localChangesDatePart( this.getFocusedDate() ) ) {
441 this.emit( 'page', this.getFocusedDate() );
442 }
443
444 this.updateUI();
445 };
446
447 /**
448 * Handles previous button click
449 *
450 * @protected
451 */
452 mw.widgets.datetime.CalendarWidget.prototype.onPrevClick = function () {
453 this.setFocusedDate( this.adjustDate( this.getFocusedDate(), 'month', -1 ) );
454 if ( !this.$widget || OO.ui.contains( this.$element[ 0 ], document.activeElement, true ) ) {
455 this.$element.focus();
456 }
457 };
458
459 /**
460 * Handles next button click
461 *
462 * @protected
463 */
464 mw.widgets.datetime.CalendarWidget.prototype.onNextClick = function () {
465 this.setFocusedDate( this.adjustDate( this.getFocusedDate(), 'month', 1 ) );
466 if ( !this.$widget || OO.ui.contains( this.$element[ 0 ], document.activeElement, true ) ) {
467 this.$element.focus();
468 }
469 };
470
471 /**
472 * Handles day button click
473 *
474 * @protected
475 * @param {OO.ui.ButtonWidget} $button
476 */
477 mw.widgets.datetime.CalendarWidget.prototype.onDayClick = function ( $button ) {
478 this.setFocusedDate( $button.getData() );
479 this.setSelected( [ $button.getData() ] );
480 if ( !this.$widget || OO.ui.contains( this.$element[ 0 ], document.activeElement, true ) ) {
481 this.$element.focus();
482 }
483 };
484
485 /**
486 * Handles document mouse down events.
487 *
488 * @protected
489 * @param {jQuery.Event} e Mouse down event
490 */
491 mw.widgets.datetime.CalendarWidget.prototype.onDocumentMouseDown = function ( e ) {
492 if ( this.$widget &&
493 !OO.ui.contains( this.$element[ 0 ], e.target, true ) &&
494 !OO.ui.contains( this.$widget[ 0 ], e.target, true )
495 ) {
496 this.toggle( false );
497 }
498 };
499
500 /**
501 * Handles key presses.
502 *
503 * @protected
504 * @param {jQuery.Event} e Key down event
505 * @return {boolean} False to cancel the default event
506 */
507 mw.widgets.datetime.CalendarWidget.prototype.onKeyDown = function ( e ) {
508 var focusedDate = this.getFocusedDate();
509
510 if ( !this.isDisabled() ) {
511 switch ( e.which ) {
512 case OO.ui.Keys.ENTER:
513 case OO.ui.Keys.SPACE:
514 this.setSelected( [ focusedDate ] );
515 return false;
516
517 case OO.ui.Keys.LEFT:
518 this.setFocusedDate( this.adjustDate( focusedDate, 'day', -1 ) );
519 return false;
520
521 case OO.ui.Keys.RIGHT:
522 this.setFocusedDate( this.adjustDate( focusedDate, 'day', 1 ) );
523 return false;
524
525 case OO.ui.Keys.UP:
526 this.setFocusedDate( this.adjustDate( focusedDate, 'week', -1 ) );
527 return false;
528
529 case OO.ui.Keys.DOWN:
530 this.setFocusedDate( this.adjustDate( focusedDate, 'week', 1 ) );
531 return false;
532
533 case OO.ui.Keys.PAGEUP:
534 this.setFocusedDate( this.adjustDate( focusedDate, 'month', -1 ) );
535 return false;
536
537 case OO.ui.Keys.PAGEDOWN:
538 this.setFocusedDate( this.adjustDate( focusedDate, 'month', 1 ) );
539 return false;
540 }
541 }
542 };
543
544 /**
545 * Handles focusout events in dependent mode
546 *
547 * @private
548 */
549 mw.widgets.datetime.CalendarWidget.prototype.onFocusOut = function () {
550 setTimeout( this.checkFocusHandler );
551 };
552
553 /**
554 * When we or our widget lost focus, check if the calendar should be hidden.
555 *
556 * @private
557 */
558 mw.widgets.datetime.CalendarWidget.prototype.checkFocus = function () {
559 var containers = [ this.$element[ 0 ], this.$widget[ 0 ] ],
560 activeElement = document.activeElement;
561
562 if ( !activeElement || !OO.ui.contains( containers, activeElement, true ) ) {
563 this.toggle( false );
564 }
565 };
566
567 /**
568 * @inheritdoc
569 */
570 mw.widgets.datetime.CalendarWidget.prototype.toggle = function ( visible ) {
571 var change;
572
573 visible = ( visible === undefined ? !this.visible : !!visible );
574 change = visible !== this.isVisible();
575
576 // Parent method
577 mw.widgets.datetime.CalendarWidget.super.prototype.toggle.call( this, visible );
578
579 if ( change ) {
580 if ( visible ) {
581 // Auto-hide
582 if ( this.$widget ) {
583 this.getElementDocument().addEventListener(
584 'mousedown', this.onDocumentMouseDownHandler, true
585 );
586 }
587 this.updateUI();
588 } else {
589 this.getElementDocument().removeEventListener(
590 'mousedown', this.onDocumentMouseDownHandler, true
591 );
592 }
593 }
594
595 return this;
596 };
597
598 }() );