{
  "schemaVersion": 1,
  "name": "calendar",
  "useFor": "Use to pick a day, several days or a range (a delivery log window, a report period) when the month view helps. Set mode to single, multiple or range, pass selected and onSelect, and disable days that cannot be picked. For a field that opens a calendar, use Date Picker.",
  "constraints": [
    "Round day keys",
    "A dark selected day",
    "Animating between months"
  ],
  "documentation": "",
  "source": "Extrapolated from the Inkbox console (Figma 427:2715): 8px keys, the lilac wash and the purple action",
  "exports": [
    "Calendar",
    "CalendarDayButton",
    "CalendarProps",
    "DateRange"
  ],
  "client": true,
  "docsUrl": "https://inkbox-ds.vercel.app/components/calendar",
  "markdownUrl": "https://inkbox-ds.vercel.app/r/calendar.md",
  "examples": [
    {
      "path": "examples/calendar.tsx",
      "content": "\"use client\";\n\nimport * as React from \"react\";\n\nimport { Calendar, type DateRange } from \"@/components/ui/calendar\";\n\nconst TODAY = new Date(2026, 7, 8);\nconst FRAME = \"w-fit rounded-control border border-line-field bg-surface\";\n\nexport function Preview() {\n  const [date, setDate] = React.useState<Date | undefined>(TODAY);\n  return <Calendar mode=\"single\" selected={date} onSelect={setDate} today={TODAY} className={FRAME} />;\n}\n\nexport function Range() {\n  const [range, setRange] = React.useState<DateRange | undefined>({ from: new Date(2026, 7, 4), to: new Date(2026, 7, 12) });\n  return <Calendar mode=\"range\" selected={range} onSelect={setRange} today={TODAY} numberOfMonths={2} className={FRAME} />;\n}\n\nexport function Multiple() {\n  const [days, setDays] = React.useState<Date[]>([new Date(2026, 7, 3), new Date(2026, 7, 10), new Date(2026, 7, 17)]);\n  return <Calendar mode=\"multiple\" selected={days} onSelect={setDays} today={TODAY} weekStartsOn={1} className={FRAME} />;\n}\n\nexport function Disabled() {\n  const [date, setDate] = React.useState<Date | undefined>();\n  return <Calendar mode=\"single\" selected={date} onSelect={setDate} today={TODAY} disabled={[{ before: TODAY }, { dayOfWeek: [0, 6] }]} showOutsideDays={false} className={FRAME} />;\n}\n\nexport function Dropdown() {\n  const [date, setDate] = React.useState<Date | undefined>(new Date(1994, 4, 21));\n  return <Calendar mode=\"single\" selected={date} onSelect={setDate} today={TODAY} captionLayout=\"dropdown\" fromYear={1940} toYear={2026} className={FRAME} />;\n}\n",
      "exports": [
        "Preview",
        "Range",
        "Multiple",
        "Disabled",
        "Dropdown"
      ],
      "requires": [
        "calendar"
      ],
      "packages": [],
      "previews": [
        {
          "name": "Preview",
          "url": "https://inkbox-ds.vercel.app/preview/calendar/Preview"
        },
        {
          "name": "Range",
          "url": "https://inkbox-ds.vercel.app/preview/calendar/Range"
        },
        {
          "name": "Multiple",
          "url": "https://inkbox-ds.vercel.app/preview/calendar/Multiple"
        },
        {
          "name": "Disabled",
          "url": "https://inkbox-ds.vercel.app/preview/calendar/Disabled"
        },
        {
          "name": "Dropdown",
          "url": "https://inkbox-ds.vercel.app/preview/calendar/Dropdown"
        }
      ]
    }
  ],
  "type": "ui",
  "title": "Calendar",
  "description": "A month grid of 36px keys: today in purple, selected days as purple keys, a range on a lilac band.",
  "surface": "site",
  "kinds": [],
  "requires": [
    "theme",
    "utils"
  ],
  "packages": [
    "@phosphor-icons/react"
  ],
  "files": [
    {
      "path": "components/ui/calendar.tsx",
      "content": "\"use client\";\n\nimport * as React from \"react\";\nimport { CaretDownIcon, CaretLeftIcon, CaretRightIcon } from \"@phosphor-icons/react/ssr\";\n\nimport { cn } from \"@/lib/utils\";\n\n// A month grid (extrapolated from the Inkbox console, Figma 427:2715: its 8px keys, the lilac wash and the purple\n// action). The caption is 14px medium ink between two ghost caret keys; weekdays are 12px muted; days are 36px keys with\n// 8px corners and 14px ink. Today is purple and medium; a selected day is the purple key with white text; the days\n// inside a range sit on a lilac band between its two purple ends; days of the next and last month are faint. Arrow keys\n// move a day or a week, Home and End go to the week's ends, Page Up and Page Down change the month (with Shift, the\n// year), Enter or Space selects. Changing month is instant: it happens many times in a row.\n\ntype DateRange = { from: Date | undefined; to?: Date | undefined };\ntype Matcher = Date | Date[] | ((date: Date) => boolean) | { before?: Date; after?: Date } | { from: Date; to: Date } | { dayOfWeek: number[] };\n\ntype CommonProps = {\n  /** The month shown first (controlled). */\n  month?: Date;\n  defaultMonth?: Date;\n  onMonthChange?: (month: Date) => void;\n  numberOfMonths?: number;\n  /** Show the days of the months before and after in the grid (default true). */\n  showOutsideDays?: boolean;\n  /** 0 is Sunday (default), 1 is Monday. */\n  weekStartsOn?: 0 | 1 | 2 | 3 | 4 | 5 | 6;\n  disabled?: Matcher | Matcher[];\n  /** \"label\" (the default) or \"dropdown\": month and year selects in the caption. */\n  captionLayout?: \"label\" | \"dropdown\";\n  fromYear?: number;\n  toYear?: number;\n  /** Today, for the marker (defaults to the current date). */\n  today?: Date;\n  locale?: string;\n  className?: string;\n  autoFocus?: boolean;\n};\n\ntype SingleProps = { mode?: \"single\"; selected?: Date; onSelect?: (date: Date | undefined) => void; required?: boolean };\ntype MultipleProps = { mode: \"multiple\"; selected?: Date[]; onSelect?: (dates: Date[]) => void; max?: number };\ntype RangeProps = { mode: \"range\"; selected?: DateRange; onSelect?: (range: DateRange | undefined) => void; min?: number; max?: number };\n\ntype CalendarProps = CommonProps & (SingleProps | MultipleProps | RangeProps);\n\nconst day0 = (d: Date) => new Date(d.getFullYear(), d.getMonth(), d.getDate());\nconst sameDay = (a?: Date, b?: Date) => !!a && !!b && a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth() && a.getDate() === b.getDate();\nconst addDays = (d: Date, n: number) => new Date(d.getFullYear(), d.getMonth(), d.getDate() + n);\nconst addMonths = (d: Date, n: number) => {\n  const target = new Date(d.getFullYear(), d.getMonth() + n, 1);\n  const last = new Date(target.getFullYear(), target.getMonth() + 1, 0).getDate();\n  return new Date(target.getFullYear(), target.getMonth(), Math.min(d.getDate(), last));\n};\nconst startOfMonth = (d: Date) => new Date(d.getFullYear(), d.getMonth(), 1);\nconst diffDays = (a: Date, b: Date) => Math.round((day0(a).getTime() - day0(b).getTime()) / 86_400_000);\n\nfunction matches(date: Date, matcher: Matcher): boolean {\n  if (matcher instanceof Date) return sameDay(date, matcher);\n  if (Array.isArray(matcher)) return matcher.some((d) => sameDay(date, d));\n  if (typeof matcher === \"function\") return matcher(date);\n  if (\"dayOfWeek\" in matcher) return matcher.dayOfWeek.includes(date.getDay());\n  if (\"from\" in matcher && \"to\" in matcher) return diffDays(date, matcher.from) >= 0 && diffDays(matcher.to, date) >= 0;\n  const m = matcher as { before?: Date; after?: Date };\n  return (!!m.before && diffDays(date, m.before) < 0) || (!!m.after && diffDays(date, m.after) > 0);\n}\n\n/** The weeks shown for a month: whole weeks from the one holding the 1st. */\nfunction weeksOf(month: Date, weekStartsOn: number) {\n  const first = startOfMonth(month);\n  const start = addDays(first, -((first.getDay() - weekStartsOn + 7) % 7));\n  const weeks: Date[][] = [];\n  for (let w = 0; w < 6; w++) {\n    const week = Array.from({ length: 7 }, (_, i) => addDays(start, w * 7 + i));\n    if (w > 3 && week[0].getMonth() !== month.getMonth()) break;\n    weeks.push(week);\n  }\n  return weeks;\n}\n\nfunction Calendar(props: CalendarProps) {\n  const { month: monthProp, defaultMonth, onMonthChange, numberOfMonths = 1, showOutsideDays = true, weekStartsOn = 0, disabled, captionLayout = \"label\", fromYear, toYear, locale, className, autoFocus } = props;\n  const today = React.useMemo(() => day0(props.today ?? new Date()), [props.today]);\n  const initial = (() => {\n    if (props.mode === \"range\") return props.selected?.from;\n    if (props.mode === \"multiple\") return props.selected?.[0];\n    return props.selected;\n  })();\n  const [monthState, setMonthState] = React.useState(() => startOfMonth(defaultMonth ?? initial ?? today));\n  const month = monthProp ? startOfMonth(monthProp) : monthState;\n  const setMonth = (m: Date) => {\n    const next = startOfMonth(m);\n    if (!monthProp) setMonthState(next);\n    onMonthChange?.(next);\n  };\n  const [focused, setFocused] = React.useState<Date | undefined>(undefined);\n  const grid = React.useRef<HTMLDivElement>(null);\n  const focusPending = React.useRef(!!autoFocus);\n\n  const isDisabled = (d: Date) => (disabled ? (Array.isArray(disabled) && !(disabled[0] instanceof Date) ? (disabled as Matcher[]).some((m) => matches(d, m)) : matches(d, disabled as Matcher)) : false);\n\n  const state = (d: Date) => {\n    if (props.mode === \"range\") {\n      const { from, to } = props.selected ?? { from: undefined };\n      const start = sameDay(d, from);\n      const end = sameDay(d, to ?? from);\n      const middle = !!from && !!to && diffDays(d, from) > 0 && diffDays(to, d) > 0;\n      return { selected: start || end || middle, start: start && !!to && !sameDay(from, to), end: end && !!to && !sameDay(from, to), middle };\n    }\n    if (props.mode === \"multiple\") return { selected: !!props.selected?.some((s) => sameDay(s, d)), start: false, end: false, middle: false };\n    return { selected: sameDay(props.selected, d), start: false, end: false, middle: false };\n  };\n\n  const select = (d: Date) => {\n    if (isDisabled(d)) return;\n    if (props.mode === \"range\") {\n      const cur = props.selected;\n      if (!cur?.from || cur.to) {\n        props.onSelect?.({ from: d, to: undefined });\n        return;\n      }\n      const [from, to] = diffDays(d, cur.from) < 0 ? [d, cur.from] : [cur.from, d];\n      const length = diffDays(to, from);\n      if ((props.max && length > props.max) || (props.min && length < props.min)) {\n        props.onSelect?.({ from: d, to: undefined });\n        return;\n      }\n      props.onSelect?.({ from, to });\n    } else if (props.mode === \"multiple\") {\n      const cur = props.selected ?? [];\n      const has = cur.some((s) => sameDay(s, d));\n      if (has) props.onSelect?.(cur.filter((s) => !sameDay(s, d)));\n      else if (!props.max || cur.length < props.max) props.onSelect?.([...cur, d]);\n    } else {\n      const p = props as SingleProps;\n      p.onSelect?.(sameDay(p.selected, d) && !p.required ? undefined : d);\n    }\n    if (d.getMonth() !== month.getMonth() && numberOfMonths === 1) setMonth(d);\n  };\n\n  // The day that takes Tab focus in the grid: the focused day, else the selection, else today, else the 1st.\n  const months = Array.from({ length: numberOfMonths }, (_, i) => addMonths(month, i));\n  const inView = (d: Date) => months.some((m) => m.getMonth() === d.getMonth() && m.getFullYear() === d.getFullYear());\n  const tabDay = [focused, initial, today, month].find((d) => d && inView(d))!;\n\n  // Focus the moved-to day, or with autoFocus the day that takes Tab focus (the selection, else today) on mount.\n  const target = focused ?? tabDay;\n  React.useEffect(() => {\n    if (!focusPending.current || !target) return;\n    focusPending.current = false;\n    grid.current?.querySelector<HTMLButtonElement>(`[data-day=\"${target.toDateString()}\"]`)?.focus();\n  }, [target, month]);\n\n  const move = (d: Date) => {\n    if (!inView(d)) setMonth(diffDays(d, month) < 0 ? d : addMonths(d, -(numberOfMonths - 1)));\n    focusPending.current = true;\n    setFocused(d);\n  };\n\n  const onKeyDown = (event: React.KeyboardEvent, d: Date) => {\n    const weekStart = addDays(d, -((d.getDay() - weekStartsOn + 7) % 7));\n    const keys: Record<string, () => Date> = {\n      ArrowLeft: () => addDays(d, -1),\n      ArrowRight: () => addDays(d, 1),\n      ArrowUp: () => addDays(d, -7),\n      ArrowDown: () => addDays(d, 7),\n      Home: () => weekStart,\n      End: () => addDays(weekStart, 6),\n      PageUp: () => addMonths(d, event.shiftKey ? -12 : -1),\n      PageDown: () => addMonths(d, event.shiftKey ? 12 : 1),\n    };\n    const next = keys[event.key];\n    if (!next) return;\n    event.preventDefault();\n    move(next());\n  };\n\n  const fmt = (opts: Intl.DateTimeFormatOptions) => new Intl.DateTimeFormat(locale, opts);\n  const weekdayNames = Array.from({ length: 7 }, (_, i) => addDays(new Date(2023, 0, 1 + weekStartsOn), i));\n  const years = (() => {\n    const from = fromYear ?? today.getFullYear() - 100;\n    const to = toYear ?? today.getFullYear() + 10;\n    return Array.from({ length: to - from + 1 }, (_, i) => from + i);\n  })();\n\n  return (\n    <div data-slot=\"calendar\" className={cn(\"w-fit p-3 font-sans text-fg\", className)}>\n      <div ref={grid} className=\"flex flex-col gap-4 sm:flex-row\">\n        {months.map((m, index) => (\n          <div key={m.toISOString()} data-slot=\"calendar-month\" className=\"flex flex-col gap-3\">\n            <div data-slot=\"calendar-caption\" className=\"relative flex h-8 items-center justify-center\">\n              {index === 0 && (\n                <button type=\"button\" aria-label=\"Go to the previous month\" onClick={() => setMonth(addMonths(month, -1))} className={NAV_BUTTON + \" absolute left-0\"}>\n                  <CaretLeftIcon />\n                </button>\n              )}\n              {captionLayout === \"dropdown\" && index === 0 ? (\n                <div className=\"flex items-center gap-1.5\">\n                  <CaptionSelect label=\"Month\" value={m.getMonth()} onChange={(v) => setMonth(new Date(m.getFullYear(), v, 1))} options={Array.from({ length: 12 }, (_, i) => ({ value: i, label: fmt({ month: \"short\" }).format(new Date(2023, i, 1)) }))} />\n                  <CaptionSelect label=\"Year\" value={m.getFullYear()} onChange={(v) => setMonth(new Date(v, m.getMonth(), 1))} options={years.map((y) => ({ value: y, label: String(y) }))} />\n                </div>\n              ) : (\n                <p aria-live=\"polite\" className=\"text-body leading-tight font-medium text-fg\">\n                  {fmt({ month: \"long\", year: \"numeric\" }).format(m)}\n                </p>\n              )}\n              {index === months.length - 1 && (\n                <button type=\"button\" aria-label=\"Go to the next month\" onClick={() => setMonth(addMonths(month, 1))} className={NAV_BUTTON + \" absolute right-0\"}>\n                  <CaretRightIcon />\n                </button>\n              )}\n            </div>\n            <table role=\"grid\" aria-label={fmt({ month: \"long\", year: \"numeric\" }).format(m)} className=\"border-collapse\">\n              <thead>\n                <tr>\n                  {weekdayNames.map((w) => (\n                    <th key={w.getDay()} scope=\"col\" aria-label={fmt({ weekday: \"long\" }).format(w)} className=\"size-9 p-0 text-caption leading-tight font-normal text-fg-muted\">\n                      {fmt({ weekday: \"short\" }).format(w).slice(0, 2)}\n                    </th>\n                  ))}\n                </tr>\n              </thead>\n              <tbody>\n                {weeksOf(m, weekStartsOn).map((week) => (\n                  <tr key={week[0].toISOString()} className=\"mt-0.5\">\n                    {week.map((d) => {\n                      const outside = d.getMonth() !== m.getMonth();\n                      if (outside && !showOutsideDays) return <td key={d.toISOString()} className=\"size-9 p-0\" />;\n                      const s = state(d);\n                      const off = isDisabled(d);\n                      return (\n                        <td\n                          key={d.toISOString()}\n                          role=\"gridcell\"\n                          aria-selected={s.selected || undefined}\n                          data-range-start={s.start || undefined}\n                          data-range-end={s.end || undefined}\n                          data-range-middle={s.middle || undefined}\n                          className=\"relative size-9 p-0 py-0.5 text-center data-range-end:bg-[linear-gradient(to_left,transparent_50%,var(--ds-color-bg-subtle)_50%)] data-range-middle:bg-subtle data-range-start:bg-[linear-gradient(to_right,transparent_50%,var(--ds-color-bg-subtle)_50%)] data-range-start:[background-clip:content-box] data-range-end:[background-clip:content-box] data-range-middle:[background-clip:content-box]\"\n                        >\n                          <CalendarDayButton\n                            date={d}\n                            label={fmt({ weekday: \"long\", month: \"long\", day: \"numeric\", year: \"numeric\" }).format(d)}\n                            today={sameDay(d, today)}\n                            outside={outside}\n                            disabled={off}\n                            selected={s.selected && !s.middle}\n                            middle={s.middle}\n                            tabIndex={sameDay(d, tabDay) && !outside ? 0 : -1}\n                            onClick={() => select(d)}\n                            onFocus={() => setFocused(d)}\n                            onKeyDown={(e) => onKeyDown(e, d)}\n                          />\n                        </td>\n                      );\n                    })}\n                  </tr>\n                ))}\n              </tbody>\n            </table>\n          </div>\n        ))}\n      </div>\n    </div>\n  );\n}\n\nconst NAV_BUTTON =\n  \"inline-flex size-8 items-center justify-center rounded-control text-icon-secondary outline-none transition-[background-color,color,scale] duration-150 ease-out hover:bg-subtle hover:text-fg active:scale-97 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-focus-ring motion-reduce:transition-none [&_svg]:size-4\";\n\nfunction CaptionSelect({ label, value, options, onChange }: { label: string; value: number; options: { value: number; label: string }[]; onChange: (value: number) => void }) {\n  return (\n    <span className=\"relative inline-flex\">\n      <select\n        aria-label={label}\n        value={value}\n        onChange={(e) => onChange(Number(e.target.value))}\n        className=\"h-8 appearance-none rounded-control bg-surface pr-7 pl-2.5 font-sans text-body leading-tight font-medium text-fg shadow-s outline-none focus-visible:shadow-focus\"\n      >\n        {options.map((o) => (\n          <option key={o.value} value={o.value}>\n            {o.label}\n          </option>\n        ))}\n      </select>\n      <CaretDownIcon aria-hidden className=\"pointer-events-none absolute top-1/2 right-2 size-3.5 -translate-y-1/2 text-fg\" />\n    </span>\n  );\n}\n\ntype DayButtonProps = Omit<React.ComponentProps<\"button\">, \"children\"> & { date: Date; label: string; today?: boolean; outside?: boolean; selected?: boolean; middle?: boolean };\n\n/** One day: a 36px key with 8px corners. Exported so an app can render a day its own way inside the same grid. */\nfunction CalendarDayButton({ date, label, today, outside, selected, middle, disabled, className, ...props }: DayButtonProps) {\n  return (\n    <button\n      type=\"button\"\n      data-slot=\"calendar-day\"\n      data-day={date.toDateString()}\n      data-today={today || undefined}\n      data-outside={outside || undefined}\n      data-selected={selected || undefined}\n      data-range-middle={middle || undefined}\n      aria-label={label}\n      aria-pressed={selected || middle || undefined}\n      aria-current={today ? \"date\" : undefined}\n      disabled={disabled}\n      className={cn(\n        \"relative mx-auto flex size-9 items-center justify-center rounded-control font-sans text-body leading-tight text-fg tabular-nums outline-none transition-[background-color,color,scale] duration-150 ease-out hover:bg-subtle active:scale-97 focus-visible:z-10 focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-focus-ring disabled:pointer-events-none disabled:text-fg-faint disabled:line-through motion-reduce:transition-none\",\n        today && \"font-medium text-fg-brand\",\n        outside && \"text-fg-faint\",\n        middle && \"rounded-none bg-transparent text-fg hover:bg-subtle-hover\",\n        selected && \"bg-primary text-on-primary hover:bg-primary-hover\",\n        className,\n      )}\n      {...props}\n    >\n      {date.getDate()}\n    </button>\n  );\n}\n\nexport { Calendar, CalendarDayButton };\nexport type { CalendarProps, DateRange };\n"
    }
  ]
}
