'use client'; import * as React from 'react'; import { CalendarIcon, XIcon } from '@/icons'; import { cn } from '@/lib/utils'; import { disabledField, focusRing, invalidState } from '@/lib/cva-presets'; import { useControllableState } from '@/hooks/use-controllable-state'; import { Popover, PopoverAnchor, PopoverContent } from '@/components/popover/popover'; import { Calendar } from './calendar'; import { addMonths, defaultFormat, formatDate, isValid, parseDate, startOfMonth, type DateGranularity, } from './date-utils'; export type { DateGranularity } from './date-utils'; export type DateRange = [Date | null, Date | null]; interface SharedProps { /** Granularity of the panel and of the value it produces. */ picker?: DateGranularity; /** Token pattern for the text field. Defaults to one matching `picker`. */ format?: string; /** BCP 47 tag driving month names, weekday names and the week start. */ locale?: string; placeholder?: string; disabled?: boolean; /** Reject a date. It stays visible but cannot be chosen. */ disabledDate?: (date: Date) => boolean; /** Show the clear button once something is selected. */ allowClear?: boolean; className?: string; id?: string; 'aria-label'?: string; 'aria-labelledby'?: string; 'aria-invalid'?: boolean; } /** The text field both pickers are built from. */ function PickerField({ className, invalid, ...props }: React.ComponentProps<'input'> & { invalid?: boolean }) { return ( ); } const fieldShell = cn( 'flex h-9 w-full items-center gap-2 rounded-md border border-input bg-transparent px-3 py-1 shadow-xs dark:bg-input/30', 'transition-[color,box-shadow] duration-(--ui-duration-fast) ease-(--ui-ease-standard)', 'focus-within:border-ring focus-within:ring-[3px] focus-within:ring-ring/50', invalidState.replaceAll('aria-invalid:', 'has-aria-invalid:'), 'has-disabled:cursor-not-allowed has-disabled:opacity-50' ); function ClearButton({ onClear }: { onClear: () => void }) { return ( ); } /** The shape both of Radix's "outside" events share. */ interface OutsideEvent { detail: { originalEvent: Event }; preventDefault: () => void; } /** * Wires a field to a Popover panel it *anchors* rather than triggers. * * Radix decides what is "outside" a panel from the panel's own subtree, and an * anchor is not part of it. A field that opens on focus therefore opened and * was dismissed by the same `focusin` — the panel flashed and never stayed up, * and a press on the calendar icon did nothing at all. Three things fix that, * and they only make sense together: * * 1. Dismissals that start inside the field are vetoed, so the field can drive * the panel without arguing with it. * 2. The whole field shell opens the panel, not just the input — the icon and * the padding around the text are part of the control. * 3. Focus is handed back to the field once the panel closes. Radix returns it * to the trigger, and an anchor-only Popover has none, so focus was landing * on `` after every pick, leaving the keyboard nowhere. * * None of it reproduces in jsdom: there the `focusin` that opens the panel has * finished propagating before Radix's document listener exists, so the suite * saw a panel that stayed open while every browser dismissed it. */ function useFieldPanel( open: boolean, setOpen: (open: boolean) => void, /* Owned by the caller, which is what puts it on the shell — a ref handed back out of a hook and straight into JSX reads as accessing it during render. */ fieldRef: React.RefObject ) { /* Raised only while focus is being handed back, so the field does not reopen the panel that just closed. `focus()` dispatches synchronously, which is what lowers the guard again by the next statement. */ const returning = React.useRef(false); /* The fields are plain function components and carry no ref of their own under React 18, so the shell is queried instead — the same way the calendar grid finds its cells. */ const inputs = () => [...(fieldRef.current?.querySelectorAll('input') ?? [])]; /** The end a range picker is filling; always the only field otherwise. */ const editingInput = () => { const all = inputs(); return all.find((input) => input.hasAttribute('data-editing')) ?? all[0]; }; /** True while focus is being restored — the field should stay quiet. */ const isReturningFocus = () => returning.current; /** Opens the panel from anywhere in the shell that is not the clear button. */ const openFromShell = (event: React.PointerEvent, disabled?: boolean) => { if (disabled) return; const target = event.target as HTMLElement; if (target.closest('[data-slot="date-picker-clear"]')) return; /* A press on the icon or the padding would take focus off the field — or, for the shell itself, put it nowhere at all. */ if (target.tagName !== 'INPUT') { event.preventDefault(); editingInput()?.focus(); } setOpen(true); }; /** * `ArrowDown` opens the panel, then steps into it. The panel's keyboard * handling lives on the grid cells and is unreachable while focus is still in * the field. */ const handleArrowDown = (event: React.KeyboardEvent, panelId: string) => { event.preventDefault(); if (!open) { setOpen(true); return; } /* The roving cell is the one the grid keeps tabbable, but it can be a disabled date — a month whose 1st falls on a rejected day would otherwise swallow the keypress and strand the user in the field. */ const grid = document.getElementById(panelId); const cell = grid?.querySelector('[data-cell][tabindex="0"]:not(:disabled)') ?? grid?.querySelector('[data-cell]:not(:disabled)'); cell?.focus(); }; /** * Vetoes a dismissal that started inside the field. Radix hands the same * event to `onPointerDownOutside` and `onFocusOutside`, so one handler serves * both. */ const keepOpenFromField = (event: OutsideEvent) => { const target = event.detail.originalEvent.target; if (target instanceof Node && fieldRef.current?.contains(target)) event.preventDefault(); }; /** * Runs once the panel has unmounted. Focus is only reclaimed when nothing * else has taken it — clicking straight into another control must not be * undone. */ const restoreFocus = (event: Event) => { event.preventDefault(); const active = document.activeElement; if (active && active !== document.body) return; returning.current = true; editingInput()?.focus(); returning.current = false; }; return { isReturningFocus, openFromShell, handleArrowDown, keepOpenFromField, restoreFocus }; } export interface DatePickerProps extends SharedProps { value?: Date | null; defaultValue?: Date | null; onChange?: (date: Date | null) => void; } /** * Date, month or year picker. * * A calendar is more than a grid: keyboard navigation that crosses month * boundaries, a locale-aware week start, disabled ranges, and text entry that * has to parse as loosely as people type. All of it is local to the kit and * built on `Date` + `Intl`, so values are plain `Date` objects in local time * and no date library reaches the consumer. * * ```tsx * * * ``` */ function DatePicker({ picker = 'date', format, locale = 'en-US', value, defaultValue = null, onChange, placeholder, disabled, disabledDate, allowClear = true, className, id, 'aria-invalid': ariaInvalid, ...props }: DatePickerProps) { const pattern = format ?? defaultFormat[picker]; const panelId = React.useId(); const [open, setOpen] = React.useState(false); const [selected, setSelected] = useControllableState({ value, defaultValue, onChange, }); const [draft, setDraft] = React.useState(null); const [month, setMonth] = React.useState(() => startOfMonth(selected ?? new Date())); const fieldRef = React.useRef(null); const panel = useFieldPanel(open, setOpen, fieldRef); /* Same rule as InputNumber: the text is derived from the value, except while someone is typing into it. */ const text = draft ?? (isValid(selected) ? formatDate(selected, pattern, locale) : ''); const typedInvalid = draft !== null && draft.trim() !== '' && parseDate(draft, pattern, locale) === null; const commit = (next: Date | null) => { setSelected(next); if (next) setMonth(startOfMonth(next)); }; return ( {/* Anchor, not Trigger: Radix's Trigger renders a button and would push `type="button"` onto the field, turning the text input into a button for both the browser and assistive tech. The panel is opened from focus and the keyboard instead. */}
panel.openFromShell(event, disabled)} > { setDraft(event.target.value); const parsed = parseDate(event.target.value, pattern, locale); /* A complete, valid date moves the panel with it — but a half-finished one must not wipe the selection. */ if (parsed) commit(parsed); else if (event.target.value.trim() === '') commit(null); }} onBlur={() => setDraft(null)} onFocus={() => { if (!panel.isReturningFocus()) setOpen(true); }} onKeyDown={(event) => { if (event.key === 'Escape') setOpen(false); if (event.key === 'ArrowDown') panel.handleArrowDown(event, panelId); }} {...props} /> {allowClear && selected && !disabled ? ( { setDraft(null); commit(null); }} /> ) : null}
event.preventDefault()} onCloseAutoFocus={(event) => panel.restoreFocus(event)} onPointerDownOutside={(event) => panel.keepOpenFromField(event)} onFocusOutside={(event) => panel.keepOpenFromField(event)} > { setDraft(null); commit(date); setOpen(false); }} />
); } export interface DateRangePickerProps extends SharedProps { value?: DateRange | null; defaultValue?: DateRange | null; onChange?: (range: DateRange) => void; /** Names for the two fields, announced to assistive tech. */ labels?: [string, string]; } /** * Two-field date range sharing one panel. * * Unlike two `DatePicker`s side by side, this keeps start and end in a single * interaction: hovering a day previews the whole span, and picking a start * moves straight on to the end field. * * ```tsx * * ``` */ function DateRangePicker({ picker = 'date', format, locale = 'en-US', value, defaultValue = null, onChange, placeholder, disabled, disabledDate, allowClear = true, className, id, labels = ['Start date', 'End date'], 'aria-invalid': ariaInvalid, ...props }: DateRangePickerProps) { const pattern = format ?? defaultFormat[picker]; const panelId = React.useId(); const [open, setOpen] = React.useState(false); /* No `onChange` on the hook: this one is narrower than the state it reports (`DateRange`, never `null`), so the commit below hands it the value it can actually take. */ const [selection, setSelection] = useControllableState({ value, defaultValue: defaultValue ?? [null, null], }); const range: DateRange = selection ?? [null, null]; /** Which end the next pick fills. */ const [editing, setEditing] = React.useState<0 | 1>(0); const [hovered, setHovered] = React.useState(null); const [month, setMonth] = React.useState(() => startOfMonth(range[0] ?? new Date())); const fieldRef = React.useRef(null); const panel = useFieldPanel(open, setOpen, fieldRef); const commit = (next: DateRange) => { setSelection(next); onChange?.(next); }; const handleSelect = (date: Date) => { /* Focusing the end field first does not make the first pick an end: with no start, a range has nothing to close. */ if (editing === 0 || !range[0]) { /* Picking a start drops an end that now precedes it, rather than silently producing a backwards range. */ const end = editing === 0 && range[1] && range[1] >= date ? range[1] : null; commit([date, end]); setEditing(1); return; } /* A second pick before the start means the user is re-anchoring, not choosing an end. */ if (range[0] && date < range[0]) { commit([date, null]); setEditing(1); return; } commit([range[0], date]); setEditing(0); setOpen(false); }; const field = (index: 0 | 1) => ( { if (panel.isReturningFocus()) return; setEditing(index); setOpen(true); }} onKeyDown={(event) => { if (event.key === 'Escape') setOpen(false); if (event.key === 'ArrowDown') panel.handleArrowDown(event, panelId); }} /> ); return (
panel.openFromShell(event, disabled)} > {field(0)} – {field(1)} {allowClear && (range[0] || range[1]) && !disabled ? ( { commit([null, null]); setEditing(0); }} /> ) : null}
event.preventDefault()} onCloseAutoFocus={(event) => panel.restoreFocus(event)} onPointerDownOutside={(event) => panel.keepOpenFromField(event)} onFocusOutside={(event) => panel.keepOpenFromField(event)} > {/* Two months side by side: a range that crosses a boundary is the common case, and paging back and forth to see it is tedious. */}
setMonth(addMonths(next, -1))} disabledDate={disabledDate} onSelect={handleSelect} className="hidden sm:block" />
); } export { DatePicker, DateRangePicker, Calendar };