import { useUniqueId, useVisibility, } from "@charcuterie/logic" import type { ControlSize } from "@charcuterie/tokens" import { FloatingFocusManager, FloatingPortal, } from "@floating-ui/react" import type { KeyboardEvent as ReactKeyboardEvent, ReactNode, } from "react" import { useRef, useState } from "react" import { Button } from "../Button/Button.tsx" import { CONTROL_SIZE_CLASS } from "../controlStyles.ts" import { IconButton } from "../IconButton/IconButton.tsx" import { DISABLED_CLASS, FOCUS_RING_CLASS, } from "../intentStyles.ts" import { PANEL_SURFACE_CLASS } from "../Overlay/overlayPanelClass.ts" import { useAnchoredOverlay } from "../Overlay/useAnchoredOverlay.ts" import type { SlotProps } from "../slotProps.ts" import { mergeSlotProps } from "../slotProps.ts" import { toClassName } from "../toClassName.ts" import { DateGrid } from "./DateGrid.tsx" import type { DatePreset } from "./datePresets.ts" import { DEFAULT_DATE_PRESETS } from "./datePresets.ts" import type { DateInputKeywords } from "./parseDateInput.ts" import { parseDateInput } from "./parseDateInput.ts" import type { DateRange, PlainDate } from "./plainDate.ts" import { addDays, addMonths, clampPlainDate, comparePlainDates, formatPlainDate, getFirstDayOfWeek, getIsWithinRange, getLocalPlainDate, parseIsoDate, toIsoDate, } from "./plainDate.ts" export type DatePickerProps = SlotProps & { className?: string /** * Sunday 0 through Saturday 6. Omit and the locale decides, via * `Intl.Locale`'s `getWeekInfo` — which is why there is no * `isMondayFirst` here. */ firstDayOfWeek?: number isDisabled?: boolean /** * Start-and-end mode. A **mode, not a sibling component** — see * the component docblock, and the `Combobox` `isMultiple` * precedent it follows. */ isRange?: boolean /** Override the English relative words (`today`, `next`, `+3d`). */ keywords?: DateInputKeywords /** * Required. It names the field for a screen reader **and** names * the calendar dialog and its grids, which a `role="dialog"` takes * from nothing else. Inside a `Field`, pass the same text as the * `Field`'s own `label` — they are the same name and WCAG 2.5.3 * wants them to agree. */ label: string /** Omit for the platform's resolved locale. */ locale?: string /** ISO `YYYY-MM-DD`. Days after this are unselectable. */ maxValue?: string /** ISO `YYYY-MM-DD`. Days before this are unselectable. */ minValue?: string /** * An ISO `YYYY-MM-DD` in single mode, a `{ end, start }` of them in * range mode, and `null` when cleared. Never a `Date`, and that is * the point — see the docblock. */ onChange?: (value: DateRange | null | string) => void placeholder?: string /** Pass `[]` to drop the shortcut row entirely. */ presets?: readonly DatePreset[] size?: ControlSize /** * Which zone's calendar "today" is read from, when `today` is * omitted. Ignored when `today` is supplied — which is what every * test and every story does. */ timeZone?: string /** * The clock, injected, as an ISO `YYYY-MM-DD`. * * Every relative form (`tomorrow`, `next fri`, `+14d`), the * `aria-current="date"` marker, and the preset row resolve against * this. Supplying it is what makes the component testable at all: * a date picker tested against the real "today" passes on the day * it was written and fails silently afterwards. Omitted, it is * read **once**, at mount, from `timeZone` or the device. */ today?: string /** **Initial** only, like every other value prop in this library. */ value?: DateRange | string } const CHEVRON_PREVIOUS = ( ) const CHEVRON_NEXT = ( ) const INPUT_CLASS = "w-full min-w-0 rounded-md border border-border-default bg-surface-raised text-content-primary transition-colors duration-(--duration-fast) ease-standard placeholder:text-content-muted hover:border-border-strong aria-invalid:border-intent-danger-border" type Endpoint = "end" | "start" /** * A date field: a text input you can actually type into, and a * calendar dialog hanging off it. * * ### The value is a calendar date, never an instant * * `onChange` reports an ISO `YYYY-MM-DD` string — the same format * `` uses — and never a `Date`. A `Date` is a * count of milliseconds, so reading one back always goes through the * browser's timezone, and a due date picked at 23:30 in Denver comes * back as the previous day in London. `plainDate.ts` has the full * argument and the arithmetic; `plainDate.test.ts` re-runs it under * UTC+14 and UTC-11 and asserts nothing moves. * * ### Typing is the feature * * The grid is the fallback, not the interface. `tomorrow`, * `next fri`, `+14d`, `19 aug`, `8/19`, `2026-08-19` and a bare `19` * all resolve, in the active locale, against an injected `today` — * and **the resolution is echoed underneath the field, in a live * region, before anything commits.** Nothing here guesses in * silence: an ambiguous month prefix comes back as "'ju' could be * June or July", not as June. `parseDateInput.ts` is the grammar and * the reasoning. * * ### Range is a mode, not a sibling * * `isRange` follows * [Combobox's `isMultiple`](../../../docs/decisions/2026-08-05-combobox-multi-select-stays-ismultiple-not-a-separate-component.md) * for the same three reasons, which hold even harder here: * * - **No distinct role.** The family is named by ARIA role. A range * picker is the same `role="dialog"` over the same `role="grid"`; * there is no `daterange` role to name a `DateRangePicker` after, * so the name would describe a *presentation*. * - **The delta is small and the shared part is everything.** The * parser, the locale plumbing, the min/max clamp, the keyboard * grid, the panel, the presets and the echo line are identical. * What changes is a second input, a second month, and a band * drawn between two days. * - **`Combobox` already set the precedent for the value shape.** * Its `selectedValue` is `readonly string[] | string`; this one is * `DateRange | string`. A discriminated props union would type * better and was rejected for that consistency, and because * Storybook's docgen renders a union props type as an untyped * object control. * * ### The panel is a container, and it is queried as one * * `@container` sits on the panel, and everything inside sizes to the * panel's own inline size rather than to the window: one-letter * weekday headers and 32px cells below `--cq-xs`, two-letter and * 36px above it, 40px above `--cq-sm`, and in range mode the two * months stack below `--cq-sm` and sit side by side above it. That * is not the same question a media query asks — the owner browses * zoomed in, where a 390px phone reports a 195px layout viewport and * a desktop window at 200% has no room either. The `size` middleware * clamps the panel to the space actually available, and the * container query reads what it got. * * ### It owns its own open state, and its own value * * Assembled, like [`Picker`](../Picker/Picker.tsx): the * `useVisibility` is in here, because a field that needs the caller * to wire `isVisible` is a field the caller has to assemble. And * `value` **seeds** rather than controls, matching `Select`, * `SegmentedControl` and `Combobox` — one owner for the value, which * is the whole of the state layer's argument. */ export const DatePicker = ({ className, firstDayOfWeek, isDisabled = false, isRange = false, keywords, label, locale, maxValue, minValue, onChange, placeholder = "e.g. tomorrow, 8/19, +14d", presets = DEFAULT_DATE_PRESETS, size = "md", timeZone, today, value, ...receivedSlotProps }: DatePickerProps): ReactNode => { const baseId = useUniqueId() const anchorElement = useRef(null) const inputElements = useRef( new Map(), ) const { hide, isVisible, show } = useVisibility() /** * Read once, lazily, and never again. * * A component that re-read the clock on every render would change * what `tomorrow` means underneath a user typing it at midnight, * and would make every test a race. `today` as a prop is the * supported way to control it, and every story and test passes it. */ const [resolvedToday] = useState( () => parseIsoDate(today) ?? getLocalPlainDate(new Date(), timeZone), ) const todayDate = parseIsoDate(today) ?? resolvedToday const minimum = parseIsoDate(minValue) ?? undefined const maximum = parseIsoDate(maxValue) ?? undefined const resolvedFirstDayOfWeek = firstDayOfWeek ?? getFirstDayOfWeek(locale) const seededRange: DateRange = typeof value === "string" ? { end: null, start: value } : (value ?? { end: null, start: null }) const [range, setRange] = useState(seededRange) const toDisplayText = (isoDate: null | string) => { const date = parseIsoDate(isoDate) return date === null ? "" : formatPlainDate(date, { dateStyle: "medium", locale, }) } const [texts, setTexts] = useState({ end: toDisplayText(seededRange.end), start: toDisplayText(seededRange.start), }) const [activeEndpoint, setActiveEndpoint] = useState("start") const [isGridFocused, setIsGridFocused] = useState(false) const [hoveredDate, setHoveredDate] = useState(null) const [focusedDate, setFocusedDate] = useState( () => clampPlainDate( parseIsoDate(seededRange.start) ?? todayDate, minimum, maximum, ), ) const [visibleMonth, setVisibleMonth] = useState( () => parseIsoDate(seededRange.start) ?? todayDate, ) const parsed = parseDateInput(texts[activeEndpoint], { keywords, locale, today: todayDate, }) const echoId = `${baseId}-echo` const dismiss = () => { hide() setIsGridFocused(false) setHoveredDate(null) inputElements.current.get(activeEndpoint)?.focus() } const { context, floatingStyles, getFloatingProps, setFloating, } = useAnchoredOverlay({ anchorRef: anchorElement, isVisible, maxHeightPx: 520, maxWidthPx: isRange ? 656 : 320, offsetValue: 4, onDismiss: () => { dismiss() }, role: "dialog", trigger: undefined, }) const { id: panelId, ...floatingProps } = getFloatingProps() as Record & { id: string } const report = (nextRange: DateRange) => { setRange(nextRange) if (isRange) { onChange?.( nextRange.end === null && nextRange.start === null ? null : nextRange, ) return } onChange?.(nextRange.start) } const writeEndpoint = ( endpoint: Endpoint, date: null | PlainDate, ) => { const isoDate = date === null ? null : toIsoDate(date) setTexts((previous) => ({ ...previous, [endpoint]: date === null ? "" : toDisplayText(isoDate), })) const nextRange = isRange ? { ...range, [endpoint]: isoDate } : { end: null, start: isoDate } // A range whose end precedes its start is not a range anybody // typed on purpose, and rejecting it would throw away the day // they just picked. Swapping keeps both dates and matches every // range control in the wild. const isBackwards = isRange && nextRange.end !== null && nextRange.start !== null && nextRange.end < nextRange.start report( isBackwards ? { end: nextRange.start, start: nextRange.end } : nextRange, ) } const commitText = (endpoint: Endpoint) => { const result = parseDateInput(texts[endpoint], { keywords, locale, today: todayDate, }) if (result.kind === "empty") { writeEndpoint(endpoint, null) return } if (result.kind === "unparsed") { // The text stays exactly as typed. Clearing it would throw // away what the user has to edit, and rewriting it to the last // good value would silently discard the thing they meant. return } writeEndpoint( endpoint, clampPlainDate(result.date, minimum, maximum), ) } const handleTextChange = ( endpoint: Endpoint, nextText: string, ) => { setTexts((previous) => ({ ...previous, [endpoint]: nextText, })) const result = parseDateInput(nextText, { keywords, locale, today: todayDate, }) // Live preview: the grid follows what has been typed so far // without committing it, which is what makes `next fri` legible // rather than an act of faith. if (result.kind === "date") { setFocusedDate(result.date) setVisibleMonth(result.date) } } const chooseDate = (date: PlainDate) => { setFocusedDate(date) if (!isRange) { writeEndpoint("start", date) dismiss() return } const isStartingOver = range.start === null || range.end !== null if (isStartingOver) { setTexts((previous) => ({ ...previous, end: "" })) setRange({ end: null, start: toIsoDate(date) }) setTexts((previous) => ({ ...previous, start: toDisplayText(toIsoDate(date)), })) setActiveEndpoint("end") return } writeEndpoint("end", date) dismiss() } const handleInputKeyDown = ( keyEvent: ReactKeyboardEvent, endpoint: Endpoint, ) => { if (keyEvent.key === "ArrowDown") { keyEvent.preventDefault() show() setIsGridFocused(true) return } if (keyEvent.key === "Enter") { keyEvent.preventDefault() commitText(endpoint) if (isVisible) { dismiss() } return } if (keyEvent.key === "Escape" && isVisible) { keyEvent.preventDefault() dismiss() } } const selectedDates = [ parseIsoDate(range.start), parseIsoDate(range.end), ].filter((one) => one !== null) const rangeStartDate = parseIsoDate(range.start) const previewEnd = isRange ? (parseIsoDate(range.end) ?? (rangeStartDate === null ? null : (hoveredDate ?? (comparePlainDates(focusedDate, rangeStartDate) > 0 ? focusedDate : null)))) : null const secondMonth = addMonths(visibleMonth, 1) const monthLabel = (month: PlainDate) => formatPlainDate(month, { locale, month: "long", year: "numeric", }) const echoText = parsed.kind === "date" ? formatPlainDate(parsed.date, { dateStyle: "full", locale, }) : parsed.kind === "unparsed" ? parsed.reason : "" const isInvalid = parsed.kind === "unparsed" const renderInput = (endpoint: Endpoint) => { const ownProps = { "aria-controls": isVisible ? panelId : undefined, "aria-describedby": echoId, "aria-expanded": isVisible, "aria-haspopup": "dialog" as const, "aria-invalid": activeEndpoint === endpoint && isInvalid ? true : undefined, "aria-label": isRange ? `${label} ${endpoint}` : label, autoComplete: "off", className: toClassName( INPUT_CLASS, CONTROL_SIZE_CLASS[size], FOCUS_RING_CLASS, DISABLED_CLASS, ), disabled: isDisabled, id: `${baseId}-${endpoint}`, // A date is digits far more often than it is words, and a // numeric-leaning soft keyboard with letters still available is // what lets `8/19` and `next fri` both be typed on a phone. inputMode: "text" as const, onChange: (changeEvent: { currentTarget: { value: string } }) => { handleTextChange( endpoint, changeEvent.currentTarget.value, ) }, onClick: () => { setActiveEndpoint(endpoint) show() }, onFocus: () => { setActiveEndpoint(endpoint) }, onKeyDown: ( keyEvent: ReactKeyboardEvent, ) => { handleInputKeyDown(keyEvent, endpoint) }, placeholder, ref: (element: HTMLInputElement | null) => { if (element) { inputElements.current.set(endpoint, element) } else { inputElements.current.delete(endpoint) } }, role: "combobox" as const, type: "text" as const, value: texts[endpoint], } return ( ` names one control. {...(endpoint === "start" ? mergeSlotProps(receivedSlotProps, ownProps) : ownProps)} key={endpoint} /> ) } const renderGrid = (month: PlainDate) => ( { setFocusedDate(date) setVisibleMonth(date) }} onHoverDate={setHoveredDate} onSelectDate={chooseDate} previewEnd={previewEnd} rangeStart={rangeStartDate} selectedDates={selectedDates} today={todayDate} /> ) return ( // The overlay anchors to THIS box, not to the input row, so the echo // line below stays visible while the calendar is open. It is the line // that says what a typed value resolved to; a panel covering it turns // the one anti-guess mechanism off at exactly the moment it matters.
{renderInput("start")} {isRange ? ( <> {renderInput("end")} ) : null}
{/* The anti-silent-guess mechanism, and the reason this component is allowed to accept `8/19` at all. It is a live region so the resolution is announced while typing, and it is wired into the input's `aria-describedby` so it is read again on focus. A parser this permissive without this line would be a field that quietly files things in the wrong month. */}

{echoText}

{isVisible ? ( ) : null}
) }