@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,231 @@
1
+ // Accordion — shadcn/ui(new-york-v4) accordion.tsx 유니버설 포팅 (사이클 30, T-3b).
2
+ // 원본: shadcn-ref/src/accordion/accordion.tsx — radix Accordion 엔진을 제거하고
3
+ // 전개 상태는 React state + context로 직접 관리한다(collapsible.tsx의 다중 항목
4
+ // 확장 구조). 원본의 data-state·셀렉터 클래스는 조건부 클래스·글리프 교체로 전개.
5
+ //
6
+ // 포팅 판단(원본 대비 차이):
7
+ // - ChevronDown 아이콘 → 글리프 Text(닫힘 ⌄ / 펼침 ⌃ 교체). 원본의 회전 전이
8
+ // 애니메이션은 상실(로드맵) — 글자 교체로 상태를 즉시 표시한다.
9
+ // - 항목 구분선의 마지막 항목 제외(last 변형)는 위치 셀렉터라 네이티브 재현 불가
10
+ // — 제거. 마지막 항목 하단선 제거는 사용처 className 오버라이드로 한다.
11
+ // - 콘텐츠 전이 애니메이션 미지원 — value 일치 시에만 마운트(즉시 전환).
12
+ // 원본의 이중 뷰(애니메이션용 바깥쪽 + 패딩 안쪽)는 애니메이션 상실로 한 벌로
13
+ // 통합하고 사용처 className은 같은 패딩 토큰 위에 병합된다.
14
+ // - single 모드의 collapsible은 기본 true(shadcn 관용 — 원본 radix 기본값은 false).
15
+ // - aria-controls ↔ 콘텐츠 nativeID를 루트 useId 토대로 결정적으로 연결한다.
16
+ import {
17
+ createContext,
18
+ forwardRef,
19
+ useContext,
20
+ useId,
21
+ useState,
22
+ type ReactNode,
23
+ } from "react";
24
+ import {
25
+ Pressable,
26
+ Text,
27
+ View,
28
+ type BridgePressableRef,
29
+ type BridgeViewRef,
30
+ type PressableProps,
31
+ type ViewProps,
32
+ } from "@unikit-bridge/ui/primitives";
33
+
34
+ import { cn } from "../lib/utils";
35
+
36
+ type AccordionContextValue = {
37
+ type: "single" | "multiple";
38
+ collapsible: boolean;
39
+ openItems: string[];
40
+ toggleItem: (value: string) => void;
41
+ /** 트리거 aria-controls ↔ 콘텐츠 nativeID 연결용 토대 ID */
42
+ baseId: string;
43
+ };
44
+
45
+ const AccordionContext = createContext<AccordionContextValue | null>(null);
46
+
47
+ function useAccordionContext(component: string): AccordionContextValue {
48
+ const ctx = useContext(AccordionContext);
49
+ if (!ctx) {
50
+ throw new Error(`<${component}>는 <Accordion> 안에서 사용해야 합니다.`);
51
+ }
52
+ return ctx;
53
+ }
54
+
55
+ type AccordionItemContextValue = {
56
+ value: string;
57
+ open: boolean;
58
+ toggle: () => void;
59
+ contentId: string;
60
+ };
61
+
62
+ const AccordionItemContext = createContext<AccordionItemContextValue | null>(
63
+ null,
64
+ );
65
+
66
+ function useAccordionItemContext(
67
+ component: string,
68
+ ): AccordionItemContextValue {
69
+ const ctx = useContext(AccordionItemContext);
70
+ if (!ctx) {
71
+ throw new Error(`<${component}>는 <AccordionItem> 안에서 사용해야 합니다.`);
72
+ }
73
+ return ctx;
74
+ }
75
+
76
+ // 콘텐츠 패널 ID — value의 공백은 ID에 쓸 수 없어 치환.
77
+ function contentIdOf(baseId: string, value: string) {
78
+ return `${baseId}-accordion-content-${value.replace(/\s+/g, "-")}`;
79
+ }
80
+
81
+ function toItems(value: string | string[] | undefined): string[] {
82
+ if (value === undefined) return [];
83
+ return Array.isArray(value) ? value : [value];
84
+ }
85
+
86
+ export type AccordionProps = ViewProps & {
87
+ type?: "single" | "multiple";
88
+ /** single 모드에서 펼친 항목 다시 접기 허용 여부(기본 true — shadcn 관용) */
89
+ collapsible?: boolean;
90
+ /** single 모드에서는 문자열 1개, multiple 모드에서는 펼친 value 배열 */
91
+ value?: string | string[];
92
+ defaultValue?: string | string[];
93
+ onValueChange?: (value: string | string[]) => void;
94
+ };
95
+
96
+ const Accordion = forwardRef<BridgeViewRef, AccordionProps>(
97
+ (
98
+ { type = "single", collapsible = true, value, defaultValue, onValueChange, children, ...props },
99
+ ref,
100
+ ) => {
101
+ // 비제어 상태 — 제어(value 지정)면 무시된다.
102
+ const [internalItems, setInternalItems] = useState(() =>
103
+ toItems(defaultValue),
104
+ );
105
+ const openItems = value !== undefined ? toItems(value) : internalItems;
106
+ const toggleItem = (itemValue: string) => {
107
+ const isOpen = openItems.includes(itemValue);
108
+ let next: string[];
109
+ if (isOpen) {
110
+ // single 비접이형(collapsible=false)은 마지막 펼침 항목을 유지한다.
111
+ if (type === "single" && !collapsible) return;
112
+ next = openItems.filter((v) => v !== itemValue);
113
+ } else {
114
+ next =
115
+ type === "single"
116
+ ? [itemValue]
117
+ : [...openItems, itemValue];
118
+ }
119
+ if (value === undefined) setInternalItems(next);
120
+ if (type === "single") onValueChange?.(next[0] ?? "");
121
+ else onValueChange?.(next);
122
+ };
123
+ const baseId = useId();
124
+ return (
125
+ <AccordionContext.Provider
126
+ value={{ type, collapsible, openItems, toggleItem, baseId }}
127
+ >
128
+ <View ref={ref} {...props}>
129
+ {children}
130
+ </View>
131
+ </AccordionContext.Provider>
132
+ );
133
+ },
134
+ );
135
+ Accordion.displayName = "Accordion";
136
+
137
+ export type AccordionItemProps = ViewProps & {
138
+ value: string;
139
+ };
140
+
141
+ const AccordionItem = forwardRef<BridgeViewRef, AccordionItemProps>(
142
+ ({ value, className, children, ...props }, ref) => {
143
+ const accordion = useAccordionContext("AccordionItem");
144
+ const open = accordion.openItems.includes(value);
145
+ return (
146
+ <AccordionItemContext.Provider
147
+ value={{
148
+ value,
149
+ open,
150
+ toggle: () => accordion.toggleItem(value),
151
+ contentId: contentIdOf(accordion.baseId, value),
152
+ }}
153
+ >
154
+ <View
155
+ ref={ref}
156
+ className={cn("border-b border-border", className)}
157
+ {...props}
158
+ >
159
+ {children}
160
+ </View>
161
+ </AccordionItemContext.Provider>
162
+ );
163
+ },
164
+ );
165
+ AccordionItem.displayName = "AccordionItem";
166
+
167
+ export type AccordionTriggerProps = PressableProps & {
168
+ // 함수형 children은 내부 Text 래핑 구조와 양립하지 않아 문자열 children 권장.
169
+ children?: ReactNode;
170
+ };
171
+
172
+ const AccordionTrigger = forwardRef<BridgePressableRef, AccordionTriggerProps>(
173
+ ({ className, children, disabled, onPress, ...props }, ref) => {
174
+ const item = useAccordionItemContext("AccordionTrigger");
175
+ return (
176
+ <Pressable
177
+ ref={ref}
178
+ accessibilityRole="button"
179
+ aria-expanded={item.open}
180
+ aria-controls={item.contentId}
181
+ disabled={disabled}
182
+ onPress={() => {
183
+ item.toggle();
184
+ onPress?.();
185
+ }}
186
+ className={cn(
187
+ "flex-row items-start justify-between gap-4 rounded-md py-4 web:transition-all",
188
+ "web:focus-visible:ring-2 web:focus-visible:ring-ring web:focus-visible:ring-offset-2 web:focus-visible:ring-offset-background",
189
+ disabled && "web:pointer-events-none opacity-50",
190
+ className,
191
+ )}
192
+ {...props}
193
+ >
194
+ {/* 이중 지정: 전경색·밑줄은 내부 Text에(네이티브는 색 상속 불가) */}
195
+ <Text className="flex-1 font-sans text-left text-sm font-medium text-foreground web:hover:underline">
196
+ {children}
197
+ </Text>
198
+ {/* 펼침 표시 글리프 — 원본 svg 회전 대신 글자 교체(⌄ 닫힘 / ⌃ 펼침) */}
199
+ <View aria-hidden>
200
+ <Text className="text-base text-muted-foreground web:select-none">
201
+ {item.open ? "⌃" : "⌄"}
202
+ </Text>
203
+ </View>
204
+ </Pressable>
205
+ );
206
+ },
207
+ );
208
+ AccordionTrigger.displayName = "AccordionTrigger";
209
+
210
+ export type AccordionContentProps = ViewProps;
211
+
212
+ const AccordionContent = forwardRef<BridgeViewRef, AccordionContentProps>(
213
+ ({ className, children, nativeID, ...props }, ref) => {
214
+ const item = useAccordionItemContext("AccordionContent");
215
+ // 펼침 항목만 렌더 — 마운트/언마운트 방식(애니메이션 없음).
216
+ if (!item.open) return null;
217
+ return (
218
+ <View
219
+ ref={ref}
220
+ nativeID={nativeID ?? item.contentId}
221
+ className={cn("overflow-hidden pb-4 pt-0", className)}
222
+ {...props}
223
+ >
224
+ {children}
225
+ </View>
226
+ );
227
+ },
228
+ );
229
+ AccordionContent.displayName = "AccordionContent";
230
+
231
+ export { Accordion, AccordionItem, AccordionTrigger, AccordionContent };
@@ -0,0 +1,157 @@
1
+ // Alert — shadcn/ui new-york-v4 alert 포팅(/tmp/shadcn-ref/src/alert). 3종 export.
2
+ // 포팅 판단:
3
+ // - 원본은 grid + has-자손 셀렉터로 선택적 아이콘 열을 배치한다.
4
+ // data·자손 셀렉터는 네이티브에서 동작하지 않으므로 flex-row(글리프 + 콘텐츠 열)
5
+ // 구조로 재작성하고, 좌측 아이콘은 글리프 Text(default ⓘ / destructive ⚠)로
6
+ // 표시한다(아이콘 없는 배치는 미지원). 색·여백·rounding 토큰은 유지.
7
+ // - 원본의 텍스트 색 상속(text-card-foreground / text-destructive)은 이중 지정
8
+ // 원칙에 따라 내부 Text로 이동. 특히 AlertDescription의 destructive 톤(원본은
9
+ // data-slot=alert-description 자손 셀렉터의 text-destructive/90)은 셀렉터를
10
+ // 쓸 수 없어 React context로 variant를 전달한다 — variant prop은 원본대로
11
+ // Alert에만 존재한다(Alert 단독 사용 시 하위 컴포넌트는 default 톤).
12
+ // - role="alert"은 원본 그대로 유지(신규 접근성 계약 BridgeRole "alert").
13
+ // - 웹 전용 토큰 처리: line-clamp-1·tracking-tight은 web: 접두, 그리드 전용
14
+ // col-start-*·min-h-4와 [&_p]:leading-relaxed(단락 자손 스타일)은 소실.
15
+ import { createContext, forwardRef, useContext } from "react";
16
+ import {
17
+ Text,
18
+ View,
19
+ type BridgeTextRef,
20
+ type BridgeViewRef,
21
+ type TextProps,
22
+ type ViewProps,
23
+ } from "@unikit-bridge/ui/primitives";
24
+ import { cva, type VariantProps } from "class-variance-authority";
25
+
26
+ import { cn } from "../lib/utils";
27
+
28
+ // 컨테이너(View) — 배경·테두리. 원본의 grid 배치를 flex-row(글리프+콘텐츠 열)로 치환.
29
+ const alertVariants = cva(
30
+ "relative w-full flex-row items-start gap-3 rounded-lg border px-4 py-3",
31
+ {
32
+ variants: {
33
+ variant: {
34
+ default: "bg-card",
35
+ // 원본 destructive의 border-destructive/50 토큰 유지(사소 5 — 포팅
36
+ // 초판에서 누락돼 destructive가 글자색으로만 구분됐다).
37
+ destructive: "bg-card border-destructive/50",
38
+ },
39
+ },
40
+ defaultVariants: {
41
+ variant: "default",
42
+ },
43
+ },
44
+ );
45
+
46
+ // 좌측 글리프 Text — 원본 [&>svg]:size-4·text-current 아이콘의 글리프 대체.
47
+ const alertGlyphVariants = cva("text-base leading-none", {
48
+ variants: {
49
+ variant: {
50
+ default: "text-card-foreground",
51
+ destructive: "text-destructive",
52
+ },
53
+ },
54
+ defaultVariants: {
55
+ variant: "default",
56
+ },
57
+ });
58
+
59
+ // 제목 Text — 원본 col-start-2 line-clamp-1 font-medium tracking-tight.
60
+ const alertTitleVariants = cva(
61
+ "font-sans text-sm font-medium leading-none web:line-clamp-1 web:tracking-tight",
62
+ {
63
+ variants: {
64
+ variant: {
65
+ default: "text-card-foreground",
66
+ destructive: "text-destructive",
67
+ },
68
+ },
69
+ defaultVariants: {
70
+ variant: "default",
71
+ },
72
+ },
73
+ );
74
+
75
+ // 설명 Text — 원본 text-muted-foreground, destructive는 /90 톤(자손 셀렉터 대체).
76
+ const alertDescriptionVariants = cva(
77
+ "font-sans text-sm text-muted-foreground",
78
+ {
79
+ variants: {
80
+ variant: {
81
+ default: "",
82
+ destructive: "text-destructive/90",
83
+ },
84
+ },
85
+ defaultVariants: {
86
+ variant: "default",
87
+ },
88
+ },
89
+ );
90
+
91
+ // variant 전파용 React context — 원본의 `*:` 자손 셀렉터를 대체한다.
92
+ const AlertContext = createContext<
93
+ NonNullable<VariantProps<typeof alertVariants>["variant"]>
94
+ >("default");
95
+
96
+ export type AlertProps = ViewProps & VariantProps<typeof alertVariants>;
97
+ export type AlertTitleProps = TextProps;
98
+ export type AlertDescriptionProps = TextProps;
99
+
100
+ const Alert = forwardRef<BridgeViewRef, AlertProps>(
101
+ ({ className, variant, children, ...props }, ref) => (
102
+ <AlertContext.Provider value={variant ?? "default"}>
103
+ <View
104
+ ref={ref}
105
+ accessibilityRole="alert"
106
+ className={cn(alertVariants({ variant }), className)}
107
+ {...props}
108
+ >
109
+ <View aria-hidden className="mt-0.5 shrink-0">
110
+ <Text className={cn(alertGlyphVariants({ variant }))}>
111
+ {variant === "destructive" ? "⚠" : "ⓘ"}
112
+ </Text>
113
+ </View>
114
+ <View className="flex flex-col gap-0.5">{children}</View>
115
+ </View>
116
+ </AlertContext.Provider>
117
+ ),
118
+ );
119
+ Alert.displayName = "Alert";
120
+
121
+ const AlertTitle = forwardRef<BridgeTextRef, AlertTitleProps>(
122
+ ({ className, ...props }, ref) => {
123
+ const variant = useContext(AlertContext);
124
+ return (
125
+ <Text
126
+ ref={ref}
127
+ className={cn(alertTitleVariants({ variant }), className)}
128
+ {...props}
129
+ />
130
+ );
131
+ },
132
+ );
133
+ AlertTitle.displayName = "AlertTitle";
134
+
135
+ const AlertDescription = forwardRef<BridgeTextRef, AlertDescriptionProps>(
136
+ ({ className, ...props }, ref) => {
137
+ const variant = useContext(AlertContext);
138
+ return (
139
+ <Text
140
+ ref={ref}
141
+ className={cn(alertDescriptionVariants({ variant }), className)}
142
+ {...props}
143
+ />
144
+ );
145
+ },
146
+ );
147
+ AlertDescription.displayName = "AlertDescription";
148
+
149
+ export {
150
+ Alert,
151
+ AlertTitle,
152
+ AlertDescription,
153
+ alertVariants,
154
+ alertGlyphVariants,
155
+ alertTitleVariants,
156
+ alertDescriptionVariants,
157
+ };
@@ -0,0 +1,40 @@
1
+ // AspectRatio — shadcn/ui(new-york-v4) aspect-ratio.tsx 유니버설 포팅 (사이클 30, T-3b).
2
+ // 원본: shadcn-ref/src/aspect-ratio/aspect-ratio.tsx — radix AspectRatio 엔진을
3
+ // 제거하고 비율은 BridgeStyle 동적 인라인 스타일 계약으로 표현한다.
4
+ //
5
+ // 포팅 판단(원본 대비 차이):
6
+ // - 원본(radix)은 웹 구형 호환을 위해 패딩 핵으로 비율을 만들었다. 브리지 계약의
7
+ // aspectRatio는 숫자 값이 웹 DOM(단위 없는 비율)·RN(원시 프로퍼티) 양쪽에
8
+ // 유효해 style 지정으로 대체한다(정적 스타일이 아닌 사용자 지정 비율 값이므로
9
+ // className 원칙의 동적 값 예외에 해당).
10
+ // - 원본의 overflow hidden은 className(overflow-hidden)으로 유지.
11
+ import { forwardRef } from "react";
12
+ import {
13
+ View,
14
+ type BridgeViewRef,
15
+ type ViewProps,
16
+ } from "@unikit-bridge/ui/primitives";
17
+
18
+ import { cn } from "../lib/utils";
19
+
20
+ export type AspectRatioProps = ViewProps & {
21
+ /** 가로세로 비율(가로 ÷ 세로, 기본 1 — 정사각형) */
22
+ ratio?: number;
23
+ };
24
+
25
+ const AspectRatio = forwardRef<BridgeViewRef, AspectRatioProps>(
26
+ ({ ratio = 1, className, style, children, ...props }, ref) => (
27
+ <View
28
+ ref={ref}
29
+ className={cn("overflow-hidden", className)}
30
+ // 사용자 style이 있으면 병합 — 비율은 컴포넌트 계약값이 우선한다.
31
+ style={{ ...style, aspectRatio: ratio }}
32
+ {...props}
33
+ >
34
+ {children}
35
+ </View>
36
+ ),
37
+ );
38
+ AspectRatio.displayName = "AspectRatio";
39
+
40
+ export { AspectRatio };
@@ -0,0 +1,275 @@
1
+ // Avatar — shadcn/ui new-york-v4 avatar(Radix Avatar 기반)를 UniKit 유니버설로 이식.
2
+ //
3
+ // 포팅 판단(radix 엔진 의존 제거):
4
+ // - 로드 실패 상태 공유는 React context로 재현 — Avatar(루트)가
5
+ // { size, hasImage, imageFailed }를 제공하고, AvatarImage가 마운트 등록/
6
+ // 실패 보고(브리지 Image onError), AvatarFallback이
7
+ // "소스 없음 또는 로드 실패"일 때만 렌더된다.
8
+ // - 브리지 Image에 onLoad 계약이 없어 '로드 성공'을 알 수 없으므로 폴백
9
+ // 표시 조건을 Radix의 "로드 전+실패"에서 "소스 없음+실패"로 단순화했다.
10
+ // 실패한 AvatarImage는 렌더를 중단한다(Radix는 깨진 img를 남긴다).
11
+ // - 원본의 data-size + group-data size/avatar 셀렉터(폴백 글자 크기·배지
12
+ // 크기 연동)는 아래 조회 테이블로 치환 — 각 컴포넌트가 context size를 읽어
13
+ // 자기 크기 클래스를 적용한다.
14
+ // - AvatarGroup의 겹침(-space-x-2)과 data-slot=avatar 자식 링은 자식 셀렉터가
15
+ // 필요하다. 겹침은 웹 전용 클래스로 유지(네이티브은 나란히 배치)하고, 링은
16
+ // Avatar가 AvatarGroupContext를 읽어 자신에게 ring-2를 건다.
17
+ // - AvatarGroupCount의 그룹 크기 반응(group-has-data-...)은 고정 토큰(size-8)으로
18
+ // 단순화. AvatarBadge 내용은 글리프 Text 또는 비움(배경 자체가 상태 점).
19
+ import {
20
+ createContext,
21
+ forwardRef,
22
+ useContext,
23
+ useEffect,
24
+ useMemo,
25
+ useState,
26
+ type ReactNode,
27
+ } from "react";
28
+ import {
29
+ Image,
30
+ Text,
31
+ View,
32
+ type BridgeImageRef,
33
+ type BridgeViewRef,
34
+ type ImageProps,
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
+ export type AvatarSize = "default" | "sm" | "lg";
42
+
43
+ // 루트 크기 — 원본 data-size 셀렉터(size-8 · sm:size-6 · lg:size-10) 치환.
44
+ const avatarVariants = cva(
45
+ "relative flex shrink-0 overflow-hidden rounded-full web:select-none",
46
+ {
47
+ variants: {
48
+ size: {
49
+ default: "size-8",
50
+ sm: "size-6",
51
+ lg: "size-10",
52
+ },
53
+ },
54
+ defaultVariants: {
55
+ size: "default",
56
+ },
57
+ },
58
+ );
59
+
60
+ // 폴백 글자 크기(원본의 group-data size=sm → text-xs)·배지 크기
61
+ // (sm 2 · default 2.5 · lg 3) — context size 값으로 조회하는 치환 테이블.
62
+ const AVATAR_FALLBACK_TEXT: Record<AvatarSize, string> = {
63
+ default: "text-sm",
64
+ sm: "text-xs",
65
+ lg: "text-sm",
66
+ };
67
+ const AVATAR_BADGE_SIZE: Record<AvatarSize, string> = {
68
+ default: "size-2.5",
69
+ sm: "size-2",
70
+ lg: "size-3",
71
+ };
72
+
73
+ // 루트가 제공하는 상태 — 폴백 전환 판단의 단일 출처.
74
+ type AvatarContextValue = {
75
+ size: AvatarSize;
76
+ /** AvatarImage 마운트 여부 — false면 "소스 없음"으로 폴백 렌더 */
77
+ hasImage: boolean;
78
+ /** AvatarImage 로드 실패 — 폴백으로 전환 */
79
+ imageFailed: boolean;
80
+ reportImageMounted: (mounted: boolean) => void;
81
+ reportImageFailed: () => void;
82
+ };
83
+
84
+ const AvatarContext = createContext<AvatarContextValue | null>(null);
85
+
86
+ // AvatarGroup 내부 여부 — Avatar가 겹침 경계 링을 자신에게 적용한다.
87
+ const AvatarGroupContext = createContext(false);
88
+
89
+ // ---------------------------------------------------------------------------
90
+ // Avatar — 루트 컨테이너. 상태 제공자역도 겸한다(원본 AvatarPrimitive.Root).
91
+ // ---------------------------------------------------------------------------
92
+ export type AvatarProps = ViewProps &
93
+ VariantProps<typeof avatarVariants> & {
94
+ children?: ReactNode;
95
+ };
96
+
97
+ const Avatar = forwardRef<BridgeViewRef, AvatarProps>(
98
+ ({ className, size, ...props }, ref) => {
99
+ const [hasImage, setHasImage] = useState(false);
100
+ const [imageFailed, setImageFailed] = useState(false);
101
+ const grouped = useContext(AvatarGroupContext);
102
+ const resolvedSize = size ?? "default";
103
+ const contextValue = useMemo<AvatarContextValue>(
104
+ () => ({
105
+ size: resolvedSize,
106
+ hasImage,
107
+ imageFailed,
108
+ // setState 설정자는 참조가 안정적이라 그대로 노출해도 안전하다.
109
+ reportImageMounted: setHasImage,
110
+ reportImageFailed: () => setImageFailed(true),
111
+ }),
112
+ [resolvedSize, hasImage, imageFailed],
113
+ );
114
+ return (
115
+ <AvatarContext.Provider value={contextValue}>
116
+ <View
117
+ ref={ref}
118
+ className={cn(
119
+ avatarVariants({ size }),
120
+ grouped && "ring-2 ring-background",
121
+ className,
122
+ )}
123
+ {...props}
124
+ />
125
+ </AvatarContext.Provider>
126
+ );
127
+ },
128
+ );
129
+ Avatar.displayName = "Avatar";
130
+
131
+ // ---------------------------------------------------------------------------
132
+ // AvatarImage — 브리지 Image(원격 {uri}만). 실패 보고는 context로.
133
+ // ---------------------------------------------------------------------------
134
+ // onError는 폴백 전환의 통로이므로 props에서 제외하고 컴포넌트가 소유한다.
135
+ export type AvatarImageProps = Omit<ImageProps, "onError">;
136
+
137
+ const AvatarImage = forwardRef<BridgeImageRef, AvatarImageProps>(
138
+ ({ className, source, ...props }, ref) => {
139
+ const avatar = useContext(AvatarContext);
140
+ // 마운트 등록/해제 — 폴백의 "소스 없음" 판단 근거(provider는 안정적).
141
+ useEffect(() => {
142
+ avatar?.reportImageMounted(true);
143
+ return () => avatar?.reportImageMounted(false);
144
+ }, [avatar]);
145
+ // 실패한 이미지는 렌더하지 않는다(포팅 노트 — Radix는 깨진 img를 남김).
146
+ if (avatar?.imageFailed) {
147
+ return null;
148
+ }
149
+ return (
150
+ <Image
151
+ ref={ref}
152
+ source={source}
153
+ onError={avatar ? avatar.reportImageFailed : undefined}
154
+ className={cn("aspect-square size-full", className)}
155
+ {...props}
156
+ />
157
+ );
158
+ },
159
+ );
160
+ AvatarImage.displayName = "AvatarImage";
161
+
162
+ // ---------------------------------------------------------------------------
163
+ // AvatarFallback — 이니셜/글리프. "소스 없음 또는 실패"일 때만 렌더.
164
+ // ---------------------------------------------------------------------------
165
+ export type AvatarFallbackProps = ViewProps & {
166
+ children?: ReactNode;
167
+ };
168
+
169
+ const AvatarFallback = forwardRef<BridgeViewRef, AvatarFallbackProps>(
170
+ ({ className, children, ...props }, ref) => {
171
+ const avatar = useContext(AvatarContext);
172
+ // Avatar 밖 단독 사용(문맥 없음)이면 항상 렌드한다.
173
+ const visible = avatar ? !avatar.hasImage || avatar.imageFailed : true;
174
+ if (!visible) {
175
+ return null;
176
+ }
177
+ return (
178
+ <View
179
+ ref={ref}
180
+ className={cn(
181
+ "flex size-full items-center justify-center rounded-full bg-muted",
182
+ className,
183
+ )}
184
+ {...props}
185
+ >
186
+ {/* 이중 지정 원칙: 전경색은 내부 Text에 — children은 이니셜/글리프 문자열 권장 */}
187
+ <Text
188
+ className={cn(
189
+ "font-sans text-muted-foreground",
190
+ avatar && AVATAR_FALLBACK_TEXT[avatar.size],
191
+ )}
192
+ >
193
+ {children}
194
+ </Text>
195
+ </View>
196
+ );
197
+ },
198
+ );
199
+ AvatarFallback.displayName = "AvatarFallback";
200
+
201
+ // ---------------------------------------------------------------------------
202
+ // AvatarBadge — 우하단 상태 점. 크기는 context size 연동.
203
+ // ---------------------------------------------------------------------------
204
+ export type AvatarBadgeProps = ViewProps;
205
+
206
+ const AvatarBadge = forwardRef<BridgeViewRef, AvatarBadgeProps>(
207
+ ({ className, ...props }, ref) => {
208
+ const avatar = useContext(AvatarContext);
209
+ return (
210
+ <View
211
+ ref={ref}
212
+ className={cn(
213
+ "absolute right-0 bottom-0 z-10 flex items-center justify-center rounded-full bg-primary ring-2 ring-background web:select-none",
214
+ avatar && AVATAR_BADGE_SIZE[avatar.size],
215
+ className,
216
+ )}
217
+ {...props}
218
+ />
219
+ );
220
+ },
221
+ );
222
+ AvatarBadge.displayName = "AvatarBadge";
223
+
224
+ // ---------------------------------------------------------------------------
225
+ // AvatarGroup — 아바타 무리. 겹침은 웹 전용(네이티브은 나란히).
226
+ // ---------------------------------------------------------------------------
227
+ export type AvatarGroupProps = ViewProps;
228
+
229
+ const AvatarGroup = forwardRef<BridgeViewRef, AvatarGroupProps>(
230
+ ({ className, ...props }, ref) => (
231
+ <AvatarGroupContext.Provider value={true}>
232
+ <View
233
+ ref={ref}
234
+ className={cn("flex flex-row items-center web:-space-x-2", className)}
235
+ {...props}
236
+ />
237
+ </AvatarGroupContext.Provider>
238
+ ),
239
+ );
240
+ AvatarGroup.displayName = "AvatarGroup";
241
+
242
+ // ---------------------------------------------------------------------------
243
+ // AvatarGroupCount — 넘친 인원 표시("+9" 등 문자열 children).
244
+ // ---------------------------------------------------------------------------
245
+ export type AvatarGroupCountProps = ViewProps & {
246
+ children?: ReactNode;
247
+ };
248
+
249
+ const AvatarGroupCount = forwardRef<BridgeViewRef, AvatarGroupCountProps>(
250
+ ({ className, children, ...props }, ref) => (
251
+ <View
252
+ ref={ref}
253
+ className={cn(
254
+ "relative flex size-8 shrink-0 items-center justify-center rounded-full bg-muted ring-2 ring-background",
255
+ className,
256
+ )}
257
+ {...props}
258
+ >
259
+ {/* 이중 지정 원칙: 전경색은 내부 Text에 */}
260
+ <Text className="font-sans text-sm text-muted-foreground">
261
+ {children}
262
+ </Text>
263
+ </View>
264
+ ),
265
+ );
266
+ AvatarGroupCount.displayName = "AvatarGroupCount";
267
+
268
+ export {
269
+ Avatar,
270
+ AvatarImage,
271
+ AvatarFallback,
272
+ AvatarBadge,
273
+ AvatarGroup,
274
+ AvatarGroupCount,
275
+ };