{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "calendar-grid",
  "title": "Calendar Grid",
  "description": "An accessible month grid for single dates, ranges or whole weeks in any Intl calendar (Persian, Hijri, Hebrew, Buddhist and more), with ISO values.",
  "registryDependencies": [
    "@tealmac/date-time-core"
  ],
  "files": [
    {
      "path": "registry/tealmac/calendar-grid.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\nimport { ChevronLeft, ChevronRight } from \"lucide-react\"\n\nimport { cn } from \"@/lib/utils\"\nimport {\n  addDays,\n  calendarDayLabel,\n  calendarFullLabel,\n  calendarMonthGrid,\n  calendarMonthLabel,\n  calendarMonthStart,\n  calendarShiftMonth,\n  clampDate,\n  compareDates,\n  endOfWeek,\n  firstDayOfWeek,\n  isoWeek,\n  isValidDate,\n  normaliseRange,\n  resolveLocale,\n  startOfWeek,\n  today as todayISO,\n  weekday,\n  weekdayNames,\n  type CalendarSystem,\n  type DateRange,\n  type ISODate,\n} from \"@/registry/tealmac/date-time-core\"\n\ntype SingleProps = {\n  mode?: \"single\"\n  value?: ISODate | null\n  defaultValue?: ISODate | null\n  onValueChange?: (value: ISODate | null) => void\n}\ntype RangeProps = {\n  /** \"week\" selects the whole week of the day that is picked. */\n  mode: \"range\" | \"week\"\n  value?: DateRange | null\n  defaultValue?: DateRange | null\n  onValueChange?: (value: DateRange | null) => void\n}\n\nexport type CalendarGridProps = (SingleProps | RangeProps) &\n  Omit<\n    React.ComponentProps<\"div\">,\n    \"defaultValue\" | \"onChange\" | \"value\" | \"role\"\n  > & {\n    /** Calendar system the grid shows. Dates stay ISO Gregorian. */\n    calendar?: CalendarSystem\n    locale?: string\n    /** Any date inside the month to show first (controlled with onMonthChange). */\n    month?: ISODate\n    defaultMonth?: ISODate\n    onMonthChange?: (month: ISODate) => void\n    /** 1 or 2 months side by side. */\n    numberOfMonths?: 1 | 2\n    min?: ISODate\n    max?: ISODate\n    isDateUnavailable?: (date: ISODate) => boolean\n    /** 0 = Sunday ... 6 = Saturday. Defaults to the locale's first day. */\n    firstDay?: number\n    showWeekNumbers?: boolean\n    showOutsideDays?: boolean\n    /** Always six rows, so the height never jumps. */\n    fixedWeeks?: boolean\n    /** Today, for tests and previews. Defaults to the viewer's date. */\n    today?: ISODate\n    /** Accessible name of the calendar. */\n    label?: string\n    /** Extra data per day: shown as a dot under the number and in the label. */\n    markers?: Record<ISODate, string>\n    /** Called with the anchor day while a range is half chosen. */\n    onAnchorChange?: (anchor: ISODate | null) => void\n    /** Minimum number of days a range must span. */\n    minRangeDays?: number\n    /** Maximum number of days a range may span. */\n    maxRangeDays?: number\n  }\n\nconst thursday = (days: ISODate[]) =>\n  days.find((d) => weekday(d) === 4) ?? days[0]\n\nfunction CalendarGrid(props: CalendarGridProps) {\n  const {\n    mode = \"single\",\n    value: _value,\n    defaultValue: _defaultValue,\n    onValueChange: _onValueChange,\n    calendar = \"gregory\",\n    locale,\n    month: monthProp,\n    defaultMonth,\n    onMonthChange,\n    numberOfMonths = 1,\n    min,\n    max,\n    isDateUnavailable,\n    firstDay: firstDayProp,\n    showWeekNumbers,\n    showOutsideDays = true,\n    fixedWeeks,\n    today: todayProp,\n    label = \"Calendar\",\n    markers,\n    onAnchorChange,\n    minRangeDays,\n    maxRangeDays,\n    className,\n    ...rest\n  } = props as CalendarGridProps & { mode?: \"single\" | \"range\" | \"week\" }\n\n  const uid = React.useId()\n  const loc = resolveLocale(locale)\n  const firstDay = firstDayProp ?? firstDayOfWeek(loc)\n  const today = todayProp ?? todayISO()\n  const isWeek = mode === \"week\"\n  const isRange = mode === \"range\" || isWeek\n\n  // Selection ---------------------------------------------------------\n  const [innerSel, setInnerSel] = React.useState<ISODate | DateRange | null>(\n    (_defaultValue as ISODate | DateRange | null | undefined) ?? null\n  )\n  const controlled = _value !== undefined\n  const selection = (controlled ? _value : innerSel) as\n    ISODate | DateRange | null\n  const [anchor, setAnchorState] = React.useState<ISODate | null>(null)\n  const [hover, setHover] = React.useState<ISODate | null>(null)\n  const setAnchor = (a: ISODate | null) => {\n    setAnchorState(a)\n    onAnchorChange?.(a)\n  }\n\n  function emit(next: ISODate | DateRange | null) {\n    if (!controlled) setInnerSel(next)\n    ;(\n      _onValueChange as ((v: ISODate | DateRange | null) => void) | undefined\n    )?.(next)\n  }\n\n  // Visible month -----------------------------------------------------\n  const startDate =\n    monthProp ??\n    defaultMonth ??\n    (isRange\n      ? ((selection as DateRange | null)?.from ?? today)\n      : ((selection as ISODate | null) ?? today))\n  const [innerMonth, setInnerMonth] = React.useState<ISODate>(\n    calendarMonthStart(isValidDate(startDate) ? startDate : today, calendar)\n  )\n  const visibleMonth = monthProp\n    ? calendarMonthStart(monthProp, calendar)\n    : innerMonth\n  function showMonth(date: ISODate) {\n    const start = calendarMonthStart(date, calendar)\n    if (!monthProp) setInnerMonth(start)\n    onMonthChange?.(start)\n  }\n  const months = Array.from({ length: numberOfMonths }, (_, i) =>\n    i === 0 ? visibleMonth : calendarShiftMonth(visibleMonth, i, calendar)\n  )\n\n  // Focus -------------------------------------------------------------\n  const initialFocus = clampDate(\n    isRange\n      ? ((selection as DateRange | null)?.from ?? today)\n      : ((selection as ISODate | null) ?? today),\n    min,\n    max\n  )\n  const [focused, setFocused] = React.useState<ISODate>(initialFocus)\n  const root = React.useRef<HTMLDivElement>(null)\n  const pendingFocus = React.useRef(false)\n\n  const visibleStart = months[0]\n  const visibleEnd = addDays(\n    calendarShiftMonth(visibleMonth, numberOfMonths, calendar),\n    -1\n  )\n  const focusTarget =\n    focused >= visibleStart && focused <= visibleEnd\n      ? focused\n      : clampDate(visibleStart, min, max)\n\n  React.useEffect(() => {\n    if (!pendingFocus.current) return\n    pendingFocus.current = false\n    root.current\n      ?.querySelector<HTMLElement>(\n        `[data-date=\"${focused}\"]:not([data-outside])`\n      )\n      ?.focus()\n  })\n\n  function isDisabled(date: ISODate) {\n    return (\n      (min !== undefined && date < min) ||\n      (max !== undefined && date > max) ||\n      !!isDateUnavailable?.(date)\n    )\n  }\n\n  function moveFocus(to: ISODate) {\n    const next = clampDate(to, min, max)\n    setFocused(next)\n    pendingFocus.current = true\n    if (next < visibleStart) showMonth(next)\n    else if (next > visibleEnd)\n      showMonth(calendarShiftMonth(next, -(numberOfMonths - 1), calendar))\n  }\n\n  function select(date: ISODate) {\n    if (isDisabled(date)) return\n    setFocused(date)\n    if (!isRange) {\n      emit(date)\n      return\n    }\n    if (isWeek) {\n      emit({ from: startOfWeek(date, firstDay), to: endOfWeek(date, firstDay) })\n      return\n    }\n    if (!anchor) {\n      setAnchor(date)\n      setHover(date)\n      return\n    }\n    const r = normaliseRange(anchor, date)\n    const len =\n      Math.round((Date.parse(r.to) - Date.parse(r.from)) / 86_400_000) + 1\n    if (\n      (minRangeDays && len < minRangeDays) ||\n      (maxRangeDays && len > maxRangeDays)\n    ) {\n      // Too short or too long: start over from this day.\n      setAnchor(date)\n      setHover(date)\n      return\n    }\n    setAnchor(null)\n    setHover(null)\n    emit(r)\n  }\n\n  function onKeyDown(e: React.KeyboardEvent<HTMLElement>, date: ISODate) {\n    const rtl =\n      (e.currentTarget.closest(\"[dir]\")?.getAttribute(\"dir\") ??\n        document.documentElement.dir) === \"rtl\"\n    const h = rtl ? -1 : 1\n    let next: ISODate | null = null\n    switch (e.key) {\n      case \"ArrowLeft\":\n        next = addDays(date, -h)\n        break\n      case \"ArrowRight\":\n        next = addDays(date, h)\n        break\n      case \"ArrowUp\":\n        next = addDays(date, -7)\n        break\n      case \"ArrowDown\":\n        next = addDays(date, 7)\n        break\n      case \"Home\":\n        next = startOfWeek(date, firstDay)\n        break\n      case \"End\":\n        next = endOfWeek(date, firstDay)\n        break\n      case \"PageUp\":\n      case \"PageDown\": {\n        const dir = e.key === \"PageUp\" ? -1 : 1\n        const n = e.shiftKey ? 12 : 1\n        // Keep the day number where the target month has it.\n        const target = calendarShiftMonth(date, dir * n, calendar)\n        const dayIndex = Math.round(\n          (Date.parse(date) - Date.parse(calendarMonthStart(date, calendar))) /\n            86_400_000\n        )\n        next = addDays(target, dayIndex)\n        const targetEnd = addDays(calendarShiftMonth(target, 1, calendar), -1)\n        if (next > targetEnd) next = targetEnd\n        break\n      }\n      case \"Escape\":\n        if (anchor) {\n          e.preventDefault()\n          setAnchor(null)\n          setHover(null)\n        }\n        return\n      default:\n        return\n    }\n    e.preventDefault()\n    moveFocus(next)\n  }\n\n  // Range preview -----------------------------------------------------\n  const committed = isRange ? (selection as DateRange | null) : null\n  const preview: DateRange | null = isWeek\n    ? hover\n      ? { from: startOfWeek(hover, firstDay), to: endOfWeek(hover, firstDay) }\n      : committed\n    : isRange && anchor && hover\n      ? normaliseRange(anchor, hover)\n      : committed\n\n  const weekdayShort = weekdayNames(loc, firstDay, \"short\", calendar)\n  const weekdayLong = weekdayNames(loc, firstDay, \"long\", calendar)\n  const prevLabel = \"Previous month\"\n  const nextLabel = \"Next month\"\n\n  return (\n    <div\n      ref={root}\n      data-slot=\"calendar-grid\"\n      data-calendar={calendar}\n      role=\"group\"\n      aria-label={label}\n      className={cn(\n        \"bg-background text-foreground inline-flex w-fit flex-col gap-3 rounded-lg p-3\",\n        className\n      )}\n      {...rest}\n    >\n      <div className=\"relative flex flex-col gap-4 sm:flex-row\">\n        {months.map((m, mi) => {\n          const grid = calendarMonthGrid(m, calendar, firstDay, fixedWeeks)\n          const title = calendarMonthLabel(m, calendar, loc)\n          const headingId = `${uid}-h${mi}`\n          return (\n            <div key={m} className=\"flex flex-col gap-2\">\n              <div className=\"flex h-8 items-center justify-between gap-1\">\n                {mi === 0 ? (\n                  <NavButton\n                    label={prevLabel}\n                    disabled={\n                      min !== undefined && addDays(grid.start, -1) < min\n                    }\n                    onClick={() =>\n                      showMonth(calendarShiftMonth(visibleMonth, -1, calendar))\n                    }\n                  >\n                    <ChevronLeft\n                      className=\"size-4 rtl:rotate-180\"\n                      aria-hidden=\"true\"\n                    />\n                  </NavButton>\n                ) : (\n                  <span className=\"size-8\" aria-hidden=\"true\" />\n                )}\n                <h2\n                  id={headingId}\n                  aria-live=\"polite\"\n                  className=\"text-sm font-medium\"\n                >\n                  {title}\n                </h2>\n                {mi === months.length - 1 ? (\n                  <NavButton\n                    label={nextLabel}\n                    disabled={max !== undefined && addDays(grid.end, 1) > max}\n                    onClick={() =>\n                      showMonth(calendarShiftMonth(visibleMonth, 1, calendar))\n                    }\n                  >\n                    <ChevronRight\n                      className=\"size-4 rtl:rotate-180\"\n                      aria-hidden=\"true\"\n                    />\n                  </NavButton>\n                ) : (\n                  <span className=\"size-8\" aria-hidden=\"true\" />\n                )}\n              </div>\n              <div\n                role=\"grid\"\n                aria-labelledby={headingId}\n                aria-multiselectable={isRange || undefined}\n                className=\"border-collapse\"\n              >\n                <div role=\"row\" className=\"flex\">\n                  {showWeekNumbers ? (\n                    <span\n                      role=\"columnheader\"\n                      aria-label=\"Week\"\n                      className=\"text-muted-foreground flex size-9 items-center justify-center text-xs\"\n                    >\n                      #\n                    </span>\n                  ) : null}\n                  {weekdayShort.map((w, i) => (\n                    <span\n                      key={i}\n                      role=\"columnheader\"\n                      aria-label={weekdayLong[i]}\n                      className=\"text-muted-foreground flex size-9 items-center justify-center text-xs font-normal\"\n                    >\n                      {w.slice(0, 3)}\n                    </span>\n                  ))}\n                </div>\n                {grid.weeks.map((week) => (\n                  <div role=\"row\" key={week.index} className=\"flex\">\n                    {showWeekNumbers ? (\n                      <span\n                        role=\"rowheader\"\n                        aria-label={`Week ${isoWeek(thursday(week.days)).week}`}\n                        className=\"text-muted-foreground flex size-9 items-center justify-center text-xs tabular-nums\"\n                      >\n                        {isoWeek(thursday(week.days)).week}\n                      </span>\n                    ) : null}\n                    {week.days.map((d) => {\n                      const outside = d < grid.start || d > grid.end\n                      if (outside && !showOutsideDays)\n                        return (\n                          <span key={d} role=\"gridcell\" className=\"size-9\" />\n                        )\n                      const disabled = isDisabled(d)\n                      const isStart = !!preview && d === preview.from\n                      const isEnd = !!preview && d === preview.to\n                      const inside =\n                        !!preview &&\n                        compareDates(d, preview.from) > 0 &&\n                        compareDates(d, preview.to) < 0\n                      const single = !isRange && selection === d\n                      const selected = single || isStart || isEnd || inside\n                      const marker = markers?.[d]\n                      return (\n                        <span\n                          key={d}\n                          role=\"gridcell\"\n                          aria-selected={selected || undefined}\n                          aria-disabled={disabled || undefined}\n                          data-range-middle={inside ? \"\" : undefined}\n                          data-range-start={isStart ? \"\" : undefined}\n                          data-range-end={isEnd ? \"\" : undefined}\n                          className={cn(\n                            \"relative flex size-9 items-center justify-center\",\n                            inside && \"bg-accent\",\n                            isStart &&\n                              preview &&\n                              preview.from !== preview.to &&\n                              \"bg-accent rounded-s-md\",\n                            isEnd &&\n                              preview &&\n                              preview.from !== preview.to &&\n                              \"bg-accent rounded-e-md\"\n                          )}\n                        >\n                          <button\n                            type=\"button\"\n                            tabIndex={d === focusTarget && !outside ? 0 : -1}\n                            data-date={d}\n                            data-outside={outside ? \"\" : undefined}\n                            data-today={d === today ? \"\" : undefined}\n                            data-selected={selected ? \"\" : undefined}\n                            data-unavailable={disabled ? \"\" : undefined}\n                            disabled={disabled}\n                            aria-label={`${calendarFullLabel(d, calendar, loc)}${marker ? `, ${marker}` : \"\"}${d === today ? \", today\" : \"\"}`}\n                            aria-current={d === today ? \"date\" : undefined}\n                            onClick={() => select(d)}\n                            onFocus={() => {\n                              setFocused(d)\n                              if (isWeek) setHover(d)\n                            }}\n                            onBlur={() => isWeek && setHover(null)}\n                            onMouseEnter={() =>\n                              (anchor || isWeek) && setHover(d)\n                            }\n                            onMouseLeave={() => isWeek && setHover(null)}\n                            onKeyDown={(e) => onKeyDown(e, d)}\n                            className={cn(\n                              \"focus-visible:ring-ring/50 inline-flex size-8 items-center justify-center rounded-md text-sm tabular-nums outline-none focus-visible:ring-[3px]\",\n                              \"hover:bg-accent hover:text-accent-foreground disabled:pointer-events-none disabled:line-through disabled:opacity-40\",\n                              outside && \"text-muted-foreground\",\n                              d === today &&\n                                !selected &&\n                                \"border-border border font-semibold\",\n                              selected &&\n                                \"bg-primary text-primary-foreground hover:bg-primary hover:text-primary-foreground\",\n                              inside &&\n                                \"text-accent-foreground hover:bg-accent bg-transparent\"\n                            )}\n                          >\n                            {calendarDayLabel(d, calendar, loc)}\n                          </button>\n                          {marker ? (\n                            <span\n                              aria-hidden=\"true\"\n                              className=\"bg-primary pointer-events-none absolute bottom-0.5 size-1 rounded-full\"\n                            />\n                          ) : null}\n                        </span>\n                      )\n                    })}\n                  </div>\n                ))}\n              </div>\n            </div>\n          )\n        })}\n      </div>\n    </div>\n  )\n}\n\nfunction NavButton({\n  label,\n  disabled,\n  onClick,\n  children,\n}: {\n  label: string\n  disabled?: boolean\n  onClick: () => void\n  children: React.ReactNode\n}) {\n  return (\n    <button\n      type=\"button\"\n      aria-label={label}\n      disabled={disabled}\n      onClick={onClick}\n      className=\"hover:bg-accent focus-visible:ring-ring/50 inline-flex size-8 items-center justify-center rounded-md outline-none focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-40\"\n    >\n      {children}\n    </button>\n  )\n}\n\nexport { CalendarGrid }\n",
      "type": "registry:ui"
    }
  ],
  "type": "registry:ui"
}