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