Implement CalendarWidget and DateInputWidget
[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|null} [date=null] Day or month date (depending on `precision`), in the
20 * format 'YYYY-MM-DD' or 'YYYY-MM'. When null, defaults to current date.
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.deactivate();
79 };
80
81 /* Inheritance */
82
83 OO.inheritClass( mw.widgets.DateInputWidget, OO.ui.InputWidget );
84
85 /* Methods */
86
87 /**
88 * @inheritdoc
89 * @protected
90 */
91 mw.widgets.DateInputWidget.prototype.getInputElement = function () {
92 return $( '<input type="hidden">' );
93 };
94
95 /**
96 * Respond to calendar date change events.
97 *
98 * @private
99 */
100 mw.widgets.DateInputWidget.prototype.onCalendarChange = function () {
101 this.inCalendar++;
102 if ( !this.inTextInput ) {
103 // If this is caused by user typing in the input field, do not set anything.
104 // The value may be invalid (see #onTextInputChange), but displayable on the calendar.
105 this.setValue( this.calendar.getDate() );
106 }
107 this.inCalendar--;
108 };
109
110 /**
111 * Respond to text input value change events.
112 *
113 * @private
114 */
115 mw.widgets.DateInputWidget.prototype.onTextInputChange = function () {
116 var
117 widget = this,
118 value = this.textInput.getValue();
119 this.inTextInput++;
120 this.textInput.isValid().done( function ( valid ) {
121 if ( valid ) {
122 // Well-formed date value, parse and set it
123 var mom = moment( value, widget.getInputFormat() );
124 // Use English locale to avoid number formatting
125 widget.setValue( mom.locale( 'en' ).format( widget.getInternalFormat() ) );
126 } else {
127 // Not well-formed, but possibly partial? Try updating the calendar, but do not set the
128 // internal value. Generally this only makes sense when 'inputFormat' is little-endian (e.g.
129 // 'YYYY-MM-DD'), but that's hard to check for, and might be difficult to handle the parsing
130 // right for weird formats. So limit this trick to only when we're using the default
131 // 'inputFormat', which is the same as the internal format, 'YYYY-MM-DD'.
132 if ( widget.getInputFormat() === widget.getInternalFormat() ) {
133 widget.calendar.setDate( widget.textInput.getValue() );
134 }
135 }
136 widget.inTextInput--;
137 } );
138 };
139
140 /**
141 * @inheritdoc
142 */
143 mw.widgets.DateInputWidget.prototype.setValue = function ( value ) {
144 if ( value === undefined || value === null ) {
145 // Default to today
146 value = this.calendar.getDate();
147 }
148
149 var oldValue = this.value;
150
151 mw.widgets.DateInputWidget.parent.prototype.setValue.call( this, value );
152
153 if ( this.value !== oldValue ) {
154 if ( !this.inCalendar ) {
155 this.calendar.setDate( this.getValue() );
156 }
157 if ( !this.inTextInput ) {
158 this.textInput.setValue( this.getMoment().format( this.getInputFormat() ) );
159 }
160 }
161
162 return this;
163 };
164
165 /**
166 * Handle text input and calendar blur events.
167 *
168 * @private
169 */
170 mw.widgets.DateInputWidget.prototype.onBlur = function () {
171 var widget = this;
172 setTimeout( function () {
173 var $focussed = $( ':focus' );
174 // Deactivate unless the focus moved to something else inside this widget
175 if ( !OO.ui.contains( widget.$element[ 0 ], $focussed[0], true ) ) {
176 widget.deactivate();
177 }
178 }, 0 );
179 };
180
181 /**
182 * Deactivate this input field for data entry. Opens the calendar and shows the text field.
183 *
184 * @private
185 */
186 mw.widgets.DateInputWidget.prototype.deactivate = function () {
187 this.textInput.setValue( this.getMoment().format( this.getInputFormat() ) );
188 this.calendar.setDate( this.getValue() );
189 this.handle.setLabel( this.getMoment().format( this.getDisplayFormat() ) );
190
191 this.$element.removeClass( 'mw-widget-dateInputWidget-active' );
192 this.handle.toggle( true );
193 this.textInput.toggle( false );
194 this.calendar.toggle( false );
195 };
196
197 /**
198 * Activate this input field for data entry. Closes the calendar and hides the text field.
199 *
200 * @private
201 */
202 mw.widgets.DateInputWidget.prototype.activate = function () {
203 this.setValue( this.getValue() );
204
205 this.$element.addClass( 'mw-widget-dateInputWidget-active' );
206 this.handle.toggle( false );
207 this.textInput.toggle( true );
208 this.calendar.toggle( true );
209
210 this.textInput.$input.focus();
211 };
212
213 /**
214 * Get the date format to be used for handle label when the input is inactive.
215 *
216 * @private
217 * @return {string} Format string
218 */
219 mw.widgets.DateInputWidget.prototype.getDisplayFormat = function () {
220 if ( this.displayFormat !== undefined ) {
221 return this.displayFormat;
222 }
223
224 if ( this.calendar.getPrecision() === 'month' ) {
225 return 'MMMM YYYY';
226 } else {
227 // The formats Moment.js provides:
228 // * ll: Month name, day of month, year
229 // * lll: Month name, day of month, year, time
230 // * llll: Month name, day of month, day of week, year, time
231 //
232 // The format we want:
233 // * ????: Month name, day of month, day of week, year
234 //
235 // We try to construct it as 'llll - (lll - ll)' and hope for the best.
236 // This seems to work well for many languages (maybe even all?).
237
238 var localeData = moment.localeData( moment.locale() ),
239 llll = localeData.longDateFormat( 'llll' ),
240 lll = localeData.longDateFormat( 'lll' ),
241 ll = localeData.longDateFormat( 'll' ),
242 format = llll.replace( lll.replace( ll, '' ), '' );
243
244 return format;
245 }
246 };
247
248 /**
249 * Get the date format to be used for the text field when the input is active.
250 *
251 * @private
252 * @return {string} Format string
253 */
254 mw.widgets.DateInputWidget.prototype.getInputFormat = function () {
255 if ( this.inputFormat !== undefined ) {
256 return this.inputFormat;
257 }
258
259 return {
260 day: 'YYYY-MM-DD',
261 month: 'YYYY-MM'
262 }[ this.calendar.getPrecision() ];
263 };
264
265 /**
266 * Get the date format to be used internally for the value. This is not configurable in any way,
267 * and always either 'YYYY-MM-DD' or 'YYYY-MM'.
268 *
269 * @private
270 * @return {string} Format string
271 */
272 mw.widgets.DateInputWidget.prototype.getInternalFormat = function () {
273 return {
274 day: 'YYYY-MM-DD',
275 month: 'YYYY-MM'
276 }[ this.calendar.getPrecision() ];
277 };
278
279 /**
280 * Get the Moment object for current value.
281 *
282 * @return {Object} Moment object
283 */
284 mw.widgets.DateInputWidget.prototype.getMoment = function () {
285 return moment( this.getValue(), this.getInternalFormat() );
286 };
287
288 /**
289 * Handle mouse click events.
290 *
291 * @private
292 * @param {jQuery.Event} e Mouse click event
293 */
294 mw.widgets.DateInputWidget.prototype.onClick = function ( e ) {
295 if ( !this.isDisabled() && e.which === 1 ) {
296 this.activate();
297 }
298 return false;
299 };
300
301 /**
302 * Handle key press events.
303 *
304 * @private
305 * @param {jQuery.Event} e Key press event
306 */
307 mw.widgets.DateInputWidget.prototype.onKeyPress = function ( e ) {
308 if ( !this.isDisabled() &&
309 ( e.which === OO.ui.Keys.SPACE || e.which === OO.ui.Keys.ENTER )
310 ) {
311 this.activate();
312 return false;
313 }
314 };
315
316 /**
317 * Handle calendar key press events.
318 *
319 * @private
320 * @param {jQuery.Event} e Key press event
321 */
322 mw.widgets.DateInputWidget.prototype.onCalendarKeyPress = function ( e ) {
323 if ( !this.isDisabled() && e.which === OO.ui.Keys.ENTER ) {
324 this.deactivate();
325 this.handle.$element.focus();
326 return false;
327 }
328 };
329
330 /**
331 * Handle text input enter events.
332 *
333 * @private
334 */
335 mw.widgets.DateInputWidget.prototype.onEnter = function () {
336 this.deactivate();
337 this.handle.$element.focus();
338 };
339
340 /**
341 * @private
342 * @param {string} date Date string, must be in 'YYYY-MM-DD' or 'YYYY-MM' format to be valid
343 */
344 mw.widgets.DateInputWidget.prototype.validateDate = function ( date ) {
345 // "Half-strict mode": for example, for the format 'YYYY-MM-DD', 2015-1-3 instead of 2015-01-03
346 // is okay, but 2015-01 isn't, and neither is 2015-01-foo. Use Moment's "fuzzy" mode and check
347 // parsing flags for the details (stoled from implementation of #isValid).
348 var
349 mom = moment( date, this.getInputFormat() ),
350 flags = mom.parsingFlags();
351
352 return mom.isValid() && flags.charsLeftOver === 0 && flags.unusedTokens.length === 0;
353 };
354
355 }( jQuery, mediaWiki ) );