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