Merge "user: Allow "CAS update failed" exceptions to be normalised"
[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 ) { return dt instanceof Date; } );
220 dates.sort();
221 } else {
222 dates = [];
223 }
224
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 }
235
236 if ( changed ) {
237 this.selected = dates;
238 this.emit( 'change', dates );
239 this.updateUI();
240 }
241
242 return this;
243 };
244
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 };
253
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;
265
266 if ( this.focusedDate.getTime() === date.getTime() ) {
267 return this;
268 }
269
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 }
279
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 }
288
289 return this;
290 };
291
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;
305
306 if ( !data ) {
307 return date;
308 }
309
310 switch ( component ) {
311 case 'month':
312 newDate = this.formatter.adjustComponent( date, data.monthComponent, delta, 'overflow' );
313 break;
314
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;
323
324 case 'day':
325 newDate = this.formatter.adjustComponent( date, data.dayComponent, delta, 'overflow' );
326 break;
327
328 default:
329 throw new Error( 'Unknown component' );
330 }
331
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 }
338
339 return newDate;
340 };
341
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 };
357
358 this.calendarData = this.formatter.getCalendarData( focusedDate );
359
360 this.$header.text( this.calendarData.header );
361
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 }
373
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 }
420
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 };
431
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 }
441
442 this.updateUI();
443 };
444
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 };
456
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 };
468
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 };
482
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 };
497
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();
507
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;
514
515 case OO.ui.Keys.LEFT:
516 this.setFocusedDate( this.adjustDate( focusedDate, 'day', -1 ) );
517 return false;
518
519 case OO.ui.Keys.RIGHT:
520 this.setFocusedDate( this.adjustDate( focusedDate, 'day', 1 ) );
521 return false;
522
523 case OO.ui.Keys.UP:
524 this.setFocusedDate( this.adjustDate( focusedDate, 'week', -1 ) );
525 return false;
526
527 case OO.ui.Keys.DOWN:
528 this.setFocusedDate( this.adjustDate( focusedDate, 'week', 1 ) );
529 return false;
530
531 case OO.ui.Keys.PAGEUP:
532 this.setFocusedDate( this.adjustDate( focusedDate, 'month', -1 ) );
533 return false;
534
535 case OO.ui.Keys.PAGEDOWN:
536 this.setFocusedDate( this.adjustDate( focusedDate, 'month', 1 ) );
537 return false;
538 }
539 }
540 };
541
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 };
550
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;
559
560 if ( !activeElement || !OO.ui.contains( containers, activeElement, true ) ) {
561 this.toggle( false );
562 }
563 };
564
565 /**
566 * @inheritdoc
567 */
568 mw.widgets.datetime.CalendarWidget.prototype.toggle = function ( visible ) {
569 var change;
570
571 visible = ( visible === undefined ? !this.visible : !!visible );
572 change = visible !== this.isVisible();
573
574 // Parent method
575 mw.widgets.datetime.CalendarWidget[ 'super' ].prototype.toggle.call( this, visible );
576
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 }
592
593 return this;
594 };
595
596 }() );