@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.
- package/README.md +1 -1
- package/docs/tokens.md +1 -1
- package/package.json +1 -1
- package/src/bridge/native.tsx +135 -4
- package/src/bridge/types.ts +79 -1
- package/src/bridge/web.tsx +87 -6
- package/src/components/accordion.tsx +231 -0
- package/src/components/alert.tsx +157 -0
- package/src/components/aspect-ratio.tsx +40 -0
- package/src/components/avatar.tsx +275 -0
- package/src/components/breadcrumb.tsx +173 -0
- package/src/components/button-group.tsx +166 -0
- package/src/components/checkbox.tsx +94 -0
- package/src/components/collapsible.tsx +120 -0
- package/src/components/empty.tsx +160 -0
- package/src/components/field.tsx +349 -0
- package/src/components/item.tsx +243 -0
- package/src/components/kbd.tsx +62 -0
- package/src/components/pagination.tsx +204 -0
- package/src/components/progress.tsx +60 -0
- package/src/components/radio-group.tsx +148 -0
- package/src/components/separator.tsx +50 -0
- package/src/components/skeleton.tsx +36 -0
- package/src/components/switch.tsx +128 -0
- package/src/components/table.tsx +193 -0
- package/src/components/tabs.tsx +213 -0
- package/src/components/textarea.tsx +64 -0
- package/src/components/toggle-group.tsx +187 -0
- package/src/components/toggle.tsx +132 -0
- package/src/index.ts +276 -0
- package/src/theme/presets.ts +5 -4
- package/src/theme/tokens.ts +3 -1
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
// Breadcrumb — shadcn/ui new-york-v4 breadcrumb 포팅(/tmp/shadcn-ref/src/breadcrumb).
|
|
2
|
+
// 7종 export. radix Slot·lucide 아이콘 제거.
|
|
3
|
+
// 포팅 판단:
|
|
4
|
+
// - 아이콘 금지 규칙에 따라 ChevronRight → "›", MoreHorizontal → "⋯" 글리프 Text.
|
|
5
|
+
// - BreadcrumbLink는 Pressable role="link" + onPress(무인자). radix Slot(asChild)은
|
|
6
|
+
// 미지원 — 사용처 라우팅은 onPress에서 호출한다.
|
|
7
|
+
// - BreadcrumbPage의 aria-current는 브리지 계약에 없어 현재 페이지를 시각(색·
|
|
8
|
+
// 굵기)으로만 표현한다. 원본의 role="link"·aria-disabled도 Text 계약에 없어
|
|
9
|
+
// 생략(활성 아닌 링크로 오독 방지).
|
|
10
|
+
// - 원본 <nav>의 내재 role(navigation)도 BridgeRole에 없어 미지원 — 라벨만
|
|
11
|
+
// 유지. 스크린리더 전용(sr-only) 보조 텍스트는 웹 전용 클래스라 생략하되,
|
|
12
|
+
// 원본처럼 장식 요소(Separator·Ellipsis)에는 aria-hidden을 지정한다.
|
|
13
|
+
// - 웹 전용 토큰(transition·hover·break-words·sm: 반응형)은 web: 접두.
|
|
14
|
+
import { forwardRef } from "react";
|
|
15
|
+
import {
|
|
16
|
+
Pressable,
|
|
17
|
+
Text,
|
|
18
|
+
View,
|
|
19
|
+
type BridgePressableRef,
|
|
20
|
+
type BridgeTextRef,
|
|
21
|
+
type BridgeViewRef,
|
|
22
|
+
type PressableProps,
|
|
23
|
+
type TextProps,
|
|
24
|
+
type ViewProps,
|
|
25
|
+
} from "@unikit-bridge/ui/primitives";
|
|
26
|
+
import { cva } from "class-variance-authority";
|
|
27
|
+
|
|
28
|
+
import { cn } from "../lib/utils";
|
|
29
|
+
|
|
30
|
+
const breadcrumbListVariants = cva(
|
|
31
|
+
"flex-row flex-wrap items-center gap-1.5 web:break-words web:sm:gap-2.5",
|
|
32
|
+
);
|
|
33
|
+
const breadcrumbItemVariants = cva("flex-row items-center gap-1.5");
|
|
34
|
+
|
|
35
|
+
// 링크 컨테이너(Pressable) — 투명 배경·무테두리, 전경색은 내부 Text에.
|
|
36
|
+
const breadcrumbLinkVariants = cva("flex-row items-center");
|
|
37
|
+
const breadcrumbLinkTextVariants = cva(
|
|
38
|
+
"font-sans text-sm text-muted-foreground web:transition-colors web:hover:text-foreground active:text-foreground",
|
|
39
|
+
);
|
|
40
|
+
|
|
41
|
+
// 현재 페이지 — 원본 font-normal text-foreground(시각적 구분만).
|
|
42
|
+
const breadcrumbPageVariants = cva(
|
|
43
|
+
"font-sans text-sm font-normal text-foreground",
|
|
44
|
+
);
|
|
45
|
+
|
|
46
|
+
const breadcrumbSeparatorVariants = cva("flex-row items-center");
|
|
47
|
+
const breadcrumbSeparatorTextVariants = cva(
|
|
48
|
+
"font-sans text-sm leading-none text-muted-foreground",
|
|
49
|
+
);
|
|
50
|
+
const breadcrumbEllipsisVariants = cva(
|
|
51
|
+
"h-9 w-9 flex-row items-center justify-center",
|
|
52
|
+
);
|
|
53
|
+
const breadcrumbEllipsisTextVariants = cva(
|
|
54
|
+
"font-sans text-base leading-none text-muted-foreground",
|
|
55
|
+
);
|
|
56
|
+
|
|
57
|
+
export type BreadcrumbProps = ViewProps;
|
|
58
|
+
export type BreadcrumbListProps = ViewProps;
|
|
59
|
+
export type BreadcrumbItemProps = ViewProps;
|
|
60
|
+
export type BreadcrumbLinkProps = PressableProps;
|
|
61
|
+
export type BreadcrumbPageProps = TextProps;
|
|
62
|
+
export type BreadcrumbSeparatorProps = ViewProps;
|
|
63
|
+
export type BreadcrumbEllipsisProps = ViewProps;
|
|
64
|
+
|
|
65
|
+
const Breadcrumb = forwardRef<BridgeViewRef, BreadcrumbProps>(
|
|
66
|
+
({ className, ...props }, ref) => (
|
|
67
|
+
<View
|
|
68
|
+
ref={ref}
|
|
69
|
+
accessibilityLabel="breadcrumb"
|
|
70
|
+
className={className}
|
|
71
|
+
{...props}
|
|
72
|
+
/>
|
|
73
|
+
),
|
|
74
|
+
);
|
|
75
|
+
Breadcrumb.displayName = "Breadcrumb";
|
|
76
|
+
|
|
77
|
+
const BreadcrumbList = forwardRef<BridgeViewRef, BreadcrumbListProps>(
|
|
78
|
+
({ className, ...props }, ref) => (
|
|
79
|
+
<View
|
|
80
|
+
ref={ref}
|
|
81
|
+
className={cn(breadcrumbListVariants(), className)}
|
|
82
|
+
{...props}
|
|
83
|
+
/>
|
|
84
|
+
),
|
|
85
|
+
);
|
|
86
|
+
BreadcrumbList.displayName = "BreadcrumbList";
|
|
87
|
+
|
|
88
|
+
const BreadcrumbItem = forwardRef<BridgeViewRef, BreadcrumbItemProps>(
|
|
89
|
+
({ className, ...props }, ref) => (
|
|
90
|
+
<View
|
|
91
|
+
ref={ref}
|
|
92
|
+
className={cn(breadcrumbItemVariants(), className)}
|
|
93
|
+
{...props}
|
|
94
|
+
/>
|
|
95
|
+
),
|
|
96
|
+
);
|
|
97
|
+
BreadcrumbItem.displayName = "BreadcrumbItem";
|
|
98
|
+
|
|
99
|
+
const BreadcrumbLink = forwardRef<BridgePressableRef, BreadcrumbLinkProps>(
|
|
100
|
+
({ className, children, ...props }, ref) => (
|
|
101
|
+
<Pressable
|
|
102
|
+
ref={ref}
|
|
103
|
+
accessibilityRole="link"
|
|
104
|
+
className={cn(breadcrumbLinkVariants(), className)}
|
|
105
|
+
{...props}
|
|
106
|
+
>
|
|
107
|
+
<Text className={cn(breadcrumbLinkTextVariants())}>{children}</Text>
|
|
108
|
+
</Pressable>
|
|
109
|
+
),
|
|
110
|
+
);
|
|
111
|
+
BreadcrumbLink.displayName = "BreadcrumbLink";
|
|
112
|
+
|
|
113
|
+
const BreadcrumbPage = forwardRef<BridgeTextRef, BreadcrumbPageProps>(
|
|
114
|
+
({ className, ...props }, ref) => (
|
|
115
|
+
<Text
|
|
116
|
+
ref={ref}
|
|
117
|
+
className={cn(breadcrumbPageVariants(), className)}
|
|
118
|
+
{...props}
|
|
119
|
+
/>
|
|
120
|
+
),
|
|
121
|
+
);
|
|
122
|
+
BreadcrumbPage.displayName = "BreadcrumbPage";
|
|
123
|
+
|
|
124
|
+
// 구분 글리프 — children 미지정 시 기본 "›"(원본 ChevronRight 대체).
|
|
125
|
+
const BreadcrumbSeparator = forwardRef<BridgeViewRef, BreadcrumbSeparatorProps>(
|
|
126
|
+
({ className, children, ...props }, ref) => (
|
|
127
|
+
<View
|
|
128
|
+
ref={ref}
|
|
129
|
+
aria-hidden
|
|
130
|
+
className={cn(breadcrumbSeparatorVariants(), className)}
|
|
131
|
+
{...props}
|
|
132
|
+
>
|
|
133
|
+
<Text className={cn(breadcrumbSeparatorTextVariants())}>
|
|
134
|
+
{children ?? "›"}
|
|
135
|
+
</Text>
|
|
136
|
+
</View>
|
|
137
|
+
),
|
|
138
|
+
);
|
|
139
|
+
BreadcrumbSeparator.displayName = "BreadcrumbSeparator";
|
|
140
|
+
|
|
141
|
+
// 생략 글리프 — "⋯"(원본 MoreHorizontal 대체).
|
|
142
|
+
const BreadcrumbEllipsis = forwardRef<BridgeViewRef, BreadcrumbEllipsisProps>(
|
|
143
|
+
({ className, ...props }, ref) => (
|
|
144
|
+
<View
|
|
145
|
+
ref={ref}
|
|
146
|
+
aria-hidden
|
|
147
|
+
className={cn(breadcrumbEllipsisVariants(), className)}
|
|
148
|
+
{...props}
|
|
149
|
+
>
|
|
150
|
+
<Text className={cn(breadcrumbEllipsisTextVariants())}>⋯</Text>
|
|
151
|
+
</View>
|
|
152
|
+
),
|
|
153
|
+
);
|
|
154
|
+
BreadcrumbEllipsis.displayName = "BreadcrumbEllipsis";
|
|
155
|
+
|
|
156
|
+
export {
|
|
157
|
+
Breadcrumb,
|
|
158
|
+
BreadcrumbList,
|
|
159
|
+
BreadcrumbItem,
|
|
160
|
+
BreadcrumbLink,
|
|
161
|
+
BreadcrumbPage,
|
|
162
|
+
BreadcrumbSeparator,
|
|
163
|
+
BreadcrumbEllipsis,
|
|
164
|
+
breadcrumbListVariants,
|
|
165
|
+
breadcrumbItemVariants,
|
|
166
|
+
breadcrumbLinkVariants,
|
|
167
|
+
breadcrumbLinkTextVariants,
|
|
168
|
+
breadcrumbPageVariants,
|
|
169
|
+
breadcrumbSeparatorVariants,
|
|
170
|
+
breadcrumbSeparatorTextVariants,
|
|
171
|
+
breadcrumbEllipsisVariants,
|
|
172
|
+
breadcrumbEllipsisTextVariants,
|
|
173
|
+
};
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
// ButtonGroup — shadcn/ui new-york-v4 button-group을 UniKit 유니버설로 이식.
|
|
2
|
+
//
|
|
3
|
+
// 포팅 판단(자식 셀렉터 제거):
|
|
4
|
+
// - 원본의 붙임(attach) 처리는 자식 셀렉터([&>*:not(:first-child)]:
|
|
5
|
+
// rounded-l-none 등)로 동작한다. 네이티브에 자식 셀렉터가 없으므로
|
|
6
|
+
// ButtonGroup이 Children.map으로 각 자식 요소에 위치 클래스(첫/끝 모서리
|
|
7
|
+
// 정리·테두리 중복 제거·포커스 z-순서)를 cloneElement로 주입한다 —
|
|
8
|
+
// CSS 셀렉터 없이 양쪽에서 동일하게 동작한다(Button 등 기존 컴포넌트의
|
|
9
|
+
// className 병합 규칙(twMerge)과 맞물려 나중 값이 이긴다).
|
|
10
|
+
// - data-slot 기반 중첩 그룹 간격 토큰(gap-2)과 select-trigger
|
|
11
|
+
// 관련 토큰은 data-slot 없이 표현 불가 → 제거.
|
|
12
|
+
// - ButtonGroupText의 asChild(radix Slot) 제거 — View+Text 구조 고정
|
|
13
|
+
// (이중 지정: 전경색은 내부 Text에).
|
|
14
|
+
// - ButtonGroupSeparator가 감싸던 Separator는 인라인 View(bg-border)로 대리
|
|
15
|
+
// (신규 파일 6종 제한 — separator.tsx 생성 불가).
|
|
16
|
+
import {
|
|
17
|
+
Children,
|
|
18
|
+
cloneElement,
|
|
19
|
+
forwardRef,
|
|
20
|
+
isValidElement,
|
|
21
|
+
type ReactElement,
|
|
22
|
+
type ReactNode,
|
|
23
|
+
} from "react";
|
|
24
|
+
import {
|
|
25
|
+
Text,
|
|
26
|
+
View,
|
|
27
|
+
type BridgeViewRef,
|
|
28
|
+
type ViewProps,
|
|
29
|
+
} from "@unikit-bridge/ui/primitives";
|
|
30
|
+
import { cva, type VariantProps } from "class-variance-authority";
|
|
31
|
+
|
|
32
|
+
import { cn } from "../lib/utils";
|
|
33
|
+
|
|
34
|
+
// 그룹 뼈대 — 원본의 방향 변형. 모서리 붙임 토큰은 아래 주입 로직으로 이동.
|
|
35
|
+
// w-fit은 uniwind 미지원 — self-start 치환으로 양쪽 플랫폼 내용 폭을 맞춘다(M-2).
|
|
36
|
+
const buttonGroupVariants = cva("flex self-start items-stretch", {
|
|
37
|
+
variants: {
|
|
38
|
+
orientation: {
|
|
39
|
+
horizontal: "flex-row",
|
|
40
|
+
vertical: "flex-col",
|
|
41
|
+
},
|
|
42
|
+
},
|
|
43
|
+
defaultVariants: {
|
|
44
|
+
orientation: "horizontal",
|
|
45
|
+
},
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
type ButtonGroupOrientation = "horizontal" | "vertical";
|
|
49
|
+
|
|
50
|
+
// 위치별 붙임 클래스 — 원본 자식 셀렉터의 치환.
|
|
51
|
+
// 첫 자식은 시작 모서리를, 끝 자식은 끝 모서리를 유지하고 나머지 모서리를
|
|
52
|
+
// 없애 붙은 모양을 만든다. 이웃 테두리 중복(border-l-0/border-t-0)도 정리.
|
|
53
|
+
function buttonGroupAttachClasses(
|
|
54
|
+
index: number,
|
|
55
|
+
total: number,
|
|
56
|
+
orientation: ButtonGroupOrientation,
|
|
57
|
+
): string {
|
|
58
|
+
const isFirst = index === 0;
|
|
59
|
+
const isLast = index === total - 1;
|
|
60
|
+
const horizontal = orientation === "horizontal";
|
|
61
|
+
return cn(
|
|
62
|
+
// 포커스 받은 자식이 위로 — 원본 [&>*]:focus-visible:relative/z-10 치환
|
|
63
|
+
"web:focus-visible:relative web:focus-visible:z-10",
|
|
64
|
+
!isFirst &&
|
|
65
|
+
(horizontal ? "rounded-l-none border-l-0" : "rounded-t-none border-t-0"),
|
|
66
|
+
!isLast && (horizontal ? "rounded-r-none" : "rounded-b-none"),
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// ---------------------------------------------------------------------------
|
|
71
|
+
// ButtonGroup — 버튼 무리 컨테이너. 자식에 붙임 클래스를 주입한다.
|
|
72
|
+
// ---------------------------------------------------------------------------
|
|
73
|
+
export type ButtonGroupProps = ViewProps &
|
|
74
|
+
VariantProps<typeof buttonGroupVariants> & {
|
|
75
|
+
children?: ReactNode;
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
const ButtonGroup = forwardRef<BridgeViewRef, ButtonGroupProps>(
|
|
79
|
+
({ className, orientation, children, ...props }, ref) => {
|
|
80
|
+
const resolvedOrientation: ButtonGroupOrientation =
|
|
81
|
+
orientation ?? "horizontal";
|
|
82
|
+
const items = Children.toArray(children);
|
|
83
|
+
const attached = items.map((child, index) => {
|
|
84
|
+
// 문자열 등 비(非)요소 자식은 그대로 둔다(Children.toArray가 null·
|
|
85
|
+
// boolean은 이미 걷어냈다). React 19 타입은 unknown props로 좁히므로
|
|
86
|
+
// className을 받는 요소로 단언해 cloneElement한다.
|
|
87
|
+
if (!isValidElement(child)) {
|
|
88
|
+
return child;
|
|
89
|
+
}
|
|
90
|
+
const typedChild = child as ReactElement<{ className?: string }>;
|
|
91
|
+
return cloneElement(typedChild, {
|
|
92
|
+
className: cn(
|
|
93
|
+
typedChild.props.className,
|
|
94
|
+
buttonGroupAttachClasses(index, items.length, resolvedOrientation),
|
|
95
|
+
),
|
|
96
|
+
});
|
|
97
|
+
});
|
|
98
|
+
return (
|
|
99
|
+
<View
|
|
100
|
+
ref={ref}
|
|
101
|
+
className={cn(buttonGroupVariants({ orientation }), className)}
|
|
102
|
+
{...props}
|
|
103
|
+
>
|
|
104
|
+
{attached}
|
|
105
|
+
</View>
|
|
106
|
+
);
|
|
107
|
+
},
|
|
108
|
+
);
|
|
109
|
+
ButtonGroup.displayName = "ButtonGroup";
|
|
110
|
+
|
|
111
|
+
// ---------------------------------------------------------------------------
|
|
112
|
+
// ButtonGroupText — 그룹 안 텍스트 조각(예: 승수 "×2"). View+Text 구조.
|
|
113
|
+
// ---------------------------------------------------------------------------
|
|
114
|
+
export type ButtonGroupTextProps = ViewProps & {
|
|
115
|
+
children?: ReactNode;
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
const ButtonGroupText = forwardRef<BridgeViewRef, ButtonGroupTextProps>(
|
|
119
|
+
({ className, children, ...props }, ref) => (
|
|
120
|
+
<View
|
|
121
|
+
ref={ref}
|
|
122
|
+
className={cn(
|
|
123
|
+
"flex items-center gap-2 rounded-md border border-border bg-muted px-4 web:shadow-xs",
|
|
124
|
+
className,
|
|
125
|
+
)}
|
|
126
|
+
{...props}
|
|
127
|
+
>
|
|
128
|
+
{/* 이중 지정 원칙: 배경·테두리는 View, 전경색은 내부 Text에 */}
|
|
129
|
+
<Text className="font-sans text-sm font-medium text-foreground">
|
|
130
|
+
{children}
|
|
131
|
+
</Text>
|
|
132
|
+
</View>
|
|
133
|
+
),
|
|
134
|
+
);
|
|
135
|
+
ButtonGroupText.displayName = "ButtonGroupText";
|
|
136
|
+
|
|
137
|
+
// ---------------------------------------------------------------------------
|
|
138
|
+
// ButtonGroupSeparator — 그룹 안 구분선. 기본 vertical(가로 무리의 세로 줄).
|
|
139
|
+
// ---------------------------------------------------------------------------
|
|
140
|
+
export type ButtonGroupSeparatorProps = ViewProps & {
|
|
141
|
+
/** 구분선 방향 — 원본 기본값 vertical 유지(self-stretch로 교차축 채움) */
|
|
142
|
+
orientation?: "horizontal" | "vertical";
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
const ButtonGroupSeparator = forwardRef<
|
|
146
|
+
BridgeViewRef,
|
|
147
|
+
ButtonGroupSeparatorProps
|
|
148
|
+
>(({ className, orientation = "vertical", ...props }, ref) => (
|
|
149
|
+
<View
|
|
150
|
+
ref={ref}
|
|
151
|
+
className={cn(
|
|
152
|
+
"self-stretch bg-border",
|
|
153
|
+
orientation === "vertical" ? "w-px" : "h-px w-full",
|
|
154
|
+
className,
|
|
155
|
+
)}
|
|
156
|
+
{...props}
|
|
157
|
+
/>
|
|
158
|
+
));
|
|
159
|
+
ButtonGroupSeparator.displayName = "ButtonGroupSeparator";
|
|
160
|
+
|
|
161
|
+
export {
|
|
162
|
+
ButtonGroup,
|
|
163
|
+
ButtonGroupSeparator,
|
|
164
|
+
ButtonGroupText,
|
|
165
|
+
buttonGroupVariants,
|
|
166
|
+
};
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
// Checkbox — shadcn/ui new-york-v4 checkbox를 UniKit 브리지로 이식.
|
|
2
|
+
// 포팅 판단:
|
|
3
|
+
// - radix-ui Checkbox(Root/Indicator) 제거 — 체크 상태는 React state(제어/비제어)로
|
|
4
|
+
// 직접 관리하고, 원본의 data-state=checked 셀렉터는 조건부 클래스 문자열로 전개
|
|
5
|
+
// (네이티브에 data 속성이 없어 상태 셀렉터를 쓸 수 없다).
|
|
6
|
+
// - CheckIcon(lucide) 제거 — ✓ 글리프를 Text children으로 대체(새 의존성 금지).
|
|
7
|
+
// - aria-invalid:* 클래스는 v0.1 API에 invalid 상태가 없어 제외.
|
|
8
|
+
// - 이중 지정 원칙(§0.5): 체크 배경·테두리는 Pressable, 글리프 색은 내부 Text에 명시.
|
|
9
|
+
import { forwardRef, useState } from "react";
|
|
10
|
+
import {
|
|
11
|
+
Pressable,
|
|
12
|
+
Text,
|
|
13
|
+
type BridgePressableRef,
|
|
14
|
+
type PressableProps,
|
|
15
|
+
} from "@unikit-bridge/ui/primitives";
|
|
16
|
+
|
|
17
|
+
import { cn } from "../lib/utils";
|
|
18
|
+
|
|
19
|
+
// 원본 크기 토큰 유지: size-4(16px) 박스·rounded-sm(UniKit 토큰 --radius-sm=4px)·border-input,
|
|
20
|
+
// 체크 시 border/bg-primary.
|
|
21
|
+
// 라벨 없음 — 라벨은 소비자가 Label과 조합한다(checkbox 원본과 동일한 계약).
|
|
22
|
+
export type CheckboxProps = Omit<PressableProps, "onPress" | "aria-checked"> & {
|
|
23
|
+
/** 제어형 체크 상태(지정 시 내부 state를 대체) */
|
|
24
|
+
checked?: boolean;
|
|
25
|
+
/** 비제어형 초기 체크 상태 */
|
|
26
|
+
defaultChecked?: boolean;
|
|
27
|
+
/** 체크 전환 콜백 — indeterminate 상태에서 누르면 false가 전달된다 */
|
|
28
|
+
onCheckedChange?: (checked: boolean) => void;
|
|
29
|
+
/** 중간 상태 — aria-checked="mixed" + − 글리프(shadcn checked="indeterminate" 대응) */
|
|
30
|
+
indeterminate?: boolean;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
// 터치 타깃 44px 보정: 16px 박스 → hitSlop = (44 − 16) / 2 = 14px.
|
|
34
|
+
const HIT_SLOP = 14;
|
|
35
|
+
|
|
36
|
+
const Checkbox = forwardRef<BridgePressableRef, CheckboxProps>(
|
|
37
|
+
(
|
|
38
|
+
{
|
|
39
|
+
className,
|
|
40
|
+
checked,
|
|
41
|
+
defaultChecked = false,
|
|
42
|
+
onCheckedChange,
|
|
43
|
+
indeterminate = false,
|
|
44
|
+
disabled,
|
|
45
|
+
hitSlop,
|
|
46
|
+
...props
|
|
47
|
+
},
|
|
48
|
+
ref,
|
|
49
|
+
) => {
|
|
50
|
+
// 제어(checked 지정)/비제어(내부 state) 통합 — 웹·네이티브 한 벌 로직.
|
|
51
|
+
const [uncontrolled, setUncontrolled] = useState(defaultChecked);
|
|
52
|
+
const isControlled = checked !== undefined;
|
|
53
|
+
const isChecked = isControlled ? checked : uncontrolled;
|
|
54
|
+
|
|
55
|
+
const handlePress = () => {
|
|
56
|
+
const next = !isChecked;
|
|
57
|
+
if (!isControlled) setUncontrolled(next);
|
|
58
|
+
onCheckedChange?.(next);
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
return (
|
|
62
|
+
<Pressable
|
|
63
|
+
ref={ref}
|
|
64
|
+
accessibilityRole="checkbox"
|
|
65
|
+
aria-checked={indeterminate ? "mixed" : isChecked}
|
|
66
|
+
aria-disabled={disabled ? true : undefined}
|
|
67
|
+
disabled={disabled}
|
|
68
|
+
hitSlop={hitSlop ?? HIT_SLOP}
|
|
69
|
+
onPress={handlePress}
|
|
70
|
+
className={cn(
|
|
71
|
+
"flex size-4 shrink-0 flex-row items-center justify-center rounded-sm border web:shadow-xs web:transition-shadow web:outline-none web:focus-visible:border-ring web:focus-visible:ring-2 web:focus-visible:ring-ring/50",
|
|
72
|
+
// 미체크 링은 border-input(라이트 1.27:1)이 배경에 묻혀 경계가 안
|
|
73
|
+
// 보이므로 foreground/50(양 모드 ≥3:1, G4)로 조정.
|
|
74
|
+
indeterminate || isChecked
|
|
75
|
+
? "border-primary bg-primary"
|
|
76
|
+
: "border-foreground/50 dark:bg-input/30",
|
|
77
|
+
disabled && "web:pointer-events-none web:cursor-not-allowed opacity-50",
|
|
78
|
+
className,
|
|
79
|
+
)}
|
|
80
|
+
{...props}
|
|
81
|
+
>
|
|
82
|
+
{/* 글리프 — 체크 ✓ / 중간 −. 원본 Indicator처럼 상태일 때만 렌더. */}
|
|
83
|
+
{indeterminate || isChecked ? (
|
|
84
|
+
<Text className="font-sans text-sm font-bold leading-none text-primary-foreground">
|
|
85
|
+
{indeterminate ? "−" : "✓"}
|
|
86
|
+
</Text>
|
|
87
|
+
) : null}
|
|
88
|
+
</Pressable>
|
|
89
|
+
);
|
|
90
|
+
},
|
|
91
|
+
);
|
|
92
|
+
Checkbox.displayName = "Checkbox";
|
|
93
|
+
|
|
94
|
+
export { Checkbox };
|
|
@@ -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 };
|