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