4 * @classdesc DateTimeInputWidgets can be used to input a date, a time, or
5 * a date and time, in either UTC or the user's local timezone.
6 * Please see the [OOUI documentation on MediaWiki](https://www.mediawiki.org/wiki/OOUI/Widgets/Inputs)
7 * for more information and examples.
9 * This widget can be used inside a HTML form, such as a OO.ui.FormLayout.
12 * // Example of a text input widget
13 * var dateTimeInput = new mw.widgets.datetime.DateTimeInputWidget( {} )
14 * $( document.body ).append( dateTimeInput.$element );
17 * @extends OO.ui.InputWidget
18 * @mixes OO.ui.mixin.IconElement
19 * @mixes OO.ui.mixin.IndicatorElement
20 * @mixes OO.ui.mixin.PendingElement
21 * @mixes OO.ui.mixin.FlaggedElement
24 * @description Create an instance of `mw.widgets.datetime.DateTimeInputWidget`.
25 * @param {Object} [config] Configuration options
26 * @param {string} [config.type='datetime'] Whether to act like a 'date', 'time', or 'datetime' input.
27 * Affects values stored in the relevant `<input>` and the formatting and
28 * interpretation of values passed to/from
29 * {@link https://doc.wikimedia.org/oojs-ui/master/js/OO.ui.InputWidget.html#getValue getValue()} and
30 * {@link https://doc.wikimedia.org/oojs-ui/master/js/OO.ui.InputWidget.html#setValue setValue()}.
31 * It's up to the user to configure the DateTimeFormatter correctly.
32 * @param {Object|mw.widgets.datetime.DateTimeFormatter} [config.formatter={}] Configuration options for
33 * {@link mw.widgets.datetime.ProlepticGregorianDateTimeFormatter} (with 'format' defaulting to
34 * '@date', '@time', or '@datetime' depending on 'type'), or an
35 * {@link mw.widgets.datetime.DateTimeFormatter} instance to use.
36 * @param {Object|null} [config.calendar={}] Configuration options for
37 * {@link mw.widgets.datetime.CalendarWidget}; note certain settings will be forced based on the
38 * settings passed to this widget. Set null to disable the calendar.
39 * @param {boolean} [config.required=false] Whether a value is required.
40 * @param {boolean} [config.clearable=true] Whether to provide for blanking the value.
41 * @param {Date|null} [config.value=null] Default value for the widget
42 * @param {Date|string|null} [config.min=null] Minimum allowed date
43 * @param {Date|string|null} [config.max=null] Maximum allowed date
45 mw.widgets.datetime.DateTimeInputWidget = function MwWidgetsDatetimeDateTimeInputWidget( config ) {
46 // Configuration initialization
47 config = Object.assign( {
57 // See InputWidget#reusePreInfuseDOM about config.$input
58 if ( config.$input ) {
59 // Hide unused <input> from PHP after infusion is done
60 config.$input.addClass( 'oo-ui-element-hidden' );
63 if ( $.isPlainObject( config.formatter ) && config.formatter.format === undefined ) {
64 config.formatter.format = '@' + config.type;
68 this.type = config.type;
71 mw.widgets.datetime.DateTimeInputWidget.super.call( this, config );
74 OO.ui.mixin.IconElement.call( this, config );
75 OO.ui.mixin.IndicatorElement.call( this, config );
76 OO.ui.mixin.PendingElement.call( this, config );
77 OO.ui.mixin.FlaggedElement.call( this, config );
80 this.$handle = $( '<span>' );
81 this.$fields = $( '<span>' );
83 this.clearable = !!config.clearable;
84 this.required = !!config.required;
86 if ( typeof config.min === 'string' ) {
87 config.min = this.parseDateValue( config.min );
89 if ( config.min instanceof Date && config.min.getTime() >= -62167219200000 ) {
90 this.min = config.min;
92 this.min = new Date( -62167219200000 ); // 0000-01-01T00:00:00.000Z
95 if ( typeof config.max === 'string' ) {
96 config.max = this.parseDateValue( config.max );
98 if ( config.max instanceof Date && config.max.getTime() <= 253402300799999 ) {
99 this.max = config.max;
101 this.max = new Date( 253402300799999 ); // 9999-12-31T12:59:59.999Z
104 switch ( this.type ) {
106 this.min.setUTCHours( 0, 0, 0, 0 );
107 this.max.setUTCHours( 23, 59, 59, 999 );
110 this.min.setUTCFullYear( 1970, 0, 1 );
111 this.max.setUTCFullYear( 1970, 0, 1 );
114 if ( this.min > this.max ) {
116 '"min" (' + this.min.toISOString() + ') must not be greater than ' +
117 '"max" (' + this.max.toISOString() + ')'
121 if ( config.formatter instanceof mw.widgets.datetime.DateTimeFormatter ) {
122 this.formatter = config.formatter;
123 } else if ( $.isPlainObject( config.formatter ) ) {
124 this.formatter = new mw.widgets.datetime.ProlepticGregorianDateTimeFormatter( config.formatter );
126 throw new Error( '"formatter" must be an mw.widgets.datetime.DateTimeFormatter or a plain object' );
129 if ( this.type === 'time' || config.calendar === null ) {
130 this.calendar = null;
132 config.calendar = Object.assign( {}, config.calendar, {
133 formatter: this.formatter,
138 this.calendar = new mw.widgets.datetime.CalendarWidget( config.calendar );
143 click: this.onHandleClick.bind( this )
145 this.connect( this, {
148 this.formatter.connect( this, {
151 if ( this.calendar ) {
152 this.calendar.connect( this, {
153 change: 'onCalendarChange'
158 this.setTabIndex( -1 );
160 this.$fields.addClass( 'mw-widgets-datetime-dateTimeInputWidget-fields' );
164 .addClass( 'mw-widgets-datetime-dateTimeInputWidget-handle' )
165 .append( this.$icon, this.$indicator, this.$fields );
168 .addClass( 'mw-widgets-datetime-dateTimeInputWidget' )
169 .append( this.$handle );
171 if ( this.calendar ) {
172 const date = this.getValueAsDate();
173 this.calendar.setSelected( date );
175 this.calendar.setFocusedDate( date );
177 this.$element.append( this.calendar.$element );
183 OO.inheritClass( mw.widgets.datetime.DateTimeInputWidget, OO.ui.InputWidget );
184 OO.mixinClass( mw.widgets.datetime.DateTimeInputWidget, OO.ui.mixin.IconElement );
185 OO.mixinClass( mw.widgets.datetime.DateTimeInputWidget, OO.ui.mixin.IndicatorElement );
186 OO.mixinClass( mw.widgets.datetime.DateTimeInputWidget, OO.ui.mixin.PendingElement );
187 OO.mixinClass( mw.widgets.datetime.DateTimeInputWidget, OO.ui.mixin.FlaggedElement );
189 /* Static properties */
191 mw.widgets.datetime.DateTimeInputWidget.static.supportsSimpleLabel = false;
200 mw.widgets.datetime.DateTimeInputWidget.static.reusePreInfuseDOM = function ( node, config ) {
201 config = mw.widgets.datetime.DateTimeInputWidget.super.static.reusePreInfuseDOM( node, config );
202 if ( config.$input ) {
203 // Ignore the extra field from PendingTextInputWidget (T382344)
204 config.$input = config.$input.first();
212 * Get the currently focused field, if any.
217 mw.widgets.datetime.DateTimeInputWidget.prototype.getFocusedField = function () {
218 return this.$fields.find( this.getElementDocument().activeElement );
222 * Convert a date string to a Date.
225 * @param {string} value
226 * @return {Date|null}
228 mw.widgets.datetime.DateTimeInputWidget.prototype.parseDateValue = function ( value ) {
229 value = String( value );
230 switch ( this.type ) {
232 value = value + 'T00:00:00Z';
235 value = '1970-01-01T' + value + 'Z';
241 const m = /^(\d{4,})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(?:\.(\d{1,3}))?Z$/.exec( value );
244 while ( m[ 7 ].length < 3 ) {
251 date.setUTCFullYear( m[ 1 ], m[ 2 ] - 1, m[ 3 ] );
252 date.setUTCHours( m[ 4 ], m[ 5 ], m[ 6 ], m[ 7 ] );
253 if ( date.getTime() < -62167219200000 || date.getTime() > 253402300799999 ||
254 date.getUTCFullYear() !== +m[ 1 ] ||
255 date.getUTCMonth() + 1 !== +m[ 2 ] ||
256 date.getUTCDate() !== +m[ 3 ] ||
257 date.getUTCHours() !== +m[ 4 ] ||
258 date.getUTCMinutes() !== +m[ 5 ] ||
259 date.getUTCSeconds() !== +m[ 6 ] ||
260 date.getUTCMilliseconds() !== +m[ 7 ]
274 mw.widgets.datetime.DateTimeInputWidget.prototype.cleanUpValue = function ( value ) {
277 if ( value === '' ) {
281 if ( value instanceof Date ) {
284 date = this.parseDateValue( value );
287 if ( date instanceof Date ) {
288 pad = function ( v, l ) {
290 while ( v.length < l ) {
296 switch ( this.type ) {
298 value = pad( date.getUTCFullYear(), 4 ) +
299 '-' + pad( date.getUTCMonth() + 1, 2 ) +
300 '-' + pad( date.getUTCDate(), 2 );
304 value = pad( date.getUTCHours(), 2 ) +
305 ':' + pad( date.getUTCMinutes(), 2 ) +
306 ':' + pad( date.getUTCSeconds(), 2 ) +
307 '.' + pad( date.getUTCMilliseconds(), 3 );
308 value = value.replace( /\.?0+$/, '' );
312 value = date.toISOString();
323 * Get the value of the input as a Date object.
325 * @return {Date|null}
327 mw.widgets.datetime.DateTimeInputWidget.prototype.getValueAsDate = function () {
328 return this.parseDateValue( this.getValue() );
332 * Set up the UI fields.
336 mw.widgets.datetime.DateTimeInputWidget.prototype.setupFields = function () {
337 let i, $field, spec, placeholder, sz, maxlength;
340 spanValFunc = function ( v ) {
341 if ( v === undefined ) {
342 return this.data( 'mw-widgets-datetime-dateTimeInputWidget-value' );
345 this.data( 'mw-widgets-datetime-dateTimeInputWidget-value', v );
347 v = this.data( 'mw-widgets-datetime-dateTimeInputWidget-placeholder' );
353 reduceFunc = function ( k, v ) {
354 maxlength = Math.max( maxlength, v );
356 disabled = this.isDisabled(),
357 specs = this.formatter.getFieldSpec();
359 this.$fields.empty();
360 this.clearButton = null;
363 for ( i = 0; i < specs.length; i++ ) {
365 if ( typeof spec === 'string' ) {
367 .addClass( 'mw-widgets-datetime-dateTimeInputWidget-field' )
369 .appendTo( this.$fields );
374 while ( placeholder.length < spec.size ) {
378 if ( spec.type === 'number' ) {
379 sz = spec.size + 'ch';
381 // Add a little for padding
382 sz = ( spec.size * 1.25 ) + 'ch';
384 if ( spec.editable && spec.type !== 'static' ) {
385 if ( spec.type === 'boolean' || spec.type === 'toggleLocal' ) {
386 $field = $( '<span>' )
388 tabindex: disabled ? -1 : 0
391 .data( 'mw-widgets-datetime-dateTimeInputWidget-placeholder', placeholder );
393 keydown: this.onFieldKeyDown.bind( this, $field ),
394 focus: this.onFieldFocus.bind( this, $field ),
395 click: this.onFieldClick.bind( this, $field ),
396 'wheel mousewheel DOMMouseScroll': this.onFieldWheel.bind( this, $field )
398 $field.val = spanValFunc;
400 maxlength = spec.size;
401 if ( spec.intercalarySize ) {
402 // eslint-disable-next-line no-jquery/no-each-util
403 $.each( spec.intercalarySize, reduceFunc );
405 $field = $( '<input>' ).attr( 'type', 'text' )
407 tabindex: disabled ? -1 : 0,
413 placeholder: placeholder
417 keydown: this.onFieldKeyDown.bind( this, $field ),
418 click: this.onFieldClick.bind( this, $field ),
419 focus: this.onFieldFocus.bind( this, $field ),
420 blur: this.onFieldBlur.bind( this, $field ),
421 change: this.onFieldChange.bind( this, $field ),
422 'wheel mousewheel DOMMouseScroll': this.onFieldWheel.bind( this, $field )
425 $field.addClass( 'mw-widgets-datetime-dateTimeInputWidget-editField' );
427 $field = $( '<span>' )
429 .data( 'mw-widgets-datetime-dateTimeInputWidget-placeholder', placeholder );
430 if ( spec.type !== 'static' ) {
431 $field.prop( 'tabIndex', -1 );
432 $field.on( 'focus', this.onFieldFocus.bind( this, $field ) );
434 if ( spec.type === 'static' ) {
435 $field.text( spec.value );
437 $field.val = spanValFunc;
441 this.fields.push( $field );
443 .addClass( 'mw-widgets-datetime-dateTimeInputWidget-field' )
444 .data( 'mw-widgets-datetime-dateTimeInputWidget-fieldSpec', spec )
445 .appendTo( this.$fields );
448 if ( this.clearable ) {
449 this.clearButton = new OO.ui.ButtonWidget( {
450 classes: [ 'mw-widgets-datetime-dateTimeInputWidget-field', 'mw-widgets-datetime-dateTimeInputWidget-clearButton' ],
455 click: 'onClearClick'
457 this.$fields.append( this.clearButton.$element );
460 this.updateFieldsFromValue();
464 * Update the UI fields from the current value.
468 mw.widgets.datetime.DateTimeInputWidget.prototype.updateFieldsFromValue = function () {
469 let i, $field, spec, intercalary, sz;
471 const date = this.getValueAsDate();
473 if ( date === null ) {
474 this.components = null;
476 for ( i = 0; i < this.fields.length; i++ ) {
477 $field = this.fields[ i ];
478 spec = $field.data( 'mw-widgets-datetime-dateTimeInputWidget-fieldSpec' );
481 .removeClass( 'mw-widgets-datetime-dateTimeInputWidget-invalid oo-ui-element-hidden' )
484 if ( spec.intercalarySize ) {
485 if ( spec.type === 'number' ) {
486 $field.width( spec.size + 'ch' );
488 // Add a little for padding
489 $field.width( ( spec.size * 1.25 ) + 'ch' );
494 this.setFlags( { invalid: this.required } );
496 this.components = this.formatter.getComponentsFromDate( date );
497 intercalary = this.components.intercalary;
499 for ( i = 0; i < this.fields.length; i++ ) {
500 $field = this.fields[ i ];
501 $field.removeClass( 'mw-widgets-datetime-dateTimeInputWidget-invalid' );
502 spec = $field.data( 'mw-widgets-datetime-dateTimeInputWidget-fieldSpec' );
503 if ( spec.type !== 'static' ) {
504 $field.val( spec.formatValue( this.components[ spec.component ] ) );
506 if ( spec.intercalarySize ) {
507 if ( intercalary && spec.intercalarySize[ intercalary ] !== undefined ) {
508 sz = spec.intercalarySize[ intercalary ];
512 $field.toggleClass( 'oo-ui-element-hidden', sz <= 0 );
513 if ( spec.type === 'number' ) {
514 this.fields[ i ].width( sz + 'ch' );
516 // Add a little for padding
517 this.fields[ i ].width( ( sz * 1.25 ) + 'ch' );
522 this.setFlags( { invalid: date < this.min || date > this.max } );
525 this.$element.toggleClass( 'mw-widgets-datetime-dateTimeInputWidget-empty', date === null );
529 * Update the value with data from the UI fields.
533 mw.widgets.datetime.DateTimeInputWidget.prototype.updateValueFromFields = function () {
534 let i, v, $field, spec, curDate, newDate,
539 const components = {};
541 for ( i = 0; i < this.fields.length; i++ ) {
542 $field = this.fields[ i ];
543 spec = $field.data( 'mw-widgets-datetime-dateTimeInputWidget-fieldSpec' );
544 if ( spec.editable ) {
545 $field.removeClass( 'mw-widgets-datetime-dateTimeInputWidget-invalid' );
548 $field.addClass( 'mw-widgets-datetime-dateTimeInputWidget-invalid' );
552 v = spec.parseValue( v );
553 if ( v === undefined ) {
554 $field.addClass( 'mw-widgets-datetime-dateTimeInputWidget-invalid' );
557 components[ spec.component ] = v;
564 for ( i = 0; i < this.fields.length; i++ ) {
565 this.fields[ i ].removeClass( 'mw-widgets-datetime-dateTimeInputWidget-invalid' );
567 } else if ( anyEmpty ) {
572 curDate = this.getValueAsDate();
573 newDate = this.formatter.getDateFromComponents( components );
574 if ( !curDate || !newDate || curDate.getTime() !== newDate.getTime() ) {
575 this.setValue( newDate );
581 * Handle change event.
585 mw.widgets.datetime.DateTimeInputWidget.prototype.onChange = function () {
588 this.updateFieldsFromValue();
590 if ( this.calendar ) {
591 date = this.getValueAsDate();
592 this.calendar.setSelected( date );
594 this.calendar.setFocusedDate( date );
600 * Handle clear button click event.
604 mw.widgets.datetime.DateTimeInputWidget.prototype.onClearClick = function () {
605 this.blur().setValue( '' );
609 * Handle click on the widget background.
612 * @param {jQuery.Event} e Click event
614 mw.widgets.datetime.DateTimeInputWidget.prototype.onHandleClick = function () {
619 * Handle key down events on our field inputs.
622 * @param {jQuery} $field
623 * @param {jQuery.Event} e Key down event
624 * @return {boolean|undefined} False to cancel the default event
626 mw.widgets.datetime.DateTimeInputWidget.prototype.onFieldKeyDown = function ( $field, e ) {
627 const spec = $field.data( 'mw-widgets-datetime-dateTimeInputWidget-fieldSpec' );
629 if ( !this.isDisabled() ) {
631 case OO.ui.Keys.ENTER:
632 case OO.ui.Keys.SPACE:
633 if ( spec.type === 'boolean' ) {
635 this.formatter.adjustComponent( this.getValueAsDate(), spec.component, 1, 'wrap' )
638 } else if ( spec.type === 'toggleLocal' ) {
639 this.formatter.toggleLocal();
644 case OO.ui.Keys.DOWN:
645 if ( spec.type === 'toggleLocal' ) {
646 this.formatter.toggleLocal();
649 this.formatter.adjustComponent( this.getValueAsDate(), spec.component,
650 e.keyCode === OO.ui.Keys.UP ? -1 : 1, 'wrap' )
653 if ( $field.is( 'input' ) ) {
654 $field.trigger( 'select' );
662 * Handle focus events on our field inputs.
665 * @param {jQuery} $field
666 * @param {jQuery.Event} e Focus event
668 mw.widgets.datetime.DateTimeInputWidget.prototype.onFieldFocus = function ( $field ) {
669 const spec = $field.data( 'mw-widgets-datetime-dateTimeInputWidget-fieldSpec' );
671 if ( !this.isDisabled() ) {
672 if ( this.getValueAsDate() === null ) {
673 this.setValue( this.formatter.getDefaultDate() );
675 if ( $field.is( 'input' ) ) {
676 $field.trigger( 'select' );
679 if ( this.calendar ) {
680 this.calendar.toggle( !!spec.calendarComponent );
686 * Handle click events on our field inputs.
689 * @param {jQuery} $field
690 * @param {jQuery.Event} e Click event
692 mw.widgets.datetime.DateTimeInputWidget.prototype.onFieldClick = function ( $field ) {
693 const spec = $field.data( 'mw-widgets-datetime-dateTimeInputWidget-fieldSpec' );
695 if ( !this.isDisabled() ) {
696 if ( spec.type === 'boolean' ) {
698 this.formatter.adjustComponent( this.getValueAsDate(), spec.component, 1, 'wrap' )
700 } else if ( spec.type === 'toggleLocal' ) {
701 this.formatter.toggleLocal();
707 * Handle blur events on our field inputs.
710 * @param {jQuery} $field
711 * @param {jQuery.Event} e Blur event
713 mw.widgets.datetime.DateTimeInputWidget.prototype.onFieldBlur = function ( $field ) {
715 const spec = $field.data( 'mw-widgets-datetime-dateTimeInputWidget-fieldSpec' );
717 this.updateValueFromFields();
720 const date = this.getValueAsDate();
724 v = spec.formatValue( this.formatter.getComponentsFromDate( date )[ spec.component ] );
725 if ( v !== $field.val() ) {
732 * Handle change events on our field inputs.
735 * @param {jQuery} $field
736 * @param {jQuery.Event} e Change event
738 mw.widgets.datetime.DateTimeInputWidget.prototype.onFieldChange = function () {
739 this.updateValueFromFields();
743 * Handle wheel events on our field inputs.
746 * @param {jQuery} $field
747 * @param {jQuery.Event} e Change event
748 * @return {boolean|undefined} False to cancel the default event
750 mw.widgets.datetime.DateTimeInputWidget.prototype.onFieldWheel = function ( $field, e ) {
752 const spec = $field.data( 'mw-widgets-datetime-dateTimeInputWidget-fieldSpec' );
754 if ( this.isDisabled() || !this.getFocusedField().length ) {
758 // Standard 'wheel' event
759 if ( e.originalEvent.deltaMode !== undefined ) {
760 this.sawWheelEvent = true;
762 if ( e.originalEvent.deltaY ) {
763 delta = -e.originalEvent.deltaY;
764 } else if ( e.originalEvent.deltaX ) {
765 delta = e.originalEvent.deltaX;
768 // Non-standard events
769 if ( !this.sawWheelEvent ) {
770 if ( e.originalEvent.wheelDeltaX ) {
771 delta = -e.originalEvent.wheelDeltaX;
772 } else if ( e.originalEvent.wheelDeltaY ) {
773 delta = e.originalEvent.wheelDeltaY;
774 } else if ( e.originalEvent.wheelDelta ) {
775 delta = e.originalEvent.wheelDelta;
776 } else if ( e.originalEvent.detail ) {
777 delta = -e.originalEvent.detail;
781 if ( delta && spec ) {
782 if ( spec.type === 'toggleLocal' ) {
783 this.formatter.toggleLocal();
786 this.formatter.adjustComponent( this.getValueAsDate(), spec.component, delta < 0 ? -1 : 1, 'wrap' )
794 * Handle calendar change event.
798 mw.widgets.datetime.DateTimeInputWidget.prototype.onCalendarChange = function () {
799 const curDate = this.getValueAsDate(),
800 newDate = this.calendar.getSelected()[ 0 ];
803 if ( !curDate || newDate.getTime() !== curDate.getTime() ) {
804 this.setValue( newDate );
813 mw.widgets.datetime.DateTimeInputWidget.prototype.getInputElement = function () {
814 return $( '<input>' ).attr( 'type', 'hidden' );
820 mw.widgets.datetime.DateTimeInputWidget.prototype.setDisabled = function ( disabled ) {
821 mw.widgets.datetime.DateTimeInputWidget.super.prototype.setDisabled.call( this, disabled );
823 // Flag all our fields as disabled
824 if ( this.$fields ) {
825 this.$fields.find( 'input' ).prop( 'disabled', this.isDisabled() );
826 this.$fields.find( '[tabindex]' ).attr( 'tabindex', this.isDisabled() ? -1 : 0 );
829 if ( this.clearButton ) {
830 this.clearButton.setDisabled( disabled );
839 mw.widgets.datetime.DateTimeInputWidget.prototype.focus = function () {
840 if ( !this.getFocusedField().length ) {
841 this.$fields.find( '.mw-widgets-datetime-dateTimeInputWidget-editField' ).first().trigger( 'focus' );
849 mw.widgets.datetime.DateTimeInputWidget.prototype.blur = function () {
850 this.getFocusedField().blur();
857 mw.widgets.datetime.DateTimeInputWidget.prototype.simulateLabelClick = function () {