"use client";
import * as React from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { CalendarDays, ChevronLeft, ChevronRight, X } from "lucide-react";
import { cn } from "@/lib/utils";
/* ----------------------------------------------------------------------------
* Types
* ------------------------------------------------------------------------- */
export type DateRange = { from?: Date; to?: Date };
export type DateRangePreset = {
id: string;
label: string;
range: (today: Date) => { from: Date; to: Date };
};
export interface DateRangePickerProps {
/** Controlled value. */
value?: DateRange;
/** Uncontrolled initial value. */
defaultValue?: DateRange;
/** Id of a preset applied as the initial value once "today" is known (client side). */
defaultPreset?: string;
onChange?: (range: DateRange) => void;
/** Earliest selectable day. */
min?: Date;
/** Latest selectable day. */
max?: Date;
/** Block every day after today (evaluated on the client). */
disableFuture?: boolean;
/** Extra predicate for blocked days (holidays, weekends…). */
isDateDisabled?: (date: Date) => boolean;
/** BCP-47 locale used for every label and caption. */
locale?: string;
/** 0 = Sunday … 6 = Saturday. Defaults to the locale's first day of week. */
weekStartsOn?: number;
presets?: DateRangePreset[];
/** Months shown side by side on wide screens (1 on phones). */
numberOfMonths?: 1 | 2;
placeholder?: string;
label?: string;
/** Emits two hidden inputs `${name}From` / `${name}To` (ISO dates) for native forms. */
name?: string;
defaultOpen?: boolean;
align?: "start" | "end";
className?: string;
}
/* ----------------------------------------------------------------------------
* Date helpers (local time, day precision)
* ------------------------------------------------------------------------- */
const DAY_MS = 86_400_000;
const startOfDay = (d: Date) => new Date(d.getFullYear(), d.getMonth(), d.getDate());
const startOfMonth = (d: Date) => new Date(d.getFullYear(), d.getMonth(), 1);
const endOfMonth = (d: Date) => new Date(d.getFullYear(), d.getMonth() + 1, 0);
const addDays = (d: Date, n: number) => new Date(d.getFullYear(), d.getMonth(), d.getDate() + n);
const addMonths = (d: Date, n: number) => {
const first = new Date(d.getFullYear(), d.getMonth() + n, 1);
return new Date(first.getFullYear(), first.getMonth(), Math.min(d.getDate(), endOfMonth(first).getDate()));
};
const cmp = (a: Date, b: Date) => startOfDay(a).getTime() - startOfDay(b).getTime();
const sameDay = (a?: Date, b?: Date) => !!a && !!b && cmp(a, b) === 0;
const monthIndex = (d: Date) => d.getFullYear() * 12 + d.getMonth();
const dayKey = (d: Date) => `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
const daysBetween = (a: Date, b: Date) => Math.round((startOfDay(b).getTime() - startOfDay(a).getTime()) / DAY_MS) + 1;
export const DEFAULT_PRESETS: DateRangePreset[] = [
{ id: "today", label: "Today", range: (t) => ({ from: t, to: t }) },
{ id: "yesterday", label: "Yesterday", range: (t) => ({ from: addDays(t, -1), to: addDays(t, -1) }) },
{ id: "last7", label: "Last 7 days", range: (t) => ({ from: addDays(t, -6), to: t }) },
{ id: "last30", label: "Last 30 days", range: (t) => ({ from: addDays(t, -29), to: t }) },
{ id: "thisMonth", label: "This month", range: (t) => ({ from: startOfMonth(t), to: endOfMonth(t) }) },
{
id: "lastMonth",
label: "Last month",
range: (t) => {
const m = addMonths(startOfMonth(t), -1);
return { from: m, to: endOfMonth(m) };
},
},
{ id: "ytd", label: "Year to date", range: (t) => ({ from: new Date(t.getFullYear(), 0, 1), to: t }) },
];
/* "Today" is read on the client only, so server and client markup always agree. */
const noopSubscribe = () => () => {};
function useTodayKey() {
return React.useSyncExternalStore(
noopSubscribe,
() => dayKey(new Date()),
() => null,
);
}
function useMediaQuery(query: string) {
return React.useSyncExternalStore(
(cb) => {
const m = window.matchMedia(query);
m.addEventListener("change", cb);
return () => m.removeEventListener("change", cb);
},
() => window.matchMedia(query).matches,
() => true,
);
}
function localeWeekStart(locale: string): number {
try {
const loc = new Intl.Locale(locale) as Intl.Locale & {
getWeekInfo?: () => { firstDay: number };
weekInfo?: { firstDay: number };
};
const info = loc.getWeekInfo?.() ?? loc.weekInfo;
if (info) return info.firstDay % 7;
} catch {
/* unsupported → fall through */
}
return /^en-(US|CA)|^ja|^he|^pt-BR/.test(locale) ? 0 : 1;
}
/* ----------------------------------------------------------------------------
* Component
* ------------------------------------------------------------------------- */
export function DateRangePicker({
value,
defaultValue,
defaultPreset,
onChange,
min,
max,
isDateDisabled,
disableFuture = false,
locale = "en-US",
weekStartsOn,
presets = DEFAULT_PRESETS,
numberOfMonths = 2,
placeholder = "Pick a date range",
label = "Date range",
name,
defaultOpen = false,
align = "start",
className,
}: DateRangePickerProps) {
const uid = React.useId();
const reduce = useReducedMotion();
const todayKey = useTodayKey();
const today = React.useMemo(() => {
if (!todayKey) return null;
const [y, m, d] = todayKey.split("-").map(Number);
return new Date(y, m - 1, d);
}, [todayKey]);
const wide = useMediaQuery("(min-width: 640px)");
const monthsShown = wide ? numberOfMonths : 1;
const weekStart = weekStartsOn ?? localeWeekStart(locale);
/* committed value */
const [inner, setInner] = React.useState<DateRange | undefined>(defaultValue);
const presetInitial = React.useMemo(() => {
const p = presets.find((x) => x.id === defaultPreset);
return p && today ? p.range(today) : undefined;
}, [presets, defaultPreset, today]);
const committed: DateRange = value ?? inner ?? presetInitial ?? {};
/* popover state */
const [open, setOpen] = React.useState(defaultOpen);
const [draft, setDraft] = React.useState<DateRange | null>(null);
const [hover, setHover] = React.useState<Date | null>(null);
const [view, setView] = React.useState<Date | null>(null);
const [focusDay, setFocusDay] = React.useState<Date | null>(null);
const [dir, setDir] = React.useState(1);
const current = draft ?? committed;
const rootRef = React.useRef<HTMLDivElement>(null);
const triggerRef = React.useRef<HTMLButtonElement>(null);
const popRef = React.useRef<HTMLDivElement>(null);
const gridsRef = React.useRef<HTMLDivElement>(null);
const shouldFocusDay = React.useRef(false);
const anchor = current.from ?? today ?? new Date(2026, 0, 1);
const viewMonth = view ?? startOfMonth(current.to && monthsShown === 1 ? current.to : anchor);
const activeDay = focusDay ?? current.from ?? today ?? viewMonth;
const maxDay = disableFuture && today && (!max || cmp(today, max) < 0) ? today : max;
const isDisabled = React.useCallback(
(d: Date) => (!!min && cmp(d, min) < 0) || (!!maxDay && cmp(d, maxDay) > 0) || !!isDateDisabled?.(d),
[min, maxDay, isDateDisabled],
);
/* formatters */
const fmt = React.useMemo(() => {
const short = new Intl.DateTimeFormat(locale, { month: "short", day: "numeric", year: "numeric" });
return {
short,
caption: new Intl.DateTimeFormat(locale, { month: "long", year: "numeric" }),
full: new Intl.DateTimeFormat(locale, { weekday: "long", month: "long", day: "numeric", year: "numeric" }),
wdShort: new Intl.DateTimeFormat(locale, { weekday: "short" }),
wdLong: new Intl.DateTimeFormat(locale, { weekday: "long" }),
num: new Intl.NumberFormat(locale),
range(r: DateRange) {
if (!r.from) return "";
if (!r.to || sameDay(r.from, r.to)) return short.format(r.from);
try {
return short.formatRange(r.from, r.to);
} catch {
return `${short.format(r.from)} – ${short.format(r.to)}`;
}
},
};
}, [locale]);
const weekdays = React.useMemo(
() =>
Array.from({ length: 7 }, (_, i) => {
const d = new Date(2023, 0, 1 + ((weekStart + i) % 7)); // 1 Jan 2023 was a Sunday
return { short: fmt.wdShort.format(d), long: fmt.wdLong.format(d) };
}),
[fmt, weekStart],
);
/* ---------------- open / close ---------------- */
const openPicker = () => {
setDraft(committed);
setView(null);
setFocusDay(null);
setHover(null);
shouldFocusDay.current = true;
setOpen(true);
};
const close = React.useCallback((restoreFocus = true) => {
setOpen(false);
setDraft(null);
setHover(null);
if (restoreFocus) triggerRef.current?.focus();
}, []);
const commit = (r: DateRange) => {
if (value === undefined) setInner(r);
onChange?.(r);
};
const apply = () => {
if (!current.from) return;
commit({ from: current.from, to: current.to ?? current.from });
close();
};
// outside press closes (discarding the draft)
React.useEffect(() => {
if (!open) return;
const onDown = (e: PointerEvent) => {
if (!rootRef.current?.contains(e.target as Node)) close(false);
};
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") close(rootRef.current?.contains(document.activeElement) || document.activeElement === document.body);
};
document.addEventListener("pointerdown", onDown);
document.addEventListener("keydown", onKey);
return () => {
document.removeEventListener("pointerdown", onDown);
document.removeEventListener("keydown", onKey);
};
}, [open, close]);
// keep the popover inside the viewport (flip up / shift left) — direct style writes, no re-render
React.useLayoutEffect(() => {
const pop = popRef.current;
const trig = triggerRef.current;
if (!open || !pop || !trig) return;
const place = () => {
pop.style.left = "";
pop.style.right = "";
pop.style.top = "";
pop.style.bottom = "";
const t = trig.getBoundingClientRect();
const p = { width: pop.offsetWidth, height: pop.offsetHeight };
const vw = document.documentElement.clientWidth;
const below = window.innerHeight - t.bottom;
if (below < p.height + 12 && t.top > below) {
pop.style.top = "auto";
pop.style.bottom = "calc(100% + 8px)";
}
if (align === "start") {
const overflow = t.left + p.width - (vw - 8);
pop.style.left = `${-Math.max(0, Math.min(overflow, t.left - 8))}px`;
} else {
const overflow = 8 - (t.right - p.width);
pop.style.right = `${-Math.max(0, Math.min(overflow, vw - t.right - 8))}px`;
}
};
place();
window.addEventListener("resize", place);
return () => window.removeEventListener("resize", place);
}, [open, align, monthsShown]);
// move DOM focus with the roving day
React.useEffect(() => {
if (!open || !shouldFocusDay.current) return;
const btn = gridsRef.current?.querySelector<HTMLButtonElement>(`[data-day="${dayKey(activeDay)}"]`);
if (btn) {
btn.focus({ preventScroll: true });
shouldFocusDay.current = false;
}
}, [open, activeDay, viewMonth]);
/* ---------------- navigation ---------------- */
const showMonth = (month: Date, direction: number) => {
setDir(direction);
setView(startOfMonth(month));
};
const moveFocus = (next: Date) => {
let target = next;
if (min && cmp(target, min) < 0) target = startOfDay(min);
if (maxDay && cmp(target, maxDay) > 0) target = startOfDay(maxDay);
shouldFocusDay.current = true;
setFocusDay(target);
const first = monthIndex(viewMonth);
const idx = monthIndex(target);
if (idx < first) showMonth(target, -1);
else if (idx > first + monthsShown - 1) showMonth(addMonths(startOfMonth(target), -(monthsShown - 1)), 1);
};
const onGridKey = (e: React.KeyboardEvent) => {
const d = activeDay;
const col = (d.getDay() - weekStart + 7) % 7;
const map: Record<string, () => Date> = {
ArrowLeft: () => addDays(d, -1),
ArrowRight: () => addDays(d, 1),
ArrowUp: () => addDays(d, -7),
ArrowDown: () => addDays(d, 7),
Home: () => addDays(d, -col),
End: () => addDays(d, 6 - col),
PageUp: () => addMonths(d, e.shiftKey ? -12 : -1),
PageDown: () => addMonths(d, e.shiftKey ? 12 : 1),
};
const fn = map[e.key];
if (!fn) return;
e.preventDefault();
moveFocus(fn());
};
const pick = (d: Date) => {
if (isDisabled(d)) return;
setFocusDay(d);
const base = draft ?? committed;
if (!base.from || base.to) setDraft({ from: d, to: undefined });
else if (cmp(d, base.from) < 0) setDraft({ from: d, to: base.from });
else setDraft({ from: base.from, to: d });
};
const choosePreset = (p: DateRangePreset) => {
if (!today) return;
const r = p.range(today);
setDraft(r);
setFocusDay(r.to);
shouldFocusDay.current = false;
const target = addMonths(startOfMonth(r.to), -(monthsShown - 1));
if (monthIndex(target) !== monthIndex(viewMonth)) showMonth(target, monthIndex(target) < monthIndex(viewMonth) ? -1 : 1);
};
const activePreset = today
? presets.find((p) => {
const r = p.range(today);
return sameDay(r.from, current.from) && sameDay(r.to, current.to);
})?.id
: undefined;
/* range preview while choosing the end */
const lo = current.from;
const hi = current.to ?? (current.from && hover ? hover : undefined);
const [rangeA, rangeB] = lo && hi && cmp(hi, lo) < 0 ? [hi, lo] : [lo, hi];
const months = Array.from({ length: monthsShown }, (_, i) => addMonths(viewMonth, i));
const weeks = Math.max(...months.map((m) => Math.ceil((((m.getDay() - weekStart + 7) % 7) + endOfMonth(m).getDate()) / 7)));
const canPrev = !min || monthIndex(viewMonth) > monthIndex(min);
const canNext = !maxDay || monthIndex(viewMonth) + monthsShown - 1 < monthIndex(maxDay);
const summary = fmt.range(current);
const count = current.from ? daysBetween(current.from, current.to ?? current.from) : 0;
const triggerText = fmt.range(committed);
return (
<div ref={rootRef} className={cn("relative inline-block w-full max-w-sm", className)}>
<span id={`${uid}-label`} className="mb-1.5 block text-sm font-medium text-foreground">
{label}
</span>
<div className="relative">
<button
ref={triggerRef}
type="button"
aria-haspopup="dialog"
aria-expanded={open}
aria-controls={open ? `${uid}-pop` : undefined}
aria-labelledby={`${uid}-label ${uid}-value`}
onClick={() => (open ? close() : openPicker())}
onKeyDown={(e) => {
if (e.key === "ArrowDown" && !open) {
e.preventDefault();
openPicker();
}
}}
className={cn(
"group flex h-11 w-full items-center gap-2.5 rounded-xl border bg-background pl-3 pr-10 text-left text-sm shadow-xs outline-none transition",
"hover:border-foreground/20 focus-visible:border-ring focus-visible:ring-4 focus-visible:ring-ring/20",
open && "border-ring ring-4 ring-ring/15",
)}
>
<CalendarDays className="size-4 shrink-0 text-muted-foreground transition group-hover:text-foreground" aria-hidden />
<span id={`${uid}-value`} className={cn("truncate tabular-nums", !triggerText && "text-muted-foreground")}>
{triggerText || placeholder}
</span>
</button>
{committed.from && (
<button
type="button"
aria-label="Clear date range"
onClick={() => {
commit({});
triggerRef.current?.focus();
}}
className="absolute right-2 top-1/2 grid size-7 -translate-y-1/2 place-items-center rounded-lg text-muted-foreground outline-none transition hover:bg-muted hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring/40"
>
<X className="size-3.5" />
</button>
)}
<AnimatePresence>
{open && today && (
<motion.div
ref={popRef}
id={`${uid}-pop`}
role="dialog"
aria-modal="false"
aria-label={`${label}: choose start and end dates`}
initial={reduce ? { opacity: 0 } : { opacity: 0, y: -6, scale: 0.97 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={reduce ? { opacity: 0 } : { opacity: 0, y: -4, scale: 0.98, transition: { duration: 0.12 } }}
transition={{ type: "spring", stiffness: 520, damping: 34, mass: 0.7 }}
className={cn(
"absolute top-[calc(100%+8px)] z-50 flex w-max max-w-[calc(100vw-1rem)] flex-col overflow-hidden rounded-2xl border bg-popover text-popover-foreground shadow-2xl shadow-black/10 dark:shadow-black/40 sm:flex-row",
align === "start" ? "left-0 origin-top-left" : "right-0 origin-top-right",
)}
>
{/* presets */}
{presets.length > 0 && (
<div
role="group"
aria-label="Quick ranges"
className="flex gap-1 overflow-x-auto border-b p-2 [scrollbar-width:none] sm:w-40 sm:flex-col sm:overflow-visible sm:border-b-0 sm:border-r sm:p-2.5"
>
{presets.map((p) => {
const on = activePreset === p.id;
return (
<button
key={p.id}
type="button"
aria-pressed={on}
onClick={() => choosePreset(p)}
className={cn(
"relative shrink-0 rounded-lg px-2.5 py-1.5 text-left text-[13px] outline-none transition focus-visible:ring-2 focus-visible:ring-ring/40",
on ? "text-primary-foreground" : "text-muted-foreground hover:bg-muted hover:text-foreground",
)}
>
{on && (
<motion.span
layoutId={`${uid}-preset`}
className="absolute inset-0 rounded-lg bg-primary"
transition={{ type: "spring", stiffness: 500, damping: 38 }}
/>
)}
<span className="relative">{p.label}</span>
</button>
);
})}
</div>
)}
<div className="flex flex-col">
<div ref={gridsRef} className="relative overflow-hidden p-3 pb-2" onKeyDown={onGridKey}>
<AnimatePresence mode="popLayout" initial={false} custom={dir}>
<motion.div
key={dayKey(viewMonth)}
custom={dir}
variants={{
enter: (d: number) => ({ x: reduce ? 0 : d * 40, opacity: 0 }),
center: { x: 0, opacity: 1 },
exit: (d: number) => ({ x: reduce ? 0 : d * -40, opacity: 0 }),
}}
initial="enter"
animate="center"
exit="exit"
transition={{ type: "spring", stiffness: 420, damping: 36 }}
className="flex gap-5"
>
{months.map((m, i) => (
<MonthGrid
key={dayKey(m)}
uid={uid}
month={m}
caption={fmt.caption.format(m)}
weekdays={weekdays}
weekStart={weekStart}
weeks={weeks}
today={today}
activeDay={activeDay}
from={current.from}
to={current.to}
rangeA={rangeA}
rangeB={rangeB}
fullFormat={fmt.full}
isDisabled={isDisabled}
onPick={pick}
onHover={setHover}
prev={i === 0 ? { can: canPrev, go: () => showMonth(addMonths(viewMonth, -1), -1) } : undefined}
next={i === months.length - 1 ? { can: canNext, go: () => showMonth(addMonths(viewMonth, 1), 1) } : undefined}
/>
))}
</motion.div>
</AnimatePresence>
</div>
<div className="flex flex-wrap items-center justify-between gap-2 border-t bg-muted/40 px-3 py-2.5">
<p className="min-w-0 text-xs text-muted-foreground" aria-live="polite">
{current.from ? (
<>
<span className="font-medium text-foreground tabular-nums">{summary}</span>
<span className="mx-1.5 opacity-50">·</span>
{current.to ? `${fmt.num.format(count)} ${count === 1 ? "day" : "days"}` : "Pick an end date"}
</>
) : (
"Pick a start date"
)}
</p>
<div className="ml-auto flex gap-2">
<button
type="button"
onClick={() => close()}
className="h-8 rounded-lg px-3 text-sm text-muted-foreground outline-none transition hover:bg-muted hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring/40"
>
Cancel
</button>
<button
type="button"
disabled={!current.from}
onClick={apply}
className="h-8 rounded-lg bg-primary px-3.5 text-sm font-medium text-primary-foreground shadow-sm outline-none transition hover:opacity-90 focus-visible:ring-2 focus-visible:ring-ring/50 focus-visible:ring-offset-2 focus-visible:ring-offset-background active:scale-[0.97] disabled:opacity-40"
>
Apply
</button>
</div>
</div>
</div>
</motion.div>
)}
</AnimatePresence>
</div>
{name && (
<>
<input type="hidden" name={`${name}From`} value={committed.from ? dayKey(committed.from) : ""} />
<input type="hidden" name={`${name}To`} value={committed.to ? dayKey(committed.to) : ""} />
</>
)}
</div>
);
}
/* ----------------------------------------------------------------------------
* Month grid
* ------------------------------------------------------------------------- */
type Nav = { can: boolean; go: () => void };
function MonthGrid({
uid,
month,
caption,
weekdays,
weekStart,
weeks,
today,
activeDay,
from,
to,
rangeA,
rangeB,
fullFormat,
isDisabled,
onPick,
onHover,
prev,
next,
}: {
uid: string;
month: Date;
caption: string;
weekdays: { short: string; long: string }[];
weekStart: number;
weeks: number;
today: Date;
activeDay: Date;
from?: Date;
to?: Date;
rangeA?: Date;
rangeB?: Date;
fullFormat: Intl.DateTimeFormat;
isDisabled: (d: Date) => boolean;
onPick: (d: Date) => void;
onHover: (d: Date | null) => void;
prev?: Nav;
next?: Nav;
}) {
const captionId = `${uid}-${dayKey(month)}-caption`;
const offset = (month.getDay() - weekStart + 7) % 7;
const daysIn = endOfMonth(month).getDate();
const cells: (Date | null)[] = Array.from({ length: weeks * 7 }, (_, i) => {
const n = i - offset + 1;
return n >= 1 && n <= daysIn ? new Date(month.getFullYear(), month.getMonth(), n) : null;
});
const rows = Array.from({ length: weeks }, (_, r) => cells.slice(r * 7, r * 7 + 7));
const activeInMonth = monthIndex(activeDay) === monthIndex(month);
const navBtn =
"grid size-8 place-items-center rounded-lg text-muted-foreground outline-none transition hover:bg-muted hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring/40 disabled:pointer-events-none disabled:opacity-30";
return (
<div className="w-[266px] shrink-0">
<div className="mb-2 flex h-8 items-center justify-between">
{prev ? (
<button type="button" className={navBtn} onClick={prev.go} disabled={!prev.can} aria-label="Previous month">
<ChevronLeft className="size-4" />
</button>
) : (
<span className="size-8" />
)}
<h2 id={captionId} className="text-sm font-semibold capitalize tracking-tight" aria-live="polite">
{caption}
</h2>
{next ? (
<button type="button" className={navBtn} onClick={next.go} disabled={!next.can} aria-label="Next month">
<ChevronRight className="size-4" />
</button>
) : (
<span className="size-8" />
)}
</div>
<table role="grid" aria-labelledby={captionId} className="w-full border-collapse" onMouseLeave={() => onHover(null)}>
<thead>
<tr>
{weekdays.map((w) => (
<th key={w.long} scope="col" abbr={w.long} className="h-7 text-[11px] font-medium uppercase tracking-wide text-muted-foreground/80">
{w.short.slice(0, 2)}
</th>
))}
</tr>
</thead>
<tbody>
{rows.map((row, r) => (
<tr key={r}>
{row.map((d, c) => {
if (!d) return <td key={c} className="h-9 p-0" />;
const disabled = isDisabled(d);
const isFrom = sameDay(d, from);
const isTo = sameDay(d, to);
const isEnd = isFrom || isTo;
const inRange = !!rangeA && !!rangeB && cmp(d, rangeA) >= 0 && cmp(d, rangeB) <= 0;
const bandStart = sameDay(d, rangeA);
const bandEnd = sameDay(d, rangeB);
const single = bandStart && bandEnd;
const rowStart = c === 0 || d.getDate() === 1;
const rowEnd = c === 6 || d.getDate() === endOfMonth(d).getDate();
const isToday = sameDay(d, today);
const tabbable = activeInMonth ? sameDay(d, activeDay) : d.getDate() === 1;
return (
<td key={c} role="gridcell" aria-selected={isEnd || (inRange && !!to)} className="relative h-9 p-0 text-center">
{inRange && !single && (
<span
aria-hidden
className={cn(
"absolute inset-y-0.5 bg-primary/10 dark:bg-primary/15",
bandStart ? "left-1/2" : "left-0",
bandEnd ? "right-1/2" : "right-0",
rowStart && !bandStart && "left-0.5 rounded-l-lg",
rowEnd && !bandEnd && "right-0.5 rounded-r-lg",
)}
/>
)}
<button
type="button"
data-day={dayKey(d)}
tabIndex={tabbable ? 0 : -1}
aria-disabled={disabled || undefined}
aria-label={`${fullFormat.format(d)}${isToday ? ", today" : ""}${isFrom ? ", range start" : ""}${isTo ? ", range end" : ""}`}
onClick={() => onPick(d)}
onMouseEnter={() => onHover(d)}
onFocus={() => onHover(d)}
className={cn(
"relative mx-auto grid size-9 place-items-center rounded-lg text-[13px] tabular-nums outline-none transition-colors",
"focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-popover",
disabled
? "cursor-not-allowed text-muted-foreground/35 line-through decoration-muted-foreground/30"
: isEnd
? "font-semibold text-primary-foreground"
: inRange
? "text-foreground hover:bg-primary/15"
: "text-foreground hover:bg-muted",
)}
>
{isEnd && (
<motion.span
key={dayKey(d)}
aria-hidden
initial={{ scale: 0.55, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
transition={{ type: "spring", stiffness: 600, damping: 28 }}
className="absolute inset-0 rounded-lg bg-primary shadow-sm shadow-primary/30"
/>
)}
<span className="relative">{d.getDate()}</span>
{isToday && (
<span
aria-hidden
className={cn("absolute bottom-1 left-1/2 size-1 -translate-x-1/2 rounded-full", isEnd ? "bg-primary-foreground" : "bg-primary")}
/>
)}
</button>
</td>
);
})}
</tr>
))}
</tbody>
</table>
</div>
);
}