Merge "Improve sorting on SpecialWanted*-Pages"
[mediawiki.git] / resources / src / mediawiki.widgets / mw.widgets.DateInputWidget.js
blob0ec6a4c518ddb88dc309ba4501664b20d7e7e383
1 /*!
2  * MediaWiki Widgets – DateInputWidget 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 ) {
10         /**
11          * Creates an mw.widgets.DateInputWidget object.
12          *
13          *     @example
14          *     // Date input widget showcase
15          *     var fieldset = new OO.ui.FieldsetLayout( {
16          *       items: [
17          *         new OO.ui.FieldLayout(
18          *           new mw.widgets.DateInputWidget(),
19          *           {
20          *             align: 'top',
21          *             label: 'Select date'
22          *           }
23          *         ),
24          *         new OO.ui.FieldLayout(
25          *           new mw.widgets.DateInputWidget( { precision: 'month' } ),
26          *           {
27          *             align: 'top',
28          *             label: 'Select month'
29          *           }
30          *         ),
31          *         new OO.ui.FieldLayout(
32          *           new mw.widgets.DateInputWidget( {
33          *             inputFormat: 'DD.MM.YYYY',
34          *             displayFormat: 'Do [of] MMMM [anno Domini] YYYY'
35          *           } ),
36          *           {
37          *             align: 'top',
38          *             label: 'Select date (custom formats)'
39          *           }
40          *         )
41          *       ]
42          *     } );
43          *     $( 'body' ).append( fieldset.$element );
44          *
45          * The value is stored in 'YYYY-MM-DD' or 'YYYY-MM' format:
46          *
47          *     @example
48          *     // Accessing values in a date input widget
49          *     var dateInput = new mw.widgets.DateInputWidget();
50          *     var $label = $( '<p>' );
51          *     $( 'body' ).append( $label, dateInput.$element );
52          *     dateInput.on( 'change', function () {
53          *       // The value will always be a valid date or empty string, malformed input is ignored
54          *       var date = dateInput.getValue();
55          *       $label.text( 'Selected date: ' + ( date || '(none)' ) );
56          *     } );
57          *
58          * @class
59          * @extends OO.ui.InputWidget
60          * @mixins OO.ui.mixin.IndicatorElement
61          *
62          * @constructor
63          * @param {Object} [config] Configuration options
64          * @cfg {string} [precision='day'] Date precision to use, 'day' or 'month'
65          * @cfg {string} [value] Day or month date (depending on `precision`), in the format 'YYYY-MM-DD'
66          *     or 'YYYY-MM'. If not given or empty string, no date is selected.
67          * @cfg {string} [inputFormat] Date format string to use for the textual input field. Displayed
68          *     while the widget is active, and the user can type in a date in this format. Should be short
69          *     and easy to type. When not given, defaults to 'YYYY-MM-DD' or 'YYYY-MM', depending on
70          *     `precision`.
71          * @cfg {string} [displayFormat] Date format string to use for the clickable label. Displayed
72          *     while the widget is inactive. Should be as unambiguous as possible (for example, prefer to
73          *     spell out the month, rather than rely on the order), even if that makes it longer. When not
74          *     given, the default is language-specific.
75          * @cfg {boolean} [longDisplayFormat=false] If a custom displayFormat is not specified, use
76          *     unabbreviated day of the week and month names in the default language-specific displayFormat.
77          * @cfg {string} [placeholderLabel=No date selected] Placeholder text shown when the widget is not
78          *     selected. Default text taken from message `mw-widgets-dateinput-no-date`.
79          * @cfg {string} [placeholderDateFormat] User-visible date format string displayed in the textual input
80          *     field when it's empty. Should be the same as `inputFormat`, but translated to the user's
81          *     language. When not given, defaults to a translated version of 'YYYY-MM-DD' or 'YYYY-MM',
82          *     depending on `precision`.
83          * @cfg {boolean} [required=false] Mark the field as required. Implies `indicator: 'required'`.
84          * @cfg {string} [mustBeAfter] Validates the date to be after this. In the 'YYYY-MM-DD' format.
85          * @cfg {string} [mustBeBefore] Validates the date to be before this. In the 'YYYY-MM-DD' format.
86          * @cfg {jQuery} [$overlay] Render the calendar into a separate layer. This configuration is
87          *     useful in cases where the expanded calendar is larger than its container. The specified
88          *     overlay layer is usually on top of the container and has a larger area. By default, the
89          *     calendar uses relative positioning.
90          */
91         mw.widgets.DateInputWidget = function MWWDateInputWidget( config ) {
92                 var placeholderDateFormat, mustBeAfter, mustBeBefore;
94                 // Config initialization
95                 config = $.extend( {
96                         precision: 'day',
97                         longDisplayFormat: false,
98                         required: false,
99                         placeholderLabel: mw.msg( 'mw-widgets-dateinput-no-date' )
100                 }, config );
101                 if ( config.required ) {
102                         if ( config.indicator === undefined ) {
103                                 config.indicator = 'required';
104                         }
105                 }
107                 if ( config.placeholderDateFormat ) {
108                         placeholderDateFormat = config.placeholderDateFormat;
109                 } else if ( config.inputFormat ) {
110                         // We have no way to display a translated placeholder for custom formats
111                         placeholderDateFormat = '';
112                 } else {
113                         // Messages: mw-widgets-dateinput-placeholder-day, mw-widgets-dateinput-placeholder-month
114                         placeholderDateFormat = mw.msg( 'mw-widgets-dateinput-placeholder-' + config.precision );
115                 }
117                 // Properties (must be set before parent constructor, which calls #setValue)
118                 this.$handle = $( '<div>' );
119                 this.label = new OO.ui.LabelWidget();
120                 this.textInput = new OO.ui.TextInputWidget( {
121                         required: config.required,
122                         placeholder: placeholderDateFormat,
123                         validate: this.validateDate.bind( this )
124                 } );
125                 this.calendar = new mw.widgets.CalendarWidget( {
126                         lazyInitOnToggle: true,
127                         // Can't pass `$floatableContainer: this.$element` here, the latter is not set yet.
128                         // Instead we call setFloatableContainer() below.
129                         precision: config.precision
130                 } );
131                 this.inCalendar = 0;
132                 this.inTextInput = 0;
133                 this.inputFormat = config.inputFormat;
134                 this.displayFormat = config.displayFormat;
135                 this.longDisplayFormat = config.longDisplayFormat;
136                 this.required = config.required;
137                 this.placeholderLabel = config.placeholderLabel;
139                 // Validate and set min and max dates as properties
140                 if ( config.mustBeAfter !== undefined ) {
141                         mustBeAfter = moment( config.mustBeAfter, 'YYYY-MM-DD' );
142                         if ( mustBeAfter.isValid() ) {
143                                 this.mustBeAfter = mustBeAfter;
144                         }
145                 }
146                 if ( config.mustBeBefore !== undefined ) {
147                         mustBeBefore = moment( config.mustBeBefore, 'YYYY-MM-DD' );
148                         if ( mustBeBefore.isValid() ) {
149                                 this.mustBeBefore = mustBeBefore;
150                         }
151                 }
153                 // Parent constructor
154                 mw.widgets.DateInputWidget.parent.call( this, config );
156                 // Mixin constructors
157                 OO.ui.mixin.IndicatorElement.call( this, config );
159                 // Events
160                 this.calendar.connect( this, {
161                         change: 'onCalendarChange'
162                 } );
163                 this.textInput.connect( this, {
164                         enter: 'onEnter',
165                         change: 'onTextInputChange'
166                 } );
167                 this.$element.on( {
168                         focusout: this.onBlur.bind( this )
169                 } );
170                 this.calendar.$element.on( {
171                         click: this.onCalendarClick.bind( this ),
172                         keypress: this.onCalendarKeyPress.bind( this )
173                 } );
174                 this.$handle.on( {
175                         click: this.onClick.bind( this ),
176                         keypress: this.onKeyPress.bind( this )
177                 } );
179                 // Initialization
180                 // Move 'tabindex' from this.$input (which is invisible) to the visible handle
181                 this.setTabIndexedElement( this.$handle );
182                 this.$handle
183                         .append( this.label.$element, this.$indicator )
184                         .addClass( 'mw-widget-dateInputWidget-handle' );
185                 this.calendar.$element
186                         .addClass( 'mw-widget-dateInputWidget-calendar' );
187                 this.$element
188                         .addClass( 'mw-widget-dateInputWidget' )
189                         .append( this.$handle, this.textInput.$element, this.calendar.$element );
191                 // config.overlay is the selector to be used for config.$overlay, specified from PHP
192                 if ( config.overlay ) {
193                         config.$overlay = $( config.overlay );
194                 }
196                 if ( config.$overlay ) {
197                         this.calendar.setFloatableContainer( this.$element );
198                         config.$overlay.append( this.calendar.$element );
200                         // The text input and calendar are not in DOM order, so fix up focus transitions.
201                         this.textInput.$input.on( 'keydown', function ( e ) {
202                                 if ( e.which === OO.ui.Keys.TAB ) {
203                                         if ( e.shiftKey ) {
204                                                 // Tabbing backward from text input: normal browser behavior
205                                                 $.noop();
206                                         } else {
207                                                 // Tabbing forward from text input: just focus the calendar
208                                                 this.calendar.$element.focus();
209                                                 return false;
210                                         }
211                                 }
212                         }.bind( this ) );
213                         this.calendar.$element.on( 'keydown', function ( e ) {
214                                 if ( e.which === OO.ui.Keys.TAB ) {
215                                         if ( e.shiftKey ) {
216                                                 // Tabbing backward from calendar: just focus the text input
217                                                 this.textInput.$input.focus();
218                                                 return false;
219                                         } else {
220                                                 // Tabbing forward from calendar: focus the text input, then allow normal browser
221                                                 // behavior to move focus to next focusable after it
222                                                 this.textInput.$input.focus();
223                                         }
224                                 }
225                         }.bind( this ) );
226                 }
228                 // Set handle label and hide stuff
229                 this.updateUI();
230                 this.textInput.toggle( false );
231                 this.calendar.toggle( false );
233                 // Hide unused <input> from PHP after infusion is done
234                 // See InputWidget#reusePreInfuseDOM about config.$input
235                 if ( config.$input ) {
236                         config.$input.addClass( 'oo-ui-element-hidden' );
237                 }
238         };
240         /* Inheritance */
242         OO.inheritClass( mw.widgets.DateInputWidget, OO.ui.InputWidget );
243         OO.mixinClass( mw.widgets.DateInputWidget, OO.ui.mixin.IndicatorElement );
245         /* Methods */
247         /**
248          * @inheritdoc
249          * @protected
250          */
251         mw.widgets.DateInputWidget.prototype.getInputElement = function () {
252                 return $( '<input>' ).attr( 'type', 'hidden' );
253         };
255         /**
256          * Respond to calendar date change events.
257          *
258          * @private
259          */
260         mw.widgets.DateInputWidget.prototype.onCalendarChange = function () {
261                 this.inCalendar++;
262                 if ( !this.inTextInput ) {
263                         // If this is caused by user typing in the input field, do not set anything.
264                         // The value may be invalid (see #onTextInputChange), but displayable on the calendar.
265                         this.setValue( this.calendar.getDate() );
266                 }
267                 this.inCalendar--;
268         };
270         /**
271          * Respond to text input value change events.
272          *
273          * @private
274          */
275         mw.widgets.DateInputWidget.prototype.onTextInputChange = function () {
276                 var mom,
277                         widget = this,
278                         value = this.textInput.getValue(),
279                         valid = this.isValidDate( value );
280                 this.inTextInput++;
282                 if ( value === '' ) {
283                         // No date selected
284                         widget.setValue( '' );
285                 } else if ( valid ) {
286                         // Well-formed date value, parse and set it
287                         mom = moment( value, widget.getInputFormat() );
288                         // Use English locale to avoid number formatting
289                         widget.setValue( mom.locale( 'en' ).format( widget.getInternalFormat() ) );
290                 } else {
291                         // Not well-formed, but possibly partial? Try updating the calendar, but do not set the
292                         // internal value. Generally this only makes sense when 'inputFormat' is little-endian (e.g.
293                         // 'YYYY-MM-DD'), but that's hard to check for, and might be difficult to handle the parsing
294                         // right for weird formats. So limit this trick to only when we're using the default
295                         // 'inputFormat', which is the same as the internal format, 'YYYY-MM-DD'.
296                         if ( widget.getInputFormat() === widget.getInternalFormat() ) {
297                                 widget.calendar.setDate( widget.textInput.getValue() );
298                         }
299                 }
300                 widget.inTextInput--;
302         };
304         /**
305          * @inheritdoc
306          */
307         mw.widgets.DateInputWidget.prototype.setValue = function ( value ) {
308                 var oldValue = this.value;
310                 if ( !moment( value, this.getInternalFormat() ).isValid() ) {
311                         value = '';
312                 }
314                 mw.widgets.DateInputWidget.parent.prototype.setValue.call( this, value );
316                 if ( this.value !== oldValue ) {
317                         this.updateUI();
318                         this.setValidityFlag();
319                 }
321                 return this;
322         };
324         /**
325          * Handle text input and calendar blur events.
326          *
327          * @private
328          */
329         mw.widgets.DateInputWidget.prototype.onBlur = function () {
330                 var widget = this;
331                 setTimeout( function () {
332                         var $focussed = $( ':focus' );
333                         // Deactivate unless the focus moved to something else inside this widget
334                         if (
335                                 !OO.ui.contains( widget.$element[ 0 ], $focussed[ 0 ], true ) &&
336                                 // Calendar might be in an $overlay
337                                 !OO.ui.contains( widget.calendar.$element[ 0 ], $focussed[ 0 ], true )
338                         ) {
339                                 widget.deactivate();
340                         }
341                 }, 0 );
342         };
344         /**
345          * @inheritdoc
346          */
347         mw.widgets.DateInputWidget.prototype.focus = function () {
348                 this.activate();
349                 return this;
350         };
352         /**
353          * @inheritdoc
354          */
355         mw.widgets.DateInputWidget.prototype.blur = function () {
356                 this.deactivate();
357                 return this;
358         };
360         /**
361          * Update the contents of the label, text input and status of calendar to reflect selected value.
362          *
363          * @private
364          */
365         mw.widgets.DateInputWidget.prototype.updateUI = function () {
366                 var moment;
367                 if ( this.getValue() === '' ) {
368                         this.textInput.setValue( '' );
369                         this.calendar.setDate( null );
370                         this.label.setLabel( this.placeholderLabel );
371                         this.$element.addClass( 'mw-widget-dateInputWidget-empty' );
372                 } else {
373                         moment = this.getMoment();
374                         if ( !this.inTextInput ) {
375                                 this.textInput.setValue( moment.format( this.getInputFormat() ) );
376                         }
377                         if ( !this.inCalendar ) {
378                                 this.calendar.setDate( this.getValue() );
379                         }
380                         this.label.setLabel( moment.format( this.getDisplayFormat() ) );
381                         this.$element.removeClass( 'mw-widget-dateInputWidget-empty' );
382                 }
383         };
385         /**
386          * Deactivate this input field for data entry. Closes the calendar and hides the text field.
387          *
388          * @private
389          */
390         mw.widgets.DateInputWidget.prototype.deactivate = function () {
391                 this.$element.removeClass( 'mw-widget-dateInputWidget-active' );
392                 this.$handle.show();
393                 this.textInput.toggle( false );
394                 this.calendar.toggle( false );
395                 this.setValidityFlag();
396         };
398         /**
399          * Activate this input field for data entry. Opens the calendar and shows the text field.
400          *
401          * @private
402          */
403         mw.widgets.DateInputWidget.prototype.activate = function () {
404                 this.calendar.resetUI();
405                 this.$element.addClass( 'mw-widget-dateInputWidget-active' );
406                 this.$handle.hide();
407                 this.textInput.toggle( true );
408                 this.calendar.toggle( true );
410                 this.textInput.$input.focus();
411         };
413         /**
414          * Get the date format to be used for handle label when the input is inactive.
415          *
416          * @private
417          * @return {string} Format string
418          */
419         mw.widgets.DateInputWidget.prototype.getDisplayFormat = function () {
420                 var localeData, llll, lll, ll, format;
422                 if ( this.displayFormat !== undefined ) {
423                         return this.displayFormat;
424                 }
426                 if ( this.calendar.getPrecision() === 'month' ) {
427                         return 'MMMM YYYY';
428                 } else {
429                         // The formats Moment.js provides:
430                         // * ll:   Month name, day of month, year
431                         // * lll:  Month name, day of month, year, time
432                         // * llll: Month name, day of month, day of week, year, time
433                         //
434                         // The format we want:
435                         // * ????: Month name, day of month, day of week, year
436                         //
437                         // We try to construct it as 'llll - (lll - ll)' and hope for the best.
438                         // This seems to work well for many languages (maybe even all?).
440                         localeData = moment.localeData( moment.locale() );
441                         llll = localeData.longDateFormat( 'llll' );
442                         lll = localeData.longDateFormat( 'lll' );
443                         ll = localeData.longDateFormat( 'll' );
444                         format = llll.replace( lll.replace( ll, '' ), '' );
446                         if ( this.longDisplayFormat ) {
447                                 format = format.replace( 'MMM', 'MMMM' ).replace( 'ddd', 'dddd' );
448                         }
450                         return format;
451                 }
452         };
454         /**
455          * Get the date format to be used for the text field when the input is active.
456          *
457          * @private
458          * @return {string} Format string
459          */
460         mw.widgets.DateInputWidget.prototype.getInputFormat = function () {
461                 if ( this.inputFormat !== undefined ) {
462                         return this.inputFormat;
463                 }
465                 return {
466                         day: 'YYYY-MM-DD',
467                         month: 'YYYY-MM'
468                 }[ this.calendar.getPrecision() ];
469         };
471         /**
472          * Get the date format to be used internally for the value. This is not configurable in any way,
473          * and always either 'YYYY-MM-DD' or 'YYYY-MM'.
474          *
475          * @private
476          * @return {string} Format string
477          */
478         mw.widgets.DateInputWidget.prototype.getInternalFormat = function () {
479                 return {
480                         day: 'YYYY-MM-DD',
481                         month: 'YYYY-MM'
482                 }[ this.calendar.getPrecision() ];
483         };
485         /**
486          * Get the Moment object for current value.
487          *
488          * @return {Object} Moment object
489          */
490         mw.widgets.DateInputWidget.prototype.getMoment = function () {
491                 return moment( this.getValue(), this.getInternalFormat() );
492         };
494         /**
495          * Handle mouse click events.
496          *
497          * @private
498          * @param {jQuery.Event} e Mouse click event
499          * @return {boolean} False to cancel the default event
500          */
501         mw.widgets.DateInputWidget.prototype.onClick = function ( e ) {
502                 if ( !this.isDisabled() && e.which === 1 ) {
503                         this.activate();
504                 }
505                 return false;
506         };
508         /**
509          * Handle key press events.
510          *
511          * @private
512          * @param {jQuery.Event} e Key press event
513          * @return {boolean} False to cancel the default event
514          */
515         mw.widgets.DateInputWidget.prototype.onKeyPress = function ( e ) {
516                 if ( !this.isDisabled() &&
517                         ( e.which === OO.ui.Keys.SPACE || e.which === OO.ui.Keys.ENTER )
518                 ) {
519                         this.activate();
520                         return false;
521                 }
522         };
524         /**
525          * Handle calendar key press events.
526          *
527          * @private
528          * @param {jQuery.Event} e Key press event
529          * @return {boolean} False to cancel the default event
530          */
531         mw.widgets.DateInputWidget.prototype.onCalendarKeyPress = function ( e ) {
532                 if ( !this.isDisabled() && e.which === OO.ui.Keys.ENTER ) {
533                         this.deactivate();
534                         this.$handle.focus();
535                         return false;
536                 }
537         };
539         /**
540          * Handle calendar click events.
541          *
542          * @private
543          * @param {jQuery.Event} e Mouse click event
544          * @return {boolean} False to cancel the default event
545          */
546         mw.widgets.DateInputWidget.prototype.onCalendarClick = function ( e ) {
547                 if (
548                         !this.isDisabled() &&
549                         e.which === 1 &&
550                         $( e.target ).hasClass( 'mw-widget-calendarWidget-day' )
551                 ) {
552                         this.deactivate();
553                         this.$handle.focus();
554                         return false;
555                 }
556         };
558         /**
559          * Handle text input enter events.
560          *
561          * @private
562          */
563         mw.widgets.DateInputWidget.prototype.onEnter = function () {
564                 this.deactivate();
565                 this.$handle.focus();
566         };
568         /**
569          * @private
570          * @param {string} date Date string, to be valid, must be in 'YYYY-MM-DD' or 'YYYY-MM' format or
571          *     (unless the field is required) empty
572          * @return {boolean}
573          */
574         mw.widgets.DateInputWidget.prototype.validateDate = function ( date ) {
575                 var isValid;
576                 if ( date === '' ) {
577                         isValid = !this.required;
578                 } else {
579                         isValid = this.isValidDate( date ) && this.isInRange( date );
580                 }
581                 return isValid;
582         };
584         /**
585          * @private
586          * @param {string} date Date string, to be valid, must be in 'YYYY-MM-DD' or 'YYYY-MM' format
587          * @return {boolean}
588          */
589         mw.widgets.DateInputWidget.prototype.isValidDate = function ( date ) {
590                 // "Half-strict mode": for example, for the format 'YYYY-MM-DD', 2015-1-3 instead of 2015-01-03
591                 // is okay, but 2015-01 isn't, and neither is 2015-01-foo. Use Moment's "fuzzy" mode and check
592                 // parsing flags for the details (stolen from implementation of moment#isValid).
593                 var
594                         mom = moment( date, this.getInputFormat() ),
595                         flags = mom.parsingFlags();
597                 return mom.isValid() && flags.charsLeftOver === 0 && flags.unusedTokens.length === 0;
598         };
600         /**
601          * Validates if the date is within the range configured with {@link #cfg-mustBeAfter}
602          * and {@link #cfg-mustBeBefore}.
603          *
604          * @private
605          * @param {string} date Date string, to be valid, must be empty (no date selected) or in
606          *     'YYYY-MM-DD' or 'YYYY-MM' format to be valid
607          * @return {boolean}
608          */
609         mw.widgets.DateInputWidget.prototype.isInRange = function ( date ) {
610                 var momentDate, isAfter, isBefore;
611                 if ( this.mustBeAfter === undefined && this.mustBeBefore === undefined ) {
612                         return true;
613                 }
614                 momentDate = moment( date, 'YYYY-MM-DD' );
615                 isAfter = ( this.mustBeAfter === undefined || momentDate.isAfter( this.mustBeAfter ) );
616                 isBefore = ( this.mustBeBefore === undefined || momentDate.isBefore( this.mustBeBefore ) );
617                 return isAfter && isBefore;
618         };
620         /**
621          * Get the validity of current value.
622          *
623          * This method returns a promise that resolves if the value is valid and rejects if
624          * it isn't. Uses {@link #validateDate}.
625          *
626          * @return {jQuery.Promise} A promise that resolves if the value is valid, rejects if not.
627          */
628         mw.widgets.DateInputWidget.prototype.getValidity = function () {
629                 var isValid = this.validateDate( this.getValue() );
631                 if ( isValid ) {
632                         return $.Deferred().resolve().promise();
633                 } else {
634                         return $.Deferred().reject().promise();
635                 }
636         };
638         /**
639          * Sets the 'invalid' flag appropriately.
640          *
641          * @param {boolean} [isValid] Optionally override validation result
642          */
643         mw.widgets.DateInputWidget.prototype.setValidityFlag = function ( isValid ) {
644                 var widget = this,
645                         setFlag = function ( valid ) {
646                                 if ( !valid ) {
647                                         widget.$input.attr( 'aria-invalid', 'true' );
648                                 } else {
649                                         widget.$input.removeAttr( 'aria-invalid' );
650                                 }
651                                 widget.setFlags( { invalid: !valid } );
652                         };
654                 if ( isValid !== undefined ) {
655                         setFlag( isValid );
656                 } else {
657                         this.getValidity().then( function () {
658                                 setFlag( true );
659                         }, function () {
660                                 setFlag( false );
661                         } );
662                 }
663         };
665 }( jQuery, mediaWiki ) );