mw.widgets.CalendarWidget: Tweak hover styling after OOUI changes
[lhc/web/wiklou.git] / resources / src / mediawiki.widgets / mw.widgets.CalendarWidget.js
1 /*!
2 * MediaWiki Widgets – CalendarWidget class.
3 *
4 * @copyright 2011-2015 MediaWiki Widgets Team and others; see AUTHORS.txt
5 * @license The MIT License (MIT); see LICENSE.txt
6 */
7 /* global moment */
8 ( function () {
9
10 /**
11 * Creates an mw.widgets.CalendarWidget object.
12 *
13 * You will most likely want to use mw.widgets.DateInputWidget instead of CalendarWidget directly.
14 *
15 * @class
16 * @extends OO.ui.Widget
17 * @mixins OO.ui.mixin.TabIndexedElement
18 * @mixins OO.ui.mixin.FloatableElement
19 *
20 * @constructor
21 * @param {Object} [config] Configuration options
22 * @cfg {boolean} [lazyInitOnToggle=false] Don't build most of the interface until
23 * `.toggle( true )` is called. Meant to be used when the calendar is not immediately visible.
24 * @cfg {string} [precision='day'] Date precision to use, 'day' or 'month'
25 * @cfg {string|null} [date=null] Day or month date (depending on `precision`), in the format
26 * 'YYYY-MM-DD' or 'YYYY-MM'. When null, the calendar will show today's date, but not select
27 * it.
28 */
29 mw.widgets.CalendarWidget = function MWWCalendarWidget( config ) {
30 // Config initialization
31 config = config || {};
32
33 // Parent constructor
34 mw.widgets.CalendarWidget.parent.call( this, config );
35
36 // Mixin constructors
37 OO.ui.mixin.TabIndexedElement.call( this, $.extend( {}, config, { $tabIndexed: this.$element } ) );
38 OO.ui.mixin.FloatableElement.call( this, config );
39
40 // Properties
41 this.lazyInitOnToggle = !!config.lazyInitOnToggle;
42 this.precision = config.precision || 'day';
43 // Currently selected date (day or month)
44 this.date = null;
45 // Current UI state (date and precision we're displaying right now)
46 this.moment = null;
47 this.displayLayer = this.getDisplayLayers()[ 0 ]; // 'month', 'year', 'duodecade'
48
49 this.$header = $( '<div>' ).addClass( 'mw-widget-calendarWidget-header' );
50 this.$bodyOuterWrapper = $( '<div>' ).addClass( 'mw-widget-calendarWidget-body-outer-wrapper' );
51 this.$bodyWrapper = $( '<div>' ).addClass( 'mw-widget-calendarWidget-body-wrapper' );
52 this.$body = $( '<div>' ).addClass( 'mw-widget-calendarWidget-body' );
53
54 // Events
55 this.$element.on( {
56 focus: this.onFocus.bind( this ),
57 mousedown: this.onClick.bind( this ),
58 keydown: this.onKeyDown.bind( this )
59 } );
60
61 // Initialization
62 this.$element
63 .addClass( 'mw-widget-calendarWidget' )
64 .append( this.$header, this.$bodyOuterWrapper.append( this.$bodyWrapper.append( this.$body ) ) );
65 if ( !this.lazyInitOnToggle ) {
66 this.buildHeaderButtons();
67 }
68 this.setDate( config.date !== undefined ? config.date : null );
69 };
70
71 /* Inheritance */
72
73 OO.inheritClass( mw.widgets.CalendarWidget, OO.ui.Widget );
74 OO.mixinClass( mw.widgets.CalendarWidget, OO.ui.mixin.TabIndexedElement );
75 OO.mixinClass( mw.widgets.CalendarWidget, OO.ui.mixin.FloatableElement );
76
77 /* Events */
78
79 /**
80 * @event change
81 *
82 * A change event is emitted when the chosen date changes.
83 *
84 * @param {string} date Day or month date, in the format 'YYYY-MM-DD' or 'YYYY-MM'
85 */
86
87 /* Methods */
88
89 /**
90 * Get the date format ('YYYY-MM-DD' or 'YYYY-MM', depending on precision), which is used
91 * internally and for dates accepted by #setDate and returned by #getDate.
92 *
93 * @private
94 * @return {string} Format
95 */
96 mw.widgets.CalendarWidget.prototype.getDateFormat = function () {
97 return {
98 day: 'YYYY-MM-DD',
99 month: 'YYYY-MM'
100 }[ this.precision ];
101 };
102
103 /**
104 * Get the date precision this calendar uses, 'day' or 'month'.
105 *
106 * @private
107 * @return {string} Precision, 'day' or 'month'
108 */
109 mw.widgets.CalendarWidget.prototype.getPrecision = function () {
110 return this.precision;
111 };
112
113 /**
114 * Get list of possible display layers.
115 *
116 * @private
117 * @return {string[]} Layers
118 */
119 mw.widgets.CalendarWidget.prototype.getDisplayLayers = function () {
120 return [ 'month', 'year', 'duodecade' ].slice( this.precision === 'month' ? 1 : 0 );
121 };
122
123 /**
124 * Update the calendar.
125 *
126 * @private
127 * @param {string|null} [fade=null] Direction in which to fade out current calendar contents,
128 * 'previous', 'next', 'up' or 'down'; or 'auto', which has the same result as 'previous' or
129 * 'next' depending on whether the current date is later or earlier than the previous.
130 */
131 mw.widgets.CalendarWidget.prototype.updateUI = function ( fade ) {
132 var items, today, selected, currentMonth, currentYear, currentDay, i, needsFade,
133 $bodyWrapper = this.$bodyWrapper;
134
135 if ( this.lazyInitOnToggle ) {
136 // We're being called from the constructor and not being shown yet, do nothing
137 return;
138 }
139
140 if (
141 this.displayLayer === this.previousDisplayLayer &&
142 this.date === this.previousDate &&
143 this.previousMoment &&
144 this.previousMoment.isSame( this.moment, this.precision === 'month' ? 'month' : 'day' )
145 ) {
146 // Already displayed
147 return;
148 }
149
150 if ( fade === 'auto' ) {
151 if ( !this.previousMoment ) {
152 fade = null;
153 } else if ( this.previousMoment.isBefore( this.moment, this.precision === 'month' ? 'month' : 'day' ) ) {
154 fade = 'next';
155 } else if ( this.previousMoment.isAfter( this.moment, this.precision === 'month' ? 'month' : 'day' ) ) {
156 fade = 'previous';
157 } else {
158 fade = null;
159 }
160 }
161
162 items = [];
163 if ( this.$oldBody ) {
164 this.$oldBody.remove();
165 }
166 this.$oldBody = this.$body.addClass( 'mw-widget-calendarWidget-old-body' );
167 // Clone without children
168 this.$body = $( this.$body[ 0 ].cloneNode( false ) )
169 .removeClass( 'mw-widget-calendarWidget-old-body' )
170 .toggleClass( 'mw-widget-calendarWidget-body-month', this.displayLayer === 'month' )
171 .toggleClass( 'mw-widget-calendarWidget-body-year', this.displayLayer === 'year' )
172 .toggleClass( 'mw-widget-calendarWidget-body-duodecade', this.displayLayer === 'duodecade' );
173
174 today = moment();
175 selected = moment( this.getDate(), this.getDateFormat() );
176
177 switch ( this.displayLayer ) {
178 case 'month':
179 this.labelButton.setLabel( this.moment.format( 'MMMM YYYY' ) );
180 this.labelButton.toggle( true );
181 this.upButton.toggle( true );
182
183 // First week displayed is the first week spanned by the month, unless it begins on Monday, in
184 // which case first week displayed is the previous week. This makes the calendar "balanced"
185 // and also neatly handles 28-day February sometimes spanning only 4 weeks.
186 currentDay = moment( this.moment ).startOf( 'month' ).subtract( 1, 'day' ).startOf( 'week' );
187
188 // Day-of-week labels. Localisation-independent: works with weeks starting on Saturday, Sunday
189 // or Monday.
190 for ( i = 0; i < 7; i++ ) {
191 items.push(
192 $( '<div>' )
193 .addClass( 'mw-widget-calendarWidget-day-heading' )
194 .text( currentDay.format( 'dd' ) )
195 );
196 currentDay.add( 1, 'day' );
197 }
198 currentDay.subtract( 7, 'days' );
199
200 // Actual calendar month. Always displays 6 weeks, for consistency (months can span 4 to 6
201 // weeks).
202 for ( i = 0; i < 42; i++ ) {
203 items.push(
204 $( '<div>' )
205 .addClass( 'mw-widget-calendarWidget-item mw-widget-calendarWidget-day' )
206 .toggleClass( 'mw-widget-calendarWidget-day-additional', !currentDay.isSame( this.moment, 'month' ) )
207 .toggleClass( 'mw-widget-calendarWidget-day-today', currentDay.isSame( today, 'day' ) )
208 .toggleClass( 'mw-widget-calendarWidget-item-selected', currentDay.isSame( selected, 'day' ) )
209 .text( currentDay.format( 'D' ) )
210 .data( 'date', currentDay.date() )
211 .data( 'month', currentDay.month() )
212 .data( 'year', currentDay.year() )
213 );
214 currentDay.add( 1, 'day' );
215 }
216 break;
217
218 case 'year':
219 this.labelButton.setLabel( this.moment.format( 'YYYY' ) );
220 this.labelButton.toggle( true );
221 this.upButton.toggle( true );
222
223 currentMonth = moment( this.moment ).startOf( 'year' );
224 for ( i = 0; i < 12; i++ ) {
225 items.push(
226 $( '<div>' )
227 .addClass( 'mw-widget-calendarWidget-item mw-widget-calendarWidget-month' )
228 .toggleClass( 'mw-widget-calendarWidget-item-selected', currentMonth.isSame( selected, 'month' ) )
229 .text( currentMonth.format( 'MMMM' ) )
230 .data( 'month', currentMonth.month() )
231 );
232 currentMonth.add( 1, 'month' );
233 }
234 // Shuffle the array to display months in columns rather than rows:
235 // | Jan | Jul |
236 // | Feb | Aug |
237 // | Mar | Sep |
238 // | Apr | Oct |
239 // | May | Nov |
240 // | Jun | Dec |
241 items = [
242 items[ 0 ], items[ 6 ],
243 items[ 1 ], items[ 7 ],
244 items[ 2 ], items[ 8 ],
245 items[ 3 ], items[ 9 ],
246 items[ 4 ], items[ 10 ],
247 items[ 5 ], items[ 11 ]
248 ];
249 break;
250
251 case 'duodecade':
252 this.labelButton.setLabel( null );
253 this.labelButton.toggle( false );
254 this.upButton.toggle( false );
255
256 currentYear = moment( { year: Math.floor( this.moment.year() / 20 ) * 20 } );
257 for ( i = 0; i < 20; i++ ) {
258 items.push(
259 $( '<div>' )
260 .addClass( 'mw-widget-calendarWidget-item mw-widget-calendarWidget-year' )
261 .toggleClass( 'mw-widget-calendarWidget-item-selected', currentYear.isSame( selected, 'year' ) )
262 .text( currentYear.format( 'YYYY' ) )
263 .data( 'year', currentYear.year() )
264 );
265 currentYear.add( 1, 'year' );
266 }
267 break;
268 }
269
270 this.$body.append.apply( this.$body, items );
271
272 $bodyWrapper
273 .removeClass( 'mw-widget-calendarWidget-body-wrapper-fade-up' )
274 .removeClass( 'mw-widget-calendarWidget-body-wrapper-fade-down' )
275 .removeClass( 'mw-widget-calendarWidget-body-wrapper-fade-previous' )
276 .removeClass( 'mw-widget-calendarWidget-body-wrapper-fade-next' );
277
278 needsFade = this.previousDisplayLayer !== this.displayLayer;
279 if ( this.displayLayer === 'month' ) {
280 needsFade = needsFade || !this.moment.isSame( this.previousMoment, 'month' );
281 } else if ( this.displayLayer === 'year' ) {
282 needsFade = needsFade || !this.moment.isSame( this.previousMoment, 'year' );
283 } else if ( this.displayLayer === 'duodecade' ) {
284 needsFade = needsFade || (
285 Math.floor( this.moment.year() / 20 ) * 20 !==
286 Math.floor( this.previousMoment.year() / 20 ) * 20
287 );
288 }
289
290 if ( fade && needsFade ) {
291 this.$oldBody.find( '.mw-widget-calendarWidget-item-selected' )
292 .removeClass( 'mw-widget-calendarWidget-item-selected' );
293 if ( fade === 'previous' || fade === 'up' ) {
294 this.$body.insertBefore( this.$oldBody );
295 } else if ( fade === 'next' || fade === 'down' ) {
296 this.$body.insertAfter( this.$oldBody );
297 }
298 setTimeout( function () {
299 $bodyWrapper.addClass( 'mw-widget-calendarWidget-body-wrapper-fade-' + fade );
300 } );
301 } else {
302 this.$oldBody.replaceWith( this.$body );
303 }
304
305 this.previousMoment = moment( this.moment );
306 this.previousDisplayLayer = this.displayLayer;
307 this.previousDate = this.date;
308
309 this.$body.on( 'click', this.onBodyClick.bind( this ) );
310 };
311
312 /**
313 * Construct and display buttons to navigate the calendar.
314 *
315 * @private
316 */
317 mw.widgets.CalendarWidget.prototype.buildHeaderButtons = function () {
318 this.labelButton = new OO.ui.ButtonWidget( {
319 tabIndex: -1,
320 label: '',
321 framed: false,
322 classes: [ 'mw-widget-calendarWidget-labelButton' ]
323 } );
324 // FIXME This button is actually not clickable because labelButton covers it,
325 // should it just be a plain icon?
326 this.upButton = new OO.ui.ButtonWidget( {
327 tabIndex: -1,
328 framed: false,
329 icon: 'collapse',
330 classes: [ 'mw-widget-calendarWidget-upButton' ]
331 } );
332 this.prevButton = new OO.ui.ButtonWidget( {
333 tabIndex: -1,
334 framed: false,
335 icon: 'previous',
336 classes: [ 'mw-widget-calendarWidget-prevButton' ]
337 } );
338 this.nextButton = new OO.ui.ButtonWidget( {
339 tabIndex: -1,
340 framed: false,
341 icon: 'next',
342 classes: [ 'mw-widget-calendarWidget-nextButton' ]
343 } );
344
345 this.labelButton.connect( this, { click: 'onUpButtonClick' } );
346 this.upButton.connect( this, { click: 'onUpButtonClick' } );
347 this.prevButton.connect( this, { click: 'onPrevButtonClick' } );
348 this.nextButton.connect( this, { click: 'onNextButtonClick' } );
349
350 this.$header.append(
351 this.prevButton.$element,
352 this.nextButton.$element,
353 this.labelButton.$element,
354 this.upButton.$element
355 );
356 };
357
358 /**
359 * Handle click events on the "up" button, switching to less precise view.
360 *
361 * @private
362 */
363 mw.widgets.CalendarWidget.prototype.onUpButtonClick = function () {
364 var
365 layers = this.getDisplayLayers(),
366 currentLayer = layers.indexOf( this.displayLayer );
367 if ( currentLayer !== layers.length - 1 ) {
368 // One layer up
369 this.displayLayer = layers[ currentLayer + 1 ];
370 this.updateUI( 'up' );
371 } else {
372 this.updateUI();
373 }
374 };
375
376 /**
377 * Handle click events on the "previous" button, switching to previous pane.
378 *
379 * @private
380 */
381 mw.widgets.CalendarWidget.prototype.onPrevButtonClick = function () {
382 switch ( this.displayLayer ) {
383 case 'month':
384 this.moment.subtract( 1, 'month' );
385 break;
386 case 'year':
387 this.moment.subtract( 1, 'year' );
388 break;
389 case 'duodecade':
390 this.moment.subtract( 20, 'years' );
391 break;
392 }
393 this.updateUI( 'previous' );
394 };
395
396 /**
397 * Handle click events on the "next" button, switching to next pane.
398 *
399 * @private
400 */
401 mw.widgets.CalendarWidget.prototype.onNextButtonClick = function () {
402 switch ( this.displayLayer ) {
403 case 'month':
404 this.moment.add( 1, 'month' );
405 break;
406 case 'year':
407 this.moment.add( 1, 'year' );
408 break;
409 case 'duodecade':
410 this.moment.add( 20, 'years' );
411 break;
412 }
413 this.updateUI( 'next' );
414 };
415
416 /**
417 * Handle click events anywhere in the body of the widget, which contains the matrix of days,
418 * months or years to choose. Maybe change the pane or switch to more precise view, depending on
419 * what gets clicked.
420 *
421 * @private
422 * @param {jQuery.Event} e Click event
423 */
424 mw.widgets.CalendarWidget.prototype.onBodyClick = function ( e ) {
425 var
426 $target = $( e.target ),
427 layers = this.getDisplayLayers(),
428 currentLayer = layers.indexOf( this.displayLayer );
429 if ( $target.data( 'year' ) !== undefined ) {
430 this.moment.year( $target.data( 'year' ) );
431 }
432 if ( $target.data( 'month' ) !== undefined ) {
433 this.moment.month( $target.data( 'month' ) );
434 }
435 if ( $target.data( 'date' ) !== undefined ) {
436 this.moment.date( $target.data( 'date' ) );
437 }
438 if ( currentLayer === 0 ) {
439 this.setDateFromMoment();
440 this.updateUI( 'auto' );
441 } else {
442 // One layer down
443 this.displayLayer = layers[ currentLayer - 1 ];
444 this.updateUI( 'down' );
445 }
446 };
447
448 /**
449 * Set the date.
450 *
451 * @param {string|null} [date=null] Day or month date, in the format 'YYYY-MM-DD' or 'YYYY-MM'.
452 * When null, the calendar will show today's date, but not select it. When invalid, the date
453 * is not changed.
454 */
455 mw.widgets.CalendarWidget.prototype.setDate = function ( date ) {
456 var mom = date !== null ? moment( date, this.getDateFormat() ) : moment();
457 if ( mom.isValid() ) {
458 this.moment = mom;
459 if ( date !== null ) {
460 this.setDateFromMoment();
461 } else if ( this.date !== null ) {
462 this.date = null;
463 this.emit( 'change', this.date );
464 }
465 this.displayLayer = this.getDisplayLayers()[ 0 ];
466 this.updateUI();
467 }
468 };
469
470 /**
471 * Reset the user interface of this widget to reflect selected date.
472 */
473 mw.widgets.CalendarWidget.prototype.resetUI = function () {
474 this.moment = this.getDate() !== null ? moment( this.getDate(), this.getDateFormat() ) : moment();
475 this.displayLayer = this.getDisplayLayers()[ 0 ];
476 this.updateUI();
477 };
478
479 /**
480 * Set the date from moment object.
481 *
482 * @private
483 */
484 mw.widgets.CalendarWidget.prototype.setDateFromMoment = function () {
485 // Switch to English locale to avoid number formatting. We want the internal value to be
486 // '2015-07-24' and not '٢٠١٥-٠٧-٢٤' even if the UI language is Arabic.
487 var newDate = moment( this.moment ).locale( 'en' ).format( this.getDateFormat() );
488 if ( this.date !== newDate ) {
489 this.date = newDate;
490 this.emit( 'change', this.date );
491 }
492 };
493
494 /**
495 * Get current date, in the format 'YYYY-MM-DD' or 'YYYY-MM', depending on precision. Digits will
496 * not be localised.
497 *
498 * @return {string|null} Date string
499 */
500 mw.widgets.CalendarWidget.prototype.getDate = function () {
501 return this.date;
502 };
503
504 /**
505 * Handle focus events.
506 *
507 * @private
508 */
509 mw.widgets.CalendarWidget.prototype.onFocus = function () {
510 this.displayLayer = this.getDisplayLayers()[ 0 ];
511 this.updateUI( 'down' );
512 };
513
514 /**
515 * Handle mouse click events.
516 *
517 * @private
518 * @param {jQuery.Event} e Mouse click event
519 * @return {boolean} False to cancel the default event
520 */
521 mw.widgets.CalendarWidget.prototype.onClick = function ( e ) {
522 if ( !this.isDisabled() && e.which === 1 ) {
523 // Prevent unintended focussing
524 return false;
525 }
526 };
527
528 /**
529 * Handle key down events.
530 *
531 * @private
532 * @param {jQuery.Event} e Key down event
533 * @return {boolean} False to cancel the default event
534 */
535 mw.widgets.CalendarWidget.prototype.onKeyDown = function ( e ) {
536 var
537 dir = OO.ui.Element.static.getDir( this.$element ),
538 nextDirectionKey = dir === 'ltr' ? OO.ui.Keys.RIGHT : OO.ui.Keys.LEFT,
539 prevDirectionKey = dir === 'ltr' ? OO.ui.Keys.LEFT : OO.ui.Keys.RIGHT,
540 changed = true;
541
542 if ( !this.isDisabled() ) {
543 switch ( e.which ) {
544 case prevDirectionKey:
545 this.moment.subtract( 1, this.precision === 'month' ? 'month' : 'day' );
546 break;
547 case nextDirectionKey:
548 this.moment.add( 1, this.precision === 'month' ? 'month' : 'day' );
549 break;
550 case OO.ui.Keys.UP:
551 this.moment.subtract( 1, this.precision === 'month' ? 'month' : 'week' );
552 break;
553 case OO.ui.Keys.DOWN:
554 this.moment.add( 1, this.precision === 'month' ? 'month' : 'week' );
555 break;
556 case OO.ui.Keys.PAGEUP:
557 this.moment.subtract( 1, this.precision === 'month' ? 'year' : 'month' );
558 break;
559 case OO.ui.Keys.PAGEDOWN:
560 this.moment.add( 1, this.precision === 'month' ? 'year' : 'month' );
561 break;
562 default:
563 changed = false;
564 break;
565 }
566
567 if ( changed ) {
568 this.displayLayer = this.getDisplayLayers()[ 0 ];
569 this.setDateFromMoment();
570 this.updateUI( 'auto' );
571 return false;
572 }
573 }
574 };
575
576 /**
577 * @inheritdoc
578 */
579 mw.widgets.CalendarWidget.prototype.toggle = function ( visible ) {
580 if ( this.lazyInitOnToggle && visible ) {
581 this.lazyInitOnToggle = false;
582 this.buildHeaderButtons();
583 this.updateUI();
584 }
585
586 // Parent method
587 mw.widgets.CalendarWidget.parent.prototype.toggle.call( this, visible );
588
589 if ( this.$floatableContainer ) {
590 this.togglePositioning( this.isVisible() );
591 }
592
593 return this;
594 };
595
596 }() );