{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "tooltip",
  "title": "Tooltip",
  "description": "shadcn/ui(new-york-v4) tooltip의 유니버설(웹+네이티브 한 벌 소스) 포팅. React state + context로, 오버레이는 lib/overlay의 AnchoredOverlay로 구성한다. 포팅 판단(원본 대비 차이):",
  "dependencies": [
    "@unikit-bridge/ui",
    "class-variance-authority"
  ],
  "registryDependencies": [
    "utils",
    "animations"
  ],
  "files": [
    {
      "path": "registry/ui/tooltip.tsx",
      "content": "// Tooltip — shadcn/ui（new-york-v4） tooltip.tsx 유니버설 포팅 （사이클 40, T-1b）.\n// 원본: shadcn-ref/ui/tooltip.tsx — radix Tooltip 엔진을 제거하고 열림 상태는\n// React state + context로, 오버레이는 lib/overlay의 AnchoredOverlay로 구성한다.\n//\n// 포팅 판단（원본 대비 차이）:\n// - 진입 조건 설계: 원본은 hover/포커스 진입 시 delayDuration 후 열리고 포인터\n//   이탈 시 닫힌다. 브리지 PressableProps에 hover/포커스 이벤트 계약이 없어\n//   （onLongPress는 웹 폐기onFocus 부재） hover 상태 열림을 포인터 이벤트로\n//   관리할 수 없다 — 진입을 press로 근사한다.\n//    onPress（웹 클릭 + 네이티브 탭）: 닫힘 상태면 지연 로직대로 열고, 열림\n//     상태면 즉시 닫는다（토글）.\n//    onLongPress（네이티브 롱프레스 전용, 웹은 브리지가 폐기）: 지연 로직대로\n//     연다. 플랫폼 분기 없이 두 핸들러를 항상 연결해 어느 쪽에서도 동작한다.\n//    웹 focus（키보드） 진입은 onFocus 계약 부재로 미지원 — aria-describedby\n//     연결로 스크린리더는 콘텐츠를 인지한다（한계）.\n// - 지연 재현: delayDuration（기본 0 — 원본 Provider 기본값） 후 열림,\n//   skipDelayDuration（기본 300 — radix 기본값） 창 안의 재진입은 지연 없이 즉시\n//   열림, displayDuration（신규 프로퍼티, 기본 3000） 후 자동 닫힘. 전부\n//   setTimeout으로 재현하고 재스케줄언마운트 시 clearTimeout 클린업한다.\n//   displayDuration은 press 진입 근사에서 이탈 이벤트가 없어 자동 닫힘용으로\n//   추가한 프로퍼티다（원본에는 없음）.\n// - 원본은 TooltipProvider가 필수（radix 제약） — 포팅은 Provider 부재 시 기본값\n//   폴백으로 동작하게 해 선택형으로 완화했다.\n// - 원본 Content의 sidesideOffset 프로퍼티명을 유지하되 내부적으로\n//   AnchoredOverlay의 placementoffset로 매핑（기본 side \"top\"sideOffset 0은\n//   원본 기본값, align \"center\"는 radix popper 기본값）.\n// - 화살표: 원본은 rotate-45 사각형 + 임의값 translate — 임의값 클래스 금지\n//   때문에 View 도형（size-2 rotate-45）으로 근사하되, 배치가 성립하는\n//   side \"top\"（기본）일 때만 카드 하단에 렌더한다（lucide 아이콘 아님）.\n// - 애니메이션（animate-in/outfadezoomdata-［side］ 슬라이드）과 origin 변형은\n//   즉시 전환으로 근사 — 토큰 전부 제거.\n// - 카드 className에 pointer-events-auto 기본 포함（웹 box-none 상속 회피）.\n// - 접근성: 카드 role=\"tooltip\"（원본과 동일） + nativeID, 트리거는 원본처럼\n//   aria-describedby로 카드를 설명 연결한다（열림 중에만 — 닫힘 카드는 언마운트\n//   되므로 끊어진 참조를 만들지 않는다）. RN은 role=\"tooltip\"aria-describedby를\n//   무시한다（브리지 계약 문서화된 한계 — 웹 스크린리더만 이득）.\n// - text-balance는 웹 전용 속성이라 web: 접두로만 지정, 카드 글자색（text-background）\n//   은 네이티브 색 상속 불가로 내부 Text에 이중 지정한다.\n// - asChild 미지원 — 트리거는 항상 브리지 Pressable（웹 <button>）로 렌더된다.\nimport {\n  createContext,\n  forwardRef,\n  useCallback,\n  useContext,\n  useEffect,\n  useId,\n  useMemo,\n  useRef,\n  useState,\n  type ForwardedRef,\n  type MutableRefObject,\n  type ReactNode,\n} from \"react\";\nimport {\n  Pressable,\n  Text,\n  View,\n  type BridgePressableRef,\n  type BridgeViewRef,\n  type PressableProps,\n  type ViewProps,\n} from \"@unikit-bridge/ui/primitives\";\nimport { cva } from \"class-variance-authority\";\n\nimport {\n  AnchoredOverlay,\n  useOverlayPlacement,\n  type OverlayAlign,\n  type OverlayPlacement,\n} from \"../lib/overlay\";\nimport { cn } from \"@/lib/utils\";\n\n// ---------------------------------------------------------------------------\n// Provider — 지연 정책 공유（원본과 달리 요소를 렌더하지 않는다）\n// ---------------------------------------------------------------------------\n\ntype TooltipPolicyContextValue = {\n  /** 진입 후 열림까지 대기 ms（기본 0 — 원본 shadcn Provider 기본값） */\n  delayDuration: number;\n  /** 한 번 열린 뒤 다른 트리거 진입 시 즉시 열림으로 간주하는 창 ms */\n  skipDelayDuration: number;\n  /** 열림 유지 ms — press 진입 근사의 자동 닫힘（원본에 없는 포팅 추가） */\n  displayDuration: number;\n  /** 툴팁이 열렸음을 정책에 기록（skip 창 갱신） */\n  markOpened: () => void;\n  /** 현재 skip 창 안인지 */\n  isSkipped: () => boolean;\n};\n\nconst TooltipPolicyContext = createContext<TooltipPolicyContextValue | null>(null);\n\n// Provider 없이 <Tooltip>만 쓸 때의 폴백 정책（원본 radix는 Provider 필수）.\nconst TOOLTIP_POLICY_DEFAULT: TooltipPolicyContextValue = {\n  delayDuration: 0,\n  skipDelayDuration: 300,\n  displayDuration: 3000,\n  markOpened: () => {},\n  isSkipped: () => false,\n};\n\nexport type TooltipProviderProps = {\n  children?: ReactNode;\n  delayDuration?: number;\n  skipDelayDuration?: number;\n  displayDuration?: number;\n};\n\n// 원본처럼 렌더 요소가 없는（context만 제공하는） 파트라 forwardRef 대상이 없다.\nfunction TooltipProvider({\n  children,\n  delayDuration = 0,\n  skipDelayDuration = 300,\n  displayDuration = 3000,\n}: TooltipProviderProps) {\n  const skipUntilRef = useRef(0);\n  const value = useMemo<TooltipPolicyContextValue>(\n    () => ({\n      delayDuration,\n      skipDelayDuration,\n      displayDuration,\n      markOpened: () => {\n        skipUntilRef.current = Date.now() + skipDelayDuration;\n      },\n      isSkipped: () => Date.now() < skipUntilRef.current,\n    }),\n    [delayDuration, skipDelayDuration, displayDuration],\n  );\n  return (\n    <TooltipPolicyContext.Provider value={value}>\n      {children}\n    </TooltipPolicyContext.Provider>\n  );\n}\n\n// ---------------------------------------------------------------------------\n// Tooltip 루트 — 열림 상태 + 지연 타이머\n// ---------------------------------------------------------------------------\n\ntype TooltipContextValue = {\n  open: boolean;\n  /** 지연 로직대로 연다（skip 창이면 즉시, 아니면 delayDuration 후） */\n  show: () => void;\n  /** 지연 타이머를 취소하고 즉시 닫는다 */\n  hide: () => void;\n  /** 카드 nativeID ↔ 트리거 aria-describedby 연결용 ID */\n  contentId: string;\n  /** 트리거 요소 ref — 앵커 측정닫힘 후 포커스 복귀에 공용 */\n  triggerRef: MutableRefObject<BridgePressableRef | null>;\n};\n\nconst TooltipContext = createContext<TooltipContextValue | null>(null);\n\nfunction useTooltipContext(component: string): TooltipContextValue {\n  const ctx = useContext(TooltipContext);\n  if (!ctx) {\n    throw new Error(`<${component}>는 <Tooltip> 안에서 사용해야 합니다.`);\n  }\n  return ctx;\n}\n\n/** 외부 ref와 루트가 소유한 ref를 한 노드에 동시 연결한다. */\nfunction composeRefs<T>(\n  local: MutableRefObject<T | null>,\n  forwarded: ForwardedRef<T>,\n) {\n  return (node: T | null) => {\n    local.current = node;\n    if (typeof forwarded === \"function\") forwarded(node);\n    else if (forwarded) forwarded.current = node;\n  };\n}\n\nexport type TooltipProps = ViewProps & {\n  open?: boolean;\n  defaultOpen?: boolean;\n  onOpenChange?: (open: boolean) => void;\n  /** 이 툴팁 진입의 delayDuration 오버라이드（기본 Provider 값） */\n  delayDuration?: number;\n};\n\nconst Tooltip = forwardRef<BridgeViewRef, TooltipProps>(\n  ({ open, defaultOpen, onOpenChange, delayDuration, children, ...props }, ref) => {\n    // 비제어 상태 — 제어（open 지정）면 무시된다.\n    const [internalOpen, setInternalOpen] = useState(defaultOpen ?? false);\n    const isOpen = open ?? internalOpen;\n    const policy = useContext(TooltipPolicyContext) ?? TOOLTIP_POLICY_DEFAULT;\n    const resolvedDelay = delayDuration ?? policy.delayDuration;\n    const baseId = useId();\n    const triggerRef = useRef<BridgePressableRef | null>(null);\n\n    // 지연 진입 타이머 — 재스케줄언마운트 시 해제가 의무다.\n    const delayTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);\n    const clearDelayTimer = useCallback(() => {\n      if (delayTimerRef.current !== null) {\n        clearTimeout(delayTimerRef.current);\n        delayTimerRef.current = null;\n      }\n    }, []);\n    useEffect(() => clearDelayTimer, [clearDelayTimer]);\n\n    // 타이머 콜백에서도 최신 열림 값/제어 여부를 읽도록 렌더마다 갱신하는 미러.\n    const stateRef = useRef({ isOpen, open, onOpenChange });\n    stateRef.current = { isOpen, open, onOpenChange };\n    const applyOpen = useCallback(\n      (next: boolean) => {\n        const state = stateRef.current;\n        if (state.open === undefined) setInternalOpen(next);\n        if (next !== state.isOpen) state.onOpenChange?.(next);\n        stateRef.current = { ...state, isOpen: next };\n        if (next) policy.markOpened();\n      },\n      [policy],\n    );\n    const applyOpenRef = useRef(applyOpen);\n    applyOpenRef.current = applyOpen;\n\n    // displayDuration 자동 닫힘 — 열림마다 1회 스케줄, 클린업 필수.\n    const displayDuration = policy.displayDuration;\n    useEffect(() => {\n      if (!isOpen || displayDuration <= 0) return;\n      const id = setTimeout(() => applyOpenRef.current(false), displayDuration);\n      return () => clearTimeout(id);\n    }, [isOpen, displayDuration]);\n\n    const show = useCallback(() => {\n      clearDelayTimer();\n      if (stateRef.current.isOpen) return;\n      // skip 창 안의 재진입은 지연 없이 즉시 열림（radix skipDelayDuration 재현）.\n      if (policy.isSkipped()) {\n        applyOpen(true);\n        return;\n      }\n      delayTimerRef.current = setTimeout(() => {\n        delayTimerRef.current = null;\n        applyOpen(true);\n      }, resolvedDelay);\n    }, [clearDelayTimer, policy, applyOpen, resolvedDelay]);\n    const hide = useCallback(() => {\n      clearDelayTimer();\n      applyOpen(false);\n    }, [clearDelayTimer, applyOpen]);\n\n    const contextValue = useMemo<TooltipContextValue>(\n      () => ({ open: isOpen, show, hide, contentId: `${baseId}-tooltip-content`, triggerRef }),\n      [isOpen, show, hide, baseId],\n    );\n\n    return (\n      <TooltipContext.Provider value={contextValue}>\n        <View ref={ref} {...props}>{children}</View>\n      </TooltipContext.Provider>\n    );\n  },\n);\nTooltip.displayName = \"Tooltip\";\n\n// ---------------------------------------------------------------------------\n// TriggerContent\n// ---------------------------------------------------------------------------\n\nexport type TooltipTriggerProps = PressableProps;\n// children에 문자열을 쓸 때는 Text로 감싸주세요 — 트리거는 내부 Text 래핑이\n// 없어서 네이티브에서 문자열 children이 런타임 에러를 낸다(2026-11-10 실증).\n\nconst TooltipTrigger = forwardRef<BridgePressableRef, TooltipTriggerProps>(\n  ({ onPress, onLongPress, ...props }, ref) => {\n    const tooltip = useTooltipContext(\"TooltipTrigger\");\n    return (\n      <Pressable\n        ref={composeRefs(tooltip.triggerRef, ref)}\n        // 원본 관행대로 트리거의 보조 설명을 카드와 연결 — 열림 중에만（닫힘 카드는\n        // 언마운트되므로 끊어진 참조를 만들지 않는다）.\n        aria-describedby={tooltip.open ? tooltip.contentId : undefined}\n        onPress={() => {\n          if (tooltip.open) tooltip.hide();\n          else tooltip.show();\n          onPress?.();\n        }}\n        onLongPress={() => {\n          tooltip.show();\n          onLongPress?.();\n        }}\n        {...props}\n      />\n    );\n  },\n);\nTooltipTrigger.displayName = \"TooltipTrigger\";\n\nconst tooltipContentVariants = cva(\n  // pointer-events-auto: 웹에서 오버레이 래퍼 box-none 상속 회피（기본 포함）.\n  // relative는 하단 화살표 절대 배치의 기준이 된다.\n  \"pointer-events-auto relative w-fit rounded-md bg-foreground px-3 py-1.5 text-background unikit-popover-in web:outline-none web:text-balance\",\n);\n\n// 네이티브 색 상속 불가로 내부 Text에 이중 지정（코드베이스 관행）.\n// text-balance는 컨테이너의 web: 토큰이 웹에서 상속된다.\nconst tooltipContentTextVariants = cva(\"font-sans text-xs text-background\");\n\nfunction TooltipArrow({ fallbackSide }: { fallbackSide: OverlayPlacement }) {\n  const resolvedSide = useOverlayPlacement() ?? fallbackSide;\n  if (resolvedSide !== \"top\" && resolvedSide !== \"bottom\") return null;\n  return (\n    <View\n      aria-hidden\n      className={cn(\n        \"absolute inset-x-0 flex-row justify-center\",\n        resolvedSide === \"top\" ? \"-bottom-1\" : \"-top-1\",\n      )}\n    >\n      <View className=\"size-2 rotate-45 bg-foreground\" />\n    </View>\n  );\n}\n\nexport type TooltipContentProps = ViewProps & {\n  /** 전개 방향（기본 top — radix 기본값） */\n  side?: OverlayPlacement;\n  /** 앵커 변 기준 정렬（기본 center — radix popper 기본값） */\n  align?: OverlayAlign;\n  /** 앵커와의 간격 px（기본 0 — 원본 기본값） */\n  sideOffset?: number;\n};\n\nconst TooltipContent = forwardRef<BridgeViewRef, TooltipContentProps>(\n  (\n    { className, side = \"top\", align = \"center\", sideOffset = 0, children, nativeID, ...props },\n    ref,\n  ) => {\n    const tooltip = useTooltipContext(\"TooltipContent\");\n    return (\n      <AnchoredOverlay\n        visible={tooltip.open}\n        onClose={tooltip.hide}\n        anchorRef={tooltip.triggerRef}\n        placement={side}\n        align={align}\n        offset={sideOffset}\n        triggerRef={tooltip.triggerRef}\n        className=\"z-50\"\n      >\n        <View\n          ref={ref}\n          accessibilityRole=\"tooltip\"\n          nativeID={nativeID ?? tooltip.contentId}\n          className={cn(tooltipContentVariants(), className)}\n          {...props}\n        >\n          <Text className={tooltipContentTextVariants()}>{children}</Text>\n          <TooltipArrow fallbackSide={side} />\n        </View>\n      </AnchoredOverlay>\n    );\n  },\n);\nTooltipContent.displayName = \"TooltipContent\";\n\nexport {\n  Tooltip,\n  TooltipTrigger,\n  TooltipContent,\n  TooltipProvider,\n  tooltipContentVariants,\n  tooltipContentTextVariants,\n};\n",
      "type": "registry:ui",
      "target": "components/ui/tooltip.tsx"
    }
  ],
  "type": "registry:ui"
}
