{"$schema":"https://ui.artbloom.tech/schema/registry-item.json","name":"date-range-picker","type":"registry:ui","title":"Date Range Picker","description":"A two-month calendar for picking a start and end date, with hover previews across the range, keyboard navigation and month paging. One file.","author":"@artbloom","dependencies":[],"registryDependencies":[],"files":[{"path":"components/ui/date-range-picker.tsx","target":"components/ui/date-range-picker.tsx","content":"\"use client\";\n\nimport { useCallback, useMemo, useState } from \"react\";\n\nimport { cn } from \"@/lib/utils\";\n\n/**\n * A calendar range picker.\n *\n * The whole component runs off a fixed reference \"today\" rather than `Date.now()`:\n * the catalogue renders this card on every build and in every screenshot, and a grid\n * whose highlighted month drifts with the wall clock would never look the same twice.\n * So the displayed month, the sample range and the today-ring are all pinned to one\n * constant here, and nothing in the render path reads the real clock.\n *\n * Selection is two clicks. The first opens a range; the second closes it, swapping the\n * two if they arrive out of order, so a range is always drawn low-to-high. Between the\n * two clicks a hover previews the range that a second click would commit — the band you\n * see under the pointer is the band you would get. A day is stored as a local-midnight\n * `Date`, and everything downstream compares `.getTime()`, so \"in range\" is an integer\n * comparison and never a string or a timezone.\n */\n\n/** The pinned clock. March 2026 opens on a Sunday, so the grid has no leading blank and\n *  reads cleanly; April (the second panel) starts on a Wednesday, so the pair shows both\n *  a flush month and an offset one. Month is 0-indexed, DOM-style. */\nconst REF_YEAR = 2026;\nconst REF_MONTH = 2; // March\nconst REF_DAY = 15;\n\n/** The range the card wears at rest. Mar 3 – Mar 12 sits inside the first two rows, so\n *  the endpoints, the band and its rounded caps are all on screen the instant it mounts. */\nconst SAMPLE_START = new Date(REF_YEAR, REF_MONTH, 3);\nconst SAMPLE_END = new Date(REF_YEAR, REF_MONTH, 12);\n\nconst TODAY_MS = new Date(REF_YEAR, REF_MONTH, REF_DAY).getTime();\n\n/** Sunday-first, to match `Date.getDay()` returning 0 for Sunday. Single glyphs keep the\n *  grid a grid; the full name rides along as an `aria-label` for the column. */\nconst WEEKDAYS = [\n  { short: \"S\", long: \"Sunday\" },\n  { short: \"M\", long: \"Monday\" },\n  { short: \"T\", long: \"Tuesday\" },\n  { short: \"W\", long: \"Wednesday\" },\n  { short: \"T\", long: \"Thursday\" },\n  { short: \"F\", long: \"Friday\" },\n  { short: \"S\", long: \"Saturday\" },\n] as const;\n\n/** All 'en-US', not the runtime locale: the label a screenshot shows must not depend on\n *  the machine that took it. */\nconst shortFmt = new Intl.DateTimeFormat(\"en-US\", { month: \"short\", day: \"numeric\" });\nconst fullFmt = new Intl.DateTimeFormat(\"en-US\", {\n  weekday: \"long\",\n  year: \"numeric\",\n  month: \"long\",\n  day: \"numeric\",\n});\nconst titleFmt = new Intl.DateTimeFormat(\"en-US\", { month: \"long\", year: \"numeric\" });\n\nfunction daysInMonth(year: number, month: number) {\n  // Day 0 of the next month is the last day of this one.\n  return new Date(year, month + 1, 0).getDate();\n}\n\n/** Which weekday the 1st lands on, 0 = Sunday — i.e. how many leading blanks the grid\n *  needs before the first number. */\nfunction firstWeekday(year: number, month: number) {\n  return new Date(year, month, 1).getDay();\n}\n\nfunction addMonths(year: number, month: number, delta: number) {\n  // Feeding an out-of-range month to the Date constructor is the documented way to roll\n  // the year over, in either direction.\n  const rolled = new Date(year, month + delta, 1);\n  return { year: rolled.getFullYear(), month: rolled.getMonth() };\n}\n\n/** The band's tint. One violet, two opacities: the grid is darker so it never washes out\n *  against the lighter card. Filled endpoints are a solid violet-600 on top of this. */\nconst BAND = \"bg-violet-500/10 dark:bg-violet-500/15\";\n\ninterface MonthGridProps {\n  year: number;\n  month: number;\n  /** Effective range endpoints in ms (low ≤ high), or null for no range. When the two are\n   *  equal the range is a single day: an endpoint with no band. */\n  lo: number | null;\n  hi: number | null;\n  /** Live picking, so days can be `<button>`s; false in the card, where nothing is\n   *  focusable and the days are plain `<span>`s. */\n  interactive: boolean;\n  onPick?: (date: Date) => void;\n  onHover?: (date: Date) => void;\n  onPrev?: () => void;\n  onNext?: () => void;\n  /** Lets the parent hide a nav control responsively without unmounting it. */\n  prevClassName?: string;\n  nextClassName?: string;\n  /** Card-sized cells. The live picker leaves this off and keeps `size-9`; the\n   *  compact catalogue still turns it on so a whole month clears the 240px frame. */\n  dense?: boolean;\n  className?: string;\n}\n\n/**\n * One month. It owns its own weekday header, its own title bar and — when the parent hands\n * it `onPrev`/`onNext` — its own nav arrows, positioned absolutely so the title stays\n * centred whether or not an arrow is showing. The grid is built once per (year, month).\n */\nfunction MonthGrid({\n  year,\n  month,\n  lo,\n  hi,\n  interactive,\n  onPick,\n  onHover,\n  onPrev,\n  onNext,\n  prevClassName,\n  nextClassName,\n  dense = false,\n  className,\n}: MonthGridProps) {\n  // One knob for the whole month. Every fixed size below reads from these so the\n  // dense card and the roomy live picker stay one grid.\n  const cell = dense ? \"size-7 text-xs\" : \"size-9 text-sm\";\n  const cellBox = dense ? \"h-7\" : \"h-9\";\n  const gridWidth = dense ? \"w-56\" : \"w-64\";\n  const weeks = useMemo(() => {\n    const lead = firstWeekday(year, month);\n    const count = daysInMonth(year, month);\n    const cells: (number | null)[] = [];\n    for (let i = 0; i < lead; i += 1) cells.push(null);\n    for (let d = 1; d <= count; d += 1) cells.push(d);\n    // Pad the tail so every row has seven cells and the columns stay square.\n    while (cells.length % 7 !== 0) cells.push(null);\n    const rows: (number | null)[][] = [];\n    for (let i = 0; i < cells.length; i += 7) rows.push(cells.slice(i, i + 7));\n    return rows;\n  }, [year, month]);\n\n  const hasRange = lo !== null && hi !== null;\n  const isSpan = hasRange && lo !== hi;\n\n  return (\n    <div className={cn(gridWidth, className)}>\n      <div className={cn(\"relative flex items-center justify-center\", dense ? \"mb-0.5 h-7\" : \"mb-1 h-9\")}>\n        {onPrev ? (\n          <NavButton className={cn(\"absolute left-0\", prevClassName)} label=\"Previous month\" dir=\"prev\" onClick={onPrev} />\n        ) : null}\n        <span className={cn(\"font-medium text-foreground\", dense ? \"text-xs\" : \"text-sm\")}>{titleFmt.format(new Date(year, month, 1))}</span>\n        {onNext ? (\n          <NavButton className={cn(\"absolute right-0\", nextClassName)} label=\"Next month\" dir=\"next\" onClick={onNext} />\n        ) : null}\n      </div>\n\n      <div role=\"grid\" aria-label={titleFmt.format(new Date(year, month, 1))}>\n        <div role=\"row\" className=\"grid grid-cols-7\">\n          {WEEKDAYS.map((w, i) => (\n            <div\n              key={i}\n              role=\"columnheader\"\n              aria-label={w.long}\n              className={cn(\n                \"flex items-center justify-center font-medium text-muted-foreground\",\n                dense ? \"h-5 text-[10px]\" : \"h-8 text-xs\",\n              )}\n            >\n              {w.short}\n            </div>\n          ))}\n        </div>\n\n        {weeks.map((week, wi) => (\n          <div key={wi} role=\"row\" className=\"grid grid-cols-7\">\n            {week.map((day, di) => {\n              if (day === null) {\n                return <div key={di} role=\"gridcell\" aria-hidden className={cellBox} />;\n              }\n\n              const date = new Date(year, month, day);\n              const t = date.getTime();\n              const isStart = hasRange && t === lo;\n              const isEnd = hasRange && t === hi;\n              const isEndpoint = isStart || isEnd;\n              const inRange = hasRange && t > (lo as number) && t < (hi as number);\n              const isToday = t === TODAY_MS;\n\n              const cellBand = isSpan\n                ? isStart\n                  ? cn(BAND, \"rounded-l-full\")\n                  : isEnd\n                    ? cn(BAND, \"rounded-r-full\")\n                    : inRange\n                      ? BAND\n                      : undefined\n                : undefined;\n\n              const circle = cn(\n                \"relative z-10 flex items-center justify-center rounded-full tabular-nums\",\n                cell,\n                \"transition-colors motion-reduce:transition-none\",\n                isEndpoint\n                  ? \"bg-violet-600 font-medium text-white\"\n                  : inRange\n                    ? \"text-violet-700 dark:text-violet-200\"\n                    : \"text-foreground\",\n                !isEndpoint && isToday && \"ring-1 ring-inset ring-violet-500/50\",\n                interactive && !isEndpoint && \"hover:bg-violet-500/10 dark:hover:bg-violet-500/15\",\n                interactive && \"cursor-pointer focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-violet-500\",\n              );\n\n              const label = fullFmt.format(date);\n\n              return (\n                <div\n                  key={di}\n                  role=\"gridcell\"\n                  aria-selected={isEndpoint || inRange}\n                  className={cn(\"relative flex items-center justify-center\", cellBox, cellBand)}\n                >\n                  {interactive ? (\n                    <button\n                      type=\"button\"\n                      className={circle}\n                      aria-label={label}\n                      aria-current={isToday ? \"date\" : undefined}\n                      onClick={() => onPick?.(date)}\n                      onMouseEnter={() => onHover?.(date)}\n                      onFocus={() => onHover?.(date)}\n                    >\n                      {day}\n                    </button>\n                  ) : (\n                    <span className={circle} aria-label={label} tabIndex={-1}>\n                      {day}\n                    </span>\n                  )}\n                </div>\n              );\n            })}\n          </div>\n        ))}\n      </div>\n    </div>\n  );\n}\n\nfunction NavButton({\n  label,\n  dir,\n  onClick,\n  className,\n}: {\n  label: string;\n  dir: \"prev\" | \"next\";\n  onClick: () => void;\n  className?: string;\n}) {\n  return (\n    <button\n      type=\"button\"\n      aria-label={label}\n      onClick={onClick}\n      className={cn(\n        \"flex size-8 items-center justify-center rounded-lg text-muted-foreground\",\n        \"transition-colors motion-reduce:transition-none hover:bg-violet-500/10 hover:text-foreground\",\n        \"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-violet-500\",\n        className,\n      )}\n    >\n      <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"2\" strokeLinecap=\"round\" strokeLinejoin=\"round\" className=\"size-4\" aria-hidden>\n        <path d={dir === \"prev\" ? \"m15 18-6-6 6-6\" : \"m9 18 6-6-6-6\"} />\n      </svg>\n    </button>\n  );\n}\n\n/** The card frame, shared by both modes. */\nfunction Shell({ children, className }: { children: React.ReactNode; className?: string }) {\n  return (\n    <div\n      className={cn(\n        \"inline-block rounded-xl border border-border bg-card p-4 text-card-foreground shadow-sm\",\n        className,\n      )}\n    >\n      {children}\n    </div>\n  );\n}\n\n/**\n * The public component. `compact` is not a hook-bearing branch — it picks between a still\n * card and the live picker, and each of those calls its own hooks unconditionally, so the\n * two never share a hook order.\n */\nexport function DateRangePicker({ compact = false, className }: { compact?: boolean; className?: string }) {\n  if (compact) {\n    // A still. One month, the sample range already on it, nothing focusable, and `pan-y` so\n    // a finger dragging down the catalogue scrolls the page instead of snagging on the grid.\n    // It shares MonthGrid with the live picker, so the two can never drift.\n    return (\n      <div className={cn(\"flex h-full items-center justify-center\", className)} style={{ touchAction: \"pan-y\" }}>\n        <Shell className=\"p-3\">\n          <p className=\"mb-2 px-0.5 text-[13px] font-medium text-foreground\">\n            {shortFmt.format(SAMPLE_START)} <span className=\"text-muted-foreground\">–</span>{\" \"}\n            {shortFmt.format(SAMPLE_END)}\n          </p>\n          <MonthGrid\n            year={REF_YEAR}\n            month={REF_MONTH}\n            lo={SAMPLE_START.getTime()}\n            hi={SAMPLE_END.getTime()}\n            interactive={false}\n            dense\n          />\n        </Shell>\n      </div>\n    );\n  }\n  return <LivePicker className={className} />;\n}\n\nfunction LivePicker({ className }: { className?: string }) {\n  const [view, setView] = useState({ year: REF_YEAR, month: REF_MONTH });\n  const [start, setStart] = useState<Date | null>(SAMPLE_START);\n  const [end, setEnd] = useState<Date | null>(SAMPLE_END);\n  const [hover, setHover] = useState<Date | null>(null);\n\n  // A click either opens a fresh range (nothing picked yet, or a full range already sitting\n  // there) or closes the open one, ordering the two endpoints so the range is always drawn\n  // low-to-high. Clicking a day twice is a legal one-day range.\n  const pick = useCallback(\n    (date: Date) => {\n      if (start === null || end !== null) {\n        setStart(date);\n        setEnd(null);\n        setHover(null);\n      } else {\n        if (date.getTime() < start.getTime()) {\n          setEnd(start);\n          setStart(date);\n        } else {\n          setEnd(date);\n        }\n        setHover(null);\n      }\n    },\n    [start, end],\n  );\n\n  const clear = useCallback(() => {\n    setStart(null);\n    setEnd(null);\n    setHover(null);\n  }, []);\n\n  // The effective range folds the hover in: once a start is down and no end is picked, the\n  // pointer's day stands in for the end, so what the grid highlights is what a click commits.\n  const { lo, hi } = useMemo(() => {\n    if (start && end) {\n      const a = start.getTime();\n      const b = end.getTime();\n      return { lo: Math.min(a, b), hi: Math.max(a, b) };\n    }\n    if (start && hover) {\n      const a = start.getTime();\n      const b = hover.getTime();\n      return { lo: Math.min(a, b), hi: Math.max(a, b) };\n    }\n    if (start) return { lo: start.getTime(), hi: start.getTime() };\n    return { lo: null, hi: null };\n  }, [start, end, hover]);\n\n  const second = addMonths(view.year, view.month, 1);\n  const goPrev = useCallback(() => setView((v) => addMonths(v.year, v.month, -1)), []);\n  const goNext = useCallback(() => setView((v) => addMonths(v.year, v.month, 1)), []);\n\n  const rangeLabel = start\n    ? end\n      ? `${shortFmt.format(start)} – ${shortFmt.format(end)}`\n      : `${shortFmt.format(start)} – …`\n    : \"Select a start date\";\n\n  return (\n    <div className={cn(\"flex justify-center\", className)}>\n      <Shell>\n        <div className=\"mb-3 flex items-center justify-between gap-4 px-1\">\n          <p className={cn(\"text-sm font-medium\", start ? \"text-foreground\" : \"text-muted-foreground\")}>\n            {rangeLabel}\n          </p>\n          <button\n            type=\"button\"\n            onClick={clear}\n            disabled={!start}\n            className={cn(\n              \"rounded-lg px-2 py-1 text-xs font-medium transition-colors motion-reduce:transition-none\",\n              \"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-violet-500\",\n              start\n                ? \"text-violet-600 hover:bg-violet-500/10 dark:text-violet-400\"\n                : \"cursor-not-allowed text-muted-foreground/50\",\n            )}\n          >\n            Clear\n          </button>\n        </div>\n\n        <div className=\"flex gap-6\">\n          <MonthGrid\n            year={view.year}\n            month={view.month}\n            lo={lo}\n            hi={hi}\n            interactive\n            onPick={pick}\n            onHover={setHover}\n            onPrev={goPrev}\n            // The left panel carries the next arrow only when it is the sole panel; on wide\n            // widths the right panel owns it.\n            onNext={goNext}\n            nextClassName=\"md:hidden\"\n          />\n          <MonthGrid\n            year={second.year}\n            month={second.month}\n            lo={lo}\n            hi={hi}\n            interactive\n            onPick={pick}\n            onHover={setHover}\n            onNext={goNext}\n            className=\"hidden md:block\"\n          />\n        </div>\n      </Shell>\n    </div>\n  );\n}\n\nexport default DateRangePicker;\n","type":"registry:ui"},{"path":"lib/utils.ts","target":"lib/utils.ts","content":"import { clsx, type ClassValue } from \"clsx\"\nimport { twMerge } from \"tailwind-merge\"\n\nexport function cn(...inputs: ClassValue[]) {\n  return twMerge(clsx(inputs))\n}\n","type":"registry:lib"}],"meta":{"kind":"components","categories":["pickers"],"docs":"https://ui.artbloom.tech/artbloom/components/date-range-picker"}}