"use client"; import { useCallback, useEffect, useId, useMemo, useRef, useState, useSyncExternalStore, type KeyboardEvent as ReactKeyboardEvent, type CSSProperties, type ReactNode, } from "react"; import { AnimatePresence, motion, useReducedMotion, type Variants, } from "motion/react"; import { Calendar, Check, ChevronLeft, ChevronRight } from "lucide-react"; import { cn } from "@/lib/utils"; export interface DateRange { start: Date; end: Date; } export interface DateRangePreset { label: string; range: (today: Date) => DateRange; } export interface DateRangePickerProps { value?: DateRange | null; defaultValue?: DateRange | null; onChange?: (range: DateRange) => void; onApply?: (range: DateRange) => void; onCancel?: () => void; label?: string; placeholder?: string; presets?: DateRangePreset[]; minDate?: Date; maxDate?: Date; weekStartsOn?: 0 | 1; locale?: string; months?: "auto" | 1 | 2; className?: string; } type Bezier = [number, number, number, number]; const DAY = 864e5; const startOfDay = (date: Date) => new Date(date.getFullYear(), date.getMonth(), date.getDate()); const monthStart = (date: Date) => new Date(date.getFullYear(), date.getMonth(), 1); const monthEnd = (date: Date) => new Date(date.getFullYear(), date.getMonth() + 1, 0); const addDays = (date: Date, amount: number) => new Date(date.getFullYear(), date.getMonth(), date.getDate() + amount); const addMonths = (date: Date, amount: number) => new Date(date.getFullYear(), date.getMonth() + amount, 1); const shiftMonths = (date: Date, amount: number) => new Date( date.getFullYear(), date.getMonth() + amount, Math.min( date.getDate(), new Date(date.getFullYear(), date.getMonth() + amount + 1, 0).getDate(), ), ); const dayDiff = (a: Date, b: Date) => Math.round((startOfDay(a).getTime() - startOfDay(b).getTime()) / DAY); const monthDiff = (a: Date, b: Date) => (a.getFullYear() - b.getFullYear()) * 12 + a.getMonth() - b.getMonth(); const sameDay = (a?: Date | null, b?: Date | null) => Boolean(a && b && dayDiff(a, b) === 0); const keyOf = (date: Date) => `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String( date.getDate(), ).padStart(2, "0")}`; const fromKey = (key: string) => { const [year, month, day] = key.split("-").map(Number); return new Date(year, month - 1, day); }; const ordered = (a: Date, b: Date): DateRange => dayDiff(a, b) <= 0 ? { start: a, end: b } : { start: b, end: a }; const viewFor = (range: DateRange | null, single: boolean, fallback: Date) => { const end = monthStart(range?.end ?? fallback); return single ? end : addMonths(end, -1); }; const sameRange = (a?: DateRange | null, b?: DateRange | null) => Boolean(a && b && sameDay(a.start, b.start) && sameDay(a.end, b.end)); const clampDate = (date: Date, min?: Date, max?: Date) => min && dayDiff(date, min) < 0 ? startOfDay(min) : max && dayDiff(date, max) > 0 ? startOfDay(max) : date; export const defaultDateRangePresets: DateRangePreset[] = [ { label: "Today", range: (today) => ({ start: today, end: today }) }, { label: "Yesterday", range: (today) => ({ start: addDays(today, -1), end: addDays(today, -1), }), }, { label: "Last 7 days", range: (today) => ({ start: addDays(today, -6), end: today }), }, { label: "Last 30 days", range: (today) => ({ start: addDays(today, -29), end: today }), }, { label: "This month", range: (today) => ({ start: monthStart(today), end: today }), }, { label: "Last month", range: (today) => ({ start: addMonths(today, -1), end: monthEnd(addMonths(today, -1)), }), }, { label: "This quarter", range: (today) => ({ start: new Date( today.getFullYear(), Math.floor(today.getMonth() / 3) * 3, 1, ), end: today, }), }, { label: "Year to date", range: (today) => ({ start: new Date(today.getFullYear(), 0, 1), end: today, }), }, ]; const subscribeToday = (notify: () => void) => { let timer = 0; const schedule = () => { const now = new Date(); timer = window.setTimeout( () => { notify(); schedule(); }, addDays(now, 1).getTime() - now.getTime() + 1000, ); }; const onVisible = () => { if (document.visibilityState === "visible") notify(); }; schedule(); document.addEventListener("visibilitychange", onVisible); return () => { window.clearTimeout(timer); document.removeEventListener("visibilitychange", onVisible); }; }; const readToday = () => keyOf(new Date()); const serverToday = () => ""; export function useToday() { const key = useSyncExternalStore(subscribeToday, readToday, serverToday); return useMemo(() => (key ? fromKey(key) : undefined), [key]); } function useIsSmallContainer(rootRef: React.RefObject) { const [isSmall, setIsSmall] = useState(true); useEffect(() => { const root = rootRef.current; if (!root) return; const observer = new ResizeObserver(([entry]) => { setIsSmall(entry.contentRect.width < 780); }); observer.observe(root); return () => observer.disconnect(); }, [rootRef]); return isSmall; } const noop = () => () => {}; function useReducedFlag() { const hydrated = useSyncExternalStore( noop, () => true, () => false, ); return !!useReducedMotion() && hydrated; } const enterEase: Bezier = [0.16, 1, 0.3, 1]; const standardEase: Bezier = [0.7, 0, 0.84, 0]; const roll: Variants = { enter: (direction: number) => ({ opacity: 0, y: `${direction * 0.45}em`, filter: "blur(2px)", }), center: { opacity: 1, y: "0em", filter: "blur(0px)", transition: { duration: 0.24, ease: enterEase }, }, exit: (direction: number) => ({ opacity: 0, y: `${direction * -0.45}em`, filter: "blur(2px)", transition: { duration: 0.14, ease: standardEase }, }), }; const fade: Variants = { enter: { opacity: 0 }, center: { opacity: 1, y: "0em", filter: "blur(0px)", transition: { duration: 0.12 }, }, exit: { opacity: 0, transition: { duration: 0.08 } }, }; const slide: Variants = { enter: (direction: number) => ({ opacity: 0, x: direction * 28, }), center: { opacity: 1, x: 0, transition: { duration: 0.2, ease: enterEase, }, }, exit: (direction: number) => ({ opacity: 0, x: direction * -28, transition: { duration: 0.14, ease: standardEase, }, }), }; function Rolling({ text, direction, reduced, }: { text: string; direction: number; reduced: boolean; }) { const words = text.split(/\s+/).filter(Boolean); return ( ); } function useDirection(range: DateRange | null) { const time = range ? range.start.getTime() * 2 + range.end.getTime() : null; const [previous, setPrevious] = useState(time); const [direction, setDirection] = useState(1); if (time !== previous) { setPrevious(time); setDirection( time === null || previous === null || time >= previous ? 1 : -1, ); } return direction; } type Segment = { left: number; width: number } | null; interface MonthProps { month: Date; range: DateRange | null; tabbable: string; today?: Date; minDate?: Date; maxDate?: Date; weekStartsOn: 0 | 1; formatters: { title: Intl.DateTimeFormat; day: Intl.DateTimeFormat; weekday: Intl.DateTimeFormat; weekdayLong: Intl.DateTimeFormat; }; onPick: (date: Date) => void; onHover: (date: Date) => void; onKey: (event: ReactKeyboardEvent, date: Date) => void; onFocusDay: (date: Date) => void; focusRequest: string | null; idBase: string; } function Month({ month, range, tabbable, today, minDate, maxDate, weekStartsOn, formatters, onPick, onHover, onKey, onFocusDay, focusRequest, idBase, }: MonthProps) { const gridRef = useRef(null); const first = useMemo( () => addDays(month, -((month.getDay() - weekStartsOn + 7) % 7)), [month, weekStartsOn], ); const rows = useMemo( () => Array.from({ length: 6 }, (_, row) => Array.from({ length: 7 }, (_, col) => addDays(first, row * 7 + col)), ), [first], ); const titleId = `${idBase}-${keyOf(month)}`; useEffect(() => { if (!focusRequest) return; gridRef.current ?.querySelector(`[data-date="${focusRequest}"]`) ?.focus(); }, [focusRequest]); const inMonth = (date: Date) => monthDiff(date, month) === 0; const lo = range?.start; const hi = range?.end; const loKey = lo ? keyOf(lo) : ""; const hiKey = hi ? keyOf(hi) : ""; const segments = useMemo(() => { if (!loKey || !hiKey) return rows.map(() => null); const start = fromKey(loKey); const end = fromKey(hiKey); const unit = 100 / 7; const colOf = (date: Date) => dayDiff(date, first) % 7; return rows.map((row) => { const days = row.filter((date) => monthDiff(date, month) === 0); if (!days.length) return null; const inRangeDays = days.filter( (date) => dayDiff(date, start) >= 0 && dayDiff(date, end) <= 0, ); if (inRangeDays.length <= 1) return null; const firstInRange = inRangeDays[0]; const lastInRange = inRangeDays[inRangeDays.length - 1]; const a = colOf(firstInRange); const b = colOf(lastInRange); return { left: a * unit, width: (b - a + 1) * unit, }; }); }, [first, hiKey, loKey, month, rows]); return (

{formatters.title.format(month)}

{rows[0].map((date) => ( {formatters.weekday.format(date).slice(0, 2)} ))}
); } function MonthsView({ children, direction, reduced, }: { children: ReactNode; direction: number; reduced: boolean; }) { return ( {children} ); } export function DateRangePicker({ value, defaultValue, onChange, onApply, onCancel, label = "Date range", placeholder = "Select dates", presets = defaultDateRangePresets, minDate, maxDate, weekStartsOn = 0, locale = "en-US", months = "auto", className, }: DateRangePickerProps) { const reduced = useReducedFlag(); const rootRef = useRef(null); const isSmallScreen = useIsSmallContainer(rootRef); const detectedToday = useToday(); const hydrated = detectedToday !== undefined; const today = useMemo(() => detectedToday ?? new Date(), [detectedToday]); const uid = useId(); const initialRange = useMemo(() => { if (value !== undefined) return value; if (defaultValue !== undefined) return defaultValue; return { start: addDays(today, -6), end: today, }; }, [defaultValue, today, value]); const [inner, setInner] = useState(initialRange); const committed = value !== undefined ? value : inner; const compact = months === 1 || (months === "auto" && isSmallScreen); const [view, setView] = useState(() => viewFor(committed, compact, today), ); const [previousCompact, setPreviousCompact] = useState(compact); if (previousCompact !== compact) { setPreviousCompact(compact); setView(viewFor(committed, compact, today)); } const [direction, setDirection] = useState(1); const [draft, setDraft] = useState(committed); const [anchor, setAnchor] = useState(null); const [hover, setHover] = useState(null); const [focusKey, setFocusKey] = useState(""); const [focusRequest, setFocusRequest] = useState(null); const [isApplied, setIsApplied] = useState(false); const appliedTimerRef = useRef(null); const formatters = useMemo( () => ({ label: new Intl.DateTimeFormat(locale, { month: "short", day: "numeric", year: "numeric", }), title: new Intl.DateTimeFormat(locale, { month: "long", year: "numeric", }), day: new Intl.DateTimeFormat(locale, { weekday: "long", month: "long", day: "numeric", year: "numeric", }), weekday: new Intl.DateTimeFormat(locale, { weekday: "short" }), weekdayLong: new Intl.DateTimeFormat(locale, { weekday: "long" }), }), [locale], ); const format = useCallback( (range: DateRange | null) => { if (!range) return placeholder; if (sameDay(range.start, range.end)) { return formatters.label.format(range.start); } return formatters.label.formatRange(range.start, range.end); }, [formatters, placeholder], ); const count = compact ? 1 : 2; const visible = useMemo( () => Array.from({ length: count }, (_, index) => addMonths(view, index)), [count, view], ); const shown = anchor ? ordered(anchor, hover ?? anchor) : draft; const activePreset = hydrated && !anchor ? presets.findIndex((preset) => sameRange(preset.range(today), draft)) : -1; const days = shown ? dayDiff(shown.end, shown.start) + 1 : 0; const shownDirection = useDirection(shown); const goTo = useCallback((next: Date) => { setView((prev) => { const delta = monthDiff(next, prev); if (!delta) return prev; setDirection(Math.sign(delta)); return next; }); }, []); const reveal = useCallback( (range: DateRange) => { const first = view; const last = addMonths(view, count - 1); if (monthDiff(range.start, first) >= 0 && monthDiff(range.end, last) <= 0) return; goTo(viewFor(range, compact, range.end)); }, [compact, count, goTo, view], ); const pick = (date: Date) => { setFocusKey(keyOf(date)); if (!anchor) { setAnchor(date); setHover(date); return; } const next = ordered(anchor, date); setDraft(next); setAnchor(null); setHover(null); if (value === undefined) setInner(next); onChange?.(next); }; const choosePreset = (preset: DateRangePreset) => { const range = preset.range(today); setDraft(range); setAnchor(null); setHover(null); setFocusKey(keyOf(range.start)); reveal(range); if (value === undefined) setInner(range); onChange?.(range); }; const handleApply = () => { const next = anchor ? { start: anchor, end: anchor } : draft; if (!next) return; if (value === undefined) setInner(next); setIsApplied(true); if (appliedTimerRef.current) window.clearTimeout(appliedTimerRef.current); appliedTimerRef.current = window.setTimeout(() => { setIsApplied(false); }, 1500); onChange?.(next); onApply?.(next); }; const handleCancel = () => { setDraft(committed); setAnchor(null); setHover(null); onCancel?.(); }; const onDayKey = ( event: ReactKeyboardEvent, date: Date, ) => { const weekday = (date.getDay() - weekStartsOn + 7) % 7; const moves: Record Date> = { ArrowLeft: () => addDays(date, -1), ArrowRight: () => addDays(date, 1), ArrowUp: () => addDays(date, -7), ArrowDown: () => addDays(date, 7), Home: () => addDays(date, -weekday), End: () => addDays(date, 6 - weekday), PageUp: () => shiftMonths(date, event.shiftKey ? -12 : -1), PageDown: () => shiftMonths(date, event.shiftKey ? 12 : 1), }; const move = moves[event.key]; if (!move) return; event.preventDefault(); const next = clampDate(move(), minDate, maxDate); setFocusKey(keyOf(next)); setFocusRequest(keyOf(next)); if (anchor) setHover(next); const first = view; const last = addMonths(view, count - 1); if (monthDiff(next, first) < 0) goTo(monthStart(next)); else if (monthDiff(next, last) > 0) goTo(addMonths(monthStart(next), -(count - 1))); }; const tabbable = useMemo(() => { const onScreen = (date: Date) => visible.some((month) => monthDiff(date, month) === 0); if (focusKey && onScreen(fromKey(focusKey))) return focusKey; if (shown && onScreen(shown.start)) return keyOf(shown.start); return keyOf(visible[0]); }, [focusKey, shown, visible]); const shownText = !hydrated ? "" : shown ? format(shown) : placeholder; const countText = hydrated && shown ? `${days} ${days === 1 ? "day" : "days"}` : ""; const status = !hydrated ? "" : anchor ? `Start ${formatters.label.format(anchor)}. Choose an end date.` : shown ? `${shownText}, ${countText}` : ""; return (
{presets.map((preset, index) => { const isActive = index === activePreset; return ( ); })}
{ if (anchor) setHover(null); }} >
goTo(addMonths(view, -1))} disabled={Boolean(minDate && monthDiff(view, minDate) <= 0)} className="w-8 h-8 rounded-full flex items-center justify-center text-zinc-500 dark:text-zinc-400 hover:text-zinc-950 dark:hover:text-white border border-zinc-200/80 dark:border-white/8 hover:bg-zinc-100 dark:hover:bg-white/6 hover:border-zinc-300 dark:hover:border-white/15 transition-all disabled:opacity-30 disabled:pointer-events-none cursor-pointer shadow-xs" >
goTo(addMonths(view, 1))} disabled={Boolean( maxDate && monthDiff(addMonths(view, count - 1), maxDate) >= 0, )} className="w-8 h-8 rounded-full flex items-center justify-center text-zinc-500 dark:text-zinc-400 hover:text-zinc-950 dark:hover:text-white border border-zinc-200/80 dark:border-white/8 hover:bg-zinc-100 dark:hover:bg-white/6 hover:border-zinc-300 dark:hover:border-white/15 transition-all disabled:opacity-30 disabled:pointer-events-none cursor-pointer shadow-xs" >
{hydrated && ( {visible.map((month) => ( { if (anchor) setHover(date); }} onKey={onDayKey} onFocusDay={(date) => { setFocusKey(keyOf(date)); setFocusRequest(null); }} focusRequest={focusRequest} /> ))} )}
{countText && ( )}
Cancel {isApplied ? ( Applied ) : ( Apply )}

{status}

); } export default DateRangePicker;