{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"date-range-picker","type":"registry:ui","title":"Date range picker","description":"A range picker that grows from its trigger into two months with presets and a stretching range highlight.","categories":["inputs"],"dependencies":["lucide-react","motion"],"registryDependencies":["https://uiarc.dev/r/arc-foundation.json"],"files":[{"path":"registry/components/date-range-picker/date-range-picker.tsx","type":"registry:component","target":"@components/arc/date-range-picker/date-range-picker.tsx","content":"\"use client\";\n\nimport { useCallback, useEffect, useId, useLayoutEffect, useMemo, useRef, useState, useSyncExternalStore } from \"react\";\nimport type { KeyboardEvent as ReactKeyboardEvent, CSSProperties, FocusEvent as ReactFocusEvent, ReactNode } from \"react\";\nimport { AnimatePresence, animate, motion, useIsPresent, useMotionValue, useReducedMotion, useTransform } from \"motion/react\";\nimport type { Transition, Variants } from \"motion/react\";\nimport { CalendarDays, ChevronDown, ChevronLeft, ChevronRight } from \"lucide-react\";\nimport { motionTokens } from \"../lib/motion-tokens\";\nimport styles from \"./date-range-picker.module.css\";\n\n/** An inclusive range of whole days. */\nexport interface DateRange {\n start: Date;\n end: Date;\n}\n\n/** A shortcut in the preset rail. `range` receives the viewer's local today. */\nexport interface DateRangePreset {\n label: string;\n range: (today: Date) => DateRange;\n}\n\n/**\n * A range picker whose trigger grows into the panel it opens. The panel shows two months beside a preset rail, or one\n * month with a scrolling preset row when space is tight. The range highlight stretches across each week as you hover\n * or move with the keyboard, the ends glide between days, months slide in the direction you travel, and on Apply the\n * formatted label flies back into the trigger as the surface shrinks around it.\n * Use it for reports, filters, and bookings where a start and end date are chosen together.\n */\nexport interface DateRangePickerProps {\n /** Controlled value. Pass `null` for no selection. */\n value?: DateRange | null;\n /** Uncontrolled starting value. */\n defaultValue?: DateRange | null;\n /** Called with the applied range. */\n onChange?: (range: DateRange) => void;\n /** Accessible name of the trigger and the dialog. Defaults to \"Date range\". */\n label?: string;\n placeholder?: string;\n presets?: DateRangePreset[];\n minDate?: Date;\n maxDate?: Date;\n /** 0 is Sunday, 1 is Monday. Defaults to 0. */\n weekStartsOn?: 0 | 1;\n locale?: string;\n /** Force one or two months. `auto` picks two when the boundary is wide enough. */\n months?: \"auto\" | 1 | 2;\n /** The element the panel should stay inside. Defaults to the viewport. */\n boundary?: () => HTMLElement | null;\n className?: string;\n}\n\ntype Size = { w: number; h: number };\ntype Bezier = [number, number, number, number];\n\nconst DAY = 864e5;\nconst startOfDay = (date: Date) => new Date(date.getFullYear(), date.getMonth(), date.getDate());\nconst monthStart = (date: Date) => new Date(date.getFullYear(), date.getMonth(), 1);\nconst monthEnd = (date: Date) => new Date(date.getFullYear(), date.getMonth() + 1, 0);\nconst addDays = (date: Date, amount: number) => new Date(date.getFullYear(), date.getMonth(), date.getDate() + amount);\nconst addMonths = (date: Date, amount: number) => new Date(date.getFullYear(), date.getMonth() + amount, 1);\nconst 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()));\nconst dayDiff = (a: Date, b: Date) => Math.round((startOfDay(a).getTime() - startOfDay(b).getTime()) / DAY);\nconst monthDiff = (a: Date, b: Date) => (a.getFullYear() - b.getFullYear()) * 12 + a.getMonth() - b.getMonth();\nconst sameDay = (a?: Date | null, b?: Date | null) => Boolean(a && b && dayDiff(a, b) === 0);\nconst keyOf = (date: Date) => `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, \"0\")}-${String(date.getDate()).padStart(2, \"0\")}`;\nconst fromKey = (key: string) => { const [year, month, day] = key.split(\"-\").map(Number); return new Date(year, month - 1, day); };\nconst ordered = (a: Date, b: Date): DateRange => dayDiff(a, b) <= 0 ? { start: a, end: b } : { start: b, end: a };\nconst sameRange = (a?: DateRange | null, b?: DateRange | null) => Boolean(a && b && sameDay(a.start, b.start) && sameDay(a.end, b.end));\nconst clampDate = (date: Date, min?: Date, max?: Date) => min && dayDiff(date, min) < 0 ? startOfDay(min) : max && dayDiff(date, max) > 0 ? startOfDay(max) : date;\n\nexport const defaultDateRangePresets: DateRangePreset[] = [\n { label: \"Today\", range: today => ({ start: today, end: today }) },\n { label: \"Yesterday\", range: today => ({ start: addDays(today, -1), end: addDays(today, -1) }) },\n { label: \"Last 7 days\", range: today => ({ start: addDays(today, -6), end: today }) },\n { label: \"Last 30 days\", range: today => ({ start: addDays(today, -29), end: today }) },\n { label: \"This month\", range: today => ({ start: monthStart(today), end: today }) },\n { label: \"Last month\", range: today => ({ start: addMonths(today, -1), end: monthEnd(addMonths(today, -1)) }) },\n { label: \"This quarter\", range: today => ({ start: new Date(today.getFullYear(), Math.floor(today.getMonth() / 3) * 3, 1), end: today }) },\n { label: \"Year to date\", range: today => ({ start: new Date(today.getFullYear(), 0, 1), end: today }) },\n];\n\n/** Today turns over at local midnight; returning to the tab reads it again. Empty on the server so markup never depends on its clock. */\nconst subscribeToday = (notify: () => void) => {\n let timer = 0;\n const schedule = () => { const now = new Date(); timer = window.setTimeout(() => { notify(); schedule(); }, addDays(now, 1).getTime() - now.getTime() + 1000); };\n const onVisible = () => { if (document.visibilityState === \"visible\") notify(); };\n schedule();\n document.addEventListener(\"visibilitychange\", onVisible);\n return () => { window.clearTimeout(timer); document.removeEventListener(\"visibilitychange\", onVisible); };\n};\nconst readToday = () => keyOf(new Date());\nconst serverToday = () => \"\";\n/** The viewer's local date, or undefined during server render and hydration. */\nexport function useToday() {\n const key = useSyncExternalStore(subscribeToday, readToday, serverToday);\n return useMemo(() => (key ? fromKey(key) : undefined), [key]);\n}\n\nconst noop = () => () => {};\nfunction useReducedFlag() {\n const hydrated = useSyncExternalStore(noop, () => true, () => false);\n return !!useReducedMotion() && hydrated;\n}\n\nconst { blur } = motionTokens;\nconst enterEase = [...motionTokens.ease.enter] as Bezier;\nconst standardEase = [...motionTokens.ease.standard] as Bezier;\n/** Duration springs restated as stiffness and damping, so every retarget keeps the velocity already in flight. */\nconst physical = (visualDuration: number, bounce: number): Transition => {\n const root = 2 * Math.PI / (visualDuration * 1.2);\n return { type: \"spring\", stiffness: root * root, damping: 2 * (1 - bounce) * root, mass: 1 };\n};\nconst GROW = physical(.48, .12), SHRINK = physical(.38, 0), STRETCH = physical(.3, .04), GLIDE = physical(.3, .1), SLIDE = physical(.4, .06);\nconst TRIGGER_RADIUS = 18, PANEL_RADIUS = 26, WIDE_CELL = 36, WIDE_MIN = 712, EDGE = 8;\n\nconst roll: Variants = {\n enter: (direction: number) => ({ opacity: 0, y: `${direction * .45}em`, filter: `blur(${blur.subtle}px)` }),\n center: { opacity: 1, y: \"0em\", filter: \"blur(0px)\", transition: { duration: .26, ease: enterEase } },\n exit: (direction: number) => ({ opacity: 0, y: `${direction * -.45}em`, filter: `blur(${blur.subtle}px)`, transition: { duration: .14, ease: standardEase } }),\n};\nconst fade: Variants = { enter: { opacity: 0 }, center: { opacity: 1, y: \"0em\", filter: \"blur(0px)\", transition: { duration: .12 } }, exit: { opacity: 0, transition: { duration: .08 } } };\n/** Panel content arrives just behind the growing surface and leaves ahead of it. */\nconst faceIn: Variants = {\n hidden: { opacity: 0, y: -4, filter: `blur(${blur.soft}px)` },\n shown: { opacity: 1, y: 0, filter: \"blur(0px)\", transition: { duration: .26, ease: enterEase, delay: .07 } },\n gone: { opacity: 0, y: -2, filter: `blur(${blur.soft}px)`, transition: { duration: .12, ease: standardEase } },\n};\nconst faceFade: Variants = { hidden: { opacity: 0 }, shown: { opacity: 1, transition: { duration: .12 } }, gone: { opacity: 0, transition: { duration: .08 } } };\nconst slide: Variants = {\n enter: (direction: number) => ({ opacity: 0, x: direction * 48, filter: `blur(${blur.soft}px)` }),\n center: { opacity: 1, x: 0, filter: \"blur(0px)\", transition: { x: SLIDE, opacity: { duration: .22, ease: enterEase }, filter: { duration: .24, ease: enterEase } } },\n exit: (direction: number) => ({ opacity: 0, x: direction * -36, filter: `blur(${blur.soft}px)`, transition: { x: SLIDE, opacity: { duration: .14, ease: standardEase }, filter: { duration: .14, ease: standardEase } } }),\n};\n\n/** Words roll one by one: a later date rises from below, an earlier one drops from above; words that stay the same hold still. */\nfunction Rolling({ text, direction, reduced }: { text: string; direction: number; reduced: boolean }) {\n const words = text.split(/\\s+/).filter(Boolean);\n return \n {words.map((word, index) => \n \n {word}\n \n )}\n ;\n}\n\n/** Remembers which way a range moved, so labels roll in time's direction. */\nfunction useDirection(range: DateRange | null) {\n const time = range ? range.start.getTime() * 2 + range.end.getTime() : null;\n const [previous, setPrevious] = useState(time);\n const [direction, setDirection] = useState(1);\n if (time !== previous) { setPrevious(time); setDirection(time === null || previous === null || time >= previous ? 1 : -1); }\n return direction;\n}\n\ntype Segment = { left: number; width: number } | null;\nconst cellIn = (date: Date, first: Date) => { const index = dayDiff(date, first); return { row: Math.floor(index / 7), col: index % 7 }; };\n\n/** One week's slice of the highlight. It grows out of the edge the range arrives from, so hovering reads as stretching. */\nfunction Bar({ row, segment, reduced }: { row: number; segment: Segment; reduced: boolean }) {\n const left = useMotionValue(segment?.left ?? 0), width = useMotionValue(segment?.width ?? 0), opacity = useMotionValue(segment ? 1 : 0);\n const leftPct = useTransform(left, value => `${value}%`), widthPct = useTransform(width, value => `${value}%`);\n const wasEmpty = useRef(!segment);\n useLayoutEffect(() => {\n if (!segment) { wasEmpty.current = true; animate(opacity, 0, { duration: reduced ? 0 : .14 }); return; }\n if (wasEmpty.current || reduced) { left.jump(segment.left); width.jump(segment.width); }\n else { animate(left, segment.left, STRETCH); animate(width, segment.width, STRETCH); }\n wasEmpty.current = false;\n animate(opacity, 1, { duration: reduced ? 0 : .16 });\n }, [left, opacity, reduced, segment, width]);\n return ;\n}\n\n/** A solid end of the range. It glides from day to day and only fades when the end leaves this month. */\nfunction Thumb({ cell, reduced }: { cell: { row: number; col: number } | null; reduced: boolean }) {\n const x = useMotionValue(cell?.col ?? 0), y = useMotionValue(cell?.row ?? 0), opacity = useMotionValue(cell ? 1 : 0), scale = useMotionValue(cell ? 1 : .8);\n const transform = useTransform(() => `translate(${x.get() * 100}%, ${y.get() * 100}%) scale(${scale.get()})`);\n const hidden = useRef(!cell);\n useLayoutEffect(() => {\n if (!cell) { hidden.current = true; animate(opacity, 0, { duration: reduced ? 0 : .12 }); animate(scale, .8, { duration: reduced ? 0 : .12 }); return; }\n if (hidden.current || reduced) { x.jump(cell.col); y.jump(cell.row); }\n else { animate(x, cell.col, GLIDE); animate(y, cell.row, GLIDE); }\n hidden.current = false;\n animate(opacity, 1, { duration: reduced ? 0 : .16 });\n animate(scale, 1, reduced ? { duration: 0 } : GLIDE);\n }, [cell, opacity, reduced, scale, x, y]);\n return ;\n}\n\ninterface MonthProps {\n month: Date;\n range: DateRange | null;\n tabbable: string;\n today?: Date;\n minDate?: Date;\n maxDate?: Date;\n weekStartsOn: 0 | 1;\n reduced: boolean;\n formatters: { title: Intl.DateTimeFormat; day: Intl.DateTimeFormat; weekday: Intl.DateTimeFormat; weekdayLong: Intl.DateTimeFormat };\n onPick: (date: Date) => void;\n onHover: (date: Date) => void;\n onKey: (event: ReactKeyboardEvent, date: Date) => void;\n onFocusDay: (date: Date) => void;\n idBase: string;\n}\n\nfunction Month({ month, range, tabbable, today, minDate, maxDate, weekStartsOn, reduced, formatters, onPick, onHover, onKey, onFocusDay, idBase }: MonthProps) {\n const first = useMemo(() => addDays(month, -((month.getDay() - weekStartsOn + 7) % 7)), [month, weekStartsOn]);\n const rows = useMemo(() => Array.from({ length: 6 }, (_, row) => Array.from({ length: 7 }, (_, col) => addDays(first, row * 7 + col))), [first]);\n const titleId = `${idBase}-${keyOf(month)}`;\n const inMonth = (date: Date) => monthDiff(date, month) === 0;\n\n const lo = range?.start, hi = range?.end;\n const loKey = lo ? keyOf(lo) : \"\", hiKey = hi ? keyOf(hi) : \"\";\n /** Each week's slice of the range, in percent of the row. Keyed by the range's days so bars only move when it does. */\n const segments = useMemo(() => {\n if (!loKey || !hiKey) return rows.map(() => null);\n const start = fromKey(loKey), end = fromKey(hiKey);\n const unit = 100 / 7;\n const colOf = (date: Date) => dayDiff(date, first) % 7;\n return rows.map(row => {\n const days = row.filter(date => monthDiff(date, month) === 0);\n if (!days.length) return null;\n const rowFirst = days[0], rowLast = days[days.length - 1];\n const from = dayDiff(start, rowFirst) > 0 ? start : rowFirst, to = dayDiff(end, rowLast) < 0 ? end : rowLast;\n if (dayDiff(from, to) <= 0) { const a = colOf(from), b = colOf(to); return { left: a * unit, width: (b - a + 1) * unit }; }\n // The week sits wholly before or after the range: collapse at the edge the range would grow in from.\n if (dayDiff(rowLast, start) < 0) return { left: (colOf(rowLast) + 1) * unit, width: 0 };\n return { left: colOf(rowFirst) * unit, width: 0 };\n });\n }, [first, hiKey, loKey, month, rows]);\n const startCell = useMemo(() => loKey && monthDiff(fromKey(loKey), month) === 0 ? cellIn(fromKey(loKey), first) : null, [loKey, month, first]);\n const endCell = useMemo(() => hiKey && hiKey !== loKey && monthDiff(fromKey(hiKey), month) === 0 ? cellIn(fromKey(hiKey), first) : null, [hiKey, loKey, month, first]);\n\n return
\n

{formatters.title.format(month)}

\n
\n
\n {rows[0].map(date => {formatters.weekday.format(date).slice(0, 2)})}\n
\n
\n \n {segments.map((segment, row) => )}\n \n \n \n {rows.map((row, index) =>
\n {row.map(date => {\n if (!inMonth(date)) return ;\n const key = keyOf(date);\n const disabled = Boolean((minDate && dayDiff(date, minDate) < 0) || (maxDate && dayDiff(date, maxDate) > 0));\n const inRange = Boolean(lo && hi && dayDiff(date, lo) >= 0 && dayDiff(date, hi) <= 0);\n const edge = sameDay(date, lo) || sameDay(date, hi);\n const isToday = sameDay(date, today);\n return \n \n ;\n })}\n
)}\n
\n
\n
;\n}\n\n/** The visible months travel together; a leaving set turns inert so focus and queries only find the current one. */\nfunction Months({ children, direction, reduced }: { children: ReactNode; direction: number; reduced: boolean }) {\n const present = useIsPresent();\n return {children};\n}\n\nexport function DateRangePicker({ value, defaultValue = null, onChange, label = \"Date range\", placeholder = \"Select dates\", presets = defaultDateRangePresets, minDate, maxDate, weekStartsOn = 0, locale = \"en-US\", months = \"auto\", boundary, className }: DateRangePickerProps) {\n const reduced = useReducedFlag();\n const today = useToday();\n const uid = useId();\n const [inner, setInner] = useState(defaultValue);\n const committed = value !== undefined ? value : inner;\n\n const [open, setOpen] = useState(false);\n const [compact, setCompact] = useState(false);\n const [panelWidth, setPanelWidth] = useState(0);\n const [view, setView] = useState(() => monthStart(committed?.end ?? new Date(2000, 0, 1)));\n const [direction, setDirection] = useState(1);\n const [draft, setDraft] = useState(committed);\n const [anchor, setAnchor] = useState(null);\n const [hover, setHover] = useState(null);\n const [focusKey, setFocusKey] = useState(\"\");\n\n const formatters = useMemo(() => ({\n label: new Intl.DateTimeFormat(locale, { month: \"short\", day: \"numeric\", year: \"numeric\" }),\n title: new Intl.DateTimeFormat(locale, { month: \"long\", year: \"numeric\" }),\n day: new Intl.DateTimeFormat(locale, { weekday: \"long\", month: \"long\", day: \"numeric\", year: \"numeric\" }),\n weekday: new Intl.DateTimeFormat(locale, { weekday: \"short\" }),\n weekdayLong: new Intl.DateTimeFormat(locale, { weekday: \"long\" }),\n }), [locale]);\n const format = useCallback((range: DateRange | null) => !range ? placeholder : sameDay(range.start, range.end) ? formatters.label.format(range.start) : formatters.label.formatRange(range.start, range.end), [formatters, placeholder]);\n\n const count = compact ? 1 : 2;\n const visible = useMemo(() => Array.from({ length: count }, (_, index) => addMonths(view, index)), [count, view]);\n const shown = anchor ? ordered(anchor, hover ?? anchor) : draft;\n const activePreset = today && !anchor ? presets.findIndex(preset => sameRange(preset.range(today), draft)) : -1;\n const days = shown ? dayDiff(shown.end, shown.start) + 1 : 0;\n const committedDirection = useDirection(committed);\n const shownDirection = useDirection(shown);\n\n const rootRef = useRef(null);\n const triggerRef = useRef(null);\n const panelRef = useRef(null);\n const railRef = useRef(null);\n const pendingFocus = useRef<\"trigger\" | \"day\" | null>(null);\n\n /* ---------- One surface: the trigger's box springs to the panel's box and back ---------- */\n // Before the first measurement the surface simply fills the root and the root wraps the trigger, so server markup already looks right.\n const width = useMotionValue(\"100%\"), height = useMotionValue(\"100%\"), radius = useMotionValue(TRIGGER_RADIUS), x = useMotionValue(0), rootWidth = useMotionValue(\"auto\");\n const sizes = useRef<{ trigger: Size; panel: Size | null }>({ trigger: { w: 0, h: 0 }, panel: null });\n const live = useRef({ open: false, reduced: false, ready: false });\n const queued = useRef(false);\n const boundaryRef = useRef(boundary);\n useLayoutEffect(() => { boundaryRef.current = boundary; }, [boundary]);\n\n const bounds = useCallback(() => {\n const element = boundaryRef.current?.();\n const viewport = { left: 0, right: document.documentElement.clientWidth };\n if (!element) return viewport;\n const rect = element.getBoundingClientRect();\n return { left: Math.max(viewport.left, rect.left), right: Math.min(viewport.right, rect.right) };\n }, []);\n\n const update = useCallback(() => {\n queued.current = false;\n const { trigger, panel } = sizes.current, state = live.current;\n if (!trigger.w) return;\n const openPanel = state.open && panel ? panel : null;\n const target = openPanel ?? trigger;\n let offset = 0;\n if (openPanel && rootRef.current) {\n const rect = rootRef.current.getBoundingClientRect(), box = bounds();\n offset = Math.min(0, box.right - EDGE - (rect.left + openPanel.w));\n offset = Math.max(offset, box.left + EDGE - rect.left);\n }\n const r = openPanel ? PANEL_RADIUS : TRIGGER_RADIUS;\n if (!state.ready || state.reduced) {\n width.jump(target.w); height.jump(target.h); radius.jump(r); x.jump(offset); rootWidth.jump(trigger.w);\n state.ready = true;\n return;\n }\n const spring = openPanel ? GROW : state.open ? GROW : SHRINK;\n animate(width, target.w, spring); animate(height, target.h, spring); animate(radius, r, spring); animate(x, offset, spring);\n animate(rootWidth, trigger.w, motionTokens.spring.morph);\n }, [bounds, height, radius, rootWidth, width, x]);\n\n const schedule = useCallback(() => {\n if (queued.current) return;\n queued.current = true;\n queueMicrotask(update);\n }, [update]);\n\n useLayoutEffect(() => { live.current.open = open; live.current.reduced = reduced; if (!open) sizes.current.panel = null; schedule(); }, [open, reduced, schedule]);\n\n useLayoutEffect(() => {\n const node = triggerRef.current;\n if (!node) return;\n const read = () => { sizes.current.trigger = { w: node.offsetWidth, h: node.offsetHeight }; schedule(); };\n read();\n if (typeof ResizeObserver === \"undefined\") return;\n const observer = new ResizeObserver(read);\n observer.observe(node);\n return () => observer.disconnect();\n }, [schedule]);\n\n useLayoutEffect(() => {\n const node = panelRef.current;\n if (!open || !node) return;\n const read = () => { sizes.current.panel = { w: node.offsetWidth, h: node.offsetHeight }; schedule(); };\n read();\n if (typeof ResizeObserver === \"undefined\") return;\n const observer = new ResizeObserver(read);\n observer.observe(node);\n return () => observer.disconnect();\n }, [open, schedule]);\n\n /* ---------- Layout: two months when there is room, one compact month otherwise ---------- */\n const measureLayout = useCallback(() => {\n const box = bounds();\n const available = Math.max(0, box.right - box.left - EDGE * 2);\n const single = months === 1 || (months === \"auto\" && available < WIDE_MIN);\n return { single, width: Math.min(352, available) };\n }, [bounds, months]);\n\n useEffect(() => {\n if (!open) return;\n const onResize = () => { const next = measureLayout(); setCompact(next.single); setPanelWidth(next.width); schedule(); };\n window.addEventListener(\"resize\", onResize);\n return () => window.removeEventListener(\"resize\", onResize);\n }, [measureLayout, open, schedule]);\n\n const viewFor = (range: DateRange | null, single: boolean, fallback: Date) => {\n const end = monthStart(range?.end ?? fallback);\n return single ? end : addMonths(end, -1);\n };\n\n const openPanel = () => {\n if (!today) return;\n const layout = measureLayout();\n setCompact(layout.single);\n setPanelWidth(layout.width);\n setDraft(committed);\n setAnchor(null);\n setHover(null);\n setView(viewFor(committed, layout.single, today));\n setDirection(0);\n setFocusKey(keyOf(committed?.start ?? today));\n pendingFocus.current = \"day\";\n setOpen(true);\n };\n\n const close = useCallback((focus: boolean) => {\n if (focus) pendingFocus.current = \"trigger\";\n setDirection(0);\n setOpen(false);\n setAnchor(null);\n setHover(null);\n }, []);\n\n const apply = () => {\n const next = anchor ? { start: anchor, end: anchor } : draft;\n if (!next) return;\n if (value === undefined) setInner(next);\n onChange?.(next);\n close(true);\n };\n\n /* ---------- Focus management ---------- */\n useLayoutEffect(() => {\n const target = pendingFocus.current;\n if (!target) return;\n if (target === \"trigger\" && !open) { pendingFocus.current = null; triggerRef.current?.focus({ preventScroll: true }); return; }\n if (target === \"day\" && open) {\n const node = panelRef.current?.querySelector(`[data-current] [data-date=\"${focusKey}\"]`);\n if (node) { pendingFocus.current = null; node.focus({ preventScroll: true }); }\n }\n }, [focusKey, open, view, compact]);\n\n useEffect(() => {\n if (!open) return;\n const down = (event: PointerEvent) => { if (!rootRef.current?.contains(event.target as Node)) close(false); };\n document.addEventListener(\"pointerdown\", down);\n return () => document.removeEventListener(\"pointerdown\", down);\n }, [close, open]);\n\n const onRootKey = (event: ReactKeyboardEvent) => {\n if (event.key === \"Escape\" && open) { event.preventDefault(); event.stopPropagation(); close(true); }\n };\n const onRootBlur = (event: ReactFocusEvent) => {\n const next = event.relatedTarget as Node | null;\n if (open && next && !event.currentTarget.contains(next)) close(false);\n };\n\n /* ---------- Month navigation and picking ---------- */\n const goTo = (next: Date) => {\n const delta = monthDiff(next, view);\n if (!delta) return;\n setDirection(Math.sign(delta));\n setView(next);\n };\n\n /** Keeps a range on screen: nothing moves when it already fits, otherwise its last month lands on the right. */\n const reveal = (range: DateRange) => {\n const first = view, last = addMonths(view, count - 1);\n if (monthDiff(range.start, first) >= 0 && monthDiff(range.end, last) <= 0) return;\n goTo(viewFor(range, compact, range.end));\n };\n\n const pick = (date: Date) => {\n setFocusKey(keyOf(date));\n if (!anchor) { setAnchor(date); setHover(date); return; }\n setDraft(ordered(anchor, date));\n setAnchor(null);\n setHover(null);\n };\n\n const choosePreset = (preset: DateRangePreset) => {\n if (!today) return;\n const range = preset.range(today);\n setDraft(range);\n setAnchor(null);\n setHover(null);\n setFocusKey(keyOf(range.start));\n reveal(range);\n };\n\n const onDayKey = (event: ReactKeyboardEvent, date: Date) => {\n const weekday = (date.getDay() - weekStartsOn + 7) % 7;\n const moves: Record Date> = {\n ArrowLeft: () => addDays(date, -1), ArrowRight: () => addDays(date, 1), ArrowUp: () => addDays(date, -7), ArrowDown: () => addDays(date, 7),\n Home: () => addDays(date, -weekday), End: () => addDays(date, 6 - weekday),\n PageUp: () => shiftMonths(date, event.shiftKey ? -12 : -1), PageDown: () => shiftMonths(date, event.shiftKey ? 12 : 1),\n };\n const move = moves[event.key];\n if (!move) return;\n event.preventDefault();\n const next = clampDate(move(), minDate, maxDate);\n setFocusKey(keyOf(next));\n if (anchor) setHover(next);\n pendingFocus.current = \"day\";\n const first = view, last = addMonths(view, count - 1);\n if (monthDiff(next, first) < 0) goTo(monthStart(next));\n else if (monthDiff(next, last) > 0) goTo(addMonths(monthStart(next), -(count - 1)));\n };\n\n const onRailKey = (event: ReactKeyboardEvent) => {\n const keys = compact ? [\"ArrowLeft\", \"ArrowRight\"] : [\"ArrowUp\", \"ArrowDown\"];\n if (!keys.includes(event.key)) return;\n const buttons = Array.from(event.currentTarget.querySelectorAll(\"[data-preset]\"));\n const at = buttons.indexOf(document.activeElement as HTMLButtonElement);\n if (at < 0) return;\n event.preventDefault();\n buttons[(at + (event.key === keys[1] ? 1 : -1) + buttons.length) % buttons.length]?.focus();\n };\n\n /** The day that owns the tab stop: the focused day when it is on screen, else the first visible day of the range, else the 1st. */\n const tabbable = useMemo(() => {\n const onScreen = (date: Date) => visible.some(month => monthDiff(date, month) === 0);\n if (focusKey && onScreen(fromKey(focusKey))) return focusKey;\n if (shown && onScreen(shown.start)) return keyOf(shown.start);\n return keyOf(visible[0]);\n }, [focusKey, shown, visible]);\n\n /* ---------- Preset highlight glides between presets ---------- */\n const gx = useMotionValue(0), gy = useMotionValue(0), gw = useMotionValue(0), gh = useMotionValue(0), go = useMotionValue(0);\n useLayoutEffect(() => {\n const rail = railRef.current;\n const node = activePreset < 0 ? null : rail?.querySelector(`[data-preset=\"${activePreset}\"]`);\n if (!rail || !node) { animate(go, 0, { duration: reduced ? 0 : .14 }); return; }\n const place = [node.offsetLeft, node.offsetTop, node.offsetWidth, node.offsetHeight];\n if (go.get() < .05 || reduced) { gx.jump(place[0]); gy.jump(place[1]); gw.jump(place[2]); gh.jump(place[3]); }\n else { animate(gx, place[0], GLIDE); animate(gy, place[1], GLIDE); animate(gw, place[2], GLIDE); animate(gh, place[3], GLIDE); }\n animate(go, 1, { duration: reduced ? 0 : .16 });\n if (compact && (node.offsetLeft < rail.scrollLeft || node.offsetLeft + node.offsetWidth > rail.scrollLeft + rail.clientWidth)) {\n rail.scrollTo({ left: node.offsetLeft - 12, behavior: reduced ? \"auto\" : \"smooth\" });\n }\n }, [activePreset, compact, go, gh, gw, gx, gy, open, reduced]);\n\n /* ---------- Copy ---------- */\n const committedText = format(committed);\n const shownText = shown ? format(shown) : \"No dates\";\n const countText = shown ? `${days} ${days === 1 ? \"day\" : \"days\"}` : \"\";\n const status = !open ? \"\" : anchor ? `Start ${formatters.label.format(anchor)}. Choose an end date.` : shown ? `${shownText}, ${countText}` : \"\";\n const layoutId = reduced ? undefined : `${uid}-value`;\n\n const cellSize = compact && panelWidth ? Math.max(32, Math.min(42, Math.floor((panelWidth - 24) / 7))) : WIDE_CELL;\n\n const quiet = open ? (reduced ? { opacity: 0 } : { opacity: 0, filter: `blur(${blur.subtle}px)` }) : { opacity: 1, filter: \"blur(0px)\" };\n const quietTransition = open ? { duration: .12, ease: standardEase } : { duration: .22, ease: enterEase, delay: reduced ? 0 : .1 };\n\n return \n {/* The trigger stays in place under the panel. Its icons fade, while the value itself flies to the panel footer and back. */}\n \n\n \n \n {open && today && \n \n
\n \n {presets.map((preset, index) => )}\n
\n\n
{ if (anchor) setHover(null); }}>\n \n \n
\n \n \n {visible.map(month => { if (anchor) setHover(date); }} onKey={onDayKey} onFocusDay={date => setFocusKey(keyOf(date))} />)}\n \n \n
\n
\n
\n\n
\n
\n \n \n \n \n {countText && }\n \n
\n \n \n \n \n
\n

{status}

\n
}\n
\n
\n
;\n}\n\nexport default DateRangePicker;\n"},{"path":"registry/components/date-range-picker/date-range-picker.module.css","type":"registry:component","target":"@components/arc/date-range-picker/date-range-picker.module.css","content":"/* The root holds the trigger's place in the page. The surface floats above it and grows into the panel, so opening never pushes content. */\n.root {\n position: relative;\n display: inline-block;\n height: var(--control-height-md);\n vertical-align: top;\n color: var(--foreground);\n font-family: var(--font-body);\n font-size: var(--text-sm);\n letter-spacing: var(--tracking-body);\n line-height: var(--leading-body);\n}\n.root { z-index: 0; transition: z-index 0s linear .5s; }\n.root[data-open] { z-index: 30; transition-delay: 0s; }\n.root:not([data-open]) .surface { z-index: 0; }\n.root *, .root *::before, .root *::after { box-sizing: border-box; }\n\n/* One material from trigger to panel. Size, corners, and offset come from springs; the border is an overlay so it never changes a measurement. */\n.surface {\n position: absolute;\n z-index: 2;\n top: 0;\n left: 0;\n overflow: clip;\n background: var(--surface-raised);\n box-shadow: var(--shadow-resting);\n transition: box-shadow var(--duration-considered) var(--ease-standard), background-color var(--duration-fast) var(--ease-standard);\n}\n.surface::after { content: \"\"; position: absolute; inset: 0; border: 1px solid var(--border); border-radius: inherit; pointer-events: none; transition: border-color var(--duration-fast) var(--ease-standard); }\n.root[data-open] .surface { box-shadow: var(--shadow-floating); }\n\n/* Trigger */\n/* The trigger sits in flow above the surface while closed; when open, the surface and its panel rise over it. */\n.trigger {\n position: relative;\n z-index: 1;\n display: inline-flex;\n width: max-content;\n height: var(--control-height-md);\n align-items: center;\n gap: 10px;\n padding: 0 12px 0 14px;\n border: 0;\n background: transparent;\n color: var(--foreground);\n cursor: pointer;\n font: inherit;\n font-weight: 500;\n white-space: nowrap;\n -webkit-tap-highlight-color: transparent;\n}\n.trigger:disabled { cursor: default; }\n.trigger[inert] { pointer-events: none; }\n.triggerIcon, .chevron { display: grid; flex: none; place-items: center; color: var(--text-secondary); }\n.chevron { color: var(--text-muted); }\n.value { display: inline-flex; font-variant-numeric: tabular-nums; }\n.value[data-empty] { color: var(--text-secondary); font-weight: 400; }\n\n/* Words roll in their own slots; a leaving word pops out of flow so the next one never waits for space. */\n.rolling { display: inline-flex; gap: .28em; white-space: nowrap; }\n.word { position: relative; display: inline-block; }\n.wordInner { display: inline-block; }\n\n/* Panel */\n.panel { position: absolute; top: 0; left: 0; width: max-content; padding: 10px; --cell: 36px; }\n.body { display: flex; gap: 10px; }\n\n.rail { position: relative; display: flex; width: 9.25rem; flex: none; flex-direction: column; gap: 2px; padding-right: 10px; border-right: 1px solid var(--border-subtle); }\n.railHighlight { position: absolute; top: 0; left: 0; border-radius: 12px; background: color-mix(in oklch, var(--foreground) 6.5%, transparent); pointer-events: none; }\n.preset {\n position: relative;\n display: flex;\n height: 32px;\n flex: none;\n align-items: center;\n padding: 0 12px;\n border: 0;\n border-radius: 12px;\n background: transparent;\n color: var(--text-secondary);\n cursor: pointer;\n font: inherit;\n text-align: left;\n white-space: nowrap;\n -webkit-tap-highlight-color: transparent;\n transition: color var(--duration-fast) var(--ease-standard), background-color var(--duration-fast) var(--ease-standard);\n}\n.preset[aria-pressed=\"true\"] { color: var(--foreground); font-weight: 500; }\n\n.calendars { position: relative; padding: 0 2px; }\n.navButton {\n position: absolute;\n z-index: 2;\n top: 0;\n display: grid;\n width: 32px;\n height: 32px;\n place-items: center;\n padding: 0;\n border: 0;\n border-radius: var(--radius-pill);\n background: transparent;\n color: var(--text-secondary);\n cursor: pointer;\n -webkit-tap-highlight-color: transparent;\n transition: background-color var(--duration-fast) var(--ease-standard), color var(--duration-fast) var(--ease-standard), transform var(--duration-instant) var(--ease-standard);\n}\n.navButton[data-side=\"prev\"] { left: 2px; }\n.navButton[data-side=\"next\"] { right: 2px; }\n.navButton:active:not(:disabled) { transform: scale(.92); }\n.navButton:disabled { opacity: .35; cursor: default; }\n\n/* Months slide horizontally inside a clipped window that keeps a little room for the blur. */\n.viewport { position: relative; overflow: clip; overflow-clip-margin: 2px; }\n.months { display: flex; gap: 24px; }\n.month { width: calc(var(--cell) * 7); }\n.monthTitle { height: 32px; margin: 0; color: var(--foreground); font-weight: 500; line-height: 32px; text-align: center; white-space: nowrap; }\n.grid { display: grid; }\n.weekdays { display: grid; height: 28px; align-items: center; color: var(--text-muted); font-size: var(--text-xs); grid-template-columns: repeat(7, var(--cell)); text-align: center; }\n.weeks { position: relative; }\n.week { display: grid; height: var(--cell); grid-template-columns: repeat(7, var(--cell)); }\n.cell, .blank { position: relative; display: block; }\n\n/* The highlight lives under the days: one bar per week plus a solid end at each side of the range. */\n.layer { position: absolute; inset: 0; pointer-events: none; }\n.bar { position: absolute; height: calc(var(--cell) - 6px); border-radius: var(--radius-pill); background: var(--accent-subtle); }\n.thumb { position: absolute; top: 0; left: 0; width: var(--cell); height: var(--cell); }\n.thumb::before { content: \"\"; position: absolute; inset: 3px; border-radius: 50%; background: var(--accent); }\n\n.day {\n position: relative;\n z-index: 1;\n display: grid;\n width: 100%;\n height: 100%;\n place-items: center;\n padding: 0;\n border: 0;\n border-radius: 50%;\n background: transparent;\n color: var(--foreground);\n cursor: pointer;\n font: inherit;\n font-variant-numeric: tabular-nums;\n -webkit-tap-highlight-color: transparent;\n transition: color var(--duration-fast) var(--ease-standard);\n}\n.day > span { position: relative; display: grid; width: calc(100% - 6px); height: calc(100% - 6px); place-items: center; border-radius: 50%; transition: background-color var(--duration-fast) var(--ease-standard), transform var(--duration-instant) var(--ease-standard); }\n.day:active:not(:disabled) > span { transform: scale(.9); }\n.day[data-edge] { color: var(--accent-foreground); font-weight: 500; transition-delay: 40ms; }\n.day[data-today]::after { content: \"\"; position: absolute; bottom: 5px; left: 50%; width: 4px; height: 4px; margin-left: -2px; border-radius: 50%; background: var(--accent); }\n.day[data-today][data-edge]::after { background: var(--accent-foreground); }\n.day:disabled { color: var(--text-muted); cursor: default; opacity: .5; }\n.day:focus-visible:not([data-edge]) > span { background: color-mix(in oklch, var(--foreground) 7%, transparent); }\n\n/* Footer: the formatted range and the two decisions. */\n.footer { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-top: 10px; padding: 10px 2px 0 6px; border-top: 1px solid var(--border-subtle); }\n.summary { display: grid; min-width: 0; gap: 1px; }\n.summaryValue { display: inline-flex; width: max-content; font-weight: 500; font-variant-numeric: tabular-nums; }\n.summaryCount { display: inline-flex; min-height: 1.4em; color: var(--text-muted); font-size: var(--text-xs); font-variant-numeric: tabular-nums; }\n.actions { display: flex; flex: none; align-items: center; gap: 6px; }\n.ghost, .primary {\n display: inline-flex;\n height: var(--control-height-sm);\n align-items: center;\n justify-content: center;\n padding: 0 14px;\n border: 0;\n border-radius: var(--radius-control);\n cursor: pointer;\n font: inherit;\n font-weight: 500;\n white-space: nowrap;\n -webkit-tap-highlight-color: transparent;\n transition: background-color var(--duration-fast) var(--ease-standard), color var(--duration-fast) var(--ease-standard), transform var(--duration-instant) var(--ease-standard), opacity var(--duration-fast) var(--ease-standard);\n}\n.ghost { background: transparent; color: var(--text-secondary); }\n.primary { min-width: 4.75rem; background: var(--accent); color: var(--accent-foreground); }\n.ghost:active, .primary:active:not(:disabled) { transform: scale(.97); }\n.primary:disabled { opacity: .45; cursor: default; }\n.ghost:focus-visible { background: var(--surface-muted); color: var(--foreground); }\n.preset:focus-visible, .navButton:focus-visible { background: color-mix(in oklch, var(--foreground) 5%, transparent); color: var(--foreground); }\n\n.srOnly { position: absolute; width: 1px; height: 1px; margin: -1px; padding: 0; overflow: hidden; clip-path: inset(50%); white-space: nowrap; border: 0; }\n\n/* Compact: one month under a scrolling row of presets. */\n.panel[data-compact] { padding: 8px; }\n.panel[data-compact] .body { flex-direction: column; gap: 8px; }\n.panel[data-compact] .rail { width: auto; flex-direction: row; gap: 2px; margin: 0 -8px; padding: 0 8px 8px; overflow-x: auto; border-right: 0; border-bottom: 1px solid var(--border-subtle); overscroll-behavior-x: contain; scrollbar-width: none; }\n.panel[data-compact] .rail::-webkit-scrollbar { display: none; }\n.panel[data-compact] .preset { padding: 0 11px; }\n.panel[data-compact] .calendars { align-self: center; }\n.panel[data-compact] .footer { flex-wrap: wrap; margin-top: 6px; }\n.panel[data-compact] .summary { flex: 1 1 8rem; }\n\n@media (hover: hover) and (pointer: fine) {\n .root:not([data-open]):has(> .trigger:hover:not(:disabled)) .surface { background: color-mix(in oklch, var(--foreground) 3%, var(--surface-raised)); }\n .root:not([data-open]):has(> .trigger:hover:not(:disabled)) .surface::after { border-color: var(--border-strong); }\n .preset:hover { color: var(--foreground); }\n .preset:not([aria-pressed=\"true\"]):hover { background: color-mix(in oklch, var(--foreground) 4%, transparent); }\n .navButton:hover:not(:disabled) { background: color-mix(in oklch, var(--foreground) 6.5%, transparent); color: var(--foreground); }\n .day:hover:not(:disabled):not([data-edge]) > span { background: color-mix(in oklch, var(--foreground) 7%, transparent); }\n .ghost:hover { background: var(--surface-muted); color: var(--foreground); }\n .primary:hover:not(:disabled) { background: var(--accent-strong); }\n}\n\n@media (prefers-reduced-motion: reduce) {\n .surface, .preset, .navButton, .day, .day > span, .ghost, .primary { transition-duration: 0ms; }\n}\n\n@media (prefers-contrast: more) {\n .surface::after { border-color: var(--border-strong); }\n}\n"}],"docs":"Docs and live preview: https://uiarc.dev/components/date-range-picker","meta":{"tier":"free","kind":"component","docs":"https://uiarc.dev/components/date-range-picker","markdown":"https://uiarc.dev/components/date-range-picker/markdown","tags":["inputs","new"]}}