/** @jsxImportSource react */ import { StringTemplate } from "../../data/StringTemplate"; import { Culture } from "../../ui/Culture"; import { Cx } from "../../ui/Cx"; import type { DropdownInstance, Instance } from "../../ui/Instance"; import { FieldInstance } from "./Field"; import { Localization } from "../../ui/Localization"; import type { RenderingContext } from "../../ui/RenderingContext"; import { VDOM, Widget, getContent } from "../../ui/Widget"; import { getActiveElement, parseDateInvariant } from "../../util"; import { dateDiff } from "../../util/date/dateDiff"; import { zeroTime } from "../../util/date/zeroTime"; import { stopPropagation } from "../../util/eventCallbacks"; import { Format } from "../../util/Format"; import { isTouchDevice } from "../../util/isTouchDevice"; import { isTouchEvent } from "../../util/isTouchEvent"; import { KeyCode } from "../../util/KeyCode"; import { autoFocus } from "../autoFocus"; import ClearIcon from "../icons/clear"; import DropdownIcon from "../icons/drop-down"; import { Dropdown, DropdownConfig } from "../overlay/Dropdown"; import { tooltipMouseLeave, tooltipMouseMove, tooltipParentDidMount, tooltipParentWillReceiveProps, tooltipParentWillUnmount, } from "../overlay/tooltip-ops"; import { Calendar } from "./Calendar"; import { DateTimePicker } from "./DateTimePicker"; import { Field, FieldConfig, getFieldTooltip } from "./Field"; import { TimeList } from "./TimeList"; import { BooleanProp, Config, Prop, StringProp } from "../../ui/Prop"; export interface DateTimeFieldConfig extends FieldConfig { /** Selected date. This should be a Date object or a valid date string consumable by Date.parse function. */ value?: Prop; /** Defaults to false. Used to make the field read-only. */ readOnly?: BooleanProp; /** The opposite of `disabled`. */ enabled?: BooleanProp; /** Default text displayed when the field is empty. */ placeholder?: StringProp; /** Minimum date value. This should be a Date object or a valid date string consumable by Date.parse function. */ minValue?: Prop; /** Set to `true` to disallow the `minValue`. Default value is `false`. */ minExclusive?: BooleanProp; /** Maximum date value. This should be a Date object or a valid date string consumable by Date.parse function. */ maxValue?: Prop; /** Set to `true` to disallow the `maxValue`. Default value is `false`. */ maxExclusive?: BooleanProp; /** Date format used to display the selected date. */ format?: StringProp; /** Base CSS class to be applied to the field. Defaults to `datefield`. */ baseClass?: string; /** Maximum value error text. */ maxValueErrorText?: string; /** Maximum exclusive value error text. */ maxExclusiveErrorText?: string; /** Minimum value error text. */ minValueErrorText?: string; /** Minimum exclusive value error text. */ minExclusiveErrorText?: string; /** Error message used to indicate wrong user input. */ inputErrorText?: string; /** Name or configuration of the icon to be put on the left side of the input. */ icon?: StringProp | Config; /** Set to false to hide the clear button. Default value is true. */ showClear?: boolean; /** Set to `true` to display the clear button even if `required` is set. Default is `false`. */ alwaysShowClear?: boolean; /** Set to true to hide the clear button. Default value is false. */ hideClear?: boolean; /** Determines which segment of date/time is used. Default value is `datetime`. */ segment?: "date" | "time" | "datetime"; /** Set to `true` to indicate that only one segment of the selected date is affected. */ partial?: boolean; /** The function that will be used to convert Date objects before writing data to the store. */ encoding?: (date: Date) => any; /** Defines which days of week should be displayed as disabled. */ disabledDaysOfWeek?: number[]; /** Set to true to focus the input field instead of the picker first. */ focusInputFirst?: boolean; /** Set to true to enable seconds segment in the picker. */ showSeconds?: boolean; /** Additional configuration to be passed to the dropdown. */ dropdownOptions?: Partial; /** Type of picker to show. Can be `calendar`, `time`, `list`, or `auto`. Default is `auto`. */ picker?: "calendar" | "time" | "list" | "auto"; /** Time step in minutes for the time picker. */ step?: number; /** Custom validation function. */ onValidate?: | string | (( value: string | Date, instance: Instance, validationParams: Record, ) => unknown); } export class DateTimeField extends Field { declare public showClear?: boolean; declare public alwaysShowClear?: boolean; declare public hideClear?: boolean; declare public format?: string; declare public segment?: string; declare public maxValueErrorText?: string; declare public maxExclusiveErrorText?: string; declare public minValueErrorText?: string; declare public minExclusiveErrorText?: string; declare public inputErrorText?: string; declare public disabledDaysOfWeekErrorText?: string; declare public value?: unknown; declare public minValue?: unknown; declare public maxValue?: unknown; declare public minExclusive?: boolean; declare public maxExclusive?: boolean; declare public picker?: string; declare public partial?: boolean; declare public encoding?: (date: Date) => string; declare public disabledDaysOfWeek?: number[] | null; declare public reactOn?: string; declare public focusInputFirst?: boolean; declare public dropdownOptions?: Partial; declare public onParseInput?: | string | ((date: unknown, instance: Instance) => Date | undefined); declare public showSeconds?: boolean; declare public step?: number; constructor(config?: DateTimeFieldConfig) { super(config); } declareData(...args: Record[]): void { super.declareData( { value: this.emptyValue, disabled: undefined, readOnly: undefined, enabled: undefined, placeholder: undefined, required: undefined, minValue: undefined, minExclusive: undefined, maxValue: undefined, maxExclusive: undefined, format: undefined, icon: undefined, autoOpen: undefined, }, ...args, ); } init(): void { if (typeof this.hideClear !== "undefined") this.showClear = !this.hideClear; if (this.alwaysShowClear) this.showClear = true; if (!this.format) { switch (this.segment) { case "datetime": this.format = "datetime;YYYYMMddhhmm"; break; case "time": this.format = "time;hhmm"; break; case "date": this.format = "date;yyyyMMMdd"; break; } } super.init(); } prepareData( context: RenderingContext, instance: FieldInstance, ): void { let { data } = instance; let dropdownInstance = instance as DropdownInstance; if (data.value) { let date = parseDateInvariant(data.value); // let date = new Date(data.value); if (isNaN(date.getTime())) data.formatted = String(data.value); else { // handle utc edge cases if (this.segment == "date") date = zeroTime(date); data.formatted = Format.value(date, data.format); } data.date = date; } else data.formatted = ""; if (data.refDate) data.refDate = zeroTime(parseDateInvariant(data.refDate)); if (data.maxValue) data.maxValue = parseDateInvariant(data.maxValue); if (data.minValue) data.minValue = parseDateInvariant(data.minValue); if (this.segment == "date") { if (data.minValue) data.minValue = zeroTime(data.minValue); if (data.maxValue) data.maxValue = zeroTime(data.maxValue); } dropdownInstance.lastDropdown = context.lastDropdown; super.prepareData(context, instance); } validate( context: RenderingContext, instance: FieldInstance, ): void { super.validate(context, instance); let { data, widget } = instance; let dateTimeWidget = widget as DateTimeField; if (!data.error && data.date) { if (isNaN(data.date)) data.error = this.inputErrorText; else { let d; if (data.maxValue) { d = dateDiff(data.date, data.maxValue); if (d > 0) data.error = StringTemplate.format( this.maxValueErrorText!, data.maxValue, ); else if (d == 0 && data.maxExclusive) data.error = StringTemplate.format( this.maxExclusiveErrorText!, data.maxValue, ); } if (data.minValue) { d = dateDiff(data.date, data.minValue); if (d < 0) data.error = StringTemplate.format( this.minValueErrorText!, data.minValue, ); else if (d == 0 && data.minExclusive) data.error = StringTemplate.format( this.minExclusiveErrorText!, data.minValue, ); } if (dateTimeWidget.disabledDaysOfWeek) { if (dateTimeWidget.disabledDaysOfWeek.includes(data.date.getDay())) data.error = this.disabledDaysOfWeekErrorText; } } } } renderInput( context: RenderingContext, instance: FieldInstance, key: string, ): React.ReactNode { return ( ); } formatValue(context: RenderingContext, { data }: Instance): React.ReactNode { return data.value ? data.formatted : null; } parseDate(date: unknown, instance: Instance): Date | null { if (!date) return null; if (date instanceof Date) return date; if (this.onParseInput) { let result = instance.invoke("onParseInput", date, instance); if (result !== undefined) return result; } date = Culture.getDateTimeCulture().parse(date, { useCurrentDateForDefaults: true, }) as Date; return date as Date | null; } } DateTimeField.prototype.baseClass = "datetimefield"; DateTimeField.prototype.maxValueErrorText = "Select {0:d} or before."; DateTimeField.prototype.maxExclusiveErrorText = "Select a date before {0:d}."; DateTimeField.prototype.minValueErrorText = "Select {0:d} or later."; DateTimeField.prototype.minExclusiveErrorText = "Select a date after {0:d}."; DateTimeField.prototype.inputErrorText = "Invalid date entered."; DateTimeField.prototype.disabledDaysOfWeekErrorText = "Selected day of week is not allowed."; DateTimeField.prototype.suppressErrorsUntilVisited = true; DateTimeField.prototype.icon = "calendar"; DateTimeField.prototype.showClear = true; DateTimeField.prototype.alwaysShowClear = false; DateTimeField.prototype.reactOn = "enter blur"; DateTimeField.prototype.segment = "datetime"; DateTimeField.prototype.picker = "auto"; DateTimeField.prototype.disabledDaysOfWeek = null; DateTimeField.prototype.focusInputFirst = false; Widget.alias("datetimefield", DateTimeField); Localization.registerPrototype("cx/widgets/DateTimeField", DateTimeField); interface DateTimeInputProps { instance: FieldInstance; data: Record; picker: Record; label?: React.ReactNode; help?: React.ReactNode; icon?: React.ReactNode; } interface DateTimeInputState { dropdownOpen: boolean; focus: boolean; dropdownOpenTime?: number; } class DateTimeInput extends VDOM.Component< DateTimeInputProps, DateTimeInputState > { input!: HTMLInputElement; dropdown?: Widget; scrollableParents?: Element[]; openDropdownOnFocus: boolean = false; updateDropdownPosition: () => void; scrolling?: boolean; constructor(props: DateTimeInputProps) { super(props); (props.instance as any).component = this; this.state = { dropdownOpen: false, focus: false, }; this.updateDropdownPosition = () => {}; } getDropdown(): Widget { if (this.dropdown) return this.dropdown; let { widget, lastDropdown } = this.props.instance as DropdownInstance; let dateTimeWidget = widget as DateTimeField; let pickerConfig; switch (dateTimeWidget.picker) { case "calendar": pickerConfig = { type: Calendar, partial: dateTimeWidget.partial, encoding: dateTimeWidget.encoding, disabledDaysOfWeek: dateTimeWidget.disabledDaysOfWeek, focusable: !dateTimeWidget.focusInputFirst, }; break; case "list": pickerConfig = { type: TimeList, style: "height: 300px", encoding: dateTimeWidget.encoding, step: dateTimeWidget.step, format: dateTimeWidget.format, scrollSelectionIntoView: true, }; break; default: pickerConfig = { type: DateTimePicker, segment: dateTimeWidget.segment, encoding: dateTimeWidget.encoding, showSeconds: dateTimeWidget.showSeconds, }; break; } let dropdown = { scrollTracking: true, inline: !isTouchDevice() || !!lastDropdown, matchWidth: false, placementOrder: "down down-right down-left up up-right up-left", touchFriendly: true, firstChildDefinesHeight: true, firstChildDefinesWidth: true, ...dateTimeWidget.dropdownOptions, type: Dropdown, relatedElement: this.input, onFocusOut: (e: unknown) => { this.closeDropdown(e); }, onMouseDown: stopPropagation, items: { mod: "dropdown", ...pickerConfig, ...this.props.picker, autoFocus: !dateTimeWidget.focusInputFirst, tabIndex: dateTimeWidget.focusInputFirst ? -1 : 0, onKeyDown: (e: React.KeyboardEvent) => this.onKeyDown(e), onSelect: (e: React.MouseEvent, calendar: any, date: Date) => { e.stopPropagation(); e.preventDefault(); let touch = isTouchEvent(); this.closeDropdown(e, () => { if (date) { // If a blur event occurs before we re-render the input, // the old input value is parsed and written to the store. // We want to prevent that by eagerly updating the input value. // This can happen if the date field is within a menu. let newFormattedValue = Format.value( date, this.props.data.format as string, ); this.input.value = newFormattedValue; } if (!touch) this.input.focus(); }); }, }, }; return (this.dropdown = Widget.create(dropdown)); } render(): React.ReactNode { let { instance, label, help, icon: iconVDOM } = this.props; let { data, widget, state } = instance; let { CSS, baseClass, suppressErrorsUntilVisited, showClear, alwaysShowClear, } = widget; let insideButton, icon; if (!data.readOnly && !data.disabled) { if ( showClear && (((alwaysShowClear || !data.required) && !data.empty) || instance.state?.inputError) ) insideButton = (
{ e.preventDefault(); e.stopPropagation(); }} onClick={(e) => this.onClearClick(e)} >
); else insideButton = (
); } if (iconVDOM) { icon = (
{iconVDOM}
); } let dropdown: React.ReactNode | undefined; if (this.state.dropdownOpen) dropdown = ( ); let empty = this.input ? !this.input.value : data.empty; return (
{ this.input = el!; }} type="text" className={CSS.expand( CSS.element(baseClass, "input"), data.inputClass, )} style={data.inputStyle as React.CSSProperties} defaultValue={data.formatted as string} disabled={data.disabled as boolean} readOnly={data.readOnly as boolean} tabIndex={data.tabIndex as number} placeholder={data.placeholder as string} {...(data.inputAttrs as Record)} onInput={(e) => this.onChange((e.target as HTMLInputElement).value, "input") } onChange={(e) => this.onChange(e.target.value, "change")} onKeyDown={(e) => this.onKeyDown(e)} onBlur={(e) => { this.onBlur(e); }} onFocus={(e) => { this.onFocus(e); }} onMouseMove={(e) => tooltipMouseMove(e, ...getFieldTooltip(this.props.instance)) } onMouseLeave={(e) => tooltipMouseLeave(e, ...getFieldTooltip(this.props.instance)) } /> {icon} {insideButton} {dropdown} {label} {help}
); } onMouseDown(e: React.MouseEvent): void { e.stopPropagation(); let { widget } = this.props.instance; if (this.state.dropdownOpen) { this.closeDropdown(e); } else { this.openDropdownOnFocus = true; } //icon click if (e.target !== this.input) { e.preventDefault(); //the field should not focus only in case when dropdown will open and autofocus if (widget.focusInputFirst || this.state.dropdownOpen) this.input.focus(); if (this.state.dropdownOpen) this.closeDropdown(e); else this.openDropdown(); } } onFocus(e: React.FocusEvent): void { let { instance } = this.props; let { widget } = instance; if (widget.trackFocus) { this.setState({ focus: true, }); } if (this.openDropdownOnFocus || widget.focusInputFirst) this.openDropdown(); } onKeyDown(e: React.KeyboardEvent): void { let { instance } = this.props; if (instance.widget.handleKeyDown(e, instance) === false) return; switch (e.keyCode) { case KeyCode.enter: this.onChange((e.target as HTMLInputElement).value, "enter"); break; case KeyCode.esc: if (this.state.dropdownOpen) { e.stopPropagation(); this.closeDropdown(e, () => { this.input.focus(); }); } break; case KeyCode.left: case KeyCode.right: e.stopPropagation(); break; case KeyCode.down: this.openDropdown(); e.stopPropagation(); e.preventDefault(); break; } } onBlur(e: React.FocusEvent): void { let { widget } = this.props.instance; let dateTimeWidget = widget as DateTimeField; if (!this.state.dropdownOpen) this.props.instance.setState({ visited: true }); else if (dateTimeWidget.focusInputFirst) this.closeDropdown(e); if (this.state.focus) this.setState({ focus: false, }); this.onChange(e.target.value, "blur"); } closeDropdown(e: unknown, callback?: () => void): void { if (this.state.dropdownOpen) { if (this.scrollableParents) this.scrollableParents.forEach((el) => { el.removeEventListener("scroll", this.updateDropdownPosition); }); this.setState({ dropdownOpen: false }, callback); this.props.instance.setState({ visited: true }); } else if (callback) callback(); } openDropdown(): void { let { data } = this.props.instance; this.openDropdownOnFocus = false; if (!this.state.dropdownOpen && !(data.disabled || data.readOnly)) { this.setState({ dropdownOpen: true, dropdownOpenTime: Date.now(), }); } } onClearClick(e: React.MouseEvent): void { this.setValue(null); e.stopPropagation(); e.preventDefault(); } UNSAFE_componentWillReceiveProps(props: DateTimeInputProps): void { let { data, state } = props.instance; if ( data.formatted !== this.input.value && (data.formatted !== this.props.data.formatted || !state?.inputError) ) { this.input.value = data.formatted || ""; props.instance.setState({ inputError: false, }); } tooltipParentWillReceiveProps( this.input, ...getFieldTooltip(this.props.instance), ); } componentDidMount(): void { tooltipParentDidMount(this.input, ...getFieldTooltip(this.props.instance)); autoFocus(this.input, this); if (this.props.data.autoOpen) this.openDropdown(); } componentDidUpdate(): void { autoFocus(this.input, this); } componentWillUnmount(): void { if ( this.input == getActiveElement() && this.input.value != this.props.data.formatted ) { this.onChange(this.input.value, "blur"); } tooltipParentWillUnmount(this.props.instance); } onChange(inputValue: string, eventType: string): void { let { instance, data } = this.props; let { widget } = instance; let dateTimeWidget = widget as DateTimeField; if (data.disabled || data.readOnly) return; if (dateTimeWidget.reactOn!.indexOf(eventType) === -1) return; if (eventType == "enter") instance.setState({ visited: true }); this.setValue(inputValue, data.value); } setValue(text: string | null, baseValue?: unknown): void { let { instance, data } = this.props; let { widget } = instance; let dateTimeWidget = widget as DateTimeField; let date = dateTimeWidget.parseDate(text, instance); instance.setState({ inputError: isNaN(date as any) && dateTimeWidget.inputErrorText, }); if (!isNaN(date as any)) { let mixed = parseDateInvariant(baseValue as string); if (date && baseValue && !isNaN(mixed as any) && dateTimeWidget.partial) { switch (dateTimeWidget.segment) { case "date": mixed.setFullYear(date!.getFullYear()); mixed.setMonth(date!.getMonth()); mixed.setDate(date!.getDate()); break; case "time": mixed.setHours(date!.getHours()); mixed.setMinutes(date!.getMinutes()); mixed.setSeconds(date!.getSeconds()); break; default: mixed = date; break; } date = mixed; } let encode = dateTimeWidget.encoding || Culture.getDefaultDateEncoding(); let value = date ? encode!(date!) : dateTimeWidget.emptyValue; if (!instance.set("value", value)) this.input.value = value ? Format.value(date!, data.format as string) : ""; } } }