@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 CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  One typed component set that **renders React Native views on native and plain DOM on web** — the same `className` strings, the same look, no platform branches in your code.
4
4
 
5
- - **Native (iOS/Android)** — components render React Native primitives styled by [uniwind](https://uniwind.dev) (Tailwind CSS v4 bindings, 2× faster than NativeWind).
5
+ - **Native (iOS/Android)** — components render React Native primitives styled by [uniwind](https://uniwind.dev) (Tailwind CSS v4 bindings).
6
6
  - **Web (React DOM)** — the very same components render `div`/`span`/`button`/`input` styled by **standard Tailwind CSS v4**. No react-native-web, no extra styling engine: your Tailwind CSS is the engine.
7
7
  - **Design tokens as a single source of truth** — light/dark themes are CSS variables generated from one `tokens.ts`; switching schemes is instant on both platforms (`UnikitThemeProvider`).
8
8
  - **shadcn-style variants** — built with `class-variance-authority`; `cn()` (clsx + tailwind-merge) is exported for custom styling.
package/docs/tokens.md CHANGED
@@ -21,7 +21,7 @@
21
21
  | `--muted-foreground` | `240 3.8% 46.1%` | `240 5% 64.9%` | 강조를 낮춘 텍스트(설명문) | `text-muted-foreground` |
22
22
  | `--accent` | `240 4.8% 95.9%` | `240 3.7% 15.9%` | 호버·눌림 강조 배경(outline/ghost active) | `active:bg-accent` |
23
23
  | `--accent-foreground` | `240 5.9% 10%` | `0 0% 98%` | 강조 배경 위 텍스트 | `active:text-accent-foreground` |
24
- | `--destructive` | `0 72.2% 50.6%` | `0 62.8% 30.6%` | 위험·삭제 행동 색 | `bg-destructive` |
24
+ | `--destructive` | `0 72.2% 50.6%` | `0 72% 65%` | 위험·삭제 행동 색 | `bg-destructive` |
25
25
  | `--destructive-foreground` | `0 0% 98%` | `0 0% 98%` | 위험 행동 위 텍스트 | `text-destructive-foreground` |
26
26
  | `--border` | `240 5.9% 90%` | `240 3.7% 15.9%` | 일반 테두리(card outline 등) | `border-border` |
27
27
  | `--input` | `240 5.9% 90%` | `240 3.7% 15.9%` | 입력창 테두리 | `border-input` |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@unikit-bridge/ui",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Bridge UI kit — one component set that renders React Native views on native (uniwind) and plain DOM on web (Tailwind CSS v4). Same className, same look.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -11,23 +11,30 @@
11
11
  */
12
12
  import { forwardRef, type Ref } from "react";
13
13
  import {
14
+ Image as RNImage,
14
15
  Pressable as RNPressable,
15
16
  Text as RNText,
16
17
  TextInput as RNTextInput,
17
18
  View as RNView,
19
+ type ImageInstance,
20
+ type ImageStyle,
18
21
  type PressableInstance,
19
22
  type TextInstance,
23
+ type TextStyle,
20
24
  type TextInputInstance,
21
25
  type ViewInstance,
26
+ type ViewStyle,
22
27
  } from "react-native";
23
28
  import { Uniwind } from "uniwind";
24
29
 
25
30
  import type {
26
31
  ApplyColorScheme,
32
+ BridgeImageRef,
27
33
  BridgePressableRef,
28
34
  BridgeTextRef,
29
35
  BridgeTextInputRef,
30
36
  BridgeViewRef,
37
+ ImageProps,
31
38
  PressableProps,
32
39
  TextProps,
33
40
  TextInputProps,
@@ -41,12 +48,30 @@ export type * from "./types";
41
48
  // View — RN View 재노출(타입만 브리지 계약으로 좁힌다)
42
49
  // ---------------------------------------------------------------------------
43
50
  const View = forwardRef<BridgeViewRef, ViewProps>(
44
- ({ className, children, nativeID, testID, ...rest }, ref) => (
51
+ (
52
+ {
53
+ className,
54
+ style,
55
+ children,
56
+ nativeID,
57
+ testID,
58
+ accessibilityRole,
59
+ accessibilityLabel,
60
+ "aria-hidden": ariaHidden,
61
+ ...rest
62
+ },
63
+ ref,
64
+ ) => (
45
65
  <RNView
46
66
  ref={ref as Ref<ViewInstance>}
47
67
  nativeID={nativeID}
48
68
  testID={testID}
69
+ accessibilityRole={accessibilityRole}
70
+ accessibilityLabel={accessibilityLabel}
71
+ aria-hidden={ariaHidden}
72
+ accessibilityElementsHidden={ariaHidden}
49
73
  className={className}
74
+ style={style as ViewStyle}
50
75
  {...rest}
51
76
  >
52
77
  {children}
@@ -59,13 +84,29 @@ View.displayName = "View";
59
84
  // Text — RN Text 재노출
60
85
  // ---------------------------------------------------------------------------
61
86
  const Text = forwardRef<BridgeTextRef, TextProps>(
62
- ({ className, children, nativeID, testID, onPress, ...rest }, ref) => (
87
+ (
88
+ {
89
+ className,
90
+ style,
91
+ children,
92
+ nativeID,
93
+ testID,
94
+ onPress,
95
+ accessibilityRole,
96
+ accessibilityLabel,
97
+ ...rest
98
+ },
99
+ ref,
100
+ ) => (
63
101
  <RNText
64
102
  ref={ref as Ref<TextInstance>}
65
103
  nativeID={nativeID}
66
104
  testID={testID}
67
105
  onPress={onPress}
106
+ accessibilityRole={accessibilityRole}
107
+ accessibilityLabel={accessibilityLabel}
68
108
  className={className}
109
+ style={style as TextStyle}
69
110
  {...rest}
70
111
  >
71
112
  {children}
@@ -78,15 +119,47 @@ Text.displayName = "Text";
78
119
  // Pressable — RN Pressable 재노출. onPress는 RN 시그니처(이벤트 인자)를
79
120
  // 무인자로 좁혀 브리지 계약과 맞춘다(컴포넌트·데모는 무인자 콜백만 쓴다).
80
121
  // ---------------------------------------------------------------------------
122
+ // aria-* 상태 프로퍼티는 RN 0.77+ 네이티브 관용 별칭으로 그대로 전달한다.
123
+ // react-native-web(Expo web 등)은 accessibilityState를 0.21부터 폐기하고
124
+ // aria-* 직접 전달만 DOM 속성으로 렌더하므로, 양쪽 모두 aria-* 경로가 정답.
125
+ // 네이티브 실기기에서도 동일 값을 accessibilityState로 명시 전달해 구버전
126
+ // 스크린리더 호환을 유지한다(코어는 aria-* 별칭을 같은 상태로 병합).
127
+ function toAccessibilityState(props: {
128
+ disabled?: boolean;
129
+ "aria-checked"?: boolean | "mixed";
130
+ "aria-selected"?: boolean;
131
+ "aria-expanded"?: boolean;
132
+ }) {
133
+ const state: {
134
+ disabled?: boolean;
135
+ checked?: boolean | "mixed";
136
+ selected?: boolean;
137
+ expanded?: boolean;
138
+ } = {};
139
+ if (props.disabled !== undefined) state.disabled = props.disabled;
140
+ if (props["aria-checked"] !== undefined) state.checked = props["aria-checked"];
141
+ if (props["aria-selected"] !== undefined) state.selected = props["aria-selected"];
142
+ if (props["aria-expanded"] !== undefined) state.expanded = props["aria-expanded"];
143
+ return Object.keys(state).length > 0 ? state : undefined;
144
+ }
145
+
81
146
  const Pressable = forwardRef<BridgePressableRef, PressableProps>(
82
147
  (
83
148
  {
84
149
  className,
150
+ style,
85
151
  children,
86
152
  onPress,
87
153
  disabled,
88
154
  accessibilityLabel,
89
155
  accessibilityRole,
156
+ "aria-disabled": ariaDisabled,
157
+ "aria-pressed": ariaPressed,
158
+ "aria-checked": ariaChecked,
159
+ "aria-selected": ariaSelected,
160
+ "aria-expanded": ariaExpanded,
161
+ "aria-controls": ariaControls,
162
+ "aria-labelledby": ariaLabelledBy,
90
163
  hitSlop,
91
164
  nativeID,
92
165
  testID,
@@ -95,15 +168,35 @@ const Pressable = forwardRef<BridgePressableRef, PressableProps>(
95
168
  ref,
96
169
  ) => (
97
170
  <RNPressable
98
- ref={ref as Ref<PressableInstance>}
171
+ // BridgePressableRef(구조적 최소 계약) ↔ RN PressableInstance 불일치를
172
+ // unknown 경유 이중 단언으로만 해소 — any 사용 금지(M-3).
173
+ ref={ref as unknown as Ref<PressableInstance>}
99
174
  nativeID={nativeID}
100
175
  testID={testID}
101
176
  onPress={onPress ? () => onPress() : undefined}
102
177
  disabled={disabled}
103
178
  accessibilityLabel={accessibilityLabel}
104
179
  accessibilityRole={accessibilityRole}
180
+ aria-disabled={ariaDisabled}
181
+ aria-checked={ariaChecked}
182
+ aria-selected={ariaSelected}
183
+ aria-expanded={ariaExpanded}
184
+ aria-labelledby={ariaLabelledBy}
185
+ accessibilityState={toAccessibilityState({
186
+ disabled,
187
+ "aria-checked": ariaChecked,
188
+ "aria-selected": ariaSelected,
189
+ "aria-expanded": ariaExpanded,
190
+ })}
191
+ // aria-pressed(toggle)·aria-controls(tab↔panel 연결)은 RN 코어 타입에
192
+ // 없는 RNW 전용 속성 — 무형 스프레드로 전달(네이티브에서는 폐기됨).
193
+ {...({
194
+ "aria-pressed": ariaPressed,
195
+ "aria-controls": ariaControls,
196
+ } as Record<string, unknown>)}
105
197
  hitSlop={hitSlop}
106
198
  className={className}
199
+ style={style as ViewStyle}
107
200
  {...rest}
108
201
  >
109
202
  {children}
@@ -120,6 +213,7 @@ const TextInput = forwardRef<BridgeTextInputRef, TextInputProps>(
120
213
  (
121
214
  {
122
215
  className,
216
+ style,
123
217
  value,
124
218
  defaultValue,
125
219
  placeholder,
@@ -132,6 +226,7 @@ const TextInput = forwardRef<BridgeTextInputRef, TextInputProps>(
132
226
  autoFocus,
133
227
  secureTextEntry,
134
228
  inputMode,
229
+ multiline,
135
230
  ...rest
136
231
  },
137
232
  ref,
@@ -142,6 +237,7 @@ const TextInput = forwardRef<BridgeTextInputRef, TextInputProps>(
142
237
  testID={testID}
143
238
  accessibilityLabelledBy={accessibilityLabelledBy}
144
239
  className={className}
240
+ style={style as TextStyle}
145
241
  value={value}
146
242
  defaultValue={defaultValue}
147
243
  placeholder={placeholder}
@@ -150,12 +246,47 @@ const TextInput = forwardRef<BridgeTextInputRef, TextInputProps>(
150
246
  autoFocus={autoFocus}
151
247
  secureTextEntry={secureTextEntry}
152
248
  inputMode={inputMode}
249
+ multiline={multiline}
153
250
  {...rest}
154
251
  />
155
252
  ),
156
253
  );
157
254
  TextInput.displayName = "TextInput";
158
255
 
256
+ // ---------------------------------------------------------------------------
257
+ // Image — RN Image 재노출. 원격 {uri} 소스만 브리지 공통 계약으로 한다
258
+ // (require() 정적 소스는 네이티브 전용 기능이므로 타입에서 제외).
259
+ // ---------------------------------------------------------------------------
260
+ const Image = forwardRef<BridgeImageRef, ImageProps>(
261
+ (
262
+ {
263
+ className,
264
+ style,
265
+ source,
266
+ alt: _alt, // 웹 관용 — 네이티브는 accessibilityLabel이 스크린리더 텍스트
267
+ onError,
268
+ accessibilityLabel,
269
+ nativeID,
270
+ testID,
271
+ ...rest
272
+ },
273
+ ref,
274
+ ) => (
275
+ <RNImage
276
+ ref={ref as Ref<ImageInstance>}
277
+ nativeID={nativeID}
278
+ testID={testID}
279
+ accessibilityLabel={accessibilityLabel}
280
+ source={source}
281
+ className={className}
282
+ style={style as ImageStyle}
283
+ onError={onError ? () => onError() : undefined}
284
+ {...rest}
285
+ />
286
+ ),
287
+ );
288
+ Image.displayName = "Image";
289
+
159
290
  // ---------------------------------------------------------------------------
160
291
  // 테마 바인딩
161
292
  // ---------------------------------------------------------------------------
@@ -167,4 +298,4 @@ const applyColorScheme: ApplyColorScheme = (colorScheme) => {
167
298
  Uniwind.setTheme(colorScheme);
168
299
  };
169
300
 
170
- export { View, Text, Pressable, TextInput, applyColorScheme };
301
+ export { View, Text, Pressable, TextInput, Image, applyColorScheme };
@@ -15,43 +15,118 @@ import type { ReactNode } from "react";
15
15
 
16
16
  export type BridgeColorScheme = "light" | "dark";
17
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
+
18
54
  // ---------------------------------------------------------------------------
19
55
  // Props
20
56
  // ---------------------------------------------------------------------------
21
57
 
22
58
  export type ViewProps = {
23
59
  className?: string;
60
+ /** 동적 값 전용 인라인 스타일(정적 스타일은 className 원칙) */
61
+ style?: BridgeStyle;
24
62
  children?: ReactNode;
25
63
  /** 네이티브 nativeID — 웹에서는 id로 전달된다 */
26
64
  nativeID?: string;
27
65
  testID?: string;
66
+ accessibilityRole?: BridgeRole;
67
+ accessibilityLabel?: string;
68
+ /** 장식용 요소 표시 — 웹 aria-hidden, 네이티브 accessibilityElementsHidden */
69
+ "aria-hidden"?: boolean;
28
70
  };
29
71
 
30
72
  export type TextProps = {
31
73
  className?: string;
74
+ /** 동적 값 전용 인라인 스타일(정적 스타일은 className 원칙) */
75
+ style?: BridgeStyle;
32
76
  children?: ReactNode;
33
77
  nativeID?: string;
34
78
  testID?: string;
35
79
  onPress?: () => void;
80
+ accessibilityRole?: BridgeRole;
81
+ accessibilityLabel?: string;
36
82
  };
37
83
 
38
84
  export type PressableProps = {
39
85
  className?: string;
86
+ /** 동적 값 전용 인라인 스타일(정적 스타일은 className 원칙) */
87
+ style?: BridgeStyle;
40
88
  children?: ReactNode;
41
89
  onPress?: () => void;
42
90
  disabled?: boolean;
43
91
  accessibilityLabel?: string;
44
- accessibilityRole?: "button" | "link" | "search" | "image" | "text";
92
+ accessibilityRole?: BridgeRole;
45
93
  /** RN 0.7x+ 관용 — 웹 DOM에서도 동일 속성으로 전달된다 */
46
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;
47
105
  /** RN 전용 터치 타깃 확장(px) — 웹에서는 무시 */
48
106
  hitSlop?: number | { top: number; bottom: number; left: number; right: number };
49
107
  nativeID?: string;
50
108
  testID?: string;
51
109
  };
52
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
+
53
126
  export type TextInputProps = {
54
127
  className?: string;
128
+ /** 동적 값 전용 인라인 스타일(정적 스타일은 className 원칙) */
129
+ style?: BridgeStyle;
55
130
  value?: string;
56
131
  defaultValue?: string;
57
132
  placeholder?: string;
@@ -69,6 +144,8 @@ export type TextInputProps = {
69
144
  autoFocus?: boolean;
70
145
  secureTextEntry?: boolean;
71
146
  inputMode?: "text" | "tel" | "url" | "email" | "numeric" | "decimal" | "search";
147
+ /** 여러 줄 입력 — 네이티브 RN multiline, 웹은 <input> 대신 <textarea>로 렌더 */
148
+ multiline?: boolean;
72
149
  };
73
150
 
74
151
  // ---------------------------------------------------------------------------
@@ -79,6 +156,7 @@ export type TextInputProps = {
79
156
  export interface BridgeViewRef {}
80
157
  export interface BridgeTextRef {}
81
158
  export interface BridgePressableRef {}
159
+ export interface BridgeImageRef {}
82
160
  export interface BridgeTextInputRef {
83
161
  focus(): void;
84
162
  blur(): void;
@@ -6,9 +6,11 @@
6
6
  * 적용한다(hover/focus-visible/:active 등 웹 전용 변형도 CSS 표준 동작).
7
7
  *
8
8
  * RN 프리미티브 관용 프로퍼티는 DOM 속성으로 치환된다:
9
- * onPress→onClick · accessibilityLabel→aria-label · nativeID→id ·
10
- * accessibilityLabelledBy→aria-labelledby · editable→readOnly ·
9
+ * onPress→onClick · accessibilityRole→role · accessibilityLabel→aria-label ·
10
+ * nativeID→id · accessibilityLabelledBy→aria-labelledby · editable→readOnly ·
11
11
  * secureTextEntry→type="password" · onChangeText→onChange(value 추출)
12
+ * 치환은 View/Text/Pressable 전 프리미티브에 동일 적용된다(M-1 — React DOM은
13
+ * camelCase 미지 prop을 렌더에서 제거하므로 치환 없이는 role·라벨이 사라진다).
12
14
  * hitSlop은 웹에서 무시된다(포인터 정밀도가 높아 불필요).
13
15
  */
14
16
  import { forwardRef, type ChangeEvent } from "react";
@@ -16,10 +18,13 @@ import { forwardRef, type ChangeEvent } from "react";
16
18
  import { cn } from "../lib/utils";
17
19
  import type {
18
20
  ApplyColorScheme,
21
+ BridgeImageRef,
19
22
  BridgePressableRef,
23
+ BridgeStyle,
20
24
  BridgeTextRef,
21
25
  BridgeTextInputRef,
22
26
  BridgeViewRef,
27
+ ImageProps,
23
28
  PressableProps,
24
29
  TextProps,
25
30
  TextInputProps,
@@ -34,12 +39,18 @@ export type * from "./types";
34
39
  // 기본 클래스로 재현한다. 충돌 시 tailwind-merge(cn)이 방향 유틸리티를 정리한다.
35
40
  // ---------------------------------------------------------------------------
36
41
  const View = forwardRef<BridgeViewRef, ViewProps>(
37
- ({ className, children, nativeID, testID, ...rest }, ref) => (
42
+ (
43
+ { className, style, children, nativeID, testID, accessibilityLabel, accessibilityRole, ...rest },
44
+ ref,
45
+ ) => (
38
46
  <div
39
47
  ref={ref as React.Ref<HTMLDivElement>}
40
48
  id={nativeID}
41
49
  data-testid={testID}
50
+ role={accessibilityRole}
51
+ aria-label={accessibilityLabel}
42
52
  className={cn("flex flex-col", className)}
53
+ style={style as React.CSSProperties}
43
54
  {...rest}
44
55
  >
45
56
  {children}
@@ -52,12 +63,18 @@ View.displayName = "View";
52
63
  // Text → <span>
53
64
  // ---------------------------------------------------------------------------
54
65
  const Text = forwardRef<BridgeTextRef, TextProps>(
55
- ({ className, children, nativeID, testID, onPress, ...rest }, ref) => (
66
+ (
67
+ { className, style, children, nativeID, testID, onPress, accessibilityLabel, accessibilityRole, ...rest },
68
+ ref,
69
+ ) => (
56
70
  <span
57
71
  ref={ref as React.Ref<HTMLSpanElement>}
58
72
  id={nativeID}
59
73
  data-testid={testID}
74
+ role={accessibilityRole}
75
+ aria-label={accessibilityLabel}
60
76
  className={className}
77
+ style={style as React.CSSProperties}
61
78
  onClick={onPress}
62
79
  {...rest}
63
80
  >
@@ -76,6 +93,7 @@ const Pressable = forwardRef<BridgePressableRef, PressableProps>(
76
93
  (
77
94
  {
78
95
  className,
96
+ style,
79
97
  children,
80
98
  onPress,
81
99
  disabled,
@@ -94,6 +112,7 @@ const Pressable = forwardRef<BridgePressableRef, PressableProps>(
94
112
  id={nativeID}
95
113
  data-testid={testID}
96
114
  className={className}
115
+ style={style as React.CSSProperties}
97
116
  onClick={onPress}
98
117
  disabled={disabled}
99
118
  aria-label={accessibilityLabel}
@@ -113,6 +132,7 @@ const TextInput = forwardRef<BridgeTextInputRef, TextInputProps>(
113
132
  (
114
133
  {
115
134
  className,
135
+ style,
116
136
  value,
117
137
  defaultValue,
118
138
  placeholder,
@@ -125,13 +145,38 @@ const TextInput = forwardRef<BridgeTextInputRef, TextInputProps>(
125
145
  autoFocus,
126
146
  secureTextEntry,
127
147
  inputMode,
148
+ multiline,
128
149
  ...rest
129
150
  },
130
151
  ref,
131
152
  ) => {
132
- const handleChange = (event: ChangeEvent<HTMLInputElement>) => {
153
+ const handleChange = (
154
+ event: ChangeEvent<HTMLInputElement | HTMLTextAreaElement>,
155
+ ) => {
133
156
  onChangeText?.(event.target.value);
134
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
+ }
135
180
  return (
136
181
  <input
137
182
  ref={ref as React.Ref<HTMLInputElement>}
@@ -139,6 +184,7 @@ const TextInput = forwardRef<BridgeTextInputRef, TextInputProps>(
139
184
  data-testid={testID}
140
185
  aria-labelledby={accessibilityLabelledBy}
141
186
  className={className}
187
+ style={style as React.CSSProperties}
142
188
  value={value}
143
189
  defaultValue={defaultValue}
144
190
  placeholder={placeholder}
@@ -155,6 +201,41 @@ const TextInput = forwardRef<BridgeTextInputRef, TextInputProps>(
155
201
  );
156
202
  TextInput.displayName = "TextInput";
157
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
+
158
239
  // ---------------------------------------------------------------------------
159
240
  // 테마 바인딩
160
241
  // ---------------------------------------------------------------------------
@@ -163,4 +244,4 @@ const applyColorScheme: ApplyColorScheme = (colorScheme) => {
163
244
  document.documentElement.classList.toggle("dark", colorScheme === "dark");
164
245
  };
165
246
 
166
- export { View, Text, Pressable, TextInput, applyColorScheme };
247
+ export { View, Text, Pressable, TextInput, Image, applyColorScheme };