/* * This file belongs to Hoist, an application development toolkit * developed by Extremely Heavy Industries (www.xh.io | info@xh.io) * * Copyright © 2026 Extremely Heavy Industries Inc. */ import {HoistInputModel, HoistInputProps, useHoistInputModel} from '@xh/hoist/cmp/input'; import {box, div, hbox} from '@xh/hoist/cmp/layout'; import {hoistCmp, HoistProps, LayoutProps, StyleProps} from '@xh/hoist/core'; import {fmtDate} from '@xh/hoist/format'; import {Icon} from '@xh/hoist/icon'; import {dayPicker, DayPickerProps} from '@xh/hoist/kit/react-day-picker'; import {button} from '@xh/hoist/mobile/cmp/button'; import {dialog} from '@xh/hoist/mobile/cmp/dialog'; import '@xh/hoist/mobile/register'; import {bindable, makeObservable} from '@xh/hoist/mobx'; import {isLocalDate, LocalDate} from '@xh/hoist/utils/datetime'; import {getTestId, TEST_ID, withDefault} from '@xh/hoist/utils/js'; import {getLayoutProps} from '@xh/hoist/utils/react'; import classNames from 'classnames'; import type {Property} from 'csstype'; import {castArray, isNil, omitBy} from 'lodash'; import {ReactElement} from 'react'; import './DateInput.scss'; export interface DateInputProps extends HoistProps, HoistInputProps, StyleProps, LayoutProps { value?: Date | LocalDate; /** * Escape hatch to pass props directly to the underlying react-day-picker calendar. * `disabled` matchers are combined with those generated by minDate/maxDate. Selection * wiring (`mode`, `selected`, `onSelect`) cannot be overridden. */ dayPickerProps?: Partial; /** True to show a "clear" button aligned to the right of the control. Defaults to false. */ enableClear?: boolean; /** MomentJS format string for the in-input display of the value. Defaults to `YYYY-MM-DD`. */ formatString?: string; /** Icon to display inline on the left side of the input. */ leftIcon?: ReactElement; /** Icon to display inline on the right side of the input. Defaults to a calendar icon. */ rightIcon?: ReactElement; /** * Maximum (inclusive) valid date. Days beyond this bound are disabled within the calendar, * and the calendar cannot be navigated past its month. */ maxDate?: Date | LocalDate; /** * Minimum (inclusive) valid date. Days before this bound are disabled within the calendar, * and the calendar cannot be navigated before its month. */ minDate?: Date | LocalDate; /** Alignment of entry text within control, default 'left'. */ textAlign?: Property.TextAlign; /** Type of value to publish. Defaults to 'date'. */ valueType?: 'date' | 'localDate'; } /** * A mobile-first calendar control for choosing a Date. Tapping the input opens a calendar * picker (react-day-picker) hosted within a modal dialog. Selecting a day commits the value * and dismisses the picker. */ export const [DateInput, dateInput] = hoistCmp.withFactory({ displayName: 'DateInput', className: 'xh-date-input', render(props, ref) { return useHoistInputModel(cmp, props, ref, DateInputModel); } }); (DateInput as any).hasLayoutSupport = true; //--------------------------------- // Implementation //--------------------------------- class DateInputModel extends HoistInputModel { override xhImpl = true; @bindable pickerIsOpen: boolean = false; constructor() { super(); makeObservable(this); } get valueType(): 'date' | 'localDate' { return withDefault(this.componentProps.valueType, 'date'); } get minDate(): Date | null { return resolveBoundDate(this.componentProps.minDate); } get maxDate(): Date | null { return resolveBoundDate(this.componentProps.maxDate); } get showClearButton(): boolean { const {enableClear, disabled} = this.componentProps; return !!enableClear && !disabled && this.renderValue != null; } get formatString(): string { return withDefault(this.componentProps.formatString, 'YYYY-MM-DD'); } get formattedRenderValue(): string { const {renderValue} = this; return renderValue ? (fmtDate(renderValue, this.formatString) as string) : ''; } override toExternal(internal: Date | null): Date | LocalDate | null { if (this.valueType === 'localDate') return internal ? LocalDate.from(internal) : null; return internal; } override toInternal(external: Date | LocalDate | null): Date | null { if (external == null) return null; return isLocalDate(external) ? external.date : (external as Date); } openPicker() { if (!this.componentProps.disabled) this.pickerIsOpen = true; } onDaySelect = (date: Date) => { this.noteValueChange(date ?? null); this.doCommit(); this.pickerIsOpen = false; }; } const cmp = hoistCmp.factory(({model, className, ...props}, ref) => { const {width, ...layoutProps} = getLayoutProps(props), textAlign = withDefault(props.textAlign, 'left'), leftIcon = withDefault(props.leftIcon, null), rightIcon = withDefault(props.rightIcon, Icon.calendar()); return hbox({ ref, className, style: { ...props.style, ...layoutProps, width: withDefault(width, null) }, items: [ leftIcon, div({ className: classNames( 'xh-date-input__display', props.disabled ? 'xh-date-input__display--disabled' : null ), style: {textAlign}, tabIndex: props.tabIndex, [TEST_ID]: props.testId, item: model.formattedRenderValue, onClick: () => model.openPicker() }), clearButton(), rightIcon && box({ className: 'xh-date-input__picker-button', onClick: () => model.openPicker(), item: rightIcon }), pickerDialog() ] }); }); const pickerDialog = hoistCmp.factory(({model}) => { let {minDate, maxDate, renderValue} = model, {dayPickerProps} = model.componentProps, disabledDays = []; if (minDate) disabledDays.push({before: minDate}); if (maxDate) disabledDays.push({after: maxDate}); if (dayPickerProps?.disabled) disabledDays.push(...castArray(dayPickerProps.disabled)); dayPickerProps = omitBy( { defaultMonth: renderValue, startMonth: minDate, endMonth: maxDate, ...dayPickerProps, mode: 'single', required: true, selected: renderValue, disabled: disabledDays, onSelect: model.onDaySelect }, isNil ); return dialog({ isOpen: model.pickerIsOpen, className: 'xh-date-input__picker-dialog', onCancel: () => (model.pickerIsOpen = false), content: dayPicker(dayPickerProps) }); }); const clearButton = hoistCmp.factory(({model}) => button({ className: 'xh-date-input__clear-button', icon: Icon.cross(), tabIndex: -1, minimal: true, omit: !model.showClearButton, testId: getTestId(model.componentProps, 'clear-btn'), onClick: () => { model.noteValueChange(null); model.doCommit(); } }) ); //--------------------------------- // Local helpers //--------------------------------- function resolveBoundDate(val: Date | LocalDate | undefined | null): Date | null { if (val == null) return null; return isLocalDate(val) ? val.date : stripTime(val); } function stripTime(date: Date): Date { return new Date(date.getFullYear(), date.getMonth(), date.getDate()); }