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