{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "sonner",
  "title": "Sonner",
  "description": "shadcn/ui(new-york-v4) sonner의 유니버설(웹+네이티브 한 벌 소스) 포팅. 포팅 판단(원본 대비 차이·이유): Set)가 관리하고 toast() 호출부와 <Toaster> 렌더를 잇는다(useSyncExternalStore).",
  "dependencies": [
    "@unikit-bridge/ui",
    "class-variance-authority"
  ],
  "registryDependencies": [
    "utils",
    "animations"
  ],
  "files": [
    {
      "path": "registry/ui/sonner.tsx",
      "content": "// Sonner — shadcn/ui（new-york-v4） sonner.tsx 유니버설 포팅 （사이클 40, T-3b）.\n// 원본: shadcn-ref/ui/sonner.tsx\n//\n// 포팅 판단（원본 대비 차이이유）:\n// - sonnernext-themes 제거 — 토스트 상태는 모듈 레벨 저장소（불변 배열 + 리스너\n//   Set）가 관리하고 toast（） 호출부와 <Toaster> 렌더를 잇는다（useSyncExternalStore）.\n//   원본 API 형태를 유지한다: toast（message|opts）toast.success/error/warning/\n//   info/loadingtoast.dismiss（id?）toast.promise（loading → resolve/reject 갱신）.\n//   toast.custom（JSX 렌더）action/cancel 버튼description 외 sonner 고유\n//   프로퍼티（unstylediconsoffset 등）는 미지원 — 원본 shadcn 래퍼도 쓰지\n//   않는 범위다（제한 기록）.\n// - theme（받아서 무시했던 next-themes 연동）은 제거 — 스타일은 UniKit UI 팝오버\n//   토큰（bg-popovertext-popover-foregroundborder）으로 통일했다（원본이\n//   --normal-bg=var（--popover） 등으로 매핑하던 것의 정착）.\n// - 비모달 오버레이: ModalOverlay（백드롭포커스 트랩ESC스크롤 잠금 포함）가\n//   아니라 브리지 Portal을 직접 소비한다 — onBackdropPress를 주지 않아 백드롭\n//   없이 body 최상위에 렌더하고, 래퍼는 pointerEvents=\"box-none\"로 토스트\n//   카드 밖 터치클릭을 통과시킨다. 네이티브는 Portal modal={false}로 RN Modal\n//   대신 절대 배치 View 경로를 탄다（배경 조용 차단 회피 — 브리지에 반영 완료）.\n//   이 경로는 진짜 포털이 아니므로 네이티브에서 Toaster를 화면 루트에 두는\n//   것이 전제다.\n// - 위치: position prop（top/bottom × left/center/right 6방향, 기본 bottom-right\n//   — sonner 기본값）을 래퍼 flex 정렬 클래스로 근사. 원본의 창 가장자리\n//   오프셋 고정 계산（offset prop）은 미지원, p-4 여백으로 근사.\n// - 등장/퇴장 애니메이션（슬라이드높이 접힘）은 즉시 전환으로 근사（다른\n//   오버레이 계열 포팅과 동일 — data-［state］ 의존 토큰 소실）.\n// - 자동 닫힘: 기본 4000ms（원본 기본）, duration 옵션/Infinity（무기한） 지원.\n//   loadingpromise 진행 중 토스트는 무기한, 해결 시 남은 수명으로 전환.\n// - 스와이프/드래그 dismiss: 브리지 useDrag로 근사 — 배치 방향으로 48px 이상\n//   끌면 닫는다（아래 배치는 아래로, 위 배치는 위로）. 원본의 방향 전환이동\n//   중 카드 추적속도 판정은 없다（즉시 판정）.\n// - 아이콘: 브리지 lucide 아이콘 프리미티브（CheckCircle2Icon·XCircleIcon·\n//   AlertCircleIcon·InfoIcon, loading은 Loader2Icon web:animate-spin）.\n// - richColors: 원본 sonner 토큰（emerald-600/red-600/amber-500/sky-500 배경）\n//   을 표준 팔레트 클래스로 재현, dark: 변형은 제거（라이트 기준 단색）.\n// - closeButton은 항상 표시 근사（원본은 hover 시 노출 — group 셀렉터 불가）.\n// - toast 카드 max-width 356px（원본） → max-w-sm（24rem） 표준 계단 근사.\nimport {\n  forwardRef,\n  useEffect,\n  useRef,\n  useSyncExternalStore,\n} from \"react\";\nimport {\n  AlertCircleIcon,\n  CheckCircle2Icon,\n  InfoIcon,\n  Loader2Icon,\n  LoopRotate,\n  Portal,\n  Pressable,\n  Text,\n  View,\n  XCircleIcon,\n  XIcon,\n  useDrag,\n  type BridgeViewRef,\n  type ViewProps,\n} from \"@unikit-bridge/ui/primitives\";\nimport { cva } from \"class-variance-authority\";\n\nimport { cn } from \"@/lib/utils\";\n\n// ---------------------------------------------------------------------------\n// 토스트 저장소 — 모듈 레벨（컴포넌트 외부에서 toast（） 호출 지원）\n// ---------------------------------------------------------------------------\n\nexport type ToastVariant =\n  | \"default\"\n  | \"success\"\n  | \"error\"\n  | \"warning\"\n  | \"info\"\n  | \"loading\";\n\nexport type ToastOptions = {\n  /** 보조 설명（제목 아래） */\n  description?: string;\n  /** 자동 닫힘 ms（기본 Toaster duration4000）. Infinity면 무기한 */\n  duration?: number;\n};\n\ntype ToastItem = {\n  id: number;\n  title: string;\n  variant: ToastVariant;\n  description?: string;\n  /** undefined면 Toaster 기본 duration 적용 */\n  duration?: number;\n};\n\nlet toasts: ToastItem[] = [];\nlet toastCounter = 0;\nconst listeners = new Set<() => void>();\n/** 서버 스냅샷 상수 — 서버 렌더 중 우연히 toast()가 호출돼도 모듈 상태가\n *  아닌 이 불변 빈 배열을 반환해 요청 간 누출·hydration 불일치를 막는다\n *  （G3 리뷰 제기）. */\nconst EMPTY_TOASTS: ToastItem[] = [];\n\nconst emit = () => {\n  listeners.forEach((listener) => listener());\n};\n\nconst dismissToast = (id: number) => {\n  toasts = toasts.filter((toast) => toast.id !== id);\n  emit();\n};\n\nconst updateToast = (id: number, patch: Partial<Omit<ToastItem, \"id\">>) => {\n  toasts = toasts.map((toast) => (toast.id === id ? { ...toast, ...patch } : toast));\n  emit();\n};\n\nconst createToast = (\n  title: string,\n  variant: ToastVariant,\n  options?: ToastOptions,\n): number => {\n  toastCounter += 1;\n  const id = toastCounter;\n  toasts = [\n    ...toasts,\n    {\n      id,\n      title,\n      variant,\n      description: options?.description,\n      duration: options?.duration,\n    },\n  ];\n  emit();\n  return id;\n};\n\nfunction toast(message: string, options?: ToastOptions): number;\nfunction toast(options: ToastOptions & { title?: string }): number;\nfunction toast(\n  messageOrOptions: string | (ToastOptions & { title?: string }),\n  maybeOptions?: ToastOptions,\n): number {\n  if (typeof messageOrOptions === \"string\") {\n    return createToast(messageOrOptions, \"default\", maybeOptions);\n  }\n  const { title, ...rest } = messageOrOptions;\n  return createToast(title ?? \"\", \"default\", rest);\n}\n\ntoast.success = (message: string, options?: ToastOptions) =>\n  createToast(message, \"success\", options);\ntoast.error = (message: string, options?: ToastOptions) =>\n  createToast(message, \"error\", options);\ntoast.warning = (message: string, options?: ToastOptions) =>\n  createToast(message, \"warning\", options);\ntoast.info = (message: string, options?: ToastOptions) =>\n  createToast(message, \"info\", options);\ntoast.loading = (message: string, options?: ToastOptions) =>\n  createToast(message, \"loading\", { ...options, duration: options?.duration ?? Infinity });\n\ntoast.dismiss = (id?: number) => {\n  if (id === undefined) {\n    toasts = [];\n  } else {\n    toasts = toasts.filter((toast) => toast.id !== id);\n  }\n  emit();\n};\n\n/** promise 토스트 — 로딩 표시 후 resolve면 success, reject면 error로 갱신. */\ntoast.promise = <T,>(\n  promise: Promise<T>,\n  config: {\n    loading?: string;\n    success?: string | ((data: T) => string);\n    error?: string | ((error: unknown) => string);\n  },\n): Promise<T> => {\n  const id = createToast(config.loading ?? \"\", \"loading\", { duration: Infinity });\n  promise\n    .then((data) => {\n      const message =\n        typeof config.success === \"function\" ? config.success(data) : config.success;\n      updateToast(id, {\n        variant: \"success\",\n        ...(message !== undefined ? { title: message } : {}),\n        // 수명 해제 — 무기한 로딩에서 자동 닫힘으로 전환. undefined로\n        // 덮어써야 Toaster duration prop 기본값이 적용된다（G3 F-16 —\n        // 프로퍼티를 아예 빼면 스프레드가 기존 Infinity를 유지한다）.\n        duration: undefined,\n      });\n    })\n    .catch((error: unknown) => {\n      const message =\n        typeof config.error === \"function\" ? config.error(error) : config.error;\n      updateToast(id, {\n        variant: \"error\",\n        ...(message !== undefined ? { title: message } : {}),\n        duration: undefined,\n      });\n    });\n  return promise;\n};\n\nexport { toast };\n\n// ---------------------------------------------------------------------------\n// Toaster — Portal 렌더（비모달）\n// ---------------------------------------------------------------------------\n\nexport type ToasterProps = ViewProps & {\n  position?:\n    | \"top-left\"\n    | \"top-center\"\n    | \"top-right\"\n    | \"bottom-left\"\n    | \"bottom-center\"\n    | \"bottom-right\";\n  /** 변형별 채색 배경（emerald/red/amber/sky） */\n  richColors?: boolean;\n  /** 카드별 닫기 버튼 */\n  closeButton?: boolean;\n  /** 자동 닫힘 기본 ms（개별 duration이 우선） */\n  duration?: number;\n};\n\n// 래퍼 정렬 — 원본 sonner 위치를 flex 정렬로 근사（주축=수직, 교차축=수평）.\nconst POSITION_CLASSES: Record<NonNullable<ToasterProps[\"position\"]>, string> = {\n  \"top-left\": \"items-start justify-start\",\n  \"top-center\": \"items-center justify-start\",\n  \"top-right\": \"items-end justify-start\",\n  \"bottom-left\": \"items-start justify-end\",\n  \"bottom-center\": \"items-center justify-end\",\n  \"bottom-right\": \"items-end justify-end\",\n};\n\nfunction ToastIcon({\n  variant,\n  className,\n}: {\n  variant: ToastVariant;\n  className?: string;\n}) {\n  switch (variant) {\n    case \"success\":\n      return <CheckCircle2Icon className={className} />;\n    case \"error\":\n      return <XCircleIcon className={className} />;\n    case \"warning\":\n      return <AlertCircleIcon className={className} />;\n    case \"info\":\n      return <InfoIcon className={className} />;\n    case \"loading\":\n      return (\n        <LoopRotate>\n          <Loader2Icon className={cn(className, \"web:animate-spin\")} />\n        </LoopRotate>\n      );\n    default:\n      return null;\n  }\n}\n\nconst subscribeToasts = (listener: () => void) => {\n  listeners.add(listener);\n  return () => {\n    listeners.delete(listener);\n  };\n};\nconst getClientToasts = () => toasts;\nconst getServerToasts = () => EMPTY_TOASTS;\nconst FIXED_WRAPPER_STYLE = { position: \"fixed\", left: 0, top: 0, right: 0, bottom: 0 } as const;\n\nconst toastCardVariants = cva(\n  \"pointer-events-auto relative flex-row items-start gap-3 rounded-lg border bg-popover px-4 py-3 shadow-lg unikit-toast-in web:max-w-sm\",\n  {\n    variants: {\n      rich: {\n        true: \"\",\n        false: \"\",\n      },\n      variant: {\n        default: \"\",\n        // 원본 richColors 팔레트（라이트 기준 — dark: 변형 제거）\n        success: \"\",\n        error: \"\",\n        warning: \"\",\n        info: \"\",\n        loading: \"\",\n      },\n    },\n    compoundVariants: [\n      {\n        rich: true,\n        variant: \"success\",\n        class: \"border-emerald-600 bg-emerald-600\",\n      },\n      { rich: true, variant: \"error\", class: \"border-red-600 bg-red-600\" },\n      { rich: true, variant: \"warning\", class: \"border-amber-500 bg-amber-500\" },\n      { rich: true, variant: \"info\", class: \"border-sky-500 bg-sky-500\" },\n    ],\n    defaultVariants: {\n      rich: false,\n      variant: \"default\",\n    },\n  },\n);\n\ntype ToastCardProps = {\n  toastItem: ToastItem;\n  position: NonNullable<ToasterProps[\"position\"]>;\n  richColors: boolean;\n  closeButton: boolean;\n  defaultDuration: number;\n};\n\nfunction ToastCard({\n  toastItem,\n  position,\n  richColors,\n  closeButton,\n  defaultDuration,\n}: ToastCardProps) {\n  // 드래그 dismiss — 배치 방향으로 48px 이상 끌면 닫는다（이동 중 추적 없음）.\n  const dyRef = useRef(0);\n  const { dragHandlers } = useDrag({\n    onBegin: () => {\n      dyRef.current = 0;\n    },\n    onMove: (event) => {\n      dyRef.current = event.dy;\n    },\n    onEnd: () => {\n      const upward = position.startsWith(\"top\");\n      const dy = dyRef.current;\n      if (upward ? dy < -48 : dy > 48) {\n        dismissToast(toastItem.id);\n      }\n    },\n  });\n\n  // 자동 닫힘 — loading（무기한）이 아니면 duration 후 제거. variant 전환（promise\n  // 갱신）마다 재스케줄된다.\n  const duration = toastItem.duration ?? defaultDuration;\n  useEffect(() => {\n    if (!Number.isFinite(duration)) return;\n    const timer = setTimeout(() => dismissToast(toastItem.id), duration);\n    return () => clearTimeout(timer);\n  }, [toastItem.id, toastItem.variant, toastItem.description, duration]);\n\n  const colored = richColors && toastItem.variant !== \"default\" && toastItem.variant !== \"loading\";\n  // richColors 시 전경은 흰색 계열로 고정（원본）, 아니면 팝오버 토큰.\n  const titleColor = colored ? \"text-white\" : \"text-popover-foreground\";\n  const descriptionColor = colored ? \"text-white/80\" : \"text-muted-foreground\";\n\n  return (\n    <View\n      accessibilityRole={toastItem.variant === \"error\" || toastItem.variant === \"warning\" ? \"alert\" : \"status\"}\n      className={cn(\n        \"w-full\",\n        toastCardVariants({ rich: richColors, variant: toastItem.variant }),\n      )}\n      {...(dragHandlers as ViewProps)}\n    >\n      {toastItem.variant !== \"default\" && (\n        <View aria-hidden className=\"shrink-0 pt-0.5\">\n          <ToastIcon\n            variant={toastItem.variant}\n            className={cn(\"size-4\", colored ? \"text-white\" : \"text-foreground\")}\n          />\n        </View>\n      )}\n      <View className=\"min-w-0 flex-1 flex-col gap-1\">\n        {toastItem.title !== \"\" && (\n          <Text className={cn(\"font-sans text-sm font-semibold\", titleColor)}>\n            {toastItem.title}\n          </Text>\n        )}\n        {toastItem.description && (\n          <Text className={cn(\"font-sans text-xs\", descriptionColor)}>\n            {toastItem.description}\n          </Text>\n        )}\n      </View>\n      {closeButton && (\n        <Pressable\n          accessibilityLabel=\"닫기\"\n          onPress={() => dismissToast(toastItem.id)}\n          className=\"shrink-0 rounded-xs p-1\"\n        >\n          <XIcon className={cn(\"size-3.5\", titleColor)} />\n        </Pressable>\n      )}\n    </View>\n  );\n}\n\nconst Toaster = forwardRef<BridgeViewRef, ToasterProps>(\n  (\n    {\n      position = \"bottom-right\",\n      richColors = false,\n      closeButton = false,\n      duration = 4000,\n      className,\n      children,\n      ...props\n    },\n    ref,\n  ) => {\n    const currentToasts = useSyncExternalStore(\n      subscribeToasts,\n      getClientToasts,\n      getServerToasts,\n    );\n\n    return (\n      // modal={false} — 네이티브에서 RN Modal이 배경 터치를 차단하는 것을\n      // 회피한다（비모달 오버레이 계약）. 웹은 원래 비모달이라 무시. 네이티브\n      // 비모달 경로는 진짜 포털이 아니므로 Toaster를 화면 루트에 두는 것이\n      // 전제다（머리주석 안내）.\n      <Portal visible={currentToasts.length > 0} modal={false}>\n        {/* 비모달 래퍼 — box-none으로 카드 밖 상호작용 통과（머리주석 참조）.\n            fixed는 네이티브 브리지가 absolute로 치환. */}\n        <View\n          ref={ref}\n          pointerEvents=\"box-none\"\n          className={cn(\n            \"gap-2 p-4 web:z-50\",\n            POSITION_CLASSES[position],\n            className,\n          )}\n          style={FIXED_WRAPPER_STYLE}\n          {...props}\n        >\n          {currentToasts.map((toastItem) => (\n            <ToastCard\n              key={toastItem.id}\n              toastItem={toastItem}\n              position={position}\n              richColors={richColors}\n              closeButton={closeButton}\n              defaultDuration={duration}\n            />\n          ))}\n          {children}\n        </View>\n      </Portal>\n    );\n  },\n);\nToaster.displayName = \"Toaster\";\n\nexport { Toaster };\n",
      "type": "registry:ui",
      "target": "components/ui/sonner.tsx"
    }
  ],
  "type": "registry:ui"
}
