// Copyright (c) Meta Platforms, Inc. and affiliates.
'use client';
/**
* @file DateInput.tsx
* @input Uses React, useId, useState, useCallback, useRef, Field, Icon, Calendar, usePopover, InputGroupContext
* @output Exports DateInput component, DateInputProps
* @position Core implementation; consumed by index.ts, tested by DateInput.test.tsx
*
* SYNC: When modified, update these files to stay in sync:
* - /packages/core/src/DateInput/DateInput.doc.mjs (props table, features, implementation notes)
* - /packages/core/src/DateInput/DateInput.test.tsx (tests for new/changed behavior)
* - /packages/core/src/DateInput/index.ts (exports if types change)
* - /apps/storybook/stories/DateInput.stories.tsx (storybook stories)
* - /packages/cli/assets/templates/blocks/components/DateInput/ (showcase blocks)
*/
import { useId, useState, useCallback, useRef, useOptimistic, useTransition } from 'react';
import * as stylex from "../style-runtime.js";
import "../theme/tokens.stylex.js";
import { colorVars, sizeVars, radiusVars, typographyVars, typeScaleVars, borderVars } from "../theme/tokens.stylex.js";
import { Field, InputClearButton, type InputStatus, inputWrapperStyles, inputStatusBorderStyles, inputStatusHoverShadowStyles, inputStatusFocusWithinStyles, type FieldStatusVariant } from "../Field/index.js";
import { Icon } from "../Icon/index.js";
import { VisuallyHidden } from "../VisuallyHidden/index.js";
import { useInputGroup } from "../InputGroup/InputGroupContext.js";
import { groupStyles } from "../InputGroup/groupStyles.js";
import { useSize } from "../SizeContext/SizeContext.js";
import { Spinner } from "../Spinner/index.js";
import { Calendar, type ISODateString, type CalendarHandle, type DayOfWeek, type DayOfWeekName } from "../Calendar/index.js";
import { useCalendarConstraints } from "../Calendar/hooks/index.js";
import { useInputStatusIcon } from "../hooks/useInputStatusIcon.js";
import { useMediaQuery } from "../hooks/useMediaQuery.js";
import { useResolvedRequired } from "../hooks/useResolvedRequired.js";
import { usePopover } from "../Popover/index.js";
import { NativeDateField } from "./NativeDateField.js";
import { TouchDateField } from "./TouchDateField.js";
import { useTooltip } from "../Tooltip/index.js";
import { getInputARIA, isImeKeyEvent, parseDateInput } from "../utils/index.js";
import { plainDateFromISO, plainDateToISO, formatSharedDate } from "../utils/plainDate.js";
import type { TimestampFormat } from "../Timestamp/index.js";
const styles = {
iconButton: {
k1xSpc: "x78zum5",
kGNEyG: "x6s0dn4",
kjj79g: "xl56j7k",
kmVPX3: "x1717udv",
kogj98: "x1ghz6dp",
kMzoRj: "xc342km",
ksu8eU: "xng3xce",
kWkggS: "xjbqb8w",
kkrTdU: "x1ypdohk x16khyan",
kaIpWk: "xh6dtrn",
$$css: true as const
},
iconButtonDisabled: {
kkrTdU: "xt0e3qv",
$$css: true as const
}
};
const sizeStyles = {
sm: {
kZKoxP: "x6k0iem",
k7Eaqz: "xfb3i0g",
$$css: true as const
},
md: {
kZKoxP: "x1ueg155",
k7Eaqz: "xfb3i0g",
$$css: true as const
},
lg: {
kZKoxP: "xssyfek",
k7Eaqz: "xfb3i0g",
$$css: true as const
}
};
export type DateInputSize = keyof typeof sizeStyles;
/**
* Named display formats for a committed date value. These are the date-only
* members of Timestamp's `format` vocabulary — reused verbatim (via
* `Extract`) so the same literal renders the same date shape in both
* `Timestamp` and `DateInput`:
* - `'date'`: locale short-month date, e.g. "Mar 21, 2026"
* - `'date_long'`: locale long-month date, e.g. "March 21, 2026" (the default)
* - `'date_weekday'`: short weekday + date, e.g. "Wed, Mar 21, 2026"
* - `'system_date'`: ISO 8601 calendar date, e.g. "2026-03-21"
*
* Because `DateInputFormat` is `Extract`ed from `TimestampFormat`, the two
* types stay in compile-time lockstep: renaming or removing one of these
* members from `TimestampFormat` breaks this type at build time.
*/
/**
* When DateInput hands date picking to the browser/OS instead of its own
* surfaces.
*
* - `'touch'`: native on touch devices (coarse pointer), Astryx's calendar
* popover on mouse-driven ones
* - `'always'`: native wherever the browser supports ``
* - `'never'`: Astryx's own pickers everywhere
*/
export type DateInputNativePicker = 'touch' | 'always' | 'never';
export type DateInputFormat = Extract;
// Re-export shared types for convenience
export type { InputStatus as DateInputStatus, InputStatusType as DateInputStatusType } from "../Field/index.js";
import { mergeProps, isFocusDetached } from "../utils/index.js";
import type { BaseProps } from "../BaseProps.js";
import type { SizeValue } from "../utils/types.js";
import { themeProps } from "../utils/themeProps.js";
import { focusOutlineStyles } from "../utils/focusOutline.stylex.js";
import { stableClassName } from "../naming.js";
import { useLocale, useTranslator } from "../i18n/index.js";
import { useMergedRefs } from "../hooks/useMergedRefs.js";
export interface DateInputProps extends Omit {
/** Ref forwarded to the root element */
ref?: React.Ref;
/**
* Label text for the input (required for accessibility).
*/
label: string;
/**
* Whether to visually hide the label (still accessible to screen readers).
* @default false
*/
isLabelHidden?: boolean;
/**
* Description text displayed between the label and input.
*/
description?: string;
/**
* Whether the field is optional. Mutually exclusive with isRequired.
* @default false
*/
isOptional?: boolean;
/**
* Whether the field is required. Mutually exclusive with isOptional.
* @default false
*/
isRequired?: boolean;
/**
* Whether the input is disabled.
* @default false
*/
isDisabled?: boolean;
/**
* Explains why the input is disabled. When set together with
* `isDisabled`, the input shows a tooltip with this text on hover and
* keyboard focus, and the field stays focusable (via `aria-disabled`)
* so the reason is discoverable by keyboard and assistive technology.
* Typing and calendar activation stay blocked.
*
* Use this instead of wrapping a disabled input in `Tooltip` — disabled
* controls don't emit the pointer events an external tooltip needs.
*
* @example
* ```
*
* ```
*/
disabledMessage?: string;
/**
* The selected date in ISO format (YYYY-MM-DD).
*/
value?: ISODateString;
/**
* Callback fired when the date changes.
* Called with undefined when input is cleared.
*/
onChange?: (value: ISODateString | undefined) => void;
/**
* Async action on change. Fires after onChange.
*/
changeAction?: (value: ISODateString | undefined) => void | Promise;
/**
* Whether the input is in a loading state.
* @default false
*/
isLoading?: boolean;
/**
* Minimum selectable date in ISO format.
*/
min?: ISODateString;
/**
* Maximum selectable date in ISO format.
*/
max?: ISODateString;
/**
* Custom date constraint functions. Date is disabled if ANY function returns false.
*/
dateConstraints?: ReadonlyArray<(date: Date) => boolean>;
/**
* Placeholder text shown when no date is selected.
* @default "Select a date"
*/
placeholder?: string;
/**
* The size of the input.
* - 'sm': Compact size (18px height)
* - 'md': Default size (26px height)
* @default 'md'
*/
size?: DateInputSize;
/**
* Status indicator for the input.
* When set, displays a colored border and status icon.
* If message is provided, displays below the input.
*/
status?: InputStatus;
/**
* How the status message is placed relative to the input.
* - 'attached': message overlaps directly below the input (bordered treatment)
* - 'detached': message floats below as a separate element with spacing
* - 'tooltip': no message box; the status icon becomes a focusable info-tip button that reveals the message on hover, keyboard focus, or tap
* @default 'attached'
*/
statusVariant?: FieldStatusVariant;
/**
* Width of the field. Numbers are treated as pixels, strings are used as-is
* (e.g. `'100%'`). Sizes the whole field (label, control, and status) so they
* stay aligned, unlike setting width via `xstyle`/`className`/`style`.
*/
width?: SizeValue;
/**
* Tooltip text to display in an info icon at the end of the label.
*/
labelTooltip?: string;
/**
* Whether to show a clear button when a date is set.
* When clicked, resets the value to undefined and returns focus to the input.
* @default false
*/
hasClear?: boolean;
/**
* Number of months to display in the calendar popover.
* @default 1
*/
numberOfMonths?: 1 | 2;
/**
* First day of week in the calendar popover. Accepts a number
* (0 = Sunday … 6 = Saturday) or a three-letter day name ('sun'–'sat',
* case-insensitive).
* @default 0
*/
weekStartsOn?: DayOfWeek | DayOfWeekName;
/**
* How the committed date value is displayed in the text field. Accepts a
* named format reused from `Timestamp`'s `format` vocabulary (so the same
* literal renders the same date shape in both components) or a function that
* maps the ISO value to a custom display string.
*
* - `'date_long'` (default): long-month date, e.g. "March 21, 2026"
* - `'date'`: short-month date, e.g. "Mar 21, 2026"
* - `'date_weekday'`: short weekday + date, e.g. "Wed, Mar 21, 2026"
* - `'system_date'`: ISO 8601 calendar date, e.g. "2026-03-21"
* - `(value: ISODateString) => string`: fully custom display string
*
* Formatting applies only to the committed value — never to text the user is
* actively typing. A custom function's output that `parseDateInput` cannot
* read back can't be re-committed after an edit; external `value` changes
* always recompute the display from the ISO value.
*
* @default 'date_long'
* @example
* ```
*
* new Date(iso + 'T00:00').toDateString()}
* />
* ```
*/
format?: DateInputFormat | ((value: ISODateString) => string);
/**
* When date picking is handed to the browser/OS instead of Astryx's own
* surfaces: the field becomes an `` and the platform
* draws the picker — the iOS wheel, the Android calendar dialog — with the
* OS's own hit areas, momentum scrolling, locale and accessibility
* settings.
*
* - `'touch'` (default): native on touch devices (coarse pointer), the text
* field and calendar popover on mouse-driven ones
* - `'always'`: native wherever the browser supports ``
* - `'never'`: Astryx's own pickers everywhere — the touch picker on a
* finger, the calendar popover on a mouse
*
* `format` and `placeholder` still apply in native mode: DateInput paints
* the closed field's text itself, over the control. `numberOfMonths` and
* `weekStartsOn` do not — they describe a calendar grid the native picker
* does not have — so a field that needs either should pass `'never'`.
*
* `min` and `max` are forwarded, but note that a native picker may not
* *show* them: on iOS they are constraint-validation flags rather than
* clamps, so an out-of-range date can be selected and is refused on commit
* (announced to assistive technology) rather than being greyed out in the
* picker. `dateConstraints` is enforced the same way, on commit, and is
* reason enough to prefer `'never'` on a field that uses it.
*
* @default 'touch'
* @example
* ```
* // Astryx's own touch picker instead of the platform's
*
* ```
*/
nativePicker?: DateInputNativePicker;
}
/**
* The pointer that decides which surface a `DateInput` renders.
*
* `pointer: coarse` is the *primary* pointing device, which is what makes it
* the whole test. A touchscreen laptop reports `fine` (its trackpad) with
* `any-pointer: coarse` alongside, so it keeps the typable field — right,
* because its keyboard is there. A tablet reports `coarse` and gets the
* picker, at any width. There is deliberately no width bound: it would only
* re-exclude the tablets, since a narrowed desktop window is still a mouse.
*
* Deliberately NOT exported. It was, briefly, on the theory that an app might
* want to ask the same question and lay out to match — but nothing asked, and
* six other core components (CheckboxInput, ChatComposerInput, ...) just
* write `@media (pointer: coarse)` inline rather than sharing a constant. An
* export is additive later and awkward to withdraw, so it waits for a real
* caller.
*/
const TOUCH_POINTER_QUERY = '(pointer: coarse)';
/**
* The pointer-driven field: a text input you can type into, with a calendar
* in a popover beside it. `DateInput` renders this whenever the primary
* pointer is not a finger — see {@link TOUCH_POINTER_QUERY}.
*/
function PointerDateField({
label,
isLabelHidden = false,
description,
isOptional = false,
isRequired = false,
isDisabled = false,
disabledMessage,
value,
onChange,
changeAction,
isLoading = false,
min,
max,
dateConstraints,
placeholder: placeholderFromProps,
size: sizeProp,
status,
statusVariant = 'attached',
labelTooltip,
hasClear = false,
numberOfMonths = 1,
weekStartsOn,
format = 'date_long',
width,
xstyle,
className,
style,
ref,
...rest
}: DateInputProps) {
const t = useTranslator();
const locale = useLocale();
const isEffectivelyRequired = useResolvedRequired({
isRequired,
isOptional
});
const placeholder = placeholderFromProps ?? t('@astryx.dateInput.placeholder');
const size = useSize(sizeProp, 'md');
const id = useId();
const inputLabelID = useId();
const descriptionID = useId();
const statusMessageID = useId();
const inputRef = useRef(null);
const calendarRef = useRef(null);
const lastFiredValueRef = useRef(undefined);
const inputGroup = useInputGroup();
const [, startTransition] = useTransition();
const [optimisticValue, setOptimisticValue] = useOptimistic(value);
const isBusy = isLoading || optimisticValue !== value;
const isEffectivelyDisabled = isDisabled || isBusy;
// Disabled-reason tooltip. Disabled controls swallow pointer events, so the
// tooltip listeners attach to the input container (which already exists) and
// the text input stays perceivable via aria-disabled instead of the disabled
// attribute. Typing is blocked with readOnly and value mutation guards;
// calendar activation is blocked by the isEffectivelyDisabled guards. Only
// the persistent isDisabled state (not the transient busy state) surfaces a
// reason.
const showsDisabledMessage = isDisabled && !!disabledMessage;
const disabledMessageTooltip = useTooltip({
placement: 'above',
// The container div is not naturally focusable; focusin bubbles up from
// the input, so always attach focus listeners.
focusTrigger: 'always',
isEnabled: showsDisabledMessage
});
// Constraint checking for text input validation (reuses calendar logic)
const {
isDateDisabled
} = useCalendarConstraints({
min,
max,
dateConstraints
});
const {
statusIcon,
describedBy: statusTooltipDescribedBy
} = useInputStatusIcon({
status,
statusVariant,
isInGroup: !!inputGroup
});
const {
ariaLabelledBy,
ariaDescribedBy
} = getInputARIA(inputLabelID, [description ? descriptionID : null, statusVariant !== 'tooltip' && status?.message ? statusMessageID : null,
// The tooltip variant renders no message box; describe the input by the
// tooltip's content instead so the status is still announced.
statusTooltipDescribedBy, showsDisabledMessage ? disabledMessageTooltip.describedBy : null], inputGroup);
// Pending input while user is typing (null = show formatted value)
const [pendingInput, setPendingInput] = useState(null);
// Clear pending input when value changes externally (computed during render
// via prev-value ref instead of useEffect to avoid an extra render cycle)
const prevValueRef = useRef(value);
if (value !== prevValueRef.current) {
prevValueRef.current = value;
if (value !== lastFiredValueRef.current) {
lastFiredValueRef.current = undefined;
if (pendingInput !== null) {
setPendingInput(null);
}
}
}
// Format a committed ISO value for display. The default `date_long` renders
// the long-month shape (byte-identical to the historical hardcoded
// DATE_FORMAT_LONG rendering, so still non-breaking); a function is called
// with the ISO value; every other named member reuses Timestamp's shared
// date mapping. Applies ONLY to the committed value, never to in-progress
// typed input.
const formatCommittedValue = useCallback((iso: ISODateString): string => typeof format === 'function' ? format(iso) : formatSharedDate(plainDateFromISO(iso), format, locale), [format, locale]);
// Display value: pending input if typing, otherwise formatted value
const displayValue = pendingInput !== null ? pendingInput : optimisticValue && /^\d{4}-\d{2}-\d{2}$/.test(optimisticValue) ? formatCommittedValue(optimisticValue) : '';
// Check if current input is valid (for styling purposes)
const isInputValid = pendingInput === null || !pendingInput.trim() ? true : parseDateInput(pendingInput, locale) !== null;
const popover = usePopover({
dialogLabel: t('@astryx.dateInput.dialogLabel'),
closeButtonLabel: t('@astryx.dateInput.closeCalendar'),
// Return focus to the input when the calendar closes — but only when the
// dismiss left focus detached (Escape, or a click on non-focusable empty
// space), which the focus trap can't restore on its own. A native
// popover="auto" light-dismiss fires synchronously with the pointer event
// that moved focus, so if the user clicked another control — the clear
// button, another field, anywhere — focus has already landed there;
// reclaiming it would fight their click.
onHide: () => {
if (isFocusDetached()) {
inputRef.current?.focus();
}
}
});
// Handle toggling the popover from button click (focus calendar)
const handleToggle = useCallback(() => {
if (!isEffectivelyDisabled) {
if (popover.isOpen) {
popover.hide();
} else {
popover.show();
}
}
}, [isEffectivelyDisabled, popover]);
// Handle opening the popover from input click (keep focus in input)
const handleInputClick = useCallback(() => {
if (!isEffectivelyDisabled && !popover.isOpen) {
popover.show({
skipAutoFocus: true
});
}
}, [isEffectivelyDisabled, popover]);
// Unified change handler that fires both onChange and changeAction
const fireChange = useCallback((newValue: ISODateString | undefined) => {
if (isBusy) {
return;
}
onChange?.(newValue);
if (changeAction) {
startTransition(async () => {
setOptimisticValue(newValue);
await changeAction(newValue);
});
}
}, [isBusy, onChange, changeAction, startTransition, setOptimisticValue]);
// Handle clear button click
const handleClear = useCallback((e?: React.MouseEvent) => {
fireChange(undefined);
if (!e || e.detail === 0) {
inputRef.current?.focus();
} else {
// Defer focus restoration past the button's unmount task so iOS Safari
// and touch browsers don't jump the page scroll to 0 on tap.
requestAnimationFrame(() => {
inputRef.current?.focus({
preventScroll: true
});
});
}
}, [fireChange]);
// Handle date selection from calendar
const handleDateSelect = useCallback((selectedDate: ISODateString) => {
fireChange(selectedDate);
setPendingInput(null);
popover.hide();
}, [fireChange, popover]);
// Handle input text change - update immediately if valid and allowed
const handleInputChange = useCallback((e: React.ChangeEvent) => {
// With a disabledMessage the input drops `disabled` for focusability, so
// guard value mutation explicitly (readOnly also blocks typing).
if (isEffectivelyDisabled) {
return;
}
const newValue = e.target.value;
setPendingInput(newValue);
// If the input is valid and passes constraints, update immediately
const parsed = parseDateInput(newValue, locale);
if (parsed && plainDateToISO(parsed) !== value && !isDateDisabled(parsed)) {
const parsedISO = plainDateToISO(parsed);
lastFiredValueRef.current = parsedISO;
fireChange(parsedISO);
// Navigate calendar to show the parsed date's month
calendarRef.current?.navigateTo(parsedISO);
}
}, [value, fireChange, isDateDisabled, isEffectivelyDisabled, locale]);
// Commit pending input (shared by blur and Enter key)
const commitPendingInput = useCallback(() => {
if (pendingInput === null) {
return;
}
if (!pendingInput.trim()) {
if (value !== undefined) {
fireChange(undefined);
}
setPendingInput(null);
return;
}
const parsed = parseDateInput(pendingInput, locale);
if (parsed && !isDateDisabled(parsed)) {
const parsedISO = plainDateToISO(parsed);
if (parsedISO !== value) {
fireChange(parsedISO);
}
}
setPendingInput(null);
}, [pendingInput, value, fireChange, isDateDisabled, locale]);
// Handle blur - validate, check constraints, and clear pending input
const handleBlur = useCallback(() => {
commitPendingInput();
}, [commitPendingInput]);
// Handle keyboard events on input
const handleInputKeyDown = useCallback((e: React.KeyboardEvent) => {
// An in-progress IME composition uses Enter to commit the candidate and
// Escape to cancel it; that composing keydown fires before
// compositionend, so without this guard a Korean/Japanese/Chinese user
// committing a syllable with Enter would instead commit the pending date
// (or Escape would close the calendar mid-composition). See utils/ime.ts.
if (isImeKeyEvent(e.nativeEvent)) {
return;
}
if (e.key === 'Escape' && popover.isOpen) {
e.preventDefault();
popover.hide();
} else if ((e.key === 'ArrowDown' || e.altKey && e.key === 'ArrowDown') && !popover.isOpen) {
// APG combobox: ArrowDown (and Alt+ArrowDown) opens the calendar
// popover from the keyboard, keeping focus in the input (forms-13).
e.preventDefault();
if (!isEffectivelyDisabled) {
popover.show({
skipAutoFocus: true
});
}
} else if (e.key === 'Enter') {
e.preventDefault();
commitPendingInput();
}
}, [popover, commitPendingInput, isEffectivelyDisabled]);
const inputWrapper =
{
popover.triggerRef(el);
// Anchor + hover/focus listeners for the disabled-message tooltip.
// Handlers are gated internally by isEnabled, and anchor names
// compose, so attaching unconditionally is safe.
disabledMessageTooltip.ref(el);
}} {...rest} {...mergeProps(themeProps('date-input', {
size,
status: status?.type ?? null,
disabled: isDisabled ? 'disabled' : null
}), stylex.props(inputWrapperStyles.base, sizeStyles[size], isEffectivelyDisabled && inputWrapperStyles.disabled, status && inputStatusBorderStyles[status.type], status && !isEffectivelyDisabled && inputStatusHoverShadowStyles[status.type], status && inputStatusFocusWithinStyles[status.type], inputGroup && groupStyles.inGroup, xstyle), className, style)}>
{inputGroup && {label}}
{/*
Live region announcing invalid typed input to assistive technology.
The value silently reverts on blur, so without this a screen-reader
user would get no feedback that their entry was rejected (WCAG 3.3.1).
*/}
{!isInputValid ? t('@astryx.dateInput.invalidDate') : ''}
{hasClear && value !== undefined && !isEffectivelyDisabled && }
{isBusy && }
{statusIcon}
{popover.render(, {
placement: 'below',
alignment: 'start'
})}
{showsDisabledMessage && disabledMessageTooltip.renderTooltip(disabledMessage)}
;
if (inputGroup) {
return inputWrapper;
}
return
{inputWrapper}
;
}
PointerDateField.displayName = 'PointerDateField';
/**
* A date picker that fits the pointer it is being used with.
*
* With a mouse or trackpad this is a text input you can type into, with a
* calendar in a popover — unchanged, and still the surface every existing
* consumer gets. With a finger it is a picker built for one: a bottom sheet
* holding one month per screen, swiped sideways, with month and year wheels
* behind the header title for the far jumps swiping is bad at.
*
* The props are identical either way — this is one component with two
* surfaces, not two components — so nothing at the call site changes, and a
* date typed on a laptop and a date thumbed on a phone are the same value.
*
* ## Why a runtime switch and not CSS
*
* The two surfaces are structurally different — a popover anchored to a text
* field versus a full-width sheet holding a scroller — so "render both, hide
* one" would double the DOM, double the tab stops, and mount two calendars.
* The condition is not layout either: it is *which interaction is faster*,
* and that depends on the pointer, which CSS cannot hand to JS.
*
* They are two components rather than one with a branch inside because the
* hook lists differ; keeping them separate is what lets each own its own.
*
* ## Hydration
*
* `useMediaQuery` reports false during SSR, so server HTML is always the
* pointer field and the swap happens after hydration. That is deliberately
* unobservable: both surfaces render the SAME closed field — a bordered input
* with a calendar icon and the formatted date — and differ only in what
* opens. Nothing moves; the field just starts opening a sheet.
*
* @example
* ```
*
* ```
*/
export function DateInput(props: DateInputProps) {
const isTouch = useMediaQuery(TOUCH_POINTER_QUERY);
const nativePicker = props.nativePicker ?? 'touch';
// The platform's picker, where the consumer asked for it — see the
// `nativePicker` prop for what that trades away.
if (nativePicker === 'always' || nativePicker === 'touch' && isTouch) {
return ;
}
return isTouch ? : ;
}
DateInput.displayName = 'DateInput';