{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "tabs",
  "title": "Tabs",
  "description": "유니버설 탭 — tablist/tab 역할, aria-selected, useId로 trigger↔content 연결. TabsList 변형 default/line(line은 인디케이터 바).",
  "dependencies": [
    "@unikit-bridge/ui",
    "class-variance-authority",
    "clsx",
    "tailwind-merge",
    "tailwindcss"
  ],
  "registryDependencies": [
    "utils"
  ],
  "files": [
    {
      "path": "registry/ui/tabs.tsx",
      "content": "// Tabs — shadcn/ui（new-york-v4） tabs.tsx 유니버설 포팅 （사이클 30, T-3b）.\n// 원본: shadcn-ref/ui/tabs.tsx — radix Tabs 엔진을 제거하고 선택 상태는\n// React state + context로 직접 관리한다. 원본의 data-state·그룹 셀렉터 클래스는\n// 네이티브에 data 속성이 없어 재현 불가하므로, 활성 여부에 따른 조건부 클래스\n// 문자열로 전개한다（aria-selected는 접근성용으로 계속 지정）.\n//\n// 포팅 판단（원본 대비 차이）:\n// - orientation（vertical） 미지원 — 세로축 스타일은 전부 그룹 셀렉터 의존이라\n//   수평 전용으로 단순화. 루트의 flex·flex-col은 브리지 View 기본 클래스가 제공.\n// - 임의값 클래스 금지（디자인 감사 （c））에 따라 원본의 calc 높이·3px 리스트\n//   패딩을 표준 계단으로 근사한다（패딩 p-1）. 트리거 높이는 명시하지\n//   않고 리스트 고정 높이 h-9와 패딩이 대신 결정한다. focus 링은 원본 규격\n//   （ring-3 · ring-ring/50 · 무오프셋）을 web: 접두로 준수하고（사이클 72）,\n//   원본 focus-visible:outline-1 focus-visible:outline-ring은\n//   web:focus-visible:outline-1 web:outline-ring 근사（G1 F-1 — outline 색은\n//   무접두 치환, outline-1 적용 시에만 발동하므로 시각 등가）.\n// - line 변형 인디케이터는 원본 가상요소（after） 대신 absolute 인디케이터 View로\n//   재현 — 렌더 여부는 리스트 변형 컨텍스트로, 표시는 활성 조건부 opacity로\n//   전개한다. 원본은 트리거 바깥 음수 오프셋에 바를 뒀지만 포팅은 트리거\n//   하단에 붙인다（네이티브 안전 근사）.\n// - dark: 변형 토큰은 기존 5종 관행（미사용）에 맞춰 제거.\n// - 콘텐츠 전환 애니메이션（높이 전이）·웹 화살표키 탐색은 radix 전용 기능이라\n//   미지원 — 로드맵 항목. 콘텐츠는 마운트/언마운트로 즉시 전환한다.\nimport {\n  createContext,\n  forwardRef,\n  useCallback,\n  useContext,\n  useEffect,\n  useId,\n  useMemo,\n  useRef,\n  useState,\n  type ForwardedRef,\n  type ReactNode,\n} from \"react\";\nimport {\n  animateLayout,\n  Pressable,\n  Text,\n  View,\n  type BridgeKeyEvent,\n  type BridgePressableRef,\n  type BridgeViewRef,\n  type PressableProps,\n  type ViewProps,\n} from \"@unikit-bridge/ui/primitives\";\nimport { cva, type VariantProps } from \"class-variance-authority\";\n\nimport { cn } from \"@/lib/utils\";\n\ntype TabTriggerRecord = {\n  value: string;\n  disabled: boolean;\n  ref: { current: BridgePressableRef | null };\n};\n\ntype TabsContextValue = {\n  value: string;\n  setValue: (value: string) => void;\n  /** 트리거 aria-controls ↔ 콘텐츠 nativeID를 결정적으로 연결하기 위한 토대 ID */\n  baseId: string;\n  registerTrigger: (\n    value: string,\n    disabled: boolean,\n    ref: { current: BridgePressableRef | null },\n  ) => () => void;\n  handleTriggerKeyDown: (currentValue: string, event: BridgeKeyEvent) => void;\n};\n\nconst TabsContext = createContext<TabsContextValue | null>(null);\n\nfunction useTabsContext(component: string): TabsContextValue {\n  const ctx = useContext(TabsContext);\n  if (!ctx) {\n    throw new Error(`<${component}>는 <Tabs> 안에서 사용해야 합니다.`);\n  }\n  return ctx;\n}\n\nfunction composeRefs(\n  ...refs: Array<\n    ForwardedRef<BridgePressableRef> | { current: BridgePressableRef | null }\n  >\n) {\n  return (node: BridgePressableRef | null) => {\n    for (const ref of refs) {\n      if (!ref) continue;\n      if (typeof ref === \"function\") ref(node);\n      else ref.current = node;\n    }\n  };\n}\n\n// 트리거（말록）와 콘텐츠（패널）를 잇는 ID — value의 공백은 ID에 쓸 수 없어 치환.\nfunction contentIdOf(baseId: string, value: string) {\n  return `${baseId}-tabs-content-${value.replace(/\\s+/g, \"-\")}`;\n}\n\n// 리스트 변형 — line 인디케이터 렌더 여부를 트리거가 알 수 있게 전달（원본은\n// 그룹 셀렉터로 전달했다）. 리스트 밖의 트리거는 default 취급.\ntype TabsListContextValue = { variant: \"default\" | \"line\" | null };\nconst TabsListContext = createContext<TabsListContextValue>({ variant: null });\n\nexport type TabsProps = ViewProps & {\n  value?: string;\n  defaultValue?: string;\n  onValueChange?: (value: string) => void;\n};\n\nconst Tabs = forwardRef<BridgeViewRef, TabsProps>(\n  ({ value, defaultValue, onValueChange, className, children, ...props }, ref) => {\n    // 비제어 상태 — 제어（value 지정）면 무시된다.\n    const [internalValue, setInternalValue] = useState(defaultValue ?? \"\");\n    const current = value ?? internalValue;\n    const currentRef = useRef(current);\n    currentRef.current = current;\n    const setValue = useCallback(\n      (next: string) => {\n        animateLayout();\n        if (value === undefined) setInternalValue(next);\n        if (next !== currentRef.current) onValueChange?.(next);\n      },\n      [value, onValueChange],\n    );\n    const triggersRef = useRef<TabTriggerRecord[]>([]);\n    const registerTrigger = useCallback(\n      (\n        triggerValue: string,\n        disabled: boolean,\n        triggerRef: { current: BridgePressableRef | null },\n      ) => {\n        const list = triggersRef.current;\n        const existingIndex = list.findIndex((item) => item.value === triggerValue);\n        const record: TabTriggerRecord = {\n          value: triggerValue,\n          disabled,\n          ref: triggerRef,\n        };\n        if (existingIndex >= 0) {\n          list[existingIndex] = record;\n        } else {\n          list.push(record);\n        }\n        return () => {\n          const idx = triggersRef.current.findIndex(\n            (item) => item.ref === triggerRef,\n          );\n          if (idx >= 0) triggersRef.current.splice(idx, 1);\n        };\n      },\n      [],\n    );\n    const handleTriggerKeyDown = useCallback(\n      (currentValue: string, event: BridgeKeyEvent) => {\n        const enabled = triggersRef.current.filter((item) => !item.disabled);\n        if (enabled.length === 0) return;\n        const currentIdx = enabled.findIndex((item) => item.value === currentValue);\n        let nextIdx = -1;\n        if (event.key === \"ArrowRight\") {\n          nextIdx = currentIdx < 0 ? 0 : (currentIdx + 1) % enabled.length;\n        } else if (event.key === \"ArrowLeft\") {\n          nextIdx =\n            currentIdx < 0\n              ? enabled.length - 1\n              : (currentIdx - 1 + enabled.length) % enabled.length;\n        } else if (event.key === \"Home\") {\n          nextIdx = 0;\n        } else if (event.key === \"End\") {\n          nextIdx = enabled.length - 1;\n        } else {\n          return;\n        }\n        event.preventDefault();\n        const target = enabled[nextIdx];\n        if (!target) return;\n        setValue(target.value);\n        const focusable = target.ref.current as { focus?: () => void } | null;\n        focusable?.focus?.();\n      },\n      [setValue],\n    );\n    const baseId = useId();\n    const contextValue = useMemo(\n      () => ({\n        value: current,\n        setValue,\n        baseId,\n        registerTrigger,\n        handleTriggerKeyDown,\n      }),\n      [current, setValue, baseId, registerTrigger, handleTriggerKeyDown],\n    );\n    return (\n      <TabsContext.Provider value={contextValue}>\n        <View ref={ref} className={cn(\"gap-2\", className)} {...props}>\n          {children}\n        </View>\n      </TabsContext.Provider>\n    );\n  },\n);\nTabs.displayName = \"Tabs\";\n\n// 리스트 — 원본 base에서 w-fit은 네이티브에 맞추기 위해 self-start（교차축 시작\n// 정렬로 내용 폭 수축）로 치환, h-9는 수평 전용 고정값을 받았고 text-muted-foreground\n// 는 이중 지정 원칙（컨테이너 글자색 무의미）으로 제거한다.\nconst tabsListVariants = cva(\n  \"flex-row self-start items-center justify-center rounded-lg p-1 h-9\",\n  {\n    variants: {\n      variant: {\n        default: \"bg-muted\",\n        line: \"gap-1 bg-transparent rounded-none\",\n      },\n    },\n    defaultVariants: {\n      variant: \"default\",\n    },\n  },\n);\n\nexport type TabsListProps = ViewProps & VariantProps<typeof tabsListVariants>;\n\nconst TabsList = forwardRef<BridgeViewRef, TabsListProps>(\n  ({ className, variant, ...props }, ref) => {\n    const resolved = variant ?? \"default\";\n    const listContextValue = useMemo(\n      () => ({ variant: resolved }),\n      [resolved],\n    );\n    return (\n      <TabsListContext.Provider value={listContextValue}>\n        <View\n          ref={ref}\n          accessibilityRole=\"tablist\"\n          className={cn(tabsListVariants({ variant: resolved }), className)}\n          {...props}\n        />\n      </TabsListContext.Provider>\n    );\n  },\n);\nTabsList.displayName = \"TabsList\";\n\nexport type TabsTriggerProps = PressableProps & {\n  value: string;\n  // 함수형 children은 내부 Text 래핑 구조와 양립하지 않아 문자열 children 권장.\n  children?: ReactNode;\n};\n\nconst TabsTrigger = forwardRef<BridgePressableRef, TabsTriggerProps>(\n  ({ value, className, children, disabled, onPress, onKeyDown, ...props }, ref) => {\n    const tabs = useTabsContext(\"TabsTrigger\");\n    const list = useContext(TabsListContext);\n    const active = tabs.value === value;\n    const localRef = useRef<BridgePressableRef | null>(null);\n    const registerTrigger = tabs.registerTrigger;\n    useEffect(\n      () => registerTrigger(value, disabled === true, localRef),\n      [registerTrigger, value, disabled],\n    );\n    return (\n      <Pressable\n        ref={composeRefs(ref, localRef)}\n        accessibilityRole=\"tab\"\n        aria-selected={active}\n        aria-controls={contentIdOf(tabs.baseId, value)}\n        disabled={disabled}\n        onPress={() => {\n          tabs.setValue(value);\n          onPress?.();\n        }}\n        onKeyDown={(event) => {\n          tabs.handleTriggerKeyDown(value, event);\n          onKeyDown?.(event);\n        }}\n        className={cn(\n          \"relative flex-row flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-2 py-1 web:transition-all\",\n          \"web:focus-visible:border-ring web:focus-visible:ring-3 web:focus-visible:ring-ring/50 web:focus-visible:outline-1 web:outline-ring\",\n          // 원본 활성 토큰（bg-background·shadow-sm） — line 변형은 활성 시 배경\n          // 없이 인디케이터 바만（원본 line 활성 토큰이 bg-transparent）.\n          active && list.variant !== \"line\" && \"bg-background\",\n          disabled && \"web:pointer-events-none opacity-50\",\n          className,\n        )}\n        {...props}\n      >\n        {/* 이중 지정: 전경색은 내부 Text에（네이티브는 색 상속 불가） */}\n        <Text\n          className={cn(\n            \"font-sans text-sm font-medium text-foreground/60 web:whitespace-nowrap web:hover:text-foreground\",\n            active && \"text-foreground\",\n          )}\n        >\n          {children}\n        </Text>\n        {list.variant === \"line\" && (\n          <View\n            aria-hidden\n            className={cn(\n              \"absolute inset-x-0 bottom-0 h-0.5 bg-foreground\",\n              active ? \"opacity-100\" : \"opacity-0\",\n            )}\n          />\n        )}\n      </Pressable>\n    );\n  },\n);\nTabsTrigger.displayName = \"TabsTrigger\";\n\nexport type TabsContentProps = ViewProps & {\n  value: string;\n};\n\nconst TabsContent = forwardRef<BridgeViewRef, TabsContentProps>(\n  ({ value, className, children, nativeID, ...props }, ref) => {\n    const tabs = useTabsContext(\"TabsContent\");\n    // 마운트/언마운트 방식 전환 — 높이 애니메이션은 불가하므로 즉시 전환.\n    if (tabs.value !== value) return null;\n    return (\n      <View\n        ref={ref}\n        accessibilityRole=\"tabpanel\"\n        nativeID={nativeID ?? contentIdOf(tabs.baseId, value)}\n        className={cn(\"flex-1 web:outline-none\", className)}\n        {...props}\n      >\n        {children}\n      </View>\n    );\n  },\n);\nTabsContent.displayName = \"TabsContent\";\n\nexport { Tabs, TabsList, TabsTrigger, TabsContent, tabsListVariants };\n",
      "type": "registry:ui",
      "target": "components/unikit/tabs.tsx"
    }
  ],
  "type": "registry:ui"
}
