@unikit-bridge/ui 0.0.0-stage → 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.
Files changed (42) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +99 -2
  3. package/docs/tokens.md +63 -0
  4. package/package.json +75 -4
  5. package/src/bridge/native.tsx +301 -0
  6. package/src/bridge/types.ts +174 -0
  7. package/src/bridge/uniwind-env.d.ts +1 -0
  8. package/src/bridge/web.tsx +247 -0
  9. package/src/components/accordion.tsx +231 -0
  10. package/src/components/alert.tsx +157 -0
  11. package/src/components/aspect-ratio.tsx +40 -0
  12. package/src/components/avatar.tsx +275 -0
  13. package/src/components/badge.tsx +65 -0
  14. package/src/components/breadcrumb.tsx +173 -0
  15. package/src/components/button-group.tsx +166 -0
  16. package/src/components/button.tsx +106 -0
  17. package/src/components/card.tsx +112 -0
  18. package/src/components/checkbox.tsx +94 -0
  19. package/src/components/collapsible.tsx +120 -0
  20. package/src/components/empty.tsx +160 -0
  21. package/src/components/field.tsx +349 -0
  22. package/src/components/input.tsx +39 -0
  23. package/src/components/item.tsx +243 -0
  24. package/src/components/kbd.tsx +62 -0
  25. package/src/components/label.tsx +33 -0
  26. package/src/components/pagination.tsx +204 -0
  27. package/src/components/progress.tsx +60 -0
  28. package/src/components/radio-group.tsx +148 -0
  29. package/src/components/separator.tsx +50 -0
  30. package/src/components/skeleton.tsx +36 -0
  31. package/src/components/switch.tsx +128 -0
  32. package/src/components/table.tsx +193 -0
  33. package/src/components/tabs.tsx +213 -0
  34. package/src/components/textarea.tsx +64 -0
  35. package/src/components/toggle-group.tsx +187 -0
  36. package/src/components/toggle.tsx +132 -0
  37. package/src/index.ts +321 -0
  38. package/src/lib/utils.ts +7 -0
  39. package/src/theme/index.ts +4 -0
  40. package/src/theme/presets.ts +187 -0
  41. package/src/theme/provider.tsx +82 -0
  42. package/src/theme/tokens.ts +75 -0
@@ -0,0 +1,120 @@
1
+ // Collapsible — shadcn/ui(new-york-v4) collapsible.tsx 유니버설 포팅 (사이클 30, T-3b).
2
+ // 원본: shadcn-ref/src/collapsible/collapsible.tsx — radix Collapsible을 그대로
3
+ // 재노출하는 무스타일 래퍼라 이식 결과에도 스타일 토큰이 없다. radix 의존 제거의
4
+ // 가장 단순한 예로, 전개 상태(open)를 React state + context로 직접 관리한다.
5
+ // (accordion.tsx가 같은 구조의 다중 항목 확장이다.)
6
+ //
7
+ // 포팅 판단(원본 대비 차이):
8
+ // - 애니메이션(radix data-state 기반 CSS 전이) 미지원 — 열림/닫힘 즉시 전환.
9
+ // - 콘텐츠는 open 시에만 마운트한다(마운트/언마운트 방식).
10
+ // - Trigger는 Pressable(웹 button) — 네이티브 Pressable(View 기반)에 문자열
11
+ // children을 직접 두면 RN 런타임이 크래시하므로("Text strings must be
12
+ // rendered within a <Text> component") children을 내부 Text로 감싼다(B-1).
13
+ // 문자열 라벨 children 권장(AccordionTrigger와 동일 구조).
14
+ import {
15
+ createContext,
16
+ forwardRef,
17
+ useContext,
18
+ useState,
19
+ type ReactNode,
20
+ } from "react";
21
+ import {
22
+ Pressable,
23
+ Text,
24
+ View,
25
+ type BridgePressableRef,
26
+ type BridgeViewRef,
27
+ type PressableProps,
28
+ type ViewProps,
29
+ } from "@unikit-bridge/ui/primitives";
30
+
31
+ type CollapsibleContextValue = {
32
+ open: boolean;
33
+ toggle: () => void;
34
+ };
35
+
36
+ const CollapsibleContext = createContext<CollapsibleContextValue | null>(null);
37
+
38
+ function useCollapsibleContext(component: string): CollapsibleContextValue {
39
+ const ctx = useContext(CollapsibleContext);
40
+ if (!ctx) {
41
+ throw new Error(`<${component}>는 <Collapsible> 안에서 사용해야 합니다.`);
42
+ }
43
+ return ctx;
44
+ }
45
+
46
+ export type CollapsibleProps = ViewProps & {
47
+ open?: boolean;
48
+ defaultOpen?: boolean;
49
+ onOpenChange?: (open: boolean) => void;
50
+ };
51
+
52
+ const Collapsible = forwardRef<BridgeViewRef, CollapsibleProps>(
53
+ ({ open, defaultOpen = false, onOpenChange, children, ...props }, ref) => {
54
+ // 비제어 상태 — 제어(open 지정)면 무시된다.
55
+ const [internalOpen, setInternalOpen] = useState(defaultOpen);
56
+ const isOpen = open ?? internalOpen;
57
+ const setOpen = (next: boolean) => {
58
+ if (open === undefined) setInternalOpen(next);
59
+ if (next !== isOpen) onOpenChange?.(next);
60
+ };
61
+ return (
62
+ <CollapsibleContext.Provider
63
+ value={{ open: isOpen, toggle: () => setOpen(!isOpen) }}
64
+ >
65
+ <View ref={ref} {...props}>
66
+ {children}
67
+ </View>
68
+ </CollapsibleContext.Provider>
69
+ );
70
+ },
71
+ );
72
+ Collapsible.displayName = "Collapsible";
73
+
74
+ export type CollapsibleTriggerProps = PressableProps & {
75
+ // 함수형 children은 내부 Text 래핑 구조와 양립하지 않아 문자열 children 권장.
76
+ children?: ReactNode;
77
+ };
78
+
79
+ const CollapsibleTrigger = forwardRef<
80
+ BridgePressableRef,
81
+ CollapsibleTriggerProps
82
+ >(({ children, onPress, ...props }, ref) => {
83
+ const { open, toggle } = useCollapsibleContext("CollapsibleTrigger");
84
+ return (
85
+ <Pressable
86
+ ref={ref}
87
+ accessibilityRole="button"
88
+ aria-expanded={open}
89
+ onPress={() => {
90
+ toggle();
91
+ onPress?.();
92
+ }}
93
+ {...props}
94
+ >
95
+ {/* 이중 지정: 전경색·타이포그래피는 내부 Text에(네이티브 색 상속 불가) */}
96
+ <Text className="font-sans text-sm font-medium text-foreground">
97
+ {children}
98
+ </Text>
99
+ </Pressable>
100
+ );
101
+ });
102
+ CollapsibleTrigger.displayName = "CollapsibleTrigger";
103
+
104
+ export type CollapsibleContentProps = ViewProps;
105
+
106
+ const CollapsibleContent = forwardRef<BridgeViewRef, CollapsibleContentProps>(
107
+ ({ children, ...props }, ref) => {
108
+ const { open } = useCollapsibleContext("CollapsibleContent");
109
+ // open 시에만 렌더 — 마운트/언마운트 방식(애니메이션 없음).
110
+ if (!open) return null;
111
+ return (
112
+ <View ref={ref} {...props}>
113
+ {children}
114
+ </View>
115
+ );
116
+ },
117
+ );
118
+ CollapsibleContent.displayName = "CollapsibleContent";
119
+
120
+ export { Collapsible, CollapsibleTrigger, CollapsibleContent };
@@ -0,0 +1,160 @@
1
+ // Empty — shadcn/ui new-york-v4 empty를 UniKit 유니버설로 이식.
2
+ //
3
+ // 포팅 판단:
4
+ // - 단순 레이아웃 컴포넌트군 — 엔진 의존이 없어 구조·토큰을 그대로 옮기고
5
+ // data-slot 속성과 [&_svg]·[&>a] 자식 셀렉터만 제거했다.
6
+ // - EmptyMedia의 아이콘: 원본 svg 대신 큰 글리프(⊕ ⬚ ◻ 등)를 내부 Text로
7
+ // 렌더한다(아이콘 금지 규칙). children에 문자열을 넘긴다.
8
+ // - 텍스트 정렬(text-center)은 웹은 상속되지만 네이티브은 되지 않으므로
9
+ // Title/Description Text에 직접 지정한다(루트의 text-center는 웹 중첩
10
+ // 자식용으로 유지 — 네이티브 View에서는 무해한 no-op).
11
+ // - text-balance·md: 반응형 패딩은 웹 전용 → web: 접두.
12
+ import { forwardRef, type ReactNode } from "react";
13
+ import {
14
+ Text,
15
+ View,
16
+ type BridgeTextRef,
17
+ type BridgeViewRef,
18
+ type TextProps,
19
+ type ViewProps,
20
+ } from "@unikit-bridge/ui/primitives";
21
+ import { cva, type VariantProps } from "class-variance-authority";
22
+
23
+ import { cn } from "../lib/utils";
24
+
25
+ // Empty — 빈 상태 컨테이너(원본 border-dashed 토큰 보존 — 테두리 굵기는
26
+ // 소비자 className으로 지정하는 원본 사용법과 동일).
27
+ export type EmptyProps = ViewProps;
28
+
29
+ const Empty = forwardRef<BridgeViewRef, EmptyProps>(
30
+ ({ className, ...props }, ref) => (
31
+ <View
32
+ ref={ref}
33
+ className={cn(
34
+ "flex min-w-0 flex-1 flex-col items-center justify-center gap-6 rounded-lg border-dashed p-6 text-center web:text-balance web:md:p-12",
35
+ className,
36
+ )}
37
+ {...props}
38
+ />
39
+ ),
40
+ );
41
+ Empty.displayName = "Empty";
42
+
43
+ // EmptyHeader — 제목·설명 묶음.
44
+ export type EmptyHeaderProps = ViewProps;
45
+
46
+ const EmptyHeader = forwardRef<BridgeViewRef, EmptyHeaderProps>(
47
+ ({ className, ...props }, ref) => (
48
+ <View
49
+ ref={ref}
50
+ className={cn(
51
+ "flex max-w-sm flex-col items-center gap-2 text-center",
52
+ className,
53
+ )}
54
+ {...props}
55
+ />
56
+ ),
57
+ );
58
+ EmptyHeader.displayName = "EmptyHeader";
59
+
60
+ // EmptyMedia — 글리프 아이콘 영역. icon 변형은 원본 bg-muted 상자(size-10)를
61
+ // 유지하고, 글리프 크기(text-2xl ≈ 원본 svg size-6)·색은 내부 Text에 명시.
62
+ const emptyMediaVariants = cva(
63
+ "mb-2 flex shrink-0 items-center justify-center",
64
+ {
65
+ variants: {
66
+ variant: {
67
+ default: "bg-transparent",
68
+ icon: "size-10 rounded-lg bg-muted",
69
+ },
70
+ },
71
+ defaultVariants: {
72
+ variant: "default",
73
+ },
74
+ },
75
+ );
76
+
77
+ export type EmptyMediaProps = ViewProps &
78
+ VariantProps<typeof emptyMediaVariants> & {
79
+ /** 큰 글리프 문자열(⊕ ⬚ ◻ 등) — svg 아이콘 치환 */
80
+ children?: ReactNode;
81
+ };
82
+
83
+ const EmptyMedia = forwardRef<BridgeViewRef, EmptyMediaProps>(
84
+ ({ className, variant, children, ...props }, ref) => (
85
+ <View
86
+ ref={ref}
87
+ className={cn(emptyMediaVariants({ variant }), className)}
88
+ {...props}
89
+ >
90
+ <Text className="font-sans text-2xl leading-none text-foreground">
91
+ {children}
92
+ </Text>
93
+ </View>
94
+ ),
95
+ );
96
+ EmptyMedia.displayName = "EmptyMedia";
97
+
98
+ // EmptyTitle — 빈 상태 제목(원본 text-lg font-medium tracking-tight).
99
+ export type EmptyTitleProps = TextProps & {
100
+ children?: ReactNode;
101
+ };
102
+
103
+ const EmptyTitle = forwardRef<BridgeTextRef, EmptyTitleProps>(
104
+ ({ className, ...props }, ref) => (
105
+ <Text
106
+ ref={ref}
107
+ className={cn(
108
+ "font-sans text-lg font-medium text-foreground text-center web:tracking-tight",
109
+ className,
110
+ )}
111
+ {...props}
112
+ />
113
+ ),
114
+ );
115
+ EmptyTitle.displayName = "EmptyTitle";
116
+
117
+ // EmptyDescription — 보조 설명(원본 text-sm/relaxed → text-sm + leading-relaxed).
118
+ export type EmptyDescriptionProps = TextProps & {
119
+ children?: ReactNode;
120
+ };
121
+
122
+ const EmptyDescription = forwardRef<BridgeTextRef, EmptyDescriptionProps>(
123
+ ({ className, ...props }, ref) => (
124
+ <Text
125
+ ref={ref}
126
+ className={cn(
127
+ "font-sans text-sm leading-relaxed text-center text-muted-foreground web:text-balance",
128
+ className,
129
+ )}
130
+ {...props}
131
+ />
132
+ ),
133
+ );
134
+ EmptyDescription.displayName = "EmptyDescription";
135
+
136
+ // EmptyContent — 빈 상태 하단 액션 영역.
137
+ export type EmptyContentProps = ViewProps;
138
+
139
+ const EmptyContent = forwardRef<BridgeViewRef, EmptyContentProps>(
140
+ ({ className, ...props }, ref) => (
141
+ <View
142
+ ref={ref}
143
+ className={cn(
144
+ "flex min-w-0 w-full max-w-sm flex-col items-center gap-4 text-sm web:text-balance",
145
+ className,
146
+ )}
147
+ {...props}
148
+ />
149
+ ),
150
+ );
151
+ EmptyContent.displayName = "EmptyContent";
152
+
153
+ export {
154
+ Empty,
155
+ EmptyHeader,
156
+ EmptyTitle,
157
+ EmptyDescription,
158
+ EmptyContent,
159
+ EmptyMedia,
160
+ };
@@ -0,0 +1,349 @@
1
+ // Field — shadcn/ui new-york-v4 field(248줄)을 UniKit 유니버설로 이식.
2
+ //
3
+ // 포팅 판단(radix/base 엔진 의존 제거):
4
+ // - Radix Field가 data-* 속성으로 흘리던 상태(invalid·disabled)는 Field의
5
+ // boolean prop → 최소한의 React context로 전달하고, 소비 컴포넌트
6
+ // (FieldLabel·FieldTitle)가 조건부 클래스로 받는다. 원본의
7
+ // data-invalid=true → text-destructive(루트 상속)·
8
+ // group-data disabled → opacity-50 치환이다.
9
+ // - FieldLabel은 원본처럼 ./label의 Label을 재사용한다(양쪽 트리 동일 상대
10
+ // 경로 — registry 복사 구조에서도 성립).
11
+ // - fieldset/legend 시맨틱은 브리지 프리미티브(bridge role 계약에 group 없음)로
12
+ // 표현 불가 → FieldSet은 View, FieldLegend는 Text로 평면화.
13
+ // - orientation "responsive"(@container 쿼리)은 컨테이너 쿼리가 없어 제거 —
14
+ // vertical/horizontal만 유지. horizontal의 라벨 flex-auto는 context
15
+ // orientation으로 FieldLabel이 받는다(원본 자식 셀렉터 치환).
16
+ // - has-자손·nth-last-2·자식 앵커 등 자식/상태 셀렉터 토큰은 제거(네이티브에
17
+ // data 속성·셀렉터 없음).
18
+ // - FieldSeparator가 감싸던 Separator는 인라인 View(h-px bg-border)로 대리
19
+ // (신규 파일 6종 제한 — separator.tsx 생성 불가).
20
+ // - FieldError의 목록 글머리(list-disc)는 글리프 "• " 접두로 치환.
21
+ import {
22
+ createContext,
23
+ forwardRef,
24
+ useContext,
25
+ useMemo,
26
+ type ReactNode,
27
+ } from "react";
28
+ import {
29
+ Text,
30
+ View,
31
+ type BridgeTextRef,
32
+ type BridgeViewRef,
33
+ type TextProps,
34
+ type ViewProps,
35
+ } from "@unikit-bridge/ui/primitives";
36
+ import { cva, type VariantProps } from "class-variance-authority";
37
+
38
+ import { cn } from "../lib/utils";
39
+ import { Label, type LabelProps } from "./label";
40
+
41
+ // Field가 제공하는 상태 — 라벨·타이틀 스타일 연동의 단일 출처.
42
+ type FieldContextValue = {
43
+ invalid: boolean;
44
+ disabled: boolean;
45
+ orientation: "vertical" | "horizontal";
46
+ };
47
+
48
+ const FieldContext = createContext<FieldContextValue | null>(null);
49
+
50
+ // ---------------------------------------------------------------------------
51
+ // FieldSet — 폼 묶음(원본 <fieldset> → View 평면화).
52
+ // ---------------------------------------------------------------------------
53
+ export type FieldSetProps = ViewProps;
54
+
55
+ const FieldSet = forwardRef<BridgeViewRef, FieldSetProps>(
56
+ ({ className, ...props }, ref) => (
57
+ <View
58
+ ref={ref}
59
+ className={cn("flex flex-col gap-6", className)}
60
+ {...props}
61
+ />
62
+ ),
63
+ );
64
+ FieldSet.displayName = "FieldSet";
65
+
66
+ // ---------------------------------------------------------------------------
67
+ // FieldLegend — 묶음 제목(원본 <legend> → Text 평면화).
68
+ // ---------------------------------------------------------------------------
69
+ const fieldLegendVariants = cva("font-medium", {
70
+ variants: {
71
+ variant: {
72
+ legend: "text-base",
73
+ label: "text-sm",
74
+ },
75
+ },
76
+ defaultVariants: {
77
+ variant: "legend",
78
+ },
79
+ });
80
+
81
+ export type FieldLegendProps = TextProps &
82
+ VariantProps<typeof fieldLegendVariants>;
83
+
84
+ const FieldLegend = forwardRef<BridgeTextRef, FieldLegendProps>(
85
+ ({ className, variant, ...props }, ref) => (
86
+ <Text
87
+ ref={ref}
88
+ className={cn(
89
+ "mb-3 font-sans text-foreground",
90
+ fieldLegendVariants({ variant }),
91
+ className,
92
+ )}
93
+ {...props}
94
+ />
95
+ ),
96
+ );
97
+ FieldLegend.displayName = "FieldLegend";
98
+
99
+ // ---------------------------------------------------------------------------
100
+ // FieldGroup — 필드 묶음(원본 gap-7 유지, 중첩 gap-4 셀렉터는 제거).
101
+ // ---------------------------------------------------------------------------
102
+ export type FieldGroupProps = ViewProps;
103
+
104
+ const FieldGroup = forwardRef<BridgeViewRef, FieldGroupProps>(
105
+ ({ className, ...props }, ref) => (
106
+ <View
107
+ ref={ref}
108
+ className={cn("flex w-full flex-col gap-7", className)}
109
+ {...props}
110
+ />
111
+ ),
112
+ );
113
+ FieldGroup.displayName = "FieldGroup";
114
+
115
+ // ---------------------------------------------------------------------------
116
+ // Field — 단일 폼 필드 루트. 상태 제공자역을 겸한다.
117
+ // ---------------------------------------------------------------------------
118
+ // 원본의 자식 w-full 셀렉터([&>*]:w-full)는 제거 — Input/Textarea가 기본
119
+ // w-full이므로 실사용에 영향 없다. role="group"은 bridge role 계약에 없어 생략.
120
+ const fieldVariants = cva("flex w-full gap-3", {
121
+ variants: {
122
+ orientation: {
123
+ vertical: "flex-col",
124
+ horizontal: "flex-row items-center",
125
+ },
126
+ },
127
+ defaultVariants: {
128
+ orientation: "vertical",
129
+ },
130
+ });
131
+
132
+ export type FieldProps = ViewProps &
133
+ VariantProps<typeof fieldVariants> & {
134
+ /** 오류 상태 — 라벨·타이틀 전경색을 destructive로(원본 data-invalid 치환) */
135
+ invalid?: boolean;
136
+ /** 비활성 상태 — 라벨·타이틀 불투명도(원본 data-disabled 치환) */
137
+ disabled?: boolean;
138
+ };
139
+
140
+ const Field = forwardRef<BridgeViewRef, FieldProps>(
141
+ ({ className, orientation, invalid, disabled, ...props }, ref) => {
142
+ const resolvedOrientation = orientation ?? "vertical";
143
+ const contextValue = useMemo<FieldContextValue>(
144
+ () => ({
145
+ invalid: invalid === true,
146
+ disabled: disabled === true,
147
+ orientation: resolvedOrientation,
148
+ }),
149
+ [invalid, disabled, resolvedOrientation],
150
+ );
151
+ return (
152
+ <FieldContext.Provider value={contextValue}>
153
+ <View
154
+ ref={ref}
155
+ className={cn(fieldVariants({ orientation }), className)}
156
+ {...props}
157
+ />
158
+ </FieldContext.Provider>
159
+ );
160
+ },
161
+ );
162
+ Field.displayName = "Field";
163
+
164
+ // ---------------------------------------------------------------------------
165
+ // FieldContent — 라벨 옆 본문 영역(컨트롤·설명·오류 묶음).
166
+ // ---------------------------------------------------------------------------
167
+ export type FieldContentProps = ViewProps;
168
+
169
+ const FieldContent = forwardRef<BridgeViewRef, FieldContentProps>(
170
+ ({ className, ...props }, ref) => (
171
+ <View
172
+ ref={ref}
173
+ className={cn(
174
+ "flex flex-1 flex-col gap-1.5 leading-snug",
175
+ className,
176
+ )}
177
+ {...props}
178
+ />
179
+ ),
180
+ );
181
+ FieldContent.displayName = "FieldContent";
182
+
183
+ // ---------------------------------------------------------------------------
184
+ // FieldLabel — 필드 라벨. Label(./label) 재사용 + Field 상태 연동.
185
+ // ---------------------------------------------------------------------------
186
+ export type FieldLabelProps = LabelProps;
187
+
188
+ const FieldLabel = forwardRef<BridgeTextRef, FieldLabelProps>(
189
+ ({ className, ...props }, ref) => {
190
+ const field = useContext(FieldContext);
191
+ return (
192
+ <Label
193
+ ref={ref}
194
+ className={cn(
195
+ "w-fit leading-snug",
196
+ // horizontal 배치에서 라벨이 남은 폭을 차지(원본 자식 셀렉터 치환)
197
+ field?.orientation === "horizontal" && "flex-auto",
198
+ field?.invalid && "text-destructive",
199
+ field?.disabled && "opacity-50",
200
+ className,
201
+ )}
202
+ {...props}
203
+ />
204
+ );
205
+ },
206
+ );
207
+ FieldLabel.displayName = "FieldLabel";
208
+
209
+ // ---------------------------------------------------------------------------
210
+ // FieldTitle — 라벨 대신 쓰는 제목형 텍스트(상태 연동은 Label과 동일).
211
+ // ---------------------------------------------------------------------------
212
+ export type FieldTitleProps = TextProps;
213
+
214
+ const FieldTitle = forwardRef<BridgeTextRef, FieldTitleProps>(
215
+ ({ className, ...props }, ref) => {
216
+ const field = useContext(FieldContext);
217
+ return (
218
+ <Text
219
+ ref={ref}
220
+ className={cn(
221
+ "w-fit font-sans text-sm font-medium leading-snug text-foreground",
222
+ field?.invalid && "text-destructive",
223
+ field?.disabled && "opacity-50",
224
+ className,
225
+ )}
226
+ {...props}
227
+ />
228
+ );
229
+ },
230
+ );
231
+ FieldTitle.displayName = "FieldTitle";
232
+
233
+ // ---------------------------------------------------------------------------
234
+ // FieldDescription — 보조 설명.
235
+ // ---------------------------------------------------------------------------
236
+ export type FieldDescriptionProps = TextProps;
237
+
238
+ const FieldDescription = forwardRef<BridgeTextRef, FieldDescriptionProps>(
239
+ ({ className, ...props }, ref) => (
240
+ <Text
241
+ ref={ref}
242
+ className={cn(
243
+ "font-sans text-sm font-normal leading-normal text-muted-foreground web:text-balance",
244
+ className,
245
+ )}
246
+ {...props}
247
+ />
248
+ ),
249
+ );
250
+ FieldDescription.displayName = "FieldDescription";
251
+
252
+ // ---------------------------------------------------------------------------
253
+ // FieldSeparator — 사이 구분선(가운데 문구 optional).
254
+ // ---------------------------------------------------------------------------
255
+ export type FieldSeparatorProps = ViewProps & {
256
+ /** 가운데 문구 — 원본과 같이 children이 있으면 선 위에 표시 */
257
+ children?: ReactNode;
258
+ };
259
+
260
+ const FieldSeparator = forwardRef<BridgeViewRef, FieldSeparatorProps>(
261
+ ({ className, children, ...props }, ref) => (
262
+ <View ref={ref} className={cn("relative -my-2 h-5", className)} {...props}>
263
+ {/* 인라인 구분선 — 원본 Separator 대리(신규 파일 제한) */}
264
+ <View className="absolute inset-x-0 top-1/2 h-px bg-border" />
265
+ {children ? (
266
+ <Text className="relative mx-auto w-fit bg-background px-2 font-sans text-sm text-muted-foreground">
267
+ {children}
268
+ </Text>
269
+ ) : null}
270
+ </View>
271
+ ),
272
+ );
273
+ FieldSeparator.displayName = "FieldSeparator";
274
+
275
+ // ---------------------------------------------------------------------------
276
+ // FieldError — 검증 오류 표시(role="alert"). children 우선, 없으면 errors로 조립.
277
+ // ---------------------------------------------------------------------------
278
+ export type FieldErrorProps = ViewProps & {
279
+ /** 유효성 오류 목록 — message 기준 중복 제거(원본 로직 그대로) */
280
+ errors?: Array<{ message?: string } | undefined>;
281
+ };
282
+
283
+ const FieldError = forwardRef<BridgeViewRef, FieldErrorProps>(
284
+ ({ className, children, errors, ...props }, ref) => {
285
+ const content = useMemo<ReactNode>(() => {
286
+ // 이중 지정 원칙: children은 문자열 권장 — 내부 Text가 색을 담당한다.
287
+ if (children) {
288
+ return (
289
+ <Text className="font-sans text-sm text-destructive">{children}</Text>
290
+ );
291
+ }
292
+ if (!errors?.length) {
293
+ return null;
294
+ }
295
+ const uniqueErrors = [
296
+ ...new Map(errors.map((error) => [error?.message, error])).values(),
297
+ ];
298
+ if (uniqueErrors.length === 1) {
299
+ return (
300
+ <Text className="font-sans text-sm text-destructive">
301
+ {uniqueErrors[0]?.message}
302
+ </Text>
303
+ );
304
+ }
305
+ // 다중 오류 — 원본 ul/list-disc를 글리프 "• " 목록으로 치환.
306
+ return (
307
+ <View className="ml-4 flex flex-col gap-1">
308
+ {uniqueErrors.map((error, index) =>
309
+ error?.message ? (
310
+ <Text
311
+ key={index}
312
+ className="font-sans text-sm text-destructive"
313
+ >{`• ${error.message}`}</Text>
314
+ ) : null,
315
+ )}
316
+ </View>
317
+ );
318
+ }, [children, errors]);
319
+
320
+ if (!content) {
321
+ return null;
322
+ }
323
+
324
+ return (
325
+ <View
326
+ ref={ref}
327
+ accessibilityRole="alert"
328
+ className={cn("text-sm font-normal", className)}
329
+ {...props}
330
+ >
331
+ {content}
332
+ </View>
333
+ );
334
+ },
335
+ );
336
+ FieldError.displayName = "FieldError";
337
+
338
+ export {
339
+ Field,
340
+ FieldLabel,
341
+ FieldDescription,
342
+ FieldError,
343
+ FieldGroup,
344
+ FieldLegend,
345
+ FieldSeparator,
346
+ FieldSet,
347
+ FieldContent,
348
+ FieldTitle,
349
+ };
@@ -0,0 +1,39 @@
1
+ // Input — docs/sdd/02-design.md v2 §3.3 그대로.
2
+ // forwardRef로 입력 인스턴스(focus()·blur())를 노출 — §3.4 Label의
3
+ // onPress → ref.focus() 라벨-입력창 클릭 연결용.
4
+ import { forwardRef } from "react";
5
+ import {
6
+ TextInput,
7
+ type BridgeTextInputRef,
8
+ type TextInputProps,
9
+ } from "@unikit-bridge/ui/primitives";
10
+ import { cva } from "class-variance-authority";
11
+
12
+ import { cn } from "../lib/utils";
13
+
14
+ const inputVariants = cva(
15
+ "font-sans h-10 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 disabled:opacity-50",
16
+ );
17
+
18
+ // InputProps — TextInputProps 그대로. 웹 관용 disabled의 치환 계약
19
+ // (native: 아래 editable={false} · web: input[disabled] + aria-disabled)은
20
+ // bridge/types.ts의 disabled 주석이 단일 출처다.
21
+ export type InputProps = TextInputProps;
22
+
23
+ const Input = forwardRef<BridgeTextInputRef, InputProps>(
24
+ ({ className, disabled, editable, ...props }, ref) => (
25
+ <TextInput
26
+ ref={ref}
27
+ editable={disabled ? false : editable}
28
+ aria-disabled={disabled ? true : undefined}
29
+ className={cn(inputVariants(), className)}
30
+ {...props}
31
+ // 브리지 계약에는 editable 치환으로 이미 반영됐으므로 disabled는 그대로 전달
32
+ // (웹 input[disabled] · RN은 위 editable={false}가 차단)
33
+ disabled={disabled}
34
+ />
35
+ ),
36
+ );
37
+ Input.displayName = "Input";
38
+
39
+ export { Input, inputVariants };