"use client";
import * as React from "react";
import { AnimatePresence, motion, useIsPresent } from "motion/react";
/* -------------------------------------------------------------------------- */
/* Dates as UTC day numbers: no time zones, no DST, deterministic on server */
/* -------------------------------------------------------------------------- */
const DAY_MS = 86_400_000;
export interface DateRange {
/** "yyyy-mm-dd" or null. */
start: string | null;
/** "yyyy-mm-dd" or null. */
end: string | null;
}
export type WeekStart = "sunday" | "monday" | "saturday";
const WEEK_START_INDEX: Record<WeekStart, number> = { sunday: 0, monday: 1, saturday: 6 };
const mod = (n: number, m: number) => ((n % m) + m) % m;
/** Parses "yyyy-mm-dd" (also "/" or "." separators). Returns a UTC day number or null. */
export function parseIsoDate(text: string): number | null {
const match = /^(\d{4})[-/.](\d{1,2})[-/.](\d{1,2})$/.exec(text.trim());
if (!match) return null;
const [y, m, d] = [Number(match[1]), Number(match[2]) - 1, Number(match[3])];
const t = Date.UTC(y, m, d);
const date = new Date(t);
if (date.getUTCFullYear() !== y || date.getUTCMonth() !== m || date.getUTCDate() !== d) return null;
return t / DAY_MS;
}
const toIso = (day: number) => new Date(day * DAY_MS).toISOString().slice(0, 10);
const weekdayOf = (day: number) => mod(day + 4, 7); // 1970-01-01 was a Thursday
const monthKeyOf = (day: number) => {
const d = new Date(day * DAY_MS);
return d.getUTCFullYear() * 12 + d.getUTCMonth();
};
const monthStart = (key: number) => Date.UTC(Math.floor(key / 12), mod(key, 12), 1) / DAY_MS;
const monthEnd = (key: number) => monthStart(key + 1) - 1;
/** Same day-of-month in another month, clamped to that month's length. */
function addMonths(day: number, months: number) {
const d = new Date(day * DAY_MS);
const key = d.getUTCFullYear() * 12 + d.getUTCMonth() + months;
return Math.min(monthStart(key) + d.getUTCDate() - 1, monthEnd(key));
}
const isoOrNull = (text: string | null | undefined) => (text ? parseIsoDate(text) : null);
/* -------------------------------------------------------------------------- */
/* Browser-only values, kept out of server markup */
/* -------------------------------------------------------------------------- */
const noopSubscribe = () => () => {};
function localTodayIso() {
const now = new Date();
return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-${String(now.getDate()).padStart(2, "0")}`;
}
/** The user's local "today" — null on the server and during hydration, so SSR stays deterministic. */
function useTodayIso(override?: string) {
const client = React.useSyncExternalStore(noopSubscribe, localTodayIso, () => null);
return override ?? client;
}
function subscribeReducedMotion(cb: () => void) {
const mq = window.matchMedia("(prefers-reduced-motion: reduce)");
mq.addEventListener("change", cb);
return () => mq.removeEventListener("change", cb);
}
function usePrefersReducedMotion() {
return React.useSyncExternalStore(
subscribeReducedMotion,
() => window.matchMedia("(prefers-reduced-motion: reduce)").matches,
() => false,
);
}
function makeFormatter(locale: string, options: Intl.DateTimeFormatOptions) {
try {
return new Intl.DateTimeFormat(locale, { ...options, timeZone: "UTC" });
} catch {
return new Intl.DateTimeFormat("en-GB", { ...options, timeZone: "UTC" });
}
}
/* -------------------------------------------------------------------------- */
/* Props */
/* -------------------------------------------------------------------------- */
export interface DatePickerProps {
/** Pick one date, or a start and end date. */
mode?: "single" | "range";
/** A text field with a calendar popover, or the calendar on its own. */
variant?: "popover" | "inline";
/** Selected date in single mode, "yyyy-mm-dd" (controlled). */
value?: string | null;
/** Initial date in single mode (uncontrolled). */
defaultValue?: string | null;
/** Called with "yyyy-mm-dd" or null in single mode. */
onValueChange?: (value: string | null) => void;
/** Selected range in range mode (controlled). */
range?: DateRange;
/** Initial range in range mode (uncontrolled). */
defaultRange?: DateRange;
/** Called whenever the range changes (end is null while the second date is pending). */
onRangeChange?: (range: DateRange) => void;
/** Earliest selectable date, "yyyy-mm-dd". */
min?: string;
/** Latest selectable date, "yyyy-mm-dd". */
max?: string;
/** Marks extra dates as unavailable (weekends, booked days…). */
isDateDisabled?: (iso: string) => boolean;
/** First column of the week. */
weekStart?: WeekStart;
/** BCP 47 locale for month and weekday names. */
locale?: string;
/** Overrides "today" ("yyyy-mm-dd"). Without it, today is read on the client after hydration. */
today?: string;
/** Visible label of the field / calendar. */
label?: string;
/** Close the popover once a date (or the range end) is picked. */
closeOnSelect?: boolean;
/** Selection, today marker and focus colour. */
accent?: string;
disabled?: boolean;
/** Submits the ISO value with a form. In range mode two hidden inputs share the name (start, end). */
name?: string;
className?: string;
style?: React.CSSProperties;
}
/* -------------------------------------------------------------------------- */
/* Month grid (one per visible month, slid in and out) */
/* -------------------------------------------------------------------------- */
interface MonthGridProps {
monthKey: number;
weekStart: number;
tabStop: number;
renderDay: (day: number, tabStop: boolean) => React.ReactNode;
direction: number;
reduceMotion: boolean;
}
function MonthGrid({ monthKey, weekStart, tabStop, renderDay, direction, reduceMotion }: MonthGridProps) {
const present = useIsPresent();
const first = monthStart(monthKey);
const last = monthEnd(monthKey);
const gridStart = first - mod(weekdayOf(first) - weekStart, 7);
const offset = reduceMotion ? 0 : 28;
return (
<motion.div
role="rowgroup"
aria-hidden={!present || undefined}
inert={!present || undefined}
className="absolute inset-x-0 top-0 flex flex-col"
// Exiting months read the latest direction from AnimatePresence's `custom`.
custom={direction}
variants={{
enter: (d: number) => ({ x: d * offset, opacity: 0 }),
center: { x: 0, opacity: 1 },
exit: (d: number) => ({ x: -d * offset, opacity: 0 }),
}}
initial="enter"
animate="center"
exit="exit"
transition={reduceMotion ? { duration: 0 } : { type: "spring", stiffness: 420, damping: 38, mass: 0.8 }}
>
{/* Always six weeks, so the height never jumps between months. */}
{Array.from({ length: 6 }, (_, week) => (
<div key={week} role="row" className="flex">
{Array.from({ length: 7 }, (_, i) => {
const day = gridStart + week * 7 + i;
if (day < first || day > last) return <div key={i} role="gridcell" className="size-9 flex-1" />;
return <React.Fragment key={i}>{renderDay(day, day === tabStop)}</React.Fragment>;
})}
</div>
))}
</motion.div>
);
}
/* -------------------------------------------------------------------------- */
/* Date picker */
/* -------------------------------------------------------------------------- */
const RANGE_SPLIT = /^\s*(\d{4}[-/.]\d{1,2}[-/.]\d{1,2})\s*(?:–|—|\bto\b|\s-\s|\.\.)\s*(\d{4}[-/.]\d{1,2}[-/.]\d{1,2})\s*$/i;
/**
* A calendar date picker with an ARIA grid (arrows, Page Up / Down, Home / End), direction-aware
* month transitions, single or range selection, min / max / disabled dates and locale-aware names.
* The popover variant pairs a typed ISO field with a native popover and returns focus on close.
* "Today" is never rendered on the server: pass `today`, or it is read after hydration.
*/
export function DatePicker({
mode = "single",
variant = "inline",
value: valueProp,
defaultValue = null,
onValueChange,
range: rangeProp,
defaultRange = { start: null, end: null },
onRangeChange,
min,
max,
isDateDisabled,
weekStart = "monday",
locale = "en-GB",
today: todayProp,
label = "Date",
closeOnSelect = true,
accent = "#FF7448",
disabled = false,
name,
className = "",
style,
}: DatePickerProps) {
const id = React.useId();
const labelId = `${id}-label`;
const headingId = `${id}-heading`;
const popoverId = `${id}-popover`;
const errorId = `${id}-error`;
const reduceMotion = usePrefersReducedMotion();
const todayIso = useTodayIso(todayProp);
const today = isoOrNull(todayIso);
const startIndex = WEEK_START_INDEX[weekStart] ?? 1;
const isRange = mode === "range";
const isPopover = variant === "popover";
/* ---------------------------------- value --------------------------------- */
const [internalValue, setInternalValue] = React.useState<string | null>(defaultValue);
const [internalRange, setInternalRange] = React.useState<DateRange>(defaultRange);
const value = valueProp !== undefined ? valueProp : internalValue;
const currentRange = rangeProp ?? internalRange;
const selected = isoOrNull(value);
const rangeStart = isoOrNull(currentRange.start);
const rangeEnd = isoOrNull(currentRange.end);
const setValue = (next: string | null) => {
if (valueProp === undefined) setInternalValue(next);
onValueChange?.(next);
};
const setRange = (next: DateRange) => {
if (rangeProp === undefined) setInternalRange(next);
onRangeChange?.(next);
};
/* ---------------------------------- limits -------------------------------- */
const minDay = isoOrNull(min);
const maxDay = isoOrNull(max);
const isDisabled = (day: number) =>
(minDay !== null && day < minDay) || (maxDay !== null && day > maxDay) || Boolean(isDateDisabled?.(toIso(day)));
const clampDay = (day: number) => Math.min(maxDay ?? Infinity, Math.max(minDay ?? -Infinity, day));
/* ------------------------------ view and focus ----------------------------- */
const [viewMonth, setViewMonth] = React.useState<number | null>(null);
const [focusDay, setFocusDay] = React.useState<number | null>(null);
const [direction, setDirection] = React.useState(1);
const [hoverDay, setHoverDay] = React.useState<number | null>(null);
const [open, setOpen] = React.useState(false);
const [draft, setDraft] = React.useState<string | null>(null);
const [error, setError] = React.useState<string | null>(null);
const anchorDay = isRange ? (rangeStart ?? rangeEnd) : selected;
const visibleMonth = viewMonth ?? (anchorDay !== null ? monthKeyOf(anchorDay) : today !== null ? monthKeyOf(today) : null);
const gridRef = React.useRef<HTMLDivElement>(null);
const fieldRef = React.useRef<HTMLDivElement>(null);
const popoverRef = React.useRef<HTMLDivElement>(null);
const triggerRef = React.useRef<HTMLButtonElement>(null);
const pendingFocus = React.useRef(false);
// The roving tab stop: the focused day, else the selection, else today, else the first enabled day.
let tabStop = -1;
if (visibleMonth !== null) {
const first = monthStart(visibleMonth);
const last = monthEnd(visibleMonth);
const inMonth = (d: number | null): d is number => d !== null && d >= first && d <= last;
if (inMonth(focusDay)) tabStop = focusDay;
else if (inMonth(anchorDay)) tabStop = anchorDay;
else if (inMonth(today)) tabStop = today;
else {
tabStop = first;
for (let d = first; d <= last; d++) {
if (!isDisabled(d)) {
tabStop = d;
break;
}
}
}
}
// Move DOM focus after keyboard navigation re-renders the grid (possibly a new month).
React.useEffect(() => {
if (!pendingFocus.current || tabStop < 0) return;
pendingFocus.current = false;
gridRef.current?.querySelector<HTMLButtonElement>(`[data-day="${tabStop}"]`)?.focus({ preventScroll: true });
});
const goToMonth = (key: number) => {
if (visibleMonth !== null && key !== visibleMonth) setDirection(key > visibleMonth ? 1 : -1);
setViewMonth(key);
};
const focusOn = (day: number) => {
const next = clampDay(day);
setFocusDay(next);
if (visibleMonth !== monthKeyOf(next)) goToMonth(monthKeyOf(next));
if (isRange && rangeStart !== null && rangeEnd === null) setHoverDay(next);
pendingFocus.current = true;
};
/* ---------------------------------- popover -------------------------------- */
const position = React.useCallback(() => {
const pop = popoverRef.current;
const field = fieldRef.current;
if (!pop || !field) return;
const rect = field.getBoundingClientRect();
const height = pop.offsetHeight;
const width = pop.offsetWidth;
const below = rect.bottom + 6;
const top = below + height > window.innerHeight - 8 && rect.top - height - 6 > 8 ? rect.top - height - 6 : below;
const left = Math.min(Math.max(rect.left, 8), Math.max(window.innerWidth - width - 8, 8));
pop.style.top = `${top}px`;
pop.style.left = `${left}px`;
}, []);
React.useEffect(() => {
const pop = popoverRef.current;
if (!pop || !isPopover) return;
const onToggle = (event: Event) => {
const next = (event as ToggleEvent).newState === "open";
setOpen(next);
if (next) {
// Every opening starts at the selection (or today) with focus inside the grid.
setViewMonth(null);
setFocusDay(null);
setHoverDay(null);
pendingFocus.current = true;
} else if (pop.contains(document.activeElement) || document.activeElement === document.body) {
triggerRef.current?.focus({ preventScroll: true });
}
};
pop.addEventListener("toggle", onToggle);
return () => pop.removeEventListener("toggle", onToggle);
}, [isPopover]);
// Keep the popover attached to the field while it is open.
React.useEffect(() => {
if (!open) return;
position();
window.addEventListener("scroll", position, { capture: true, passive: true });
window.addEventListener("resize", position);
return () => {
window.removeEventListener("scroll", position, { capture: true });
window.removeEventListener("resize", position);
};
}, [open, position]);
const closePopover = () => {
const pop = popoverRef.current;
if (pop?.matches(":popover-open")) pop.hidePopover();
};
const openPopover = () => {
const pop = popoverRef.current;
if (pop && !pop.matches(":popover-open")) pop.showPopover();
};
/* --------------------------------- selection ------------------------------- */
const select = (day: number) => {
if (isDisabled(day) || disabled) return;
setFocusDay(day);
setDraft(null);
setError(null);
if (!isRange) {
setValue(toIso(day));
if (isPopover && closeOnSelect) closePopover();
return;
}
if (rangeStart === null || rangeEnd !== null) {
setRange({ start: toIso(day), end: null });
setHoverDay(day);
return;
}
// Second click: a date before the start swaps the ends.
const [a, b] = day < rangeStart ? [day, rangeStart] : [rangeStart, day];
setRange({ start: toIso(a), end: toIso(b) });
setHoverDay(null);
if (isPopover && closeOnSelect) closePopover();
};
const handleGridKey = (event: React.KeyboardEvent<HTMLDivElement>) => {
if (tabStop < 0) return;
const from = tabStop;
const weekday = mod(weekdayOf(from) - startIndex, 7);
const moves: Record<string, () => number> = {
ArrowLeft: () => from - 1,
ArrowRight: () => from + 1,
ArrowUp: () => from - 7,
ArrowDown: () => from + 7,
PageUp: () => addMonths(from, event.shiftKey ? -12 : -1),
PageDown: () => addMonths(from, event.shiftKey ? 12 : 1),
Home: () => from - weekday,
End: () => from + (6 - weekday),
};
const move = moves[event.key];
if (!move) return;
event.preventDefault();
focusOn(move());
};
/* ---------------------------------- text field ----------------------------- */
const fieldText = isRange
? currentRange.start
? `${currentRange.start} – ${currentRange.end ?? ""}`.trim()
: ""
: (value ?? "");
const commitText = (text: string) => {
const trimmed = text.trim();
const fail = (message: string) => {
setError(message);
return false;
};
if (!trimmed) {
if (isRange) setRange({ start: null, end: null });
else setValue(null);
setDraft(null);
setError(null);
return true;
}
let days: number[];
if (isRange) {
const match = RANGE_SPLIT.exec(trimmed);
const a = match ? parseIsoDate(match[1]) : null;
const b = match ? parseIsoDate(match[2]) : null;
if (a === null || b === null) return fail("Use yyyy-mm-dd – yyyy-mm-dd");
days = a <= b ? [a, b] : [b, a];
} else {
const day = parseIsoDate(trimmed);
if (day === null) return fail("Use yyyy-mm-dd");
days = [day];
}
if (days.some(isDisabled)) return fail("That date is not available");
if (isRange) setRange({ start: toIso(days[0]), end: toIso(days[1]) });
else setValue(toIso(days[0]));
setViewMonth(monthKeyOf(days[0]));
setDraft(null);
setError(null);
return true;
};
/* --------------------------------- rendering ------------------------------- */
const formats = React.useMemo(
() => ({
heading: makeFormatter(locale, { month: "long", year: "numeric" }),
full: makeFormatter(locale, { weekday: "long", day: "numeric", month: "long", year: "numeric" }),
weekdayShort: makeFormatter(locale, { weekday: "short" }),
weekdayLong: makeFormatter(locale, { weekday: "long" }),
}),
[locale],
);
const SUNDAY = Date.UTC(2023, 0, 1) / DAY_MS;
const weekdays = Array.from({ length: 7 }, (_, i) => new Date((SUNDAY + mod(startIndex + i, 7)) * DAY_MS));
// Range band, including the live preview while the end date is still being chosen.
const previewing = isRange && rangeStart !== null && rangeEnd === null && hoverDay !== null;
const bandFrom = rangeEnd !== null && rangeStart !== null ? rangeStart : previewing ? Math.min(rangeStart, hoverDay) : null;
const bandTo = rangeEnd !== null && rangeStart !== null ? rangeEnd : previewing ? Math.max(rangeStart, hoverDay) : null;
const renderDay = (day: number, isTabStop: boolean) => {
const dayDisabled = isDisabled(day);
const isToday = day === today;
const endpoint = isRange ? day === rangeStart || day === rangeEnd : day === selected;
const inBand = bandFrom !== null && bandTo !== null && day >= bandFrom && day <= bandTo && bandFrom !== bandTo;
const ariaSelected = endpoint || (inBand && rangeEnd !== null);
const role = isRange && day === rangeStart ? ", range start" : isRange && day === rangeEnd ? ", range end" : "";
return (
<div role="gridcell" aria-selected={ariaSelected} className="relative flex size-9 flex-1 items-center justify-center">
{inBand && (
<span
aria-hidden="true"
className={`absolute inset-y-1 ${previewing ? "bg-[color-mix(in_oklab,var(--dp-accent)_10%,transparent)]" : "bg-[color-mix(in_oklab,var(--dp-accent)_20%,transparent)]"}`}
style={{ left: day === bandFrom ? "50%" : 0, right: day === bandTo ? "50%" : 0 }}
/>
)}
<button
type="button"
data-day={day}
tabIndex={isTabStop ? 0 : -1}
aria-label={`${formats.full.format(new Date(day * DAY_MS))}${role}${dayDisabled ? ", unavailable" : ""}`}
aria-current={isToday ? "date" : undefined}
aria-disabled={dayDisabled || disabled || undefined}
onClick={() => select(day)}
onFocus={() => {
setFocusDay(day);
if (isRange && rangeStart !== null && rangeEnd === null) setHoverDay(day);
}}
onPointerEnter={(event) => {
if (event.pointerType === "mouse" && isRange && rangeStart !== null && rangeEnd === null) setHoverDay(day);
}}
className={`relative inline-flex size-8 items-center justify-center rounded-full font-mono text-xs tabular-nums outline-none transition-colors duration-150 focus-visible:ring-2 focus-visible:ring-[var(--dp-accent)] focus-visible:ring-offset-1 focus-visible:ring-offset-white motion-reduce:transition-none dark:focus-visible:ring-offset-zinc-900 ${
endpoint
? "bg-[var(--dp-accent)] font-medium text-zinc-950"
: dayDisabled
? "cursor-not-allowed text-zinc-400 line-through decoration-zinc-400/60 dark:text-zinc-600 dark:decoration-zinc-600/60"
: "text-zinc-800 hover:bg-zinc-900/[0.06] dark:text-zinc-200 dark:hover:bg-white/[0.08]"
}`}
>
{new Date(day * DAY_MS).getUTCDate()}
{isToday && (
<span aria-hidden="true" className={`absolute bottom-1 left-1/2 size-1 -translate-x-1/2 rounded-full ${endpoint ? "bg-zinc-950" : "bg-[var(--dp-accent)]"}`} />
)}
</button>
</div>
);
};
const prevDisabled = visibleMonth === null || (minDay !== null && monthEnd(visibleMonth - 1) < minDay);
const nextDisabled = visibleMonth === null || (maxDay !== null && monthStart(visibleMonth + 1) > maxDay);
const navButton =
"inline-flex size-8 items-center justify-center rounded-md text-zinc-600 outline-none transition-colors hover:bg-zinc-900/[0.06] focus-visible:ring-2 focus-visible:ring-[var(--dp-accent)] disabled:pointer-events-none disabled:opacity-30 dark:text-zinc-300 dark:hover:bg-white/[0.08]";
const footerButton =
"rounded-md px-2 py-1 font-mono text-[10px] uppercase tracking-[0.12em] text-zinc-500 outline-none transition-colors hover:bg-zinc-900/[0.06] hover:text-zinc-900 focus-visible:ring-2 focus-visible:ring-[var(--dp-accent)] disabled:pointer-events-none disabled:opacity-40 dark:text-zinc-400 dark:hover:bg-white/[0.08] dark:hover:text-zinc-100";
const hasSelection = isRange ? rangeStart !== null : selected !== null;
const calendar = (
<div className="w-[17.5rem] p-3">
<div className="mb-2 flex items-center justify-between gap-2">
<button type="button" className={navButton} aria-label="Previous month" disabled={prevDisabled} onClick={() => visibleMonth !== null && goToMonth(visibleMonth - 1)}>
<svg viewBox="0 0 16 16" className="size-4" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" aria-hidden="true">
<path d="M10 3.5 5.5 8l4.5 4.5" />
</svg>
</button>
<h2 id={headingId} aria-live="polite" className="min-w-0 truncate text-sm font-medium capitalize tracking-tight text-zinc-900 dark:text-zinc-100">
{visibleMonth !== null ? formats.heading.format(new Date(monthStart(visibleMonth) * DAY_MS)) : " "}
</h2>
<button type="button" className={navButton} aria-label="Next month" disabled={nextDisabled} onClick={() => visibleMonth !== null && goToMonth(visibleMonth + 1)}>
<svg viewBox="0 0 16 16" className="size-4" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" aria-hidden="true">
<path d="M6 3.5 10.5 8 6 12.5" />
</svg>
</button>
</div>
<div
ref={gridRef}
role="grid"
aria-labelledby={headingId}
aria-multiselectable={isRange || undefined}
aria-disabled={disabled || undefined}
onKeyDown={handleGridKey}
onPointerLeave={() => previewing && setHoverDay(null)}
>
<div role="rowgroup">
<div role="row" className="flex">
{weekdays.map((d) => (
<div
key={d.getTime()}
role="columnheader"
aria-label={formats.weekdayLong.format(d)}
className="flex h-7 flex-1 items-center justify-center font-mono text-[10px] uppercase tracking-[0.08em] text-zinc-500 dark:text-zinc-400"
>
<span aria-hidden="true">{formats.weekdayShort.format(d).replace(/\.$/, "").slice(0, 2)}</span>
</div>
))}
</div>
</div>
{/* Fixed height (6 × 36px) so sliding months overlap instead of pushing the footer. */}
<div className="relative h-[13.5rem] overflow-hidden">
{visibleMonth !== null ? (
<AnimatePresence initial={false} custom={direction}>
<MonthGrid
key={visibleMonth}
monthKey={visibleMonth}
weekStart={startIndex}
tabStop={tabStop}
renderDay={renderDay}
direction={direction}
reduceMotion={reduceMotion}
/>
</AnimatePresence>
) : (
<div role="rowgroup" aria-hidden="true" className="grid h-full grid-cols-7 place-items-center">
{Array.from({ length: 35 }, (_, i) => (
<span key={i} className="size-1 rounded-full bg-zinc-900/10 dark:bg-white/10" />
))}
</div>
)}
</div>
</div>
<div className="mt-2 flex items-center justify-between border-t border-zinc-900/10 pt-2 dark:border-white/10">
<button
type="button"
className={footerButton}
disabled={today === null || isDisabled(today)}
onClick={() => today !== null && focusOn(today)}
>
Today
</button>
<span aria-hidden="true" className="font-mono text-[10px] tracking-[0.06em] text-zinc-400 dark:text-zinc-500">
{isRange && rangeStart !== null && rangeEnd === null ? "Pick an end date" : ""}
</span>
<button
type="button"
className={footerButton}
disabled={!hasSelection}
onClick={() => {
if (isRange) setRange({ start: null, end: null });
else setValue(null);
setHoverDay(null);
setDraft(null);
setError(null);
}}
>
Clear
</button>
</div>
</div>
);
const hidden =
name === undefined ? null : isRange ? (
<>
<input type="hidden" name={name} value={currentRange.start ?? ""} />
<input type="hidden" name={name} value={currentRange.end ?? ""} />
</>
) : (
<input type="hidden" name={name} value={value ?? ""} />
);
const rootStyle = { ["--dp-accent" as string]: accent, ...style };
if (!isPopover) {
return (
<div
role="group"
aria-labelledby={labelId}
className={`inline-flex flex-col gap-2 ${disabled ? "pointer-events-none opacity-50" : ""} ${className}`}
style={rootStyle}
>
<span id={labelId} className="font-mono text-[10px] uppercase tracking-[0.14em] text-zinc-500 dark:text-zinc-400">
{label}
</span>
<div className="rounded-xl border border-zinc-900/10 bg-white dark:border-white/10 dark:bg-zinc-900">{calendar}</div>
{hidden}
</div>
);
}
return (
<div className={`inline-flex w-full max-w-[17.5rem] flex-col gap-1.5 ${className}`} style={rootStyle}>
<label id={labelId} htmlFor={`${id}-input`} className="font-mono text-[10px] uppercase tracking-[0.14em] text-zinc-500 dark:text-zinc-400">
{label}
</label>
<div
ref={fieldRef}
className="flex h-10 items-stretch rounded-lg border border-zinc-900/15 bg-white transition-colors focus-within:border-[var(--dp-accent)] dark:border-white/15 dark:bg-white/[0.04]"
>
<input
id={`${id}-input`}
type="text"
inputMode="numeric"
autoComplete="off"
spellCheck={false}
disabled={disabled}
placeholder={isRange ? "yyyy-mm-dd – yyyy-mm-dd" : "yyyy-mm-dd"}
value={draft ?? fieldText}
aria-invalid={error ? true : undefined}
aria-describedby={errorId}
onChange={(event) => {
setDraft(event.target.value);
setError(null);
}}
onBlur={() => {
if (draft !== null) commitText(draft);
}}
onKeyDown={(event) => {
if (event.key === "Enter" && draft !== null) {
event.preventDefault();
commitText(draft);
} else if (event.key === "ArrowDown" && event.altKey) {
event.preventDefault();
openPopover();
}
}}
className="min-w-0 flex-1 bg-transparent px-3 font-mono text-[13px] tabular-nums text-zinc-900 outline-none placeholder:text-zinc-400 aria-[invalid]:text-red-600 dark:text-zinc-100 dark:placeholder:text-zinc-500 dark:aria-[invalid]:text-red-400"
/>
<button
ref={triggerRef}
type="button"
disabled={disabled}
popoverTarget={popoverId}
aria-label={`Choose ${label.toLowerCase()}`}
aria-haspopup="dialog"
aria-expanded={open}
aria-controls={popoverId}
className="inline-flex w-10 shrink-0 items-center justify-center border-l border-zinc-900/10 text-zinc-500 outline-none transition-colors hover:text-zinc-900 focus-visible:bg-zinc-900/[0.05] focus-visible:text-[var(--dp-accent)] dark:border-white/10 dark:text-zinc-400 dark:hover:text-zinc-100 dark:focus-visible:bg-white/[0.06]"
>
<svg viewBox="0 0 16 16" className="size-4" fill="none" stroke="currentColor" strokeWidth="1.3" aria-hidden="true">
<rect x="2.5" y="3.5" width="11" height="10" rx="1.5" />
<path d="M2.5 6.5h11M5.5 2v3M10.5 2v3" strokeLinecap="round" />
</svg>
</button>
</div>
<p id={errorId} aria-live="polite" className="min-h-4 font-mono text-[10px] tracking-[0.04em] text-red-600 dark:text-red-400">
{error ?? ""}
</p>
<div
ref={popoverRef}
id={popoverId}
popover="auto"
role="dialog"
aria-labelledby={labelId}
className="fixed inset-auto m-0 overflow-visible rounded-xl border border-zinc-900/10 bg-white p-0 text-zinc-900 shadow-[0_24px_48px_-24px_rgb(0_0_0/0.35)] dark:border-white/10 dark:bg-zinc-900 dark:text-zinc-100"
style={rootStyle}
>
{open ? calendar : null}
</div>
{hidden}
</div>
);
}