'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 (
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. */}