{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "calendar",
  "type": "registry:ui",
  "title": "Calendar",
  "description": "Locale-true month grid that slides the way you travel and sweeps ranges in.",
  "categories": [
    "dates"
  ],
  "dependencies": [
    "motion"
  ],
  "registryDependencies": [
    "https://www.stealth.pm/r/tokens.json",
    "https://www.stealth.pm/r/cn.json",
    "https://www.stealth.pm/r/icons.json",
    "https://www.stealth.pm/r/motion.json",
    "https://www.stealth.pm/r/use-controllable-state.json"
  ],
  "files": [
    {
      "path": "components/ui/calendar.tsx",
      "content": "\"use client\";\nimport { AnimatePresence, motion, useIsPresent, useReducedMotion } from \"motion/react\";\nimport { useEffect, useId, useMemo, useRef, useState, useSyncExternalStore } from \"react\";\nimport { cn } from \"@/lib/cn\";\nimport { ChevronLeft, ChevronRight } from \"@/lib/icons\";\nimport { ease } from \"@/lib/motion\";\nimport { useControllableState } from \"@/lib/use-controllable-state\";\n\n/* ------------------------------------------------------------------ */\n/* Plain-date helpers. Every Date here is local midnight; math goes    */\n/* through the calendar fields, never milliseconds, so DST can't bite. */\n/* ------------------------------------------------------------------ */\n\nexport type DateRange = { start: Date; end: Date | null };\n\nexport const startOfDay = (d: Date) => new Date(d.getFullYear(), d.getMonth(), d.getDate());\nexport const startOfMonth = (d: Date) => new Date(d.getFullYear(), d.getMonth(), 1);\nexport const addDays = (d: Date, n: number) => new Date(d.getFullYear(), d.getMonth(), d.getDate() + n);\nexport const daysInMonth = (y: number, m: number) => new Date(y, m + 1, 0).getDate();\n/** Adds months and clamps the day, so Jan 31 + 1 month is Feb 28, not Mar 3. */\nexport function addMonths(d: Date, n: number) {\n  const first = new Date(d.getFullYear(), d.getMonth() + n, 1);\n  return new Date(first.getFullYear(), first.getMonth(), Math.min(d.getDate(), daysInMonth(first.getFullYear(), first.getMonth())));\n}\nconst dayNum = (d: Date) => d.getFullYear() * 10000 + d.getMonth() * 100 + d.getDate();\nexport const compareDays = (a: Date, b: Date) => dayNum(a) - dayNum(b);\nexport const isSameDay = (a: Date | null | undefined, b: Date | null | undefined) => !!a && !!b && dayNum(a) === dayNum(b);\n/** Whole days from a to b, DST-safe. */\nexport const diffDays = (a: Date, b: Date) =>\n  Math.round((Date.UTC(b.getFullYear(), b.getMonth(), b.getDate()) - Date.UTC(a.getFullYear(), a.getMonth(), a.getDate())) / 864e5);\nconst pad = (n: number, w = 2) => String(n).padStart(w, \"0\");\nexport const toISODate = (d: Date) => `${pad(d.getFullYear(), 4)}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;\nexport function parseISODate(s: string): Date | null {\n  const m = /^(\\d{4})-(\\d{2})-(\\d{2})$/.exec(s);\n  if (!m) return null;\n  const d = new Date(+m[1], +m[2] - 1, +m[3]);\n  return d.getMonth() === +m[2] - 1 ? d : null;\n}\nconst clampDate = (d: Date, min?: Date, max?: Date) =>\n  min && compareDays(d, min) < 0 ? startOfDay(min) : max && compareDays(d, max) > 0 ? startOfDay(max) : d;\nconst monthIndex = (d: Date) => d.getFullYear() * 12 + d.getMonth();\n\n// Regions whose week starts on Sunday or Saturday, for browsers without Intl week info.\nconst SUNDAY = new Set(\"AG AS BD BR BS BT BW BZ CA CN CO DM DO ET GT GU HK HN ID IL IN JM JP KE KH KR LA MH MM MO MT MX MZ NI NP PA PE PH PK PR PT PY SA SG SV TH TT TW UM US VE VI WS YE ZA ZW\".split(\" \"));\nconst SATURDAY = new Set(\"AE AF BH DJ DZ EG IQ IR JO KW LY OM QA SD SY\".split(\" \"));\n\n/** First day of the week for a locale, 0 = Sunday. */\nexport function getWeekStart(locale: string): number {\n  try {\n    const l = new Intl.Locale(locale) as Intl.Locale & { getWeekInfo?: () => { firstDay: number }; weekInfo?: { firstDay: number } };\n    const info = l.getWeekInfo?.() ?? l.weekInfo;\n    if (info) return info.firstDay % 7;\n    const region = l.maximize().region ?? \"\";\n    return SUNDAY.has(region) ? 0 : SATURDAY.has(region) ? 6 : 1;\n  } catch {\n    return 0;\n  }\n}\n\n/* Today and the viewer's locale are only known in the browser. Both read  */\n/* null / en-US on the server and settle on hydration without a mismatch. */\n\nfunction subscribeToday(notify: () => void) {\n  let timer = 0;\n  const arm = () => {\n    const now = new Date();\n    const midnight = new Date(now.getFullYear(), now.getMonth(), now.getDate() + 1);\n    timer = window.setTimeout(() => (notify(), arm()), midnight.getTime() - now.getTime() + 50);\n  };\n  arm();\n  const onVisible = () => document.visibilityState === \"visible\" && notify();\n  document.addEventListener(\"visibilitychange\", onVisible);\n  return () => {\n    window.clearTimeout(timer);\n    document.removeEventListener(\"visibilitychange\", onVisible);\n  };\n}\n\n/** Today at local midnight, rolling over at midnight. Null during server render. */\nexport function useToday(): Date | null {\n  const key = useSyncExternalStore(subscribeToday, () => toISODate(new Date()), () => null);\n  return useMemo(() => (key ? parseISODate(key) : null), [key]);\n}\n\nfunction subscribeLanguage(notify: () => void) {\n  window.addEventListener(\"languagechange\", notify);\n  return () => window.removeEventListener(\"languagechange\", notify);\n}\n\n/** The locale prop, or the browser's language once hydrated. */\nexport function useResolvedLocale(locale?: string) {\n  const browser = useSyncExternalStore(subscribeLanguage, () => navigator.language, () => \"en-US\");\n  return locale ?? browser;\n}\n\n/* ------------------------------------------------------------------ */\n\ntype Base = Omit<React.ComponentProps<\"div\">, \"defaultValue\" | \"onChange\" | \"children\" | \"dir\"> & {\n  /** First visible month (controlled). */\n  month?: Date;\n  defaultMonth?: Date;\n  onMonthChange?: (month: Date) => void;\n  /** Months shown side by side. They stack under 640px. */\n  numberOfMonths?: number;\n  min?: Date;\n  max?: Date;\n  /** Return true for days that can't be picked (booked, weekends, holidays). */\n  isDateDisabled?: (date: Date) => boolean;\n  /** BCP 47 tag. Defaults to the browser's language. Drives names, digits and the week start. */\n  locale?: string;\n  /** Override the locale's first day of the week, 0 = Sunday. */\n  weekStartsOn?: 0 | 1 | 2 | 3 | 4 | 5 | 6;\n  /** Faint days from the neighboring months. Defaults to on for one month, off for several. */\n  showOutsideDays?: boolean;\n  /** Always six rows, so the height never jumps between months. */\n  fixedWeeks?: boolean;\n  showWeekNumbers?: boolean;\n  disabled?: boolean;\n  /** Navigable and readable, but days can't be picked. */\n  readOnly?: boolean;\n  /** Move focus to the selected (or today's) day on mount. Use inside popovers. */\n  autoFocus?: boolean;\n  /** Drop the card chrome, for use inside a popover or another surface. */\n  bare?: boolean;\n  /** Visible and accessible strings, for translation. */\n  labels?: { today?: string; previousMonth?: string; nextMonth?: string };\n};\n\nexport type CalendarSingleProps = Base & {\n  mode?: \"single\";\n  value?: Date | null;\n  defaultValue?: Date | null;\n  onValueChange?: (date: Date | null) => void;\n  maxDays?: never;\n};\n\nexport type CalendarRangeProps = Base & {\n  mode: \"range\";\n  /** end is null while the second day is being picked. */\n  value?: DateRange | null;\n  defaultValue?: DateRange | null;\n  onValueChange?: (range: DateRange | null) => void;\n  /** Longest range allowed, in days. Days further from the start are disabled while picking the end. */\n  maxDays?: number;\n};\n\nexport type CalendarProps = CalendarSingleProps | CalendarRangeProps;\n\ntype AnyValue = Date | DateRange | null;\ntype Band = { kind: \"mid\" | \"start\" | \"end\"; roundL: boolean; roundR: boolean; preview: boolean; delay: number } | null;\ntype Cell = {\n  date: Date;\n  key: string;\n  label: string;\n  numeral: string;\n  outside: boolean;\n  hidden: boolean;\n  today: boolean;\n  selected: boolean;\n  endpoint: boolean;\n  disabled: boolean;\n  /** Disabled by isDateDisabled (booked, closed), not merely outside min/max. */\n  unavailable: boolean;\n  tabbable: boolean;\n  band: Band;\n  /** The end of a swept range lands once the band reaches it. */\n  landsLate: boolean;\n};\n\ntype Slide = { dir: number; instant: boolean; reduce: boolean };\nconst DISTANCE = 28;\nconst gridVariants = {\n  enter: (c: Slide) => (c.instant ? { opacity: 1, x: 0 } : c.reduce ? { opacity: 0, x: 0 } : { opacity: 0, x: c.dir * DISTANCE }),\n  center: (c: Slide) => ({ opacity: 1, x: 0, transition: { duration: c.instant ? 0 : c.reduce ? 0.15 : 0.26, ease: ease.out } }),\n  exit: (c: Slide) =>\n    c.instant\n      ? { opacity: 0, transition: { duration: 0 } }\n      : c.reduce\n        ? { opacity: 0, transition: { duration: 0.1 } }\n        : { opacity: 0, x: c.dir * -DISTANCE, transition: { duration: 0.16, ease: ease.out } },\n};\nconst captionVariants = {\n  enter: (c: Slide) => (c.instant ? { opacity: 1, x: 0 } : c.reduce ? { opacity: 0 } : { opacity: 0, x: c.dir * 10, filter: \"blur(2px)\" }),\n  center: (c: Slide) => ({ opacity: 1, x: 0, filter: \"blur(0px)\", transition: { duration: c.instant ? 0 : 0.24, ease: ease.out } }),\n  exit: (c: Slide) =>\n    c.instant ? { opacity: 0, transition: { duration: 0 } } : { opacity: 0, x: c.reduce ? 0 : c.dir * -10, filter: c.reduce ? \"blur(0px)\" : \"blur(2px)\", transition: { duration: 0.14, ease: ease.in } },\n};\n\nconst SWEEP_CSS = \"@keyframes stealth-calendar-sweep{from{opacity:0}}\";\n/** How long a swept range takes to travel from its first day to its last, in ms. */\nconst SWEEP_MS = 280;\n\nexport function Calendar(props: CalendarProps) {\n  const {\n    mode = \"single\",\n    value: valueProp,\n    defaultValue,\n    onValueChange,\n    month: monthProp,\n    defaultMonth,\n    onMonthChange,\n    numberOfMonths = 1,\n    min,\n    max,\n    isDateDisabled,\n    maxDays,\n    locale: localeProp,\n    weekStartsOn,\n    showOutsideDays = numberOfMonths === 1,\n    fixedWeeks = true,\n    showWeekNumbers = false,\n    disabled = false,\n    readOnly = false,\n    autoFocus = false,\n    bare = false,\n    labels,\n    className,\n    onPointerDown,\n    ...rest\n  } = props as Base & {\n    mode?: \"single\" | \"range\";\n    value?: AnyValue;\n    defaultValue?: AnyValue;\n    onValueChange?: (v: AnyValue) => void;\n    maxDays?: number;\n  };\n\n  const uid = useId();\n  const reduce = !!useReducedMotion();\n  const today = useToday();\n  const locale = useResolvedLocale(localeProp);\n  const weekStart = useMemo(() => weekStartsOn ?? getWeekStart(locale), [weekStartsOn, locale]);\n  const fmt = useMemo(\n    () => ({\n      caption: new Intl.DateTimeFormat(locale, { month: \"long\", year: \"numeric\" }),\n      day: new Intl.DateTimeFormat(locale, { day: \"numeric\" }),\n      full: new Intl.DateTimeFormat(locale, { weekday: \"long\", day: \"numeric\", month: \"long\", year: \"numeric\" }),\n      weekShort: new Intl.DateTimeFormat(locale, { weekday: \"short\" }),\n      weekLong: new Intl.DateTimeFormat(locale, { weekday: \"long\" }),\n      num: new Intl.NumberFormat(locale),\n    }),\n    [locale],\n  );\n\n  const [value, setValue] = useControllableState<AnyValue>({ value: valueProp, defaultValue: defaultValue ?? null, onChange: onValueChange });\n  const single = mode === \"single\" ? (value as Date | null) : null;\n  const range = mode === \"range\" ? (value as DateRange | null) : null;\n\n  const [monthState, setMonthState] = useControllableState<Date | null>({\n    value: monthProp,\n    defaultValue: defaultMonth ? startOfMonth(defaultMonth) : null,\n    onChange: onMonthChange ? (m) => m && onMonthChange(m) : undefined,\n  });\n  const anchor = single ?? range?.start ?? today;\n  const month = monthState ? startOfMonth(monthState) : anchor ? startOfMonth(anchor) : null;\n  const lastMonth = month ? addMonths(month, numberOfMonths - 1) : null;\n  const inView = (d: Date) => !!month && !!lastMonth && monthIndex(d) >= monthIndex(month) && monthIndex(d) <= monthIndex(lastMonth);\n\n  // Direction of travel, derived whenever the visible month changes, whoever changed it.\n  const mKey = month ? monthIndex(month) : null;\n  const [nav, setNav] = useState<{ key: number | null; dir: number }>({ key: mKey, dir: 1 });\n  if (nav.key !== mKey) setNav({ key: mKey, dir: nav.key == null || mKey == null ? 1 : Math.sign(mKey - nav.key) || 1 });\n  // Keyboard-driven month changes render instantly; pointer ones slide.\n  const [instant, setInstant] = useState(false);\n  const slide: Slide = { dir: nav.key !== mKey && nav.key != null && mKey != null ? Math.sign(mKey - nav.key) || 1 : nav.dir, instant, reduce };\n\n  const isDisabled = (d: Date) =>\n    (min && compareDays(d, min) < 0) ||\n    (max && compareDays(d, max) > 0) ||\n    !!isDateDisabled?.(d) ||\n    (!!maxDays && !!range && !range.end && Math.abs(diffDays(range.start, d)) >= maxDays);\n\n  // Roving focus: one day is tabbable. Falls back to the selection, then today, then the 1st.\n  const [focusedState, setFocused] = useState<Date | null>(null);\n  const selectedAnchor = single ?? range?.start ?? null;\n  const focused = month\n    ? focusedState && inView(focusedState)\n      ? focusedState\n      : clampDate(selectedAnchor && inView(selectedAnchor) ? selectedAnchor : today && inView(today) ? today : month, min, max)\n    : null;\n  const [keyboard, setKeyboard] = useState(false);\n  const [focusRequest, setFocusRequest] = useState<{ key: string; n: number } | null>(autoFocus ? { key: \"\", n: 1 } : null);\n\n  // Range preview: while the end is being picked, the band follows the pointer (or keyboard focus).\n  const [hover, setHover] = useState<Date | null>(null);\n  const pendingStart = range && !range.end ? range.start : null;\n  const previewEnd = pendingStart ? (hover ?? (keyboard ? focused : null)) : null;\n  let bandFrom: Date | null = null;\n  let bandTo: Date | null = null;\n  if (range?.end) [bandFrom, bandTo] = [range.start, range.end];\n  else if (pendingStart && previewEnd) [bandFrom, bandTo] = compareDays(previewEnd, pendingStart) < 0 ? [previewEnd, pendingStart] : [pendingStart, previewEnd];\n  const previewing = !!pendingStart;\n\n  // A range that arrives from outside (a preset, a typed value) sweeps in from its start.\n  // Ranges the person just drew with the pointer were already visible as a preview, so they don't.\n  const rangeKey = range ? `${toISODate(range.start)}:${range.end ? toISODate(range.end) : \"\"}` : \"\";\n  const [own, setOwn] = useState<string | null>(null);\n  const [seen, setSeen] = useState(rangeKey);\n  const [sweep, setSweep] = useState<{ id: number; key: string } | null>(null);\n  if (seen !== rangeKey) {\n    setSeen(rangeKey);\n    if (range?.end && rangeKey !== own && !isSameDay(range.start, range.end)) setSweep({ id: (sweep?.id ?? 0) + 1, key: rangeKey });\n  }\n  useEffect(() => {\n    if (!sweep) return;\n    const t = window.setTimeout(() => setSweep(null), 700);\n    return () => window.clearTimeout(t);\n  }, [sweep]);\n  const sweeping = !reduce && sweep?.key === rangeKey;\n  const sweepSpan = bandFrom && bandTo ? Math.max(1, diffDays(bandFrom, bandTo)) : 1;\n\n  const rootRef = useRef<HTMLDivElement>(null);\n  useEffect(() => {\n    if (!focusRequest) return;\n    const root = rootRef.current;\n    const el = focusRequest.key\n      ? root?.querySelector<HTMLElement>(`[data-present=\"true\"] [data-day=\"${focusRequest.key}\"]:not([data-outside])`)\n      : root?.querySelector<HTMLElement>(`[data-present=\"true\"] [data-day][tabindex=\"0\"]`);\n    el?.focus();\n  }, [focusRequest]);\n\n  function go(delta: number) {\n    if (!month) return;\n    setInstant(false);\n    setKeyboard(false);\n    setMonthState(addMonths(month, delta));\n    setFocused((f) => (f ? addMonths(f, delta) : null));\n  }\n\n  function goToday() {\n    if (!today) return;\n    setInstant(false);\n    setMonthState(addMonths(startOfMonth(today), -(numberOfMonths - 1)));\n    setFocused(today);\n  }\n\n  function moveFocus(target: Date) {\n    if (!month) return;\n    const t = clampDate(target, min, max);\n    setKeyboard(true);\n    setFocused(t);\n    setFocusRequest((r) => ({ key: toISODate(t), n: (r?.n ?? 0) + 1 }));\n    if (!inView(t)) {\n      setInstant(true);\n      setMonthState(compareDays(t, month) < 0 ? startOfMonth(t) : addMonths(startOfMonth(t), -(numberOfMonths - 1)));\n    }\n  }\n\n  function select(d: Date, outside: boolean) {\n    setFocused(d);\n    if (outside) {\n      setInstant(false);\n      setMonthState(compareDays(d, month!) < 0 ? startOfMonth(d) : addMonths(startOfMonth(d), -(numberOfMonths - 1)));\n    }\n    if (readOnly || disabled || isDisabled(d)) return;\n    if (mode === \"single\") {\n      if (!isSameDay(single, d)) setValue(d);\n      return;\n    }\n    let next: DateRange;\n    if (!range || range.end) next = { start: d, end: null };\n    else next = compareDays(d, range.start) < 0 ? { start: d, end: range.start } : { start: range.start, end: d };\n    setOwn(`${toISODate(next.start)}:${next.end ? toISODate(next.end) : \"\"}`);\n    setHover(null);\n    setValue(next);\n  }\n\n  function onGridKeyDown(e: React.KeyboardEvent) {\n    if (!focused || e.altKey || e.metaKey || e.ctrlKey) return;\n    const col = (focused.getDay() - weekStart + 7) % 7;\n    const moves: Record<string, () => Date> = {\n      ArrowLeft: () => addDays(focused, -1),\n      ArrowRight: () => addDays(focused, 1),\n      ArrowUp: () => addDays(focused, -7),\n      ArrowDown: () => addDays(focused, 7),\n      PageUp: () => addMonths(focused, e.shiftKey ? -12 : -1),\n      PageDown: () => addMonths(focused, e.shiftKey ? 12 : 1),\n      Home: () => addDays(focused, -col),\n      End: () => addDays(focused, 6 - col),\n    };\n    const move = moves[e.key];\n    if (!move) return;\n    e.preventDefault();\n    moveFocus(move());\n  }\n\n  function buildMonth(m: Date): Cell[][] {\n    const first = startOfMonth(m);\n    const offset = (first.getDay() - weekStart + 7) % 7;\n    const count = daysInMonth(m.getFullYear(), m.getMonth());\n    const weeks = fixedWeeks ? 6 : Math.ceil((offset + count) / 7);\n    const rows: Cell[][] = [];\n    for (let w = 0; w < weeks; w++) {\n      const row: Cell[] = [];\n      for (let c = 0; c < 7; c++) {\n        const date = addDays(first, w * 7 + c - offset);\n        const outside = date.getMonth() !== m.getMonth();\n        const dis = !!isDisabled(date);\n        const unavailable = !!isDateDisabled?.(date);\n        const isToday = isSameDay(date, today);\n        let selected = false;\n        let endpoint = false;\n        let band: Band = null;\n        if (!outside) {\n          if (single) selected = isSameDay(date, single);\n          if (range) {\n            const isStart = isSameDay(date, range.start) || (previewing && isSameDay(date, bandFrom));\n            const isEnd = isSameDay(date, range.end) || (previewing && isSameDay(date, bandTo));\n            selected = isSameDay(date, range.start) || isSameDay(date, range.end);\n            endpoint = isStart || isEnd;\n            if (bandFrom && bandTo && !isSameDay(bandFrom, bandTo) && compareDays(date, bandFrom) >= 0 && compareDays(date, bandTo) <= 0) {\n              const edgeL = c === 0 || date.getDate() === 1;\n              const edgeR = c === 6 || date.getDate() === count;\n              const kind = isSameDay(date, bandFrom) ? \"start\" : isSameDay(date, bandTo) ? \"end\" : \"mid\";\n              // A start at the end of a row (or an end at the start of one) has nothing to connect to.\n              if (!((kind === \"start\" && edgeR) || (kind === \"end\" && edgeL))) {\n                band = { kind, roundL: edgeL, roundR: edgeR, preview: previewing, delay: (diffDays(bandFrom, date) / sweepSpan) * SWEEP_MS };\n              }\n            }\n          }\n        }\n        const key = toISODate(date);\n        row.push({\n          date,\n          key,\n          label: fmt.full.format(date),\n          // Just the day part: ja-JP formats a bare day as 22日, which crowds the grid.\n          numeral: fmt.day.formatToParts(date).find((p) => p.type === \"day\")?.value ?? String(date.getDate()),\n          outside,\n          hidden: outside && !showOutsideDays,\n          today: isToday,\n          selected,\n          endpoint,\n          disabled: dis,\n          unavailable,\n          tabbable: !disabled && !outside && isSameDay(date, focused),\n          band,\n          landsLate: sweeping && !!range?.end && isSameDay(date, range.end),\n        });\n      }\n      rows.push(row);\n    }\n    return rows;\n  }\n\n  const weekdays = useMemo(() => {\n    // Jan 4 2026 is a Sunday; walk from the locale's first day.\n    return Array.from({ length: 7 }, (_, i) => {\n      const d = new Date(2026, 0, 4 + ((weekStart + i) % 7));\n      return { short: fmt.weekShort.format(d), long: fmt.weekLong.format(d) };\n    });\n  }, [weekStart, fmt]);\n\n  const canPrev = !!month && !(min && monthIndex(month) <= monthIndex(min));\n  const canNext = !!lastMonth && !(max && monthIndex(lastMonth) >= monthIndex(max));\n  const todayInView = !!today && inView(today);\n  const multi = numberOfMonths > 1;\n\n  return (\n    <div\n      ref={rootRef}\n      data-disabled={disabled || undefined}\n      data-readonly={readOnly || undefined}\n      data-mode={mode}\n      onPointerDown={(e) => {\n        onPointerDown?.(e);\n        setKeyboard(false);\n      }}\n      className={cn(\n        \"inline-flex select-none flex-col text-fg\",\n        !bare && \"rounded-xl border border-line bg-raised p-3 shadow-[var(--shadow)]\",\n        disabled && \"pointer-events-none opacity-50\",\n        className,\n      )}\n      {...rest}\n    >\n      {/* Hoisted and deduped by React: the one keyframe the range sweep needs. */}\n      <style href=\"stealth-calendar\" precedence=\"default\">\n        {SWEEP_CSS}\n      </style>\n      <div className={cn(\"flex flex-col gap-5\", multi && \"sm:flex-row sm:gap-6\")}>\n        {Array.from({ length: numberOfMonths }, (_, i) => {\n          const m = month ? addMonths(month, i) : null;\n          const first = i === 0;\n          const last = i === numberOfMonths - 1;\n          const captionId = `${uid}-caption-${i}`;\n          return (\n            <div key={i} className=\"flex flex-col gap-2\">\n              <div className=\"flex h-8 items-center gap-1\">\n                {multi && (first ? <NavButton side=\"prev\" label={labels?.previousMonth} disabled={!canPrev || disabled} onClick={() => go(-1)} /> : <span className=\"size-7 shrink-0 pointer-coarse:size-10\" />)}\n                <div className={cn(\"relative h-5 min-w-0 flex-1 overflow-hidden\", multi ? \"text-center\" : \"pl-1.5\")}>\n                  <AnimatePresence initial={false} custom={slide} mode=\"popLayout\">\n                    {m && (\n                      <motion.div\n                        key={monthIndex(m)}\n                        id={captionId}\n                        custom={slide}\n                        variants={captionVariants}\n                        initial=\"enter\"\n                        animate=\"center\"\n                        exit=\"exit\"\n                        aria-live={first ? \"polite\" : undefined}\n                        className=\"truncate text-[13px] font-medium leading-5 tracking-[-0.01em]\"\n                      >\n                        {fmt.caption.format(m)}\n                      </motion.div>\n                    )}\n                  </AnimatePresence>\n                </div>\n                {!multi && (\n                  <AnimatePresence initial={false}>\n                    {today && !todayInView && (\n                      <motion.button\n                        type=\"button\"\n                        key=\"today\"\n                        onClick={goToday}\n                        disabled={disabled}\n                        initial={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.9, filter: \"blur(2px)\" }}\n                        animate={{ opacity: 1, scale: 1, filter: \"blur(0px)\" }}\n                        exit={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.9, transition: { duration: 0.12 } }}\n                        transition={{ duration: 0.2, ease: ease.out }}\n                        className=\"h-7 shrink-0 rounded-md px-2 text-[12px] font-medium text-fg-2 outline-none transition-[background-color,color,scale] duration-150 hover:bg-hover hover:text-fg focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-offset-2 focus-visible:outline-fg-3 active:scale-[0.95] active:duration-75 pointer-coarse:h-10\"\n                      >\n                        {labels?.today ?? \"Today\"}\n                      </motion.button>\n                    )}\n                  </AnimatePresence>\n                )}\n                {!multi && <NavButton side=\"prev\" label={labels?.previousMonth} disabled={!canPrev || disabled} onClick={() => go(-1)} />}\n                {(!multi || last) && <NavButton side=\"next\" label={labels?.nextMonth} disabled={!canNext || disabled} onClick={() => go(1)} />}\n                {multi && !last && <span className=\"size-7 shrink-0 pointer-coarse:size-10\" />}\n              </div>\n\n              <div>\n              {/* Weekday names stay put while the days slide under them. The grid keeps its own (visually hidden)\n                  column headers for screen readers. */}\n              <div aria-hidden className=\"flex\">\n                {showWeekNumbers && <span className=\"grid h-7 w-7 place-items-center font-mono text-[10px] uppercase tracking-[0.06em] text-fg-4\">Wk</span>}\n                {weekdays.map((d) => (\n                  <span key={d.long} className=\"grid h-7 w-9 place-items-center text-[11px] text-fg-3 pointer-coarse:w-11\">\n                    {d.short}\n                  </span>\n                ))}\n              </div>\n              <div className=\"relative -m-1 overflow-hidden p-1\">\n                {m ? (\n                  <AnimatePresence initial={false} custom={slide} mode=\"popLayout\">\n                    <MonthGrid\n                      key={monthIndex(m)}\n                      slide={slide}\n                      rows={buildMonth(m)}\n                      weekdays={weekdays}\n                      captionId={captionId}\n                      weekNumbers={showWeekNumbers}\n                      num={fmt.num}\n                      sweeping={sweeping}\n                      sweepId={sweep?.id ?? 0}\n                      readOnly={readOnly}\n                      onKeyDown={onGridKeyDown}\n                      onSelect={select}\n                      onHover={previewing ? setHover : undefined}\n                    />\n                  </AnimatePresence>\n                ) : (\n                  <GridPlaceholder weekNumbers={showWeekNumbers} />\n                )}\n              </div>\n              </div>\n            </div>\n          );\n        })}\n      </div>\n    </div>\n  );\n}\n\nfunction NavButton({ side, label, disabled, onClick }: { side: \"prev\" | \"next\"; label?: string; disabled: boolean; onClick: () => void }) {\n  const Icon = side === \"prev\" ? ChevronLeft : ChevronRight;\n  return (\n    <button\n      type=\"button\"\n      onClick={onClick}\n      disabled={disabled}\n      aria-label={label ?? (side === \"prev\" ? \"Previous month\" : \"Next month\")}\n      className={cn(\n        \"group/nav grid size-7 shrink-0 place-items-center rounded-md text-fg-2 outline-none pointer-coarse:size-10\",\n        \"transition-[background-color,color,scale] duration-150 hover:bg-hover hover:text-fg active:scale-[0.92] active:duration-75\",\n        \"focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-offset-2 focus-visible:outline-fg-3\",\n        \"disabled:pointer-events-none disabled:opacity-35\",\n      )}\n    >\n      {/* The chevron leans a pixel toward where it will take you. */}\n      <Icon\n        className={cn(\n          \"transition-transform duration-200 ease-out-expo\",\n          side === \"prev\" ? \"group-hover/nav:-translate-x-px group-active/nav:-translate-x-0.5\" : \"group-hover/nav:translate-x-px group-active/nav:translate-x-0.5\",\n        )}\n      />\n    </button>\n  );\n}\n\ntype GridProps = {\n  ref?: React.Ref<HTMLTableElement>;\n  slide: Slide;\n  rows: Cell[][];\n  weekdays: { short: string; long: string }[];\n  captionId: string;\n  weekNumbers: boolean;\n  num: Intl.NumberFormat;\n  sweeping: boolean;\n  sweepId: number;\n  readOnly: boolean;\n  onKeyDown: (e: React.KeyboardEvent) => void;\n  onSelect: (d: Date, outside: boolean) => void;\n  onHover?: (d: Date | null) => void;\n};\n\nfunction MonthGrid({ ref, slide, rows, weekdays, captionId, weekNumbers, num, sweeping, sweepId, readOnly, onKeyDown, onSelect, onHover }: GridProps) {\n  // Exiting grids linger for their exit animation; they must not take focus or clicks.\n  const present = useIsPresent();\n  return (\n    <motion.table\n      ref={ref}\n      role=\"grid\"\n      aria-labelledby={captionId}\n      aria-readonly={readOnly || undefined}\n      data-present={present}\n      inert={!present}\n      custom={slide}\n      variants={gridVariants}\n      initial=\"enter\"\n      animate=\"center\"\n      exit=\"exit\"\n      onKeyDown={onKeyDown}\n      onPointerLeave={() => onHover?.(null)}\n      className=\"border-separate border-spacing-x-0 border-spacing-y-0.5\"\n    >\n      <thead>\n        <tr>\n          {weekNumbers && (\n            <th scope=\"col\" abbr=\"Week\" className=\"sr-only\">\n              Wk\n            </th>\n          )}\n          {weekdays.map((d) => (\n            <th key={d.long} scope=\"col\" abbr={d.long} className=\"sr-only\">\n              {d.long}\n            </th>\n          ))}\n        </tr>\n      </thead>\n      <tbody>\n        {rows.map((row, r) => (\n          <tr key={r}>\n            {weekNumbers && (\n              <th scope=\"row\" className=\"h-9 w-7 text-center align-middle font-mono text-[10px] font-normal tabular text-fg-4 pointer-coarse:h-11\">\n                {num.format(isoWeek(row[3].date))}\n              </th>\n            )}\n            {row.map((cell) => (\n              <DayCell key={cell.key} cell={cell} sweeping={sweeping} sweepId={sweepId} readOnly={readOnly} onSelect={onSelect} onHover={onHover} />\n            ))}\n          </tr>\n        ))}\n      </tbody>\n    </motion.table>\n  );\n}\n\nfunction DayCell({\n  cell,\n  sweeping,\n  sweepId,\n  readOnly,\n  onSelect,\n  onHover,\n}: {\n  cell: Cell;\n  sweeping: boolean;\n  sweepId: number;\n  readOnly: boolean;\n  onSelect: (d: Date, outside: boolean) => void;\n  onHover?: (d: Date | null) => void;\n}) {\n  const { band } = cell;\n  if (cell.hidden) return <td role=\"presentation\" className=\"size-9 p-0 pointer-coarse:size-11\" />;\n  return (\n    <td role=\"gridcell\" aria-selected={cell.outside ? undefined : cell.selected} className=\"relative size-9 p-0 pointer-coarse:size-11\">\n      {band && (\n        <span\n          // Remounted per sweep so the keyframe replays even over days that were already in range.\n          key={sweeping ? `s${sweepId}` : \"band\"}\n          aria-hidden\n          data-preview={band.preview || undefined}\n          style={sweeping ? { animationDelay: `${Math.round(band.delay)}ms` } : undefined}\n          className={cn(\n            \"pointer-events-none absolute inset-y-0 transition-colors duration-150\",\n            band.kind === \"start\" ? \"left-1/2 right-0\" : band.kind === \"end\" ? \"left-0 right-1/2\" : \"inset-x-0\",\n            band.kind === \"mid\" && band.roundL && \"rounded-l-lg\",\n            band.kind === \"mid\" && band.roundR && \"rounded-r-lg\",\n            band.preview ? \"bg-fg/[0.05]\" : \"bg-fg/[0.09]\",\n            sweeping && \"animate-[stealth-calendar-sweep_220ms_var(--ease-out-quart)_both]\",\n          )}\n        />\n      )}\n      <button\n        type=\"button\"\n        data-day={cell.key}\n        data-outside={cell.outside || undefined}\n        data-selected={cell.selected || undefined}\n        data-today={cell.today || undefined}\n        data-disabled={cell.disabled || undefined}\n        tabIndex={cell.tabbable ? 0 : -1}\n        aria-label={cell.label}\n        aria-current={cell.today ? \"date\" : undefined}\n        aria-disabled={cell.disabled || undefined}\n        onClick={() => onSelect(cell.date, cell.outside)}\n        onPointerEnter={onHover && !cell.outside && !cell.disabled ? () => onHover(cell.date) : undefined}\n        className={cn(\n          \"group/day relative grid size-9 place-items-center rounded-lg text-[13px] tabular outline-none pointer-coarse:size-11\",\n          \"transition-[color,scale] duration-150 focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-offset-1 focus-visible:outline-fg-3\",\n          cell.outside ? \"text-fg-4\" : \"text-fg\",\n          cell.today && \"font-medium\",\n          cell.selected && \"text-frame\",\n          cell.endpoint && !cell.selected && \"text-fg\",\n          cell.disabled\n            ? cn(\"cursor-not-allowed text-fg-4\", cell.unavailable && \"line-through decoration-fg-4\")\n            : cn(!readOnly && \"active:scale-[0.92] active:duration-75\", !cell.selected && !readOnly && \"hover:bg-hover\"),\n          cell.disabled && cell.selected && \"text-frame\",\n        )}\n      >\n        {/* The fill pops from 60% when picked and fades faster when it leaves. CSS transitions don't run on mount, so\n            navigating to a month that already holds the selection shows it at rest. */}\n        <span\n          aria-hidden\n          data-on={cell.selected}\n          style={cell.landsLate ? { transitionDelay: `${SWEEP_MS - 60}ms` } : undefined}\n          className=\"absolute inset-0 scale-[0.6] rounded-lg bg-fg opacity-0 transition-[scale,opacity] duration-100 ease-out data-[on=true]:scale-100 data-[on=true]:opacity-100 data-[on=true]:duration-200 data-[on=true]:ease-out-expo\"\n        />\n        {/* A pending range end, previewed under the pointer, gets an outline instead of a fill. */}\n        {cell.endpoint && !cell.selected && <span aria-hidden className=\"absolute inset-0 rounded-lg border border-fg-3\" />}\n        <span className=\"relative\">{cell.numeral}</span>\n        {cell.today && <span aria-hidden className=\"absolute bottom-1 left-1/2 size-1 -translate-x-1/2 rounded-full bg-current opacity-70 pointer-coarse:bottom-1.5\" />}\n      </button>\n    </td>\n  );\n}\n\nfunction GridPlaceholder({ weekNumbers }: { weekNumbers: boolean }) {\n  // Same footprint as a real month, for the moment before the browser says what today is.\n  return (\n    <div aria-hidden className=\"grid gap-y-0.5 pt-0.5\" style={{ gridTemplateColumns: `repeat(${weekNumbers ? 8 : 7}, auto)` }}>\n      {Array.from({ length: (weekNumbers ? 8 : 7) * 6 }, (_, i) => (\n        <span key={i} className={cn(\"h-9 pointer-coarse:h-11\", weekNumbers && i % 8 === 0 ? \"w-7\" : \"w-9 pointer-coarse:w-11\")} />\n      ))}\n    </div>\n  );\n}\n\nfunction isoWeek(d: Date) {\n  const t = new Date(Date.UTC(d.getFullYear(), d.getMonth(), d.getDate()));\n  t.setUTCDate(t.getUTCDate() + 4 - (t.getUTCDay() || 7));\n  const yearStart = new Date(Date.UTC(t.getUTCFullYear(), 0, 1));\n  return Math.ceil(((t.getTime() - yearStart.getTime()) / 864e5 + 1) / 7);\n}\n",
      "type": "registry:ui"
    }
  ],
  "docs": "https://www.stealth.pm/ui/calendar"
}
