/*
* 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, hbox} from '@xh/hoist/cmp/layout';
import {hoistCmp, HoistProps, LayoutProps, StyleProps} from '@xh/hoist/core';
import {Icon} from '@xh/hoist/icon';
import {input} from '@xh/hoist/kit/onsen';
import {button} from '@xh/hoist/mobile/cmp/button';
import '@xh/hoist/mobile/register';
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 type {Property} from 'csstype';
import {ChangeEvent, ReactElement} from 'react';
import './DateInput.scss';
export interface DateInputProps extends HoistProps, HoistInputProps, StyleProps, LayoutProps {
value?: Date | LocalDate;
/** True to show a "clear" button aligned to the right of the control. Defaults to false. */
enableClear?: boolean;
/** 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. Applied to the native input's `max` attribute and also
* enforced on commit, resetting any out-of-bounds value to `null`.
*
* Note this is distinct in these ways from FormModel based validation, which will leave an
* invalid date entry in place but flag as invalid via FormField. For cases where it is
* possible to use FormField, that is often a better choice.
*/
maxDate?: Date | LocalDate;
/**
* Minimum (inclusive) valid date. Applied to the native input's `min` attribute and also
* enforced on commit, resetting any out-of-bounds value to `null`.
*
* See note re. validation on maxDate, above.
*/
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, backed by the browser's native
* `` element. Tapping the input invokes the OS-provided date picker -
* a drum/wheel on iOS, a Material date dialog on Android, and a popover calendar on desktop
* browsers.
*
* The in-input display and picker UI follow the user's OS locale. Values are read from and
* written to the underlying input as ISO-8601 (`YYYY-MM-DD`) strings.
*/
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;
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;
}
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);
}
onInputChange = (ev: ChangeEvent) => {
const str = ev.target.value;
if (!str) {
this.noteValueChange(null);
return;
}
let date = isoToDate(str);
if (date && this.isOutsideRange(date)) {
this.logDebug('Value exceeded max/minDate bounds on change - reset to null.');
date = null;
// Force the native input back in sync with the reset value. When the prior value was
// also null, the internalValue observable does not change, so no re-render fires and
// the out-of-bounds entry would otherwise remain displayed in the control.
ev.target.value = '';
}
this.noteValueChange(date);
};
isOutsideRange(date: Date): boolean {
const {minDate, maxDate} = this,
stamped = stripTime(date);
if (minDate && stamped < minDate) return true;
if (maxDate && stamped > maxDate) return true;
return false;
}
// No-op on browsers without showPicker() (pre-16.4 Safari); tapping the field still opens it.
showPicker() {
if (this.componentProps.disabled) return;
(this.inputEl as HTMLInputElement)?.showPicker?.();
}
}
const cmp = hoistCmp.factory(({model, className, ...props}, ref) => {
const {width, ...layoutProps} = getLayoutProps(props),
{renderValue} = model,
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,
input({
type: 'date',
className: 'xh-date-input__input',
value: dateToIso(renderValue as Date) ?? '',
min: dateToIso(model.minDate),
max: dateToIso(model.maxDate),
disabled: props.disabled,
tabIndex: props.tabIndex,
style: {textAlign},
[TEST_ID]: props.testId,
onChange: model.onInputChange,
onFocus: model.onFocus,
onBlur: model.onBlur
}),
clearButton(),
rightIcon &&
box({
className: 'xh-date-input__picker-button',
onClick: () => model.showPicker(),
item: rightIcon
})
]
});
});
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: () => {
// Intentionally no refocus after clearing - on iOS/Android, focusing the native
// date input reopens the OS picker, which is unwanted UX after an explicit clear.
model.noteValueChange(null);
model.doCommit();
}
})
);
//---------------------------------
// Local helpers - date <-> ISO
//---------------------------------
function resolveBoundDate(val: Date | LocalDate | undefined | null): Date | null {
if (val == null) return null;
return isLocalDate(val) ? val.date : stripTime(val);
}
function isoToDate(iso: string): Date | null {
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(iso);
if (!match) return null;
const [, y, m, d] = match;
return new Date(Number(y), Number(m) - 1, Number(d));
}
function dateToIso(date: Date | null | undefined): string | undefined {
if (date == null) return undefined;
const y = date.getFullYear(),
m = String(date.getMonth() + 1).padStart(2, '0'),
d = String(date.getDate()).padStart(2, '0');
return `${y}-${m}-${d}`;
}
function stripTime(date: Date): Date {
return new Date(date.getFullYear(), date.getMonth(), date.getDate());
}