Merge "Allow to enable OOUI via a parser tag extension"
[lhc/web/wiklou.git] / resources / src / mediawiki.widgets / mw.widgets.DateInputWidget.js
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 ) {
9
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 *
61 * @constructor
62 * @param {Object} [config] Configuration options
63 * @cfg {string} [precision='day'] Date precision to use, 'day' or 'month'
64 * @cfg {string} [value] Day or month date (depending on `precision`), in the format 'YYYY-MM-DD'
65 * or 'YYYY-MM'. If not given or empty string, no date is selected.
66 * @cfg {string} [inputFormat] Date format string to use for the textual input field. Displayed
67 * while the widget is active, and the user can type in a date in this format. Should be short
68 * and easy to type. When not given, defaults to 'YYYY-MM-DD' or 'YYYY-MM', depending on
69 * `precision`.
70 * @cfg {string} [displayFormat] Date format string to use for the clickable label. Displayed
71 * while the widget is inactive. Should be as unambiguous as possible (for example, prefer to
72 * spell out the month, rather than rely on the order), even if that makes it longer. When not
73 * given, the default is language-specific.
74 * @cfg {string} [placeholder] User-visible date format string displayed in the textual input
75 * field when it's empty. Should be the same as `inputFormat`, but translated to the user's
76 * language. When not given, defaults to a translated version of 'YYYY-MM-DD' or 'YYYY-MM',
77 * depending on `precision`.
78 */
79 mw.widgets.DateInputWidget = function MWWDateInputWidget( config ) {
80 // Config initialization
81 config = $.extend( { precision: 'day' }, config );
82
83 var placeholder;
84 if ( config.placeholder ) {
85 placeholder = config.placeholder;
86 } else if ( config.inputFormat ) {
87 // We have no way to display a translated placeholder for custom formats
88 placeholder = '';
89 } else {
90 // Messages: mw-widgets-dateinput-placeholder-day, mw-widgets-dateinput-placeholder-month
91 placeholder = mw.msg( 'mw-widgets-dateinput-placeholder-' + config.precision );
92 }
93
94 // Properties (must be set before parent constructor, which calls #setValue)
95 this.handle = new OO.ui.LabelWidget();
96 this.textInput = new OO.ui.TextInputWidget( {
97 placeholder: placeholder,
98 validate: this.validateDate.bind( this )
99 } );
100 this.calendar = new mw.widgets.CalendarWidget( {
101 precision: config.precision
102 } );
103 this.inCalendar = 0;
104 this.inTextInput = 0;
105 this.inputFormat = config.inputFormat;
106 this.displayFormat = config.displayFormat;
107
108 // Parent constructor
109 mw.widgets.DateInputWidget.parent.call( this, config );
110
111 // Events
112 this.calendar.connect( this, {
113 change: 'onCalendarChange'
114 } );
115 this.textInput.connect( this, {
116 enter: 'onEnter',
117 change: 'onTextInputChange'
118 } );
119 this.$element.on( {
120 focusout: this.onBlur.bind( this )
121 } );
122 this.calendar.$element.on( {
123 keypress: this.onCalendarKeyPress.bind( this )
124 } );
125 this.handle.$element.on( {
126 click: this.onClick.bind( this ),
127 keypress: this.onKeyPress.bind( this )
128 } );
129
130 // Initialization
131 // Move 'tabindex' from this.$input (which is invisible) to the visible handle
132 this.setTabIndexedElement( this.handle.$element );
133 this.handle.$element
134 .addClass( 'mw-widget-dateInputWidget-handle' );
135 this.$element
136 .addClass( 'mw-widget-dateInputWidget' )
137 .append( this.handle.$element, this.textInput.$element, this.calendar.$element );
138 // Set handle label and hide stuff
139 this.updateUI();
140 this.deactivate();
141 };
142
143 /* Inheritance */
144
145 OO.inheritClass( mw.widgets.DateInputWidget, OO.ui.InputWidget );
146
147 /* Methods */
148
149 /**
150 * @inheritdoc
151 * @protected
152 */
153 mw.widgets.DateInputWidget.prototype.getInputElement = function () {
154 return $( '<input type="hidden">' );
155 };
156
157 /**
158 * Respond to calendar date change events.
159 *
160 * @private
161 */
162 mw.widgets.DateInputWidget.prototype.onCalendarChange = function () {
163 this.inCalendar++;
164 if ( !this.inTextInput ) {
165 // If this is caused by user typing in the input field, do not set anything.
166 // The value may be invalid (see #onTextInputChange), but displayable on the calendar.
167 this.setValue( this.calendar.getDate() );
168 }
169 this.inCalendar--;
170 };
171
172 /**
173 * Respond to text input value change events.
174 *
175 * @private
176 */
177 mw.widgets.DateInputWidget.prototype.onTextInputChange = function () {
178 var
179 widget = this,
180 value = this.textInput.getValue();
181 this.inTextInput++;
182 this.textInput.isValid().done( function ( valid ) {
183 if ( value === '' ) {
184 // No date selected
185 widget.setValue( '' );
186 } else if ( valid ) {
187 // Well-formed date value, parse and set it
188 var mom = moment( value, widget.getInputFormat() );
189 // Use English locale to avoid number formatting
190 widget.setValue( mom.locale( 'en' ).format( widget.getInternalFormat() ) );
191 } else {
192 // Not well-formed, but possibly partial? Try updating the calendar, but do not set the
193 // internal value. Generally this only makes sense when 'inputFormat' is little-endian (e.g.
194 // 'YYYY-MM-DD'), but that's hard to check for, and might be difficult to handle the parsing
195 // right for weird formats. So limit this trick to only when we're using the default
196 // 'inputFormat', which is the same as the internal format, 'YYYY-MM-DD'.
197 if ( widget.getInputFormat() === widget.getInternalFormat() ) {
198 widget.calendar.setDate( widget.textInput.getValue() );
199 }
200 }
201 widget.inTextInput--;
202 } );
203 };
204
205 /**
206 * @inheritdoc
207 */
208 mw.widgets.DateInputWidget.prototype.setValue = function ( value ) {
209 var oldValue = this.value;
210
211 if ( !moment( value, this.getInternalFormat() ).isValid() ) {
212 value = '';
213 }
214
215 mw.widgets.DateInputWidget.parent.prototype.setValue.call( this, value );
216
217 if ( this.value !== oldValue ) {
218 this.updateUI();
219 }
220
221 return this;
222 };
223
224 /**
225 * Handle text input and calendar blur events.
226 *
227 * @private
228 */
229 mw.widgets.DateInputWidget.prototype.onBlur = function () {
230 var widget = this;
231 setTimeout( function () {
232 var $focussed = $( ':focus' );
233 // Deactivate unless the focus moved to something else inside this widget
234 if ( !OO.ui.contains( widget.$element[ 0 ], $focussed[0], true ) ) {
235 widget.deactivate();
236 }
237 }, 0 );
238 };
239
240 /**
241 * @inheritdoc
242 */
243 mw.widgets.DateInputWidget.prototype.focus = function () {
244 this.activate();
245 return this;
246 };
247
248 /**
249 * @inheritdoc
250 */
251 mw.widgets.DateInputWidget.prototype.blur = function () {
252 this.deactivate();
253 return this;
254 };
255
256 /**
257 * Update the contents of the label, text input and status of calendar to reflect selected value.
258 *
259 * @private
260 */
261 mw.widgets.DateInputWidget.prototype.updateUI = function () {
262 if ( this.getValue() === '' ) {
263 this.textInput.setValue( '' );
264 this.calendar.setDate( null );
265 this.handle.setLabel( mw.msg( 'mw-widgets-dateinput-no-date' ) );
266 this.$element.addClass( 'mw-widget-dateInputWidget-empty' );
267 } else {
268 if ( !this.inTextInput ) {
269 this.textInput.setValue( this.getMoment().format( this.getInputFormat() ) );
270 }
271 if ( !this.inCalendar ) {
272 this.calendar.setDate( this.getValue() );
273 }
274 this.handle.setLabel( this.getMoment().format( this.getDisplayFormat() ) );
275 this.$element.removeClass( 'mw-widget-dateInputWidget-empty' );
276 }
277 };
278
279 /**
280 * Deactivate this input field for data entry. Closes the calendar and hides the text field.
281 *
282 * @private
283 */
284 mw.widgets.DateInputWidget.prototype.deactivate = function () {
285 this.$element.removeClass( 'mw-widget-dateInputWidget-active' );
286 this.handle.toggle( true );
287 this.textInput.toggle( false );
288 this.calendar.toggle( false );
289 };
290
291 /**
292 * Activate this input field for data entry. Opens the calendar and shows the text field.
293 *
294 * @private
295 */
296 mw.widgets.DateInputWidget.prototype.activate = function () {
297 this.$element.addClass( 'mw-widget-dateInputWidget-active' );
298 this.handle.toggle( false );
299 this.textInput.toggle( true );
300 this.calendar.toggle( true );
301
302 this.textInput.$input.focus();
303 };
304
305 /**
306 * Get the date format to be used for handle label when the input is inactive.
307 *
308 * @private
309 * @return {string} Format string
310 */
311 mw.widgets.DateInputWidget.prototype.getDisplayFormat = function () {
312 if ( this.displayFormat !== undefined ) {
313 return this.displayFormat;
314 }
315
316 if ( this.calendar.getPrecision() === 'month' ) {
317 return 'MMMM YYYY';
318 } else {
319 // The formats Moment.js provides:
320 // * ll: Month name, day of month, year
321 // * lll: Month name, day of month, year, time
322 // * llll: Month name, day of month, day of week, year, time
323 //
324 // The format we want:
325 // * ????: Month name, day of month, day of week, year
326 //
327 // We try to construct it as 'llll - (lll - ll)' and hope for the best.
328 // This seems to work well for many languages (maybe even all?).
329
330 var localeData = moment.localeData( moment.locale() ),
331 llll = localeData.longDateFormat( 'llll' ),
332 lll = localeData.longDateFormat( 'lll' ),
333 ll = localeData.longDateFormat( 'll' ),
334 format = llll.replace( lll.replace( ll, '' ), '' );
335
336 return format;
337 }
338 };
339
340 /**
341 * Get the date format to be used for the text field when the input is active.
342 *
343 * @private
344 * @return {string} Format string
345 */
346 mw.widgets.DateInputWidget.prototype.getInputFormat = function () {
347 if ( this.inputFormat !== undefined ) {
348 return this.inputFormat;
349 }
350
351 return {
352 day: 'YYYY-MM-DD',
353 month: 'YYYY-MM'
354 }[ this.calendar.getPrecision() ];
355 };
356
357 /**
358 * Get the date format to be used internally for the value. This is not configurable in any way,
359 * and always either 'YYYY-MM-DD' or 'YYYY-MM'.
360 *
361 * @private
362 * @return {string} Format string
363 */
364 mw.widgets.DateInputWidget.prototype.getInternalFormat = function () {
365 return {
366 day: 'YYYY-MM-DD',
367 month: 'YYYY-MM'
368 }[ this.calendar.getPrecision() ];
369 };
370
371 /**
372 * Get the Moment object for current value.
373 *
374 * @return {Object} Moment object
375 */
376 mw.widgets.DateInputWidget.prototype.getMoment = function () {
377 return moment( this.getValue(), this.getInternalFormat() );
378 };
379
380 /**
381 * Handle mouse click events.
382 *
383 * @private
384 * @param {jQuery.Event} e Mouse click event
385 */
386 mw.widgets.DateInputWidget.prototype.onClick = function ( e ) {
387 if ( !this.isDisabled() && e.which === 1 ) {
388 this.activate();
389 }
390 return false;
391 };
392
393 /**
394 * Handle key press events.
395 *
396 * @private
397 * @param {jQuery.Event} e Key press event
398 */
399 mw.widgets.DateInputWidget.prototype.onKeyPress = function ( e ) {
400 if ( !this.isDisabled() &&
401 ( e.which === OO.ui.Keys.SPACE || e.which === OO.ui.Keys.ENTER )
402 ) {
403 this.activate();
404 return false;
405 }
406 };
407
408 /**
409 * Handle calendar key press events.
410 *
411 * @private
412 * @param {jQuery.Event} e Key press event
413 */
414 mw.widgets.DateInputWidget.prototype.onCalendarKeyPress = function ( e ) {
415 if ( !this.isDisabled() && e.which === OO.ui.Keys.ENTER ) {
416 this.deactivate();
417 this.handle.$element.focus();
418 return false;
419 }
420 };
421
422 /**
423 * Handle text input enter events.
424 *
425 * @private
426 */
427 mw.widgets.DateInputWidget.prototype.onEnter = function () {
428 this.deactivate();
429 this.handle.$element.focus();
430 };
431
432 /**
433 * @private
434 * @param {string} date Date string, to be valid, must be empty (no date selected) or in
435 * 'YYYY-MM-DD' or 'YYYY-MM' format to be valid
436 */
437 mw.widgets.DateInputWidget.prototype.validateDate = function ( date ) {
438 if ( date === '' ) {
439 return true;
440 }
441
442 // "Half-strict mode": for example, for the format 'YYYY-MM-DD', 2015-1-3 instead of 2015-01-03
443 // is okay, but 2015-01 isn't, and neither is 2015-01-foo. Use Moment's "fuzzy" mode and check
444 // parsing flags for the details (stoled from implementation of #isValid).
445 var
446 mom = moment( date, this.getInputFormat() ),
447 flags = mom.parsingFlags();
448
449 return mom.isValid() && flags.charsLeftOver === 0 && flags.unusedTokens.length === 0;
450 };
451
452 }( jQuery, mediaWiki ) );