@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,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
+ };