@unikit-bridge/ui 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,213 @@
1
+ // Tabs — shadcn/ui(new-york-v4) tabs.tsx 유니버설 포팅 (사이클 30, T-3b).
2
+ // 원본: shadcn-ref/src/tabs/tabs.tsx — radix Tabs 엔진을 제거하고 선택 상태는
3
+ // React state + context로 직접 관리한다. 원본의 data-state·그룹 셀렉터 클래스는
4
+ // 네이티브에 data 속성이 없어 재현 불가하므로, 활성 여부에 따른 조건부 클래스
5
+ // 문자열로 전개한다(aria-selected는 접근성용으로 계속 지정).
6
+ //
7
+ // 포팅 판단(원본 대비 차이):
8
+ // - orientation(vertical) 미지원 — 세로축 스타일은 전부 그룹 셀렉터 의존이라
9
+ // 수평 전용으로 단순화. 루트의 flex·flex-col은 브리지 View 기본 클래스가 제공.
10
+ // - 임의값 클래스 금지(디자인 감사 (c))에 따라 원본의 calc 높이·3px 링·3px 리스트
11
+ // 패딩을 표준 계단으로 근사한다(링 ring-2, 패딩 p-1). 트리거 높이는 명시하지
12
+ // 않고 리스트 고정 높이 h-9와 패딩이 대신 결정한다.
13
+ // - line 변형 인디케이터는 원본 가상요소(after) 대신 absolute 인디케이터 View로
14
+ // 재현 — 렌더 여부는 리스트 변형 컨텍스트로, 표시는 활성 조건부 opacity로
15
+ // 전개한다. 원본은 트리거 바깥 음수 오프셋에 바를 뒀지만 포팅은 트리거
16
+ // 하단에 붙인다(네이티브 안전 근사).
17
+ // - dark: 변형 토큰은 기존 5종 관행(미사용)에 맞춰 제거.
18
+ // - 콘텐츠 전환 애니메이션(높이 전이)·웹 화살표키 탐색은 radix 전용 기능이라
19
+ // 미지원 — 로드맵 항목. 콘텐츠는 마운트/언마운트로 즉시 전환한다.
20
+ import {
21
+ createContext,
22
+ forwardRef,
23
+ useContext,
24
+ useId,
25
+ useState,
26
+ type ReactNode,
27
+ } from "react";
28
+ import {
29
+ Pressable,
30
+ Text,
31
+ View,
32
+ type BridgePressableRef,
33
+ type BridgeViewRef,
34
+ type PressableProps,
35
+ type ViewProps,
36
+ } from "@unikit-bridge/ui/primitives";
37
+ import { cva, type VariantProps } from "class-variance-authority";
38
+
39
+ import { cn } from "../lib/utils";
40
+
41
+ type TabsContextValue = {
42
+ value: string;
43
+ setValue: (value: string) => void;
44
+ /** 트리거 aria-controls ↔ 콘텐츠 nativeID를 결정적으로 연결하기 위한 토대 ID */
45
+ baseId: string;
46
+ };
47
+
48
+ const TabsContext = createContext<TabsContextValue | null>(null);
49
+
50
+ function useTabsContext(component: string): TabsContextValue {
51
+ const ctx = useContext(TabsContext);
52
+ if (!ctx) {
53
+ throw new Error(`<${component}>는 <Tabs> 안에서 사용해야 합니다.`);
54
+ }
55
+ return ctx;
56
+ }
57
+
58
+ // 트리거(말록)와 콘텐츠(패널)를 잇는 ID — value의 공백은 ID에 쓸 수 없어 치환.
59
+ function contentIdOf(baseId: string, value: string) {
60
+ return `${baseId}-tabs-content-${value.replace(/\s+/g, "-")}`;
61
+ }
62
+
63
+ // 리스트 변형 — line 인디케이터 렌더 여부를 트리거가 알 수 있게 전달(원본은
64
+ // 그룹 셀렉터로 전달했다). 리스트 밖의 트리거는 default 취급.
65
+ type TabsListContextValue = { variant: "default" | "line" | null };
66
+ const TabsListContext = createContext<TabsListContextValue>({ variant: null });
67
+
68
+ export type TabsProps = ViewProps & {
69
+ value?: string;
70
+ defaultValue?: string;
71
+ onValueChange?: (value: string) => void;
72
+ };
73
+
74
+ const Tabs = forwardRef<BridgeViewRef, TabsProps>(
75
+ ({ value, defaultValue, onValueChange, className, children, ...props }, ref) => {
76
+ // 비제어 상태 — 제어(value 지정)면 무시된다.
77
+ const [internalValue, setInternalValue] = useState(defaultValue ?? "");
78
+ const current = value ?? internalValue;
79
+ const setValue = (next: string) => {
80
+ if (value === undefined) setInternalValue(next);
81
+ if (next !== current) onValueChange?.(next);
82
+ };
83
+ const baseId = useId();
84
+ return (
85
+ <TabsContext.Provider value={{ value: current, setValue, baseId }}>
86
+ <View ref={ref} className={cn("gap-2", className)} {...props}>
87
+ {children}
88
+ </View>
89
+ </TabsContext.Provider>
90
+ );
91
+ },
92
+ );
93
+ Tabs.displayName = "Tabs";
94
+
95
+ // 리스트 — 원본 base에서 w-fit은 네이티브에 맞추기 위해 self-start(교차축 시작
96
+ // 정렬로 내용 폭 수축)로 치환, h-9는 수평 전용 고정값을 받았고 text-muted-foreground
97
+ // 는 이중 지정 원칙(컨테이너 글자색 무의미)으로 제거한다.
98
+ const tabsListVariants = cva(
99
+ "flex-row self-start items-center justify-center rounded-lg p-1 h-9",
100
+ {
101
+ variants: {
102
+ variant: {
103
+ default: "bg-muted",
104
+ line: "gap-1 bg-transparent rounded-none",
105
+ },
106
+ },
107
+ defaultVariants: {
108
+ variant: "default",
109
+ },
110
+ },
111
+ );
112
+
113
+ export type TabsListProps = ViewProps & VariantProps<typeof tabsListVariants>;
114
+
115
+ const TabsList = forwardRef<BridgeViewRef, TabsListProps>(
116
+ ({ className, variant, ...props }, ref) => {
117
+ const resolved = variant ?? "default";
118
+ return (
119
+ <TabsListContext.Provider value={{ variant: resolved }}>
120
+ <View
121
+ ref={ref}
122
+ accessibilityRole="tablist"
123
+ className={cn(tabsListVariants({ variant: resolved }), className)}
124
+ {...props}
125
+ />
126
+ </TabsListContext.Provider>
127
+ );
128
+ },
129
+ );
130
+ TabsList.displayName = "TabsList";
131
+
132
+ export type TabsTriggerProps = PressableProps & {
133
+ value: string;
134
+ // 함수형 children은 내부 Text 래핑 구조와 양립하지 않아 문자열 children 권장.
135
+ children?: ReactNode;
136
+ };
137
+
138
+ const TabsTrigger = forwardRef<BridgePressableRef, TabsTriggerProps>(
139
+ ({ value, className, children, disabled, onPress, ...props }, ref) => {
140
+ const tabs = useTabsContext("TabsTrigger");
141
+ const list = useContext(TabsListContext);
142
+ const active = tabs.value === value;
143
+ return (
144
+ <Pressable
145
+ ref={ref}
146
+ accessibilityRole="tab"
147
+ aria-selected={active}
148
+ aria-controls={contentIdOf(tabs.baseId, value)}
149
+ disabled={disabled}
150
+ onPress={() => {
151
+ tabs.setValue(value);
152
+ onPress?.();
153
+ }}
154
+ className={cn(
155
+ "relative flex-row flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-2 py-1 web:transition-all",
156
+ "web:focus-visible:border-ring web:focus-visible:ring-2 web:focus-visible:ring-ring web:focus-visible:ring-offset-2 web:focus-visible:ring-offset-background",
157
+ // 원본 활성 토큰(bg-background·shadow-sm) — line 변형은 활성 시 배경
158
+ // 없이 인디케이터 바만(원본 line 활성 토큰이 bg-transparent).
159
+ active && list.variant !== "line" && "bg-background web:shadow-sm",
160
+ disabled && "web:pointer-events-none opacity-50",
161
+ className,
162
+ )}
163
+ {...props}
164
+ >
165
+ {/* 이중 지정: 전경색은 내부 Text에(네이티브는 색 상속 불가) */}
166
+ <Text
167
+ className={cn(
168
+ "font-sans text-sm font-medium text-foreground/60 web:whitespace-nowrap web:hover:text-foreground",
169
+ active && "text-foreground",
170
+ )}
171
+ >
172
+ {children}
173
+ </Text>
174
+ {list.variant === "line" && (
175
+ <View
176
+ aria-hidden
177
+ className={cn(
178
+ "absolute inset-x-0 bottom-0 h-0.5 bg-foreground",
179
+ active ? "opacity-100" : "opacity-0",
180
+ )}
181
+ />
182
+ )}
183
+ </Pressable>
184
+ );
185
+ },
186
+ );
187
+ TabsTrigger.displayName = "TabsTrigger";
188
+
189
+ export type TabsContentProps = ViewProps & {
190
+ value: string;
191
+ };
192
+
193
+ const TabsContent = forwardRef<BridgeViewRef, TabsContentProps>(
194
+ ({ value, className, children, nativeID, ...props }, ref) => {
195
+ const tabs = useTabsContext("TabsContent");
196
+ // 마운트/언마운트 방식 전환 — 높이 애니메이션은 불가하므로 즉시 전환.
197
+ if (tabs.value !== value) return null;
198
+ return (
199
+ <View
200
+ ref={ref}
201
+ accessibilityRole="tabpanel"
202
+ nativeID={nativeID ?? contentIdOf(tabs.baseId, value)}
203
+ className={cn("flex-1 web:outline-none", className)}
204
+ {...props}
205
+ >
206
+ {children}
207
+ </View>
208
+ );
209
+ },
210
+ );
211
+ TabsContent.displayName = "TabsContent";
212
+
213
+ export { Tabs, TabsList, TabsTrigger, TabsContent, tabsListVariants };
@@ -0,0 +1,64 @@
1
+ // Textarea — shadcn/ui new-york-v4 textarea를 UniKit 유니버설로 이식.
2
+ //
3
+ // 포팅 판단:
4
+ // - <textarea> 요소 대신 브리지 TextInput에 multiline을 준다(웹 <textarea> ·
5
+ // 네이티브 RN TextInput multiline — bridge/types.ts 계약). Input(src/components/
6
+ // input.tsx)과 같은 패턴: focus 링 토큰은 Input을 따르고, 원본 고유 토큰
7
+ // (min-h-16 최소 높이)은 보존.
8
+ // - 원본의 aria-invalid:* 속성 셀렉터 스타일은 data 속성을 쓸 수 없어
9
+ // invalid?: boolean prop → 조건부 클래스(붉은 테두리·링)로 치환.
10
+ // - 웹 전용 효과(field-sizing-content, shadow-xs, transition-*, outline-none,
11
+ // disabled 커서, md: 반응형 본문 크기)는 web: 접두.
12
+ // - disabled 치환 계약(native: editable={false} · web: textarea[disabled] +
13
+ // aria-disabled)은 Input과 동일. 시각 효과(opacity-50)는 조건부 클래스로
14
+ // 양쪽 플랫폼에 적용한다.
15
+ import { forwardRef } from "react";
16
+ import {
17
+ TextInput,
18
+ type BridgeTextInputRef,
19
+ type TextInputProps,
20
+ } from "@unikit-bridge/ui/primitives";
21
+ import { cva } from "class-variance-authority";
22
+
23
+ import { cn } from "../lib/utils";
24
+
25
+ // 컨테이너 — Input과 동일한 focus 링 + 원본의 min-h-16. 색은 TextInput
26
+ // 자체가 글자 요소이므로 text-foreground를 여기에 명시(이중 지정 원칙).
27
+ const textareaVariants = cva(
28
+ "font-sans min-h-16 w-full rounded-md border border-input bg-transparent px-3 py-2 text-base text-foreground placeholder:text-muted-foreground focus:border-ring focus:ring-2 focus:ring-ring focus:ring-offset-2 focus:ring-offset-background web:focus-visible:border-ring web:field-sizing-content web:shadow-xs web:transition-all web:outline-none web:md:text-sm",
29
+ );
30
+
31
+ // invalid=true일 때 덧씌우는 조건부 클래스 — 원본 aria-invalid:* 치환.
32
+ // twMerge가 border-input/focus:ring-ring 충돌을 뒤 값으로 정리한다.
33
+ const textareaInvalidClasses =
34
+ "border-destructive focus:border-destructive focus:ring-destructive/20";
35
+
36
+ // TextInputProps 그대로 + invalid(원본 aria-invalid 상태 visual 치환).
37
+ export type TextareaProps = TextInputProps & {
38
+ /** 오류 상태 — 붉은 테두리·링을 조건부 클래스로 적용 */
39
+ invalid?: boolean;
40
+ };
41
+
42
+ const Textarea = forwardRef<BridgeTextInputRef, TextareaProps>(
43
+ ({ className, disabled, editable, invalid, ...props }, ref) => (
44
+ <TextInput
45
+ ref={ref}
46
+ multiline
47
+ editable={disabled ? false : editable}
48
+ aria-disabled={disabled ? true : undefined}
49
+ className={cn(
50
+ textareaVariants(),
51
+ invalid && textareaInvalidClasses,
52
+ disabled && "opacity-50 web:cursor-not-allowed",
53
+ className,
54
+ )}
55
+ {...props}
56
+ // 브리지 계약에 editable 치환으로 반영됐으므로 disabled는 그대로 전달
57
+ // (웹 textarea[disabled] · RN은 위 editable={false}가 차단)
58
+ disabled={disabled}
59
+ />
60
+ ),
61
+ );
62
+ Textarea.displayName = "Textarea";
63
+
64
+ export { Textarea, textareaVariants };
@@ -0,0 +1,187 @@
1
+ // ToggleGroup — shadcn/ui new-york-v4 toggle-group을 UniKit 브리지로 이식.
2
+ // 포팅 판단:
3
+ // - radix ToggleGroup(Root/Item) 제거 — 선택 상태는 React context로 관리하고
4
+ // 하위 항목은 Toggle(./toggle)에 pressed를 주입해 재사용한다.
5
+ // - spacing prop · --gap CSS 변수 · data-spacing/first:/last: 셀렉터 제거 —
6
+ // 의사 클래스·CSS 변수는 네이티브에서 재현 불가라 연접 대신 고정 gap-1 간격의
7
+ // 독립 항목 배치로 단순화(항목의 rounded-md·테두리가 그대로 살아있다).
8
+ // - single은 이미 선택된 항목 재선택 시 onValueChange("")(radix 원본 동작 준수),
9
+ // multiple은 배열 토글. 그룹 disabled는 항목에 일괄 전파.
10
+ import {
11
+ createContext,
12
+ forwardRef,
13
+ useContext,
14
+ useState,
15
+ } from "react";
16
+ import {
17
+ View,
18
+ type BridgePressableRef,
19
+ type BridgeViewRef,
20
+ type ViewProps,
21
+ } from "@unikit-bridge/ui/primitives";
22
+ import type { VariantProps } from "class-variance-authority";
23
+
24
+ import { cn } from "../lib/utils";
25
+
26
+ import { Toggle, toggleVariants, type ToggleProps } from "./toggle";
27
+
28
+ // 그룹 상태 — ToggleGroup이 공급하고 ToggleGroupItem이 소비.
29
+ type ToggleGroupContextValue = {
30
+ type: "single" | "multiple";
31
+ isSelected: (value: string) => boolean;
32
+ onItemPress: (value: string) => void;
33
+ variant: VariantProps<typeof toggleVariants>["variant"];
34
+ size: VariantProps<typeof toggleVariants>["size"];
35
+ disabled: boolean;
36
+ };
37
+
38
+ // 그룹 밖에서 Item을 쓴 경우의 폴백 — 눌림 없는 정적 항목으로 렌더(비권장).
39
+ const ToggleGroupContext = createContext<ToggleGroupContextValue>({
40
+ type: "single",
41
+ isSelected: () => false,
42
+ onItemPress: () => {},
43
+ variant: "default",
44
+ size: "default",
45
+ disabled: false,
46
+ });
47
+
48
+ // shadcn 원본 API 준수 — type에 따라 value/onValueChange 시그니처가 갈린다.
49
+ type ToggleGroupBaseProps = ViewProps &
50
+ VariantProps<typeof toggleVariants> & {
51
+ /** 단일/다중 선택 — 기본 single */
52
+ type?: "single" | "multiple";
53
+ /** 비제어형 초기값(single: 문자열, multiple: 배열) */
54
+ defaultValue?: string | string[];
55
+ /** 그룹 전체 차단 — 항목에 일괄 전파 */
56
+ disabled?: boolean;
57
+ };
58
+
59
+ export type ToggleGroupProps = ToggleGroupBaseProps &
60
+ (
61
+ | {
62
+ type?: "single";
63
+ value?: string;
64
+ onValueChange?: (value: string) => void;
65
+ }
66
+ | {
67
+ type: "multiple";
68
+ value?: string[];
69
+ onValueChange?: (value: string[]) => void;
70
+ }
71
+ );
72
+
73
+ // 내부 상태는 string[]로 통합(single은 길이 0~1 배열) — ""·undefined는 선택 없음.
74
+ const toArray = (v: string | string[] | undefined): string[] =>
75
+ v === undefined || v === "" ? [] : Array.isArray(v) ? v : [v];
76
+
77
+ const ToggleGroup = forwardRef<BridgeViewRef, ToggleGroupProps>(
78
+ (
79
+ {
80
+ className,
81
+ variant,
82
+ size,
83
+ type = "single",
84
+ value,
85
+ defaultValue,
86
+ onValueChange,
87
+ disabled = false,
88
+ children,
89
+ ...props
90
+ },
91
+ ref,
92
+ ) => {
93
+ const [uncontrolled, setUncontrolled] = useState(() =>
94
+ toArray(defaultValue),
95
+ );
96
+ const isControlled = value !== undefined;
97
+ const selected = isControlled ? toArray(value) : uncontrolled;
98
+
99
+ const isSelected = (itemValue: string) => selected.includes(itemValue);
100
+
101
+ const handleItemPress = (itemValue: string) => {
102
+ if (type === "multiple") {
103
+ const next = isSelected(itemValue)
104
+ ? selected.filter((v) => v !== itemValue)
105
+ : [...selected, itemValue];
106
+ if (!isControlled) setUncontrolled(next);
107
+ if (onValueChange) {
108
+ // type 브랜치가 보장하는 시그니처로 좁힌다(any 아님).
109
+ (onValueChange as (value: string[]) => void)(next);
110
+ }
111
+ return;
112
+ }
113
+ // single — 이미 선택된 항목 재선택 시 해제(radix 동작: 빈 문자열 방출)
114
+ const next = selected[0] === itemValue ? [] : [itemValue];
115
+ if (!isControlled) setUncontrolled(next);
116
+ if (onValueChange) {
117
+ (onValueChange as (value: string) => void)(next[0] ?? "");
118
+ }
119
+ };
120
+
121
+ return (
122
+ <View
123
+ ref={ref}
124
+ className={cn(
125
+ // w-fit은 uniwind 미지원 — self-start 치환으로 양쪽 플랫폼 내용 폭(M-2).
126
+ "flex self-start flex-row items-center gap-1 rounded-md",
127
+ className,
128
+ )}
129
+ {...props}
130
+ >
131
+ <ToggleGroupContext.Provider
132
+ value={{
133
+ type,
134
+ isSelected,
135
+ onItemPress: handleItemPress,
136
+ variant,
137
+ size,
138
+ disabled,
139
+ }}
140
+ >
141
+ {children}
142
+ </ToggleGroupContext.Provider>
143
+ </View>
144
+ );
145
+ },
146
+ );
147
+ ToggleGroup.displayName = "ToggleGroup";
148
+
149
+ export type ToggleGroupItemProps = Omit<
150
+ ToggleProps,
151
+ "pressed" | "defaultPressed" | "onPressedChange"
152
+ > & {
153
+ /** 이 항목의 값 */
154
+ value: string;
155
+ };
156
+
157
+ const ToggleGroupItem = forwardRef<BridgePressableRef, ToggleGroupItemProps>(
158
+ (
159
+ { className, variant, size, disabled, value, children, ...props },
160
+ ref,
161
+ ) => {
162
+ const group = useContext(ToggleGroupContext);
163
+ const isPressed = group.isSelected(value);
164
+ const isDisabled = disabled || group.disabled;
165
+
166
+ return (
167
+ <Toggle
168
+ ref={ref}
169
+ variant={group.variant ?? variant}
170
+ size={group.size ?? size}
171
+ pressed={isPressed}
172
+ disabled={isDisabled}
173
+ className={cn(
174
+ // 원본 항목 오버라이드: min-w-9 → min-w-0, px-2 → px-3. z-index는 웹 전용.
175
+ "w-auto min-w-0 shrink-0 px-3 web:focus:z-10 web:focus-visible:z-10",
176
+ className,
177
+ )}
178
+ {...props}
179
+ >
180
+ {children}
181
+ </Toggle>
182
+ );
183
+ },
184
+ );
185
+ ToggleGroupItem.displayName = "ToggleGroupItem";
186
+
187
+ export { ToggleGroup, ToggleGroupItem };
@@ -0,0 +1,132 @@
1
+ // Toggle — shadcn/ui new-york-v4 toggle을 UniKit 브리지로 이식.
2
+ // 포팅 판단:
3
+ // - radix Toggle 제거 — 눌림 상태는 React state(제어/비제어)로 관리.
4
+ // - 원본의 data-state=on 셀렉터 → pressed 조건부 클래스(bg-accent·text-accent-foreground).
5
+ // - [&_svg]:* 규칙은 아이콘 없는 글리프 구조(내부 Text)에서 대상이 없어 제외.
6
+ // - 이중 지정 원칙(§0.5): 눌림 배경은 Pressable, 전경색(hover 포함)은 내부 Text에
7
+ // 명시(네이티브는 컨테이너 색이 자식 Text로 상속되지 않는다).
8
+ import { forwardRef, useState, type ReactNode } from "react";
9
+ import {
10
+ Pressable,
11
+ Text,
12
+ type BridgePressableRef,
13
+ type PressableProps,
14
+ } from "@unikit-bridge/ui/primitives";
15
+ import { cva, type VariantProps } from "class-variance-authority";
16
+
17
+ import { cn } from "../lib/utils";
18
+
19
+ // 컨테이너(Pressable) — 원본 base의 hover:bg-muted(기본 변형 호버)를 포함.
20
+ // outline 변형의 hover:bg-accent는 변형 클래스가 뒤에 오므로 twMerge가 대체한다.
21
+ const toggleVariants = cva(
22
+ "flex flex-row items-center justify-center gap-2 rounded-md web:whitespace-nowrap web:transition-all web:outline-none web:hover:bg-muted web:focus-visible:border-ring web:focus-visible:ring-2 web:focus-visible:ring-ring/50",
23
+ {
24
+ variants: {
25
+ variant: {
26
+ default: "bg-transparent",
27
+ outline:
28
+ "border border-input bg-transparent web:shadow-xs web:hover:bg-accent",
29
+ },
30
+ size: {
31
+ default: "h-9 min-w-9 px-2",
32
+ sm: "h-8 min-w-8 px-1.5",
33
+ lg: "h-10 min-w-10 px-2.5",
34
+ },
35
+ },
36
+ defaultVariants: {
37
+ variant: "default",
38
+ size: "default",
39
+ },
40
+ },
41
+ );
42
+
43
+ // 내부 Text — 변형별 hover 전경색(원본 base의 hover:text-muted-foreground와
44
+ // outline 변형의 hover:text-accent-foreground 대응). pressed 오버라이드
45
+ // (text-accent-foreground)는 렌더 시 조건부로 뒤에 합성한다.
46
+ const toggleTextVariants = cva("font-sans text-sm font-medium", {
47
+ variants: {
48
+ variant: {
49
+ default: "text-foreground web:hover:text-muted-foreground",
50
+ outline: "text-foreground web:hover:text-accent-foreground",
51
+ },
52
+ },
53
+ defaultVariants: {
54
+ variant: "default",
55
+ },
56
+ });
57
+
58
+ // 터치 타깃 44px 보정 — h-9/h-8/h-10(button.tsx 관례와 동일 스케일).
59
+ const HIT_SLOP = { default: 4, sm: 4, lg: 0 } as const;
60
+
61
+ export type ToggleProps = Omit<PressableProps, "onPress" | "aria-pressed"> &
62
+ VariantProps<typeof toggleVariants> & {
63
+ /** 제어형 눌림 상태(지정 시 내부 state를 대체) */
64
+ pressed?: boolean;
65
+ /** 비제어형 초기 눌림 상태 */
66
+ defaultPressed?: boolean;
67
+ /** 눌림 전환 콜백 */
68
+ onPressedChange?: (pressed: boolean) => void;
69
+ children?: ReactNode;
70
+ };
71
+
72
+ const Toggle = forwardRef<BridgePressableRef, ToggleProps>(
73
+ (
74
+ {
75
+ className,
76
+ variant,
77
+ size,
78
+ pressed,
79
+ defaultPressed = false,
80
+ onPressedChange,
81
+ disabled,
82
+ hitSlop,
83
+ children,
84
+ ...props
85
+ },
86
+ ref,
87
+ ) => {
88
+ // 제어/비제어 통합 — toggle-group이 pressed를 주입할 때도 이 경로로 제어된다.
89
+ const [uncontrolled, setUncontrolled] = useState(defaultPressed);
90
+ const isControlled = pressed !== undefined;
91
+ const isPressed = isControlled ? pressed : uncontrolled;
92
+ const resolvedSize = size ?? "default";
93
+
94
+ const handlePress = () => {
95
+ const next = !isPressed;
96
+ if (!isControlled) setUncontrolled(next);
97
+ onPressedChange?.(next);
98
+ };
99
+
100
+ return (
101
+ <Pressable
102
+ ref={ref}
103
+ accessibilityRole="button"
104
+ aria-pressed={isPressed}
105
+ aria-disabled={disabled ? true : undefined}
106
+ disabled={disabled}
107
+ hitSlop={hitSlop ?? HIT_SLOP[resolvedSize]}
108
+ onPress={handlePress}
109
+ className={cn(
110
+ toggleVariants({ variant, size }),
111
+ // 원본의 on 상태 bg-accent 전개 — bg-transparent를 twMerge가 대체.
112
+ isPressed && "bg-accent",
113
+ disabled && "web:pointer-events-none opacity-50",
114
+ className,
115
+ )}
116
+ {...props}
117
+ >
118
+ <Text
119
+ className={cn(
120
+ toggleTextVariants({ variant }),
121
+ isPressed && "text-accent-foreground",
122
+ )}
123
+ >
124
+ {children}
125
+ </Text>
126
+ </Pressable>
127
+ );
128
+ },
129
+ );
130
+ Toggle.displayName = "Toggle";
131
+
132
+ export { Toggle, toggleVariants, toggleTextVariants };