@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.
- package/LICENSE +21 -0
- package/README.md +99 -2
- package/docs/tokens.md +63 -0
- package/package.json +75 -4
- package/src/bridge/native.tsx +301 -0
- package/src/bridge/types.ts +174 -0
- package/src/bridge/uniwind-env.d.ts +1 -0
- package/src/bridge/web.tsx +247 -0
- 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/badge.tsx +65 -0
- package/src/components/breadcrumb.tsx +173 -0
- package/src/components/button-group.tsx +166 -0
- package/src/components/button.tsx +106 -0
- package/src/components/card.tsx +112 -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/input.tsx +39 -0
- package/src/components/item.tsx +243 -0
- package/src/components/kbd.tsx +62 -0
- package/src/components/label.tsx +33 -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 +321 -0
- package/src/lib/utils.ts +7 -0
- package/src/theme/index.ts +4 -0
- package/src/theme/presets.ts +187 -0
- package/src/theme/provider.tsx +82 -0
- package/src/theme/tokens.ts +75 -0
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 브리지 프리미티브의 공개 타입 — 플랫폼 공통 부분집합.
|
|
3
|
+
*
|
|
4
|
+
* `@unikit-bridge/ui/primitives`는 package.json exports의 조건분기로 해석된다:
|
|
5
|
+
* - 네이티브(Metro, "react-native" 조건) → native.tsx — RN View/Text/Pressable/TextInput
|
|
6
|
+
* + uniwind(className을 RN 스타일로 컴파일)
|
|
7
|
+
* - 웹(Next/Vite 등, "default" 조건) → web.tsx — div/span/button/input에 className을
|
|
8
|
+
* 그대로 싣고 표준 Tailwind CSS v4가 스타일링한다
|
|
9
|
+
*
|
|
10
|
+
* 두 구현 모두 이 파일의 타입을 충족해야 한다(컴포넌트 소스는 플랫폼 무관하게
|
|
11
|
+
* 한 벌로 컴파일된다). 프로퍼티는 v0.1 컴포넌트·데모가 실제 쓰는 공통 부분집합이며,
|
|
12
|
+
* 플랫폼 고유 프로퍼티(hitSlop 등)는 웹에서 무시된다.
|
|
13
|
+
*/
|
|
14
|
+
import type { ReactNode } from "react";
|
|
15
|
+
|
|
16
|
+
export type BridgeColorScheme = "light" | "dark";
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* 플랫폼 공통 인라인 스타일 최소 계약 — className 문자열로 표현할 수 없는
|
|
20
|
+
* 동적 값(진행률 폭 %, 가로세로 비율 등)용. RN 스타일 객체와 DOM
|
|
21
|
+
* CSSProperties의 실용 교집합(프로퍼티 이름은 camelCase, 값은 문자열·숫자).
|
|
22
|
+
* 정적 스타일은 여전히 className이 원칙이고, 여기는 동적 값 전용이다.
|
|
23
|
+
*/
|
|
24
|
+
export type BridgeStyle = Record<string, string | number | undefined>;
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* 플랫폼 공통 접근성 역할 — RN accessibilityRole과 ARIA role의 교집합(컴포넌트
|
|
28
|
+
* 카탈로그 확장 시 필요한 역할까지 포함). 웹에서는 그대로 role 속성이 된다.
|
|
29
|
+
*/
|
|
30
|
+
export type BridgeRole =
|
|
31
|
+
| "button"
|
|
32
|
+
| "link"
|
|
33
|
+
| "search"
|
|
34
|
+
| "image"
|
|
35
|
+
| "text"
|
|
36
|
+
| "tab"
|
|
37
|
+
| "tablist"
|
|
38
|
+
| "tabpanel"
|
|
39
|
+
| "switch"
|
|
40
|
+
| "checkbox"
|
|
41
|
+
| "radio"
|
|
42
|
+
| "radiogroup"
|
|
43
|
+
| "option"
|
|
44
|
+
| "menuitem"
|
|
45
|
+
| "separator"
|
|
46
|
+
| "heading"
|
|
47
|
+
| "list"
|
|
48
|
+
| "listitem"
|
|
49
|
+
| "progressbar"
|
|
50
|
+
| "alert"
|
|
51
|
+
| "status"
|
|
52
|
+
| "combobox";
|
|
53
|
+
|
|
54
|
+
// ---------------------------------------------------------------------------
|
|
55
|
+
// Props
|
|
56
|
+
// ---------------------------------------------------------------------------
|
|
57
|
+
|
|
58
|
+
export type ViewProps = {
|
|
59
|
+
className?: string;
|
|
60
|
+
/** 동적 값 전용 인라인 스타일(정적 스타일은 className 원칙) */
|
|
61
|
+
style?: BridgeStyle;
|
|
62
|
+
children?: ReactNode;
|
|
63
|
+
/** 네이티브 nativeID — 웹에서는 id로 전달된다 */
|
|
64
|
+
nativeID?: string;
|
|
65
|
+
testID?: string;
|
|
66
|
+
accessibilityRole?: BridgeRole;
|
|
67
|
+
accessibilityLabel?: string;
|
|
68
|
+
/** 장식용 요소 표시 — 웹 aria-hidden, 네이티브 accessibilityElementsHidden */
|
|
69
|
+
"aria-hidden"?: boolean;
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
export type TextProps = {
|
|
73
|
+
className?: string;
|
|
74
|
+
/** 동적 값 전용 인라인 스타일(정적 스타일은 className 원칙) */
|
|
75
|
+
style?: BridgeStyle;
|
|
76
|
+
children?: ReactNode;
|
|
77
|
+
nativeID?: string;
|
|
78
|
+
testID?: string;
|
|
79
|
+
onPress?: () => void;
|
|
80
|
+
accessibilityRole?: BridgeRole;
|
|
81
|
+
accessibilityLabel?: string;
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
export type PressableProps = {
|
|
85
|
+
className?: string;
|
|
86
|
+
/** 동적 값 전용 인라인 스타일(정적 스타일은 className 원칙) */
|
|
87
|
+
style?: BridgeStyle;
|
|
88
|
+
children?: ReactNode;
|
|
89
|
+
onPress?: () => void;
|
|
90
|
+
disabled?: boolean;
|
|
91
|
+
accessibilityLabel?: string;
|
|
92
|
+
accessibilityRole?: BridgeRole;
|
|
93
|
+
/** RN 0.7x+ 관용 — 웹 DOM에서도 동일 속성으로 전달된다 */
|
|
94
|
+
"aria-disabled"?: boolean;
|
|
95
|
+
/** 눌림 유지형 컨트롤(toggle) 상태 — 웹 DOM 그대로, 네이티브는 시각 상태로만 반영 */
|
|
96
|
+
"aria-pressed"?: boolean;
|
|
97
|
+
/** 체크형 컨트롤(checkbox/radio/switch) 상태 — 네이티브 accessibilityState.checked로 */
|
|
98
|
+
"aria-checked"?: boolean | "mixed";
|
|
99
|
+
/** 탭·옵션 등 선택 상태 — 네이티브 accessibilityState.selected로 */
|
|
100
|
+
"aria-selected"?: boolean;
|
|
101
|
+
/** 아코디언·콤보박스 등 전개 상태 — 네이티브 accessibilityState.expanded로 */
|
|
102
|
+
"aria-expanded"?: boolean;
|
|
103
|
+
"aria-controls"?: string;
|
|
104
|
+
"aria-labelledby"?: string;
|
|
105
|
+
/** RN 전용 터치 타깃 확장(px) — 웹에서는 무시 */
|
|
106
|
+
hitSlop?: number | { top: number; bottom: number; left: number; right: number };
|
|
107
|
+
nativeID?: string;
|
|
108
|
+
testID?: string;
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
export type ImageProps = {
|
|
112
|
+
className?: string;
|
|
113
|
+
/** 동적 값 전용 인라인 스타일(정적 스타일은 className 원칙) */
|
|
114
|
+
style?: BridgeStyle;
|
|
115
|
+
/** 원격 이미지 — 네이티브 RN source, 웹 img src. require() 정적 소스는 네이티브 전용 */
|
|
116
|
+
source: { uri: string };
|
|
117
|
+
/** 웹 img alt — 네이티브에서는 accessibilityLabel로 전달된다 */
|
|
118
|
+
alt?: string;
|
|
119
|
+
/** 로드 실패 콜백(무인자) — Avatar 등의 폴백 전환용 */
|
|
120
|
+
onError?: () => void;
|
|
121
|
+
accessibilityLabel?: string;
|
|
122
|
+
nativeID?: string;
|
|
123
|
+
testID?: string;
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
export type TextInputProps = {
|
|
127
|
+
className?: string;
|
|
128
|
+
/** 동적 값 전용 인라인 스타일(정적 스타일은 className 원칙) */
|
|
129
|
+
style?: BridgeStyle;
|
|
130
|
+
value?: string;
|
|
131
|
+
defaultValue?: string;
|
|
132
|
+
placeholder?: string;
|
|
133
|
+
/** RN 관용 — 웹에서는 readOnly로 치환 */
|
|
134
|
+
editable?: boolean;
|
|
135
|
+
/** 웹 관용 disabled — 네이티브에서는 editable={false}로 치환된다(Input이 처리) */
|
|
136
|
+
disabled?: boolean;
|
|
137
|
+
onChangeText?: (text: string) => void;
|
|
138
|
+
nativeID?: string;
|
|
139
|
+
testID?: string;
|
|
140
|
+
/** 웹 DOM에서 aria-labelledby로 전달 — 입력창↔라벨 연결 */
|
|
141
|
+
accessibilityLabelledBy?: string;
|
|
142
|
+
/** RN 0.7x+ 관용 — 웹 DOM에서도 동일 속성으로 전달된다 */
|
|
143
|
+
"aria-disabled"?: boolean;
|
|
144
|
+
autoFocus?: boolean;
|
|
145
|
+
secureTextEntry?: boolean;
|
|
146
|
+
inputMode?: "text" | "tel" | "url" | "email" | "numeric" | "decimal" | "search";
|
|
147
|
+
/** 여러 줄 입력 — 네이티브 RN multiline, 웹은 <input> 대신 <textarea>로 렌더 */
|
|
148
|
+
multiline?: boolean;
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
// ---------------------------------------------------------------------------
|
|
152
|
+
// Refs — 컴포넌트가 실제로 호출하는 메서드만 계약으로 선언(구조적 타이핑).
|
|
153
|
+
// 네이티브 인스턴스(RN)와 DOM 요소(웹) 모두 이 인터페이스를 충족한다.
|
|
154
|
+
// ---------------------------------------------------------------------------
|
|
155
|
+
|
|
156
|
+
export interface BridgeViewRef {}
|
|
157
|
+
export interface BridgeTextRef {}
|
|
158
|
+
export interface BridgePressableRef {}
|
|
159
|
+
export interface BridgeImageRef {}
|
|
160
|
+
export interface BridgeTextInputRef {
|
|
161
|
+
focus(): void;
|
|
162
|
+
blur(): void;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// ---------------------------------------------------------------------------
|
|
166
|
+
// 테마 엔진 바인딩
|
|
167
|
+
// ---------------------------------------------------------------------------
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* 색 스킴을 스타일링 엔진에 반영한다(UnikitThemeProvider가 호출).
|
|
171
|
+
* - native.tsx → uniwind `Uniwind.setTheme()`(native 표준 다이얼로그까지 동기)
|
|
172
|
+
* - web.tsx → document.documentElement의 `.dark` 클래스 토글(표준 Tailwind dark 변형)
|
|
173
|
+
*/
|
|
174
|
+
export type ApplyColorScheme = (colorScheme: BridgeColorScheme) => void;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
/// <reference types="uniwind/types" />
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 웹 브리지 — 표준 Tailwind CSS v4 소비자용(Next.js/Vite 등).
|
|
3
|
+
*
|
|
4
|
+
* 같은 className 문자열을 DOM 요소에 그대로 얹는다. 스타일링 엔진이 별도로
|
|
5
|
+
* 필요하지 않다 — 소비자 앱의 Tailwind CSS가 클래스를 생성하고 브라우저가
|
|
6
|
+
* 적용한다(hover/focus-visible/:active 등 웹 전용 변형도 CSS 표준 동작).
|
|
7
|
+
*
|
|
8
|
+
* RN 프리미티브 관용 프로퍼티는 DOM 속성으로 치환된다:
|
|
9
|
+
* onPress→onClick · accessibilityRole→role · accessibilityLabel→aria-label ·
|
|
10
|
+
* nativeID→id · accessibilityLabelledBy→aria-labelledby · editable→readOnly ·
|
|
11
|
+
* secureTextEntry→type="password" · onChangeText→onChange(value 추출)
|
|
12
|
+
* 치환은 View/Text/Pressable 전 프리미티브에 동일 적용된다(M-1 — React DOM은
|
|
13
|
+
* camelCase 미지 prop을 렌더에서 제거하므로 치환 없이는 role·라벨이 사라진다).
|
|
14
|
+
* hitSlop은 웹에서 무시된다(포인터 정밀도가 높아 불필요).
|
|
15
|
+
*/
|
|
16
|
+
import { forwardRef, type ChangeEvent } from "react";
|
|
17
|
+
|
|
18
|
+
import { cn } from "../lib/utils";
|
|
19
|
+
import type {
|
|
20
|
+
ApplyColorScheme,
|
|
21
|
+
BridgeImageRef,
|
|
22
|
+
BridgePressableRef,
|
|
23
|
+
BridgeStyle,
|
|
24
|
+
BridgeTextRef,
|
|
25
|
+
BridgeTextInputRef,
|
|
26
|
+
BridgeViewRef,
|
|
27
|
+
ImageProps,
|
|
28
|
+
PressableProps,
|
|
29
|
+
TextProps,
|
|
30
|
+
TextInputProps,
|
|
31
|
+
ViewProps,
|
|
32
|
+
} from "./types";
|
|
33
|
+
|
|
34
|
+
// 공유 타입 전량 재수출 — 소비자는 @unikit-bridge/ui/primitives에서 타입까지 한 번에 import
|
|
35
|
+
export type * from "./types";
|
|
36
|
+
|
|
37
|
+
// ---------------------------------------------------------------------------
|
|
38
|
+
// View → <div> — RN View 기본값(display:flex, flexDirection:column)을
|
|
39
|
+
// 기본 클래스로 재현한다. 충돌 시 tailwind-merge(cn)이 방향 유틸리티를 정리한다.
|
|
40
|
+
// ---------------------------------------------------------------------------
|
|
41
|
+
const View = forwardRef<BridgeViewRef, ViewProps>(
|
|
42
|
+
(
|
|
43
|
+
{ className, style, children, nativeID, testID, accessibilityLabel, accessibilityRole, ...rest },
|
|
44
|
+
ref,
|
|
45
|
+
) => (
|
|
46
|
+
<div
|
|
47
|
+
ref={ref as React.Ref<HTMLDivElement>}
|
|
48
|
+
id={nativeID}
|
|
49
|
+
data-testid={testID}
|
|
50
|
+
role={accessibilityRole}
|
|
51
|
+
aria-label={accessibilityLabel}
|
|
52
|
+
className={cn("flex flex-col", className)}
|
|
53
|
+
style={style as React.CSSProperties}
|
|
54
|
+
{...rest}
|
|
55
|
+
>
|
|
56
|
+
{children}
|
|
57
|
+
</div>
|
|
58
|
+
),
|
|
59
|
+
);
|
|
60
|
+
View.displayName = "View";
|
|
61
|
+
|
|
62
|
+
// ---------------------------------------------------------------------------
|
|
63
|
+
// Text → <span>
|
|
64
|
+
// ---------------------------------------------------------------------------
|
|
65
|
+
const Text = forwardRef<BridgeTextRef, TextProps>(
|
|
66
|
+
(
|
|
67
|
+
{ className, style, children, nativeID, testID, onPress, accessibilityLabel, accessibilityRole, ...rest },
|
|
68
|
+
ref,
|
|
69
|
+
) => (
|
|
70
|
+
<span
|
|
71
|
+
ref={ref as React.Ref<HTMLSpanElement>}
|
|
72
|
+
id={nativeID}
|
|
73
|
+
data-testid={testID}
|
|
74
|
+
role={accessibilityRole}
|
|
75
|
+
aria-label={accessibilityLabel}
|
|
76
|
+
className={className}
|
|
77
|
+
style={style as React.CSSProperties}
|
|
78
|
+
onClick={onPress}
|
|
79
|
+
{...rest}
|
|
80
|
+
>
|
|
81
|
+
{children}
|
|
82
|
+
</span>
|
|
83
|
+
),
|
|
84
|
+
);
|
|
85
|
+
Text.displayName = "Text";
|
|
86
|
+
|
|
87
|
+
// ---------------------------------------------------------------------------
|
|
88
|
+
// Pressable → <button type="button">
|
|
89
|
+
// disabled는 네이티브 속성으로 전달돼 CSS :disabled/disabled: 변형과 포커스·클릭
|
|
90
|
+
// 차단이 함께 동작한다. active: 변형은 브라우저 :active와 대응한다.
|
|
91
|
+
// ---------------------------------------------------------------------------
|
|
92
|
+
const Pressable = forwardRef<BridgePressableRef, PressableProps>(
|
|
93
|
+
(
|
|
94
|
+
{
|
|
95
|
+
className,
|
|
96
|
+
style,
|
|
97
|
+
children,
|
|
98
|
+
onPress,
|
|
99
|
+
disabled,
|
|
100
|
+
accessibilityLabel,
|
|
101
|
+
accessibilityRole,
|
|
102
|
+
nativeID,
|
|
103
|
+
testID,
|
|
104
|
+
hitSlop: _hitSlop,
|
|
105
|
+
...rest
|
|
106
|
+
},
|
|
107
|
+
ref,
|
|
108
|
+
) => (
|
|
109
|
+
<button
|
|
110
|
+
ref={ref as React.Ref<HTMLButtonElement>}
|
|
111
|
+
type="button"
|
|
112
|
+
id={nativeID}
|
|
113
|
+
data-testid={testID}
|
|
114
|
+
className={className}
|
|
115
|
+
style={style as React.CSSProperties}
|
|
116
|
+
onClick={onPress}
|
|
117
|
+
disabled={disabled}
|
|
118
|
+
aria-label={accessibilityLabel}
|
|
119
|
+
role={accessibilityRole}
|
|
120
|
+
{...rest}
|
|
121
|
+
>
|
|
122
|
+
{children}
|
|
123
|
+
</button>
|
|
124
|
+
),
|
|
125
|
+
);
|
|
126
|
+
Pressable.displayName = "Pressable";
|
|
127
|
+
|
|
128
|
+
// ---------------------------------------------------------------------------
|
|
129
|
+
// TextInput → <input>
|
|
130
|
+
// ---------------------------------------------------------------------------
|
|
131
|
+
const TextInput = forwardRef<BridgeTextInputRef, TextInputProps>(
|
|
132
|
+
(
|
|
133
|
+
{
|
|
134
|
+
className,
|
|
135
|
+
style,
|
|
136
|
+
value,
|
|
137
|
+
defaultValue,
|
|
138
|
+
placeholder,
|
|
139
|
+
editable,
|
|
140
|
+
disabled,
|
|
141
|
+
onChangeText,
|
|
142
|
+
nativeID,
|
|
143
|
+
testID,
|
|
144
|
+
accessibilityLabelledBy,
|
|
145
|
+
autoFocus,
|
|
146
|
+
secureTextEntry,
|
|
147
|
+
inputMode,
|
|
148
|
+
multiline,
|
|
149
|
+
...rest
|
|
150
|
+
},
|
|
151
|
+
ref,
|
|
152
|
+
) => {
|
|
153
|
+
const handleChange = (
|
|
154
|
+
event: ChangeEvent<HTMLInputElement | HTMLTextAreaElement>,
|
|
155
|
+
) => {
|
|
156
|
+
onChangeText?.(event.target.value);
|
|
157
|
+
};
|
|
158
|
+
// multiline → <textarea>(웹 표준 여러 줄 입력). 나머지 계약은 <input>과 동일.
|
|
159
|
+
if (multiline) {
|
|
160
|
+
return (
|
|
161
|
+
<textarea
|
|
162
|
+
ref={ref as React.Ref<HTMLTextAreaElement>}
|
|
163
|
+
id={nativeID}
|
|
164
|
+
data-testid={testID}
|
|
165
|
+
aria-labelledby={accessibilityLabelledBy}
|
|
166
|
+
className={className}
|
|
167
|
+
style={style as React.CSSProperties}
|
|
168
|
+
value={value}
|
|
169
|
+
defaultValue={defaultValue}
|
|
170
|
+
placeholder={placeholder}
|
|
171
|
+
disabled={disabled}
|
|
172
|
+
readOnly={editable === false}
|
|
173
|
+
autoFocus={autoFocus}
|
|
174
|
+
inputMode={inputMode}
|
|
175
|
+
onChange={handleChange}
|
|
176
|
+
{...rest}
|
|
177
|
+
/>
|
|
178
|
+
);
|
|
179
|
+
}
|
|
180
|
+
return (
|
|
181
|
+
<input
|
|
182
|
+
ref={ref as React.Ref<HTMLInputElement>}
|
|
183
|
+
id={nativeID}
|
|
184
|
+
data-testid={testID}
|
|
185
|
+
aria-labelledby={accessibilityLabelledBy}
|
|
186
|
+
className={className}
|
|
187
|
+
style={style as React.CSSProperties}
|
|
188
|
+
value={value}
|
|
189
|
+
defaultValue={defaultValue}
|
|
190
|
+
placeholder={placeholder}
|
|
191
|
+
disabled={disabled}
|
|
192
|
+
readOnly={editable === false}
|
|
193
|
+
autoFocus={autoFocus}
|
|
194
|
+
type={secureTextEntry ? "password" : undefined}
|
|
195
|
+
inputMode={inputMode}
|
|
196
|
+
onChange={handleChange}
|
|
197
|
+
{...rest}
|
|
198
|
+
/>
|
|
199
|
+
);
|
|
200
|
+
},
|
|
201
|
+
);
|
|
202
|
+
TextInput.displayName = "TextInput";
|
|
203
|
+
|
|
204
|
+
// ---------------------------------------------------------------------------
|
|
205
|
+
// Image → <img> — 원격 {uri} 소스를 src로 푼다. 정적 require() 소스는
|
|
206
|
+
// 네이티브 전용 기능이라 웹 계약에 없다.
|
|
207
|
+
// ---------------------------------------------------------------------------
|
|
208
|
+
const Image = forwardRef<BridgeImageRef, ImageProps>(
|
|
209
|
+
(
|
|
210
|
+
{
|
|
211
|
+
className,
|
|
212
|
+
style,
|
|
213
|
+
source,
|
|
214
|
+
alt = "",
|
|
215
|
+
onError,
|
|
216
|
+
accessibilityLabel, // 웹은 alt가 스크린리더 텍스트 표준
|
|
217
|
+
nativeID,
|
|
218
|
+
testID,
|
|
219
|
+
...rest
|
|
220
|
+
},
|
|
221
|
+
ref,
|
|
222
|
+
) => (
|
|
223
|
+
<img
|
|
224
|
+
ref={ref as React.Ref<HTMLImageElement>}
|
|
225
|
+
id={nativeID}
|
|
226
|
+
data-testid={testID}
|
|
227
|
+
src={source.uri}
|
|
228
|
+
alt={alt || accessibilityLabel}
|
|
229
|
+
loading="lazy"
|
|
230
|
+
className={className}
|
|
231
|
+
style={style as React.CSSProperties}
|
|
232
|
+
onError={onError}
|
|
233
|
+
{...rest}
|
|
234
|
+
/>
|
|
235
|
+
),
|
|
236
|
+
);
|
|
237
|
+
Image.displayName = "Image";
|
|
238
|
+
|
|
239
|
+
// ---------------------------------------------------------------------------
|
|
240
|
+
// 테마 바인딩
|
|
241
|
+
// ---------------------------------------------------------------------------
|
|
242
|
+
/** 색 스킴 → 문서 루트의 .dark 클래스(표준 Tailwind dark 변형 — @custom-variant dark) */
|
|
243
|
+
const applyColorScheme: ApplyColorScheme = (colorScheme) => {
|
|
244
|
+
document.documentElement.classList.toggle("dark", colorScheme === "dark");
|
|
245
|
+
};
|
|
246
|
+
|
|
247
|
+
export { View, Text, Pressable, TextInput, Image, applyColorScheme };
|
|
@@ -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 };
|