'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 { 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 ( ); } 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 [uncontrolled, setUncontrolled] = React.useState(defaultValue); const isControlled = value !== undefined; const selected = isControlled ? value : uncontrolled; const [draft, setDraft] = React.useState(null); const [month, setMonth] = React.useState(() => startOfMonth(selected ?? new Date())); /* 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) => { if (!isControlled) setUncontrolled(next); onChange?.(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. */}
{ 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={() => setOpen(true)} onKeyDown={(event) => { if (event.key === 'Escape') setOpen(false); if (event.key === 'ArrowDown' && !open) { event.preventDefault(); setOpen(true); } }} {...props} /> {allowClear && selected && !disabled ? ( { setDraft(null); commit(null); }} /> ) : null}
event.preventDefault()} > { 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); const [uncontrolled, setUncontrolled] = React.useState(defaultValue ?? [null, null]); const isControlled = value !== undefined; const range: DateRange = (isControlled ? value : uncontrolled) ?? [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 commit = (next: DateRange) => { if (!isControlled) setUncontrolled(next); onChange?.(next); }; const handleSelect = (date: Date) => { if (editing === 0) { /* Picking a start drops an end that now precedes it, rather than silently producing a backwards range. */ const end = 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) => ( { setEditing(index); setOpen(true); }} onKeyDown={(event) => { if (event.key === 'Escape') setOpen(false); }} /> ); return (
{field(0)} – {field(1)} {allowClear && (range[0] || range[1]) && !disabled ? ( { commit([null, null]); setEditing(0); }} /> ) : null}
event.preventDefault()} > {/* 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 };