{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "calendar",
  "title": "Calendar",
  "description": "shadcn/ui(new-york-v4) calendar의 유니버설(웹+네이티브 한 벌 소스) 포팅. 달력(월 이동 + 6주 날짜 격자)을 직접 구현한다. 포팅 판단(원본 대비 차이·이유):",
  "dependencies": [
    "@unikit-bridge/ui"
  ],
  "registryDependencies": [
    "utils"
  ],
  "files": [
    {
      "path": "registry/ui/calendar.tsx",
      "content": "// Calendar — shadcn/ui（new-york-v4） calendar.tsx 유니버설 포팅 （사이클 40, T-2c）.\n// 원본: shadcn-ref/ui/calendar.tsx — react-day-picker（DayPicker） 엔진을 제거하고\n// 달력（월 이동 + 6주 날짜 격자）을 직접 구현한다.\n//\n// 포팅 판단（원본 대비 차이이유）:\n// - react-day-picker 의존 제거 — 월 표시 상태（기본 오늘, selected 우선）와\n//   6행 7열（42칸） 날짜 격자를 직접 렌더한다. mode=\"single\"만 지원하며\n//   다중 선택범위 선택（range_*）은 미지원 — 로드맵 기록. 원본의 matcher 기반\n//   disabled（matchers 체계）는 판정 함수 disabled?（date） 하나로 단순화했다.\n// - 로케일은 한국어 고정（요일 \"일월화수목금토\", 캡션 \"2026년 11월\"） — i18n\n//   prop은 로드맵. 원본의 RTL 화살표 회전 규칙（rtl:）은 제거（SVG 아이콘 고정）.\n// - 접근성: 루트 role=\"grid\", 요일 헤더 role=\"columnheader\", 주 role=\"row\",\n//   날짜 칸 role=\"gridcell\" — 전부 bridge role 계약（types.ts）에 포함돼 있다.\n//   날짜의 aria-selectedaria-current=\"date\"는 원본과 같이 gridcell이 아니라\n//   내부 Pressable（날짜 버튼）에 부여한다（ViewProps 계약에 두 속성이 없고\n//   react-day-picker도 버튼에 둔다）. 그리드 이름은 캡션 nativeID ↔ 루트\n//   aria-labelledby로 연결한다.\n// - 원본 셀 크기 CSS 변수 ［--cell-size:--spacing（8）］（2rem）은 표준 계단\n//   size-8（32px）로 고정（임의값변수 금지）. 요일 텍스트 text-［0.8rem］은\n//   text-xs에 근사. dark: 변형 토큰은 관행대로 제거.\n// - 이전/다음 월 버튼은 ./button의 Button（variant=\"ghost\"） 재사용 — 원본이\n//   buttonVariants를 쓴 것과 동일한 관계（방향 아이콘은 브리지 SVG\n//   ChevronLeftIcon/ChevronRightIcon 프리미티브）. 날짜 셀은 상태별 전경색을 내부 Text에\n//   줘야 해서（이중 지정 원칙） Button을 못 쓰고 Pressable에 ghost 토큰의 실질\n//   （hover/active/focus-visible정렬）을 인라인 재현했다 — buttonVariants의\n//   size 기본값（h-10 px-4 py-2）이 셀 형태（aspect-square w-full）와 경합해서\n//   합성이 불가하다.\n// - 원본 showOutsideDays 기본값（true） 유지 — 42칸에 앞뒤 달 날짜가 함께\n//   렌더되고（muted）, 누르면 그 달로 이동하며 선택한다（원본 동작）.\n// - 키보드 방향키 탐색captionLayout 드롭다운주 번호（showWeekNumber）\n//   선택 애니메이션은 미지원 — 로드맵.\n// - RN 한계: gridrowgridcell 등 role은 RN이 무시한다（웹 스크린리더만\n//   이득 — types.ts 계약 주석）, aria-current도 웹 전용 계약이다. 오늘/선택\n//   강조는 시각（bg-accentbg-primary）으로도 표현된다.\nimport { forwardRef, useId, useMemo, useState } from \"react\";\nimport {\n  ChevronLeftIcon,\n  ChevronRightIcon,\n  Pressable,\n  Text,\n  View,\n  type BridgePressableRef,\n  type BridgeViewRef,\n  type ViewProps,\n} from \"@unikit-bridge/ui/primitives\";\n\nimport { cn } from \"@/lib/utils\";\nimport { Button } from \"./button\";\n\n// 요일 라벨 — 일요일 시작（한국어 고정）\nconst WEEKDAY_LABELS = [\"일\", \"월\", \"화\", \"수\", \"목\", \"금\", \"토\"] as const;\nconst WEEK_COUNT = 6;\n\ntype DayCell = {\n  date: Date;\n  /** 표시 중인 달이 아닌 날（앞뒤 달 — 원본 outside） */\n  outside: boolean;\n};\n\nfunction startOfMonth(date: Date): Date {\n  return new Date(date.getFullYear(), date.getMonth(), 1);\n}\n\nfunction addMonths(date: Date, amount: number): Date {\n  return new Date(date.getFullYear(), date.getMonth() + amount, 1);\n}\n\nfunction isSameDay(a: Date, b: Date): boolean {\n  return (\n    a.getFullYear() === b.getFullYear() &&\n    a.getMonth() === b.getMonth() &&\n    a.getDate() === b.getDate()\n  );\n}\n\n// 6행 7열 격자 — 첫 칸은 1일이 속한 주의 일요일（42칸이면 달 끝까지 빈칸이 없다）\nfunction buildMonthWeeks(month: Date): DayCell[][] {\n  const first = startOfMonth(month);\n  const start = new Date(\n    first.getFullYear(),\n    first.getMonth(),\n    1 - first.getDay(),\n  );\n  return Array.from({ length: WEEK_COUNT }, (_, weekIndex) =>\n    Array.from({ length: WEEKDAY_LABELS.length }, (_, dayIndex) => {\n      const date = new Date(\n        start.getFullYear(),\n        start.getMonth(),\n        start.getDate() + weekIndex * 7 + dayIndex,\n      );\n      return { date, outside: date.getMonth() !== first.getMonth() };\n    }),\n  );\n}\n\nfunction formatMonthLabel(month: Date): string {\n  return `${month.getFullYear()}년 ${month.getMonth() + 1}월`;\n}\n\nexport type CalendarProps = Omit<ViewProps, \"children\"> & {\n  /** 선택된 날짜（mode=\"single\"） */\n  selected?: Date;\n  /** 날짜 선택 콜백 — 원본 onSelect（date, ） 신호에서 단일 인자로 단순화 */\n  onSelect?: (date: Date) => void;\n  /** 표시 월 제어（각 달의 1일로 정규화） */\n  month?: Date;\n  /** 초기 표시 월 — 없으면 selected, 그것도 없으면 오늘 */\n  defaultMonth?: Date;\n  onMonthChange?: (month: Date) => void;\n  /** 비활성 판정 함수 — 원본의 DayPicker matcher 체계 대신 predicate */\n  disabled?: (date: Date) => boolean;\n};\n\nconst Calendar = forwardRef<BridgeViewRef, CalendarProps>(\n  (\n    {\n      selected,\n      onSelect,\n      month,\n      defaultMonth,\n      onMonthChange,\n      disabled,\n      className,\n      ...props\n    },\n    ref,\n  ) => {\n    // 비제어 월 상태 — 제어（month 지정）면 무시된다.\n    const [internalMonth, setInternalMonth] = useState<Date>(() =>\n      startOfMonth(defaultMonth ?? selected ?? new Date()),\n    );\n    const currentMonth = month !== undefined ? startOfMonth(month) : internalMonth;\n    const baseId = useId();\n    const captionId = `${baseId}-calendar-caption`;\n    // 오늘 판정 기준 — 마운트 시점 하루로 고정（불필요한 리렌더 판정 방지 관행）\n    const today = useMemo(() => new Date(), []);\n    const weeks = useMemo(() => buildMonthWeeks(currentMonth), [currentMonth]);\n\n    const goToMonth = (next: Date) => {\n      const normalized = startOfMonth(next);\n      if (month === undefined) setInternalMonth(normalized);\n      onMonthChange?.(normalized);\n    };\n\n    const handleSelect = (cell: DayCell) => {\n      // 앞뒤 달 날짜는 월을 넘겨가며 선택（원본 outside day 동작）\n      if (cell.outside) goToMonth(cell.date);\n      onSelect?.(cell.date);\n    };\n\n    return (\n      <View\n        ref={ref}\n        accessibilityRole=\"grid\"\n        aria-labelledby={captionId}\n        className={cn(\"self-start bg-background p-3 web:w-fit\", className)}\n        {...props}\n      >\n        {/* 상단 — 이전 월  캡션  다음 월（원본 nav absolute 구조는 평면화） */}\n        <View className=\"flex-row items-center justify-between gap-1\">\n          <Button\n            variant=\"ghost\"\n            className=\"size-8 p-0\"\n            accessibilityLabel=\"이전 달\"\n            onPress={() => goToMonth(addMonths(currentMonth, -1))}\n          >\n            <ChevronLeftIcon className=\"size-4\" />\n          </Button>\n          <Text\n            nativeID={captionId}\n            className=\"font-sans text-sm font-medium text-foreground web:select-none\"\n          >\n            {formatMonthLabel(currentMonth)}\n          </Text>\n          <Button\n            variant=\"ghost\"\n            className=\"size-8 p-0\"\n            accessibilityLabel=\"다음 달\"\n            onPress={() => goToMonth(addMonths(currentMonth, 1))}\n          >\n            <ChevronRightIcon className=\"size-4\" />\n          </Button>\n        </View>\n\n        {/* 요일 헤더 행 */}\n        <View accessibilityRole=\"row\" className=\"mt-4 flex-row\">\n          {WEEKDAY_LABELS.map((label) => (\n            <View\n              key={label}\n              accessibilityRole=\"columnheader\"\n              className=\"flex-1 items-center rounded-md\"\n            >\n              {/* 원본 text-［0.8rem］ → text-xs 근사（임의값 금지） */}\n              <Text className=\"font-sans text-xs font-normal text-muted-foreground web:select-none\">\n                {label}\n              </Text>\n            </View>\n          ))}\n        </View>\n\n        {/* 6주 날짜 격자 */}\n        <View>\n          {weeks.map((week, weekIndex) => (\n            <View\n              key={weekIndex}\n              accessibilityRole=\"row\"\n              className=\"mt-2 flex-row\"\n            >\n              {week.map((cell) => {\n                const isSelected =\n                  selected !== undefined && isSameDay(cell.date, selected);\n                const isToday = isSameDay(cell.date, today);\n                const isDisabled = disabled?.(cell.date) ?? false;\n                return (\n                  <View\n                    key={cell.date.toISOString()}\n                    accessibilityRole=\"gridcell\"\n                    className=\"flex-1 items-center justify-center\"\n                  >\n                    <Pressable\n                      accessibilityRole=\"button\"\n                      aria-selected={isSelected}\n                      aria-current={isToday ? \"date\" : undefined}\n                      disabled={isDisabled}\n                      onPress={() => handleSelect(cell)}\n                      className={cn(\n                        // 원본 Button（ghost） 토큰의 실질 인라인（머리주석 판단）.\n                        // 원본 day 셀은 aspect-square·day_button size-8 — 셀을\n                        // size-8로 고정해 격자 높이가 콘텐츠로 수축하지 않게 한다.\n                        // focus 링은 button 원본 규격（ring-3 무오프셋, 사이클 72）.\n                        \"size-8 flex-row items-center justify-center rounded-md active:bg-accent web:select-none web:focus-visible:border-ring web:focus-visible:ring-3 web:focus-visible:ring-ring/50\",\n                        isSelected\n                          ? \"bg-primary web:hover:bg-primary/90 active:bg-primary/90\"\n                          : \"web:hover:bg-accent\",\n                        !isSelected && isToday && \"bg-accent\",\n                        isDisabled && \"opacity-50\",\n                      )}\n                    >\n                      {/* 이중 지정: 전경색은 상태별로 내부 Text에 */}\n                      <Text\n                        className={cn(\n                          \"font-sans text-sm leading-none\",\n                          isSelected\n                            ? \"text-primary-foreground\"\n                            : isToday\n                              ? \"text-accent-foreground\"\n                              : cell.outside || isDisabled\n                                ? \"text-muted-foreground\"\n                                : \"text-foreground\",\n                        )}\n                      >\n                        {cell.date.getDate()}\n                      </Text>\n                    </Pressable>\n                  </View>\n                );\n              })}\n            </View>\n          ))}\n        </View>\n      </View>\n    );\n  },\n);\nCalendar.displayName = \"Calendar\";\n\nexport { Calendar };\n",
      "type": "registry:ui",
      "target": "components/ui/calendar.tsx"
    }
  ],
  "type": "registry:ui"
}
