// 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';