sh-ui-cli 0.15.0 → 0.21.1

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.
Files changed (163) hide show
  1. package/bin/sh-ui.mjs +6 -0
  2. package/data/changelog/versions.json +366 -0
  3. package/data/registry/flutter/foundation/sh_ui_tokens.dart +385 -0
  4. package/data/registry/flutter/registry.json +336 -0
  5. package/data/registry/flutter/widgets/sh_ui_accordion.dart +255 -0
  6. package/data/registry/flutter/widgets/sh_ui_app_shell.dart +267 -0
  7. package/data/registry/flutter/widgets/sh_ui_avatar.dart +95 -0
  8. package/data/registry/flutter/widgets/sh_ui_badge.dart +82 -0
  9. package/data/registry/flutter/widgets/sh_ui_breadcrumb.dart +107 -0
  10. package/data/registry/flutter/widgets/sh_ui_button.dart +201 -0
  11. package/data/registry/flutter/widgets/sh_ui_card.dart +159 -0
  12. package/data/registry/flutter/widgets/sh_ui_carousel.dart +204 -0
  13. package/data/registry/flutter/widgets/sh_ui_checkbox.dart +154 -0
  14. package/data/registry/flutter/widgets/sh_ui_color_picker.dart +264 -0
  15. package/data/registry/flutter/widgets/sh_ui_combobox.dart +614 -0
  16. package/data/registry/flutter/widgets/sh_ui_context_menu.dart +71 -0
  17. package/data/registry/flutter/widgets/sh_ui_date_picker.dart +648 -0
  18. package/data/registry/flutter/widgets/sh_ui_dialog.dart +567 -0
  19. package/data/registry/flutter/widgets/sh_ui_dropdown_menu.dart +251 -0
  20. package/data/registry/flutter/widgets/sh_ui_file_upload.dart +200 -0
  21. package/data/registry/flutter/widgets/sh_ui_header.dart +488 -0
  22. package/data/registry/flutter/widgets/sh_ui_input.dart +664 -0
  23. package/data/registry/flutter/widgets/sh_ui_label.dart +145 -0
  24. package/data/registry/flutter/widgets/sh_ui_menubar.dart +98 -0
  25. package/data/registry/flutter/widgets/sh_ui_pagination.dart +276 -0
  26. package/data/registry/flutter/widgets/sh_ui_popover.dart +248 -0
  27. package/data/registry/flutter/widgets/sh_ui_progress.dart +47 -0
  28. package/data/registry/flutter/widgets/sh_ui_radio.dart +108 -0
  29. package/data/registry/flutter/widgets/sh_ui_select.dart +904 -0
  30. package/data/registry/flutter/widgets/sh_ui_separator.dart +42 -0
  31. package/data/registry/flutter/widgets/sh_ui_sidebar.dart +1116 -0
  32. package/data/registry/flutter/widgets/sh_ui_skeleton.dart +129 -0
  33. package/data/registry/flutter/widgets/sh_ui_slider.dart +147 -0
  34. package/data/registry/flutter/widgets/sh_ui_spinner.dart +56 -0
  35. package/data/registry/flutter/widgets/sh_ui_switch.dart +109 -0
  36. package/data/registry/flutter/widgets/sh_ui_tabs.dart +329 -0
  37. package/data/registry/flutter/widgets/sh_ui_textarea.dart +126 -0
  38. package/data/registry/flutter/widgets/sh_ui_toast.dart +362 -0
  39. package/data/registry/flutter/widgets/sh_ui_toggle.dart +229 -0
  40. package/data/registry/flutter/widgets/sh_ui_tooltip.dart +62 -0
  41. package/data/registry/react/components/accordion/index.tsx +85 -0
  42. package/data/registry/react/components/accordion/styles.css +94 -0
  43. package/data/registry/react/components/animations/animations.css +51 -0
  44. package/data/registry/react/components/avatar/index.tsx +75 -0
  45. package/data/registry/react/components/avatar/styles.css +36 -0
  46. package/data/registry/react/components/badge/index.tsx +42 -0
  47. package/data/registry/react/components/badge/styles.css +57 -0
  48. package/data/registry/react/components/base/base.css +102 -0
  49. package/data/registry/react/components/breadcrumb/index.tsx +154 -0
  50. package/data/registry/react/components/breadcrumb/styles.css +82 -0
  51. package/data/registry/react/components/breakpoints/breakpoints.css +17 -0
  52. package/data/registry/react/components/button/index.tsx +47 -0
  53. package/data/registry/react/components/button/styles.css +93 -0
  54. package/data/registry/react/components/card/index.tsx +86 -0
  55. package/data/registry/react/components/card/styles.css +73 -0
  56. package/data/registry/react/components/carousel/index.tsx +432 -0
  57. package/data/registry/react/components/carousel/styles.css +155 -0
  58. package/data/registry/react/components/checkbox/index.tsx +98 -0
  59. package/data/registry/react/components/checkbox/styles.css +75 -0
  60. package/data/registry/react/components/code-panel/copy.tsx +56 -0
  61. package/data/registry/react/components/code-panel/index.tsx +193 -0
  62. package/data/registry/react/components/code-panel/styles.css +124 -0
  63. package/data/registry/react/components/color-picker/index.tsx +466 -0
  64. package/data/registry/react/components/color-picker/styles.css +166 -0
  65. package/data/registry/react/components/combobox/index.tsx +167 -0
  66. package/data/registry/react/components/combobox/styles.css +151 -0
  67. package/data/registry/react/components/context-menu/index.tsx +253 -0
  68. package/data/registry/react/components/context-menu/styles.css +140 -0
  69. package/data/registry/react/components/date-picker/index.tsx +757 -0
  70. package/data/registry/react/components/date-picker/styles.css +279 -0
  71. package/data/registry/react/components/dialog/index.tsx +97 -0
  72. package/data/registry/react/components/dialog/styles.css +127 -0
  73. package/data/registry/react/components/dropdown-menu/index.tsx +257 -0
  74. package/data/registry/react/components/dropdown-menu/styles.css +150 -0
  75. package/data/registry/react/components/file-upload/index.tsx +489 -0
  76. package/data/registry/react/components/file-upload/styles.css +170 -0
  77. package/data/registry/react/components/focus-ring/focus-ring.css +23 -0
  78. package/data/registry/react/components/form/context.ts +92 -0
  79. package/data/registry/react/components/form/field.test.tsx +230 -0
  80. package/data/registry/react/components/form/field.tsx +236 -0
  81. package/data/registry/react/components/form/focus-first-error.ts +54 -0
  82. package/data/registry/react/components/form/form.section.test.tsx +58 -0
  83. package/data/registry/react/components/form/form.test.tsx +146 -0
  84. package/data/registry/react/components/form/form.tsx +180 -0
  85. package/data/registry/react/components/form/index.tsx +61 -0
  86. package/data/registry/react/components/form/steps.test.tsx +106 -0
  87. package/data/registry/react/components/form/steps.tsx +193 -0
  88. package/data/registry/react/components/form/store.test.ts +206 -0
  89. package/data/registry/react/components/form/store.ts +318 -0
  90. package/data/registry/react/components/form/styles.css +47 -0
  91. package/data/registry/react/components/form/types.ts +104 -0
  92. package/data/registry/react/components/form/use-sh-ui-form.ts +15 -0
  93. package/data/registry/react/components/form/utils.test.ts +44 -0
  94. package/data/registry/react/components/form/utils.ts +49 -0
  95. package/data/registry/react/components/form/validation.test.ts +67 -0
  96. package/data/registry/react/components/form/validation.ts +64 -0
  97. package/data/registry/react/components/form-rhf/README.md +27 -0
  98. package/data/registry/react/components/form-rhf/index.tsx +289 -0
  99. package/data/registry/react/components/form-rhf/rhf.test.tsx +42 -0
  100. package/data/registry/react/components/form-tanstack/README.md +27 -0
  101. package/data/registry/react/components/form-tanstack/index.tsx +352 -0
  102. package/data/registry/react/components/form-tanstack/tanstack.test.tsx +45 -0
  103. package/data/registry/react/components/form-yup/README.md +22 -0
  104. package/data/registry/react/components/form-yup/index.tsx +50 -0
  105. package/data/registry/react/components/form-yup/yup.test.ts +27 -0
  106. package/data/registry/react/components/header/index.tsx +257 -0
  107. package/data/registry/react/components/header/styles.css +190 -0
  108. package/data/registry/react/components/input/index.tsx +517 -0
  109. package/data/registry/react/components/input/styles.css +203 -0
  110. package/data/registry/react/components/label/index.tsx +54 -0
  111. package/data/registry/react/components/label/styles.css +90 -0
  112. package/data/registry/react/components/menubar/index.tsx +34 -0
  113. package/data/registry/react/components/menubar/styles.css +45 -0
  114. package/data/registry/react/components/pagination/index.tsx +271 -0
  115. package/data/registry/react/components/pagination/styles.css +105 -0
  116. package/data/registry/react/components/popover/index.tsx +115 -0
  117. package/data/registry/react/components/popover/styles.css +65 -0
  118. package/data/registry/react/components/progress/index.tsx +56 -0
  119. package/data/registry/react/components/progress/styles.css +41 -0
  120. package/data/registry/react/components/radio/index.tsx +67 -0
  121. package/data/registry/react/components/radio/styles.css +80 -0
  122. package/data/registry/react/components/select/index.tsx +236 -0
  123. package/data/registry/react/components/select/styles.css +193 -0
  124. package/data/registry/react/components/separator/index.tsx +48 -0
  125. package/data/registry/react/components/separator/styles.css +15 -0
  126. package/data/registry/react/components/sidebar/index.tsx +1084 -0
  127. package/data/registry/react/components/sidebar/styles.css +502 -0
  128. package/data/registry/react/components/skeleton/index.tsx +24 -0
  129. package/data/registry/react/components/skeleton/styles.css +24 -0
  130. package/data/registry/react/components/slider/index.tsx +300 -0
  131. package/data/registry/react/components/slider/styles.css +64 -0
  132. package/data/registry/react/components/spinner/index.tsx +40 -0
  133. package/data/registry/react/components/spinner/styles.css +37 -0
  134. package/data/registry/react/components/switch/index.tsx +41 -0
  135. package/data/registry/react/components/switch/styles.css +83 -0
  136. package/data/registry/react/components/tabs/index.tsx +93 -0
  137. package/data/registry/react/components/tabs/styles.css +148 -0
  138. package/data/registry/react/components/textarea/index.tsx +25 -0
  139. package/data/registry/react/components/textarea/styles.css +54 -0
  140. package/data/registry/react/components/theme/index.tsx +91 -0
  141. package/data/registry/react/components/toast/index.tsx +257 -0
  142. package/data/registry/react/components/toast/styles.css +290 -0
  143. package/data/registry/react/components/toggle/index.tsx +133 -0
  144. package/data/registry/react/components/toggle/styles.css +85 -0
  145. package/data/registry/react/components/tooltip/index.tsx +85 -0
  146. package/data/registry/react/components/tooltip/styles.css +44 -0
  147. package/data/registry/react/components/z-index/z-index.css +16 -0
  148. package/data/registry/react/hooks/use-active-section.ts +104 -0
  149. package/data/registry/react/hooks/use-media-query.ts +27 -0
  150. package/data/registry/react/lib/cn.ts +39 -0
  151. package/data/registry/react/peer-versions.json +10 -0
  152. package/data/registry/react/registry.json +835 -0
  153. package/data/summaries/flutter.json +42 -0
  154. package/data/summaries/react.json +50 -0
  155. package/data/tokens/build.mjs +553 -0
  156. package/data/tokens/src/primitives.json +146 -0
  157. package/data/tokens/src/semantic.json +146 -0
  158. package/package.json +9 -2
  159. package/src/add.mjs +41 -15
  160. package/src/list.mjs +3 -11
  161. package/src/mcp.mjs +308 -0
  162. package/src/paths.mjs +59 -0
  163. package/src/remove.mjs +4 -11
@@ -0,0 +1,489 @@
1
+ "use client";
2
+
3
+ import * as React from "react";
4
+ import "./styles.css";
5
+
6
+ function cx(...args: (string | false | undefined)[]) {
7
+ return args.filter(Boolean).join(" ");
8
+ }
9
+
10
+ function formatBytes(bytes: number): string {
11
+ if (bytes < 1024) return `${bytes} B`;
12
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
13
+ if (bytes < 1024 * 1024 * 1024) return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
14
+ return `${(bytes / 1024 / 1024 / 1024).toFixed(1)} GB`;
15
+ }
16
+
17
+ /* ───────────── context ───────────── */
18
+
19
+ interface FileUploadContextValue {
20
+ files: File[];
21
+ dragging: boolean;
22
+ disabled: boolean;
23
+ multiple: boolean;
24
+ accept?: string;
25
+ id?: string;
26
+ name?: string;
27
+ inputRef: React.RefObject<HTMLInputElement | null>;
28
+ setDragging: (v: boolean) => void;
29
+ addFiles: (incoming: FileList | File[]) => void;
30
+ remove: (idx: number) => void;
31
+ openPicker: () => void;
32
+ }
33
+
34
+ const FileUploadContext = React.createContext<FileUploadContextValue | null>(null);
35
+
36
+ function useFileUpload() {
37
+ const ctx = React.useContext(FileUploadContext);
38
+ if (!ctx) {
39
+ throw new Error(
40
+ "FileUpload 하위 컴포넌트는 <FileUpload> 내부에서만 사용할 수 있습니다.",
41
+ );
42
+ }
43
+ return ctx;
44
+ }
45
+
46
+ /* ───────────── root ───────────── */
47
+
48
+ export interface FileUploadProps {
49
+ /** 제어 모드 파일 배열. 지정 시 순수 제어 컴포넌트로 동작한다. */
50
+ value?: File[];
51
+ /** 비제어 모드 초기값. */
52
+ defaultValue?: File[];
53
+ /** 파일 목록 변경 콜백. 추가/제거/대체 모두 이 콜백으로 통보된다. */
54
+ onValueChange?: (files: File[]) => void;
55
+ /** `onValueChange` 별칭 (compound API 호환). 보통 `onValueChange` 사용 권장. */
56
+ onFiles?: (files: File[]) => void;
57
+ /**
58
+ * 다중 선택 허용. `false`면 새 파일이 기존 파일을 대체한다.
59
+ * @default false
60
+ */
61
+ multiple?: boolean;
62
+ /**
63
+ * 네이티브 `<input accept>`. MIME 또는 확장자.
64
+ * @example "image/*", ".pdf,.docx"
65
+ */
66
+ accept?: string;
67
+ /** 파일당 최대 바이트. 초과 시 `onError`로 알림 후 해당 파일은 거부된다. */
68
+ maxSize?: number;
69
+ /** 총 파일 개수 상한. 초과 시 `onError` 후 잘려서 보관. */
70
+ maxFiles?: number;
71
+ /** 비활성. 클릭/드롭 모두 차단. */
72
+ disabled?: boolean;
73
+ /** 검증 실패(크기·개수 초과) 시 한국어 메시지가 전달된다. 토스트 등에 연결. */
74
+ onError?: (message: string) => void;
75
+ /** 기본 dropzone 중앙 텍스트 커스터마이즈. children 미지정 시에만 적용. */
76
+ placeholder?: React.ReactNode;
77
+ /** 기본 dropzone 하단 힌트. children 미지정 시에만 적용. */
78
+ hint?: React.ReactNode;
79
+ /**
80
+ * 기본 레이아웃에서 파일 목록 노출 여부. children 조립 모드에서는 FileUploadList 존재 여부로 결정.
81
+ * @default true
82
+ */
83
+ showFileList?: boolean;
84
+ className?: string;
85
+ /** 루트 래퍼 div에 적용할 인라인 스타일. */
86
+ style?: React.CSSProperties;
87
+ /** 네이티브 input의 `id`. `<Label htmlFor>`와 연결할 때 사용. */
88
+ id?: string;
89
+ /** 네이티브 input의 `name`. form submit 시 필드명. */
90
+ name?: string;
91
+ /**
92
+ * compound 모드. 미지정 시 기본 dropzone+목록 레이아웃이 자동 렌더된다.
93
+ * 직접 조립하려면 `FileUploadDropzone`/`FileUploadTrigger`/`FileUploadList`/`FileUploadItem`을 자식으로 넘긴다.
94
+ */
95
+ children?: React.ReactNode;
96
+ }
97
+
98
+ /**
99
+ * 파일 선택·드래그앤드롭 업로드. children 없이 쓰면 기본 dropzone+목록 레이아웃이 자동으로 그려지고,
100
+ * 직접 조립하려면 FileUploadDropzone/Trigger/List/Item을 자식으로 사용한다.
101
+ * 파일은 컴포넌트가 보관할 뿐 실제 업로드는 호출 측에서 onValueChange로 받아 처리한다.
102
+ */
103
+ export const FileUpload = React.forwardRef<HTMLInputElement, FileUploadProps>(
104
+ (
105
+ {
106
+ value,
107
+ defaultValue,
108
+ onValueChange,
109
+ onFiles,
110
+ multiple = false,
111
+ accept,
112
+ maxSize,
113
+ maxFiles,
114
+ disabled = false,
115
+ onError,
116
+ placeholder,
117
+ hint,
118
+ showFileList = true,
119
+ className,
120
+ style,
121
+ id,
122
+ name,
123
+ children,
124
+ },
125
+ ref,
126
+ ) => {
127
+ const isControlled = value !== undefined;
128
+ const [internal, setInternal] = React.useState<File[]>(defaultValue ?? []);
129
+ const files = isControlled ? value! : internal;
130
+
131
+ const inputRef = React.useRef<HTMLInputElement>(null);
132
+ React.useImperativeHandle(ref, () => inputRef.current as HTMLInputElement);
133
+ const [dragging, setDragging] = React.useState(false);
134
+
135
+ const update = React.useCallback(
136
+ (next: File[]) => {
137
+ if (!isControlled) setInternal(next);
138
+ onValueChange?.(next);
139
+ onFiles?.(next);
140
+ },
141
+ [isControlled, onValueChange, onFiles],
142
+ );
143
+
144
+ const addFiles = React.useCallback(
145
+ (incoming: FileList | File[]) => {
146
+ const arr = Array.from(incoming);
147
+ const accepted: File[] = [];
148
+ for (const f of arr) {
149
+ if (maxSize && f.size > maxSize) {
150
+ onError?.(
151
+ `${f.name}: 최대 ${formatBytes(maxSize)}까지 업로드 가능합니다.`,
152
+ );
153
+ continue;
154
+ }
155
+ accepted.push(f);
156
+ }
157
+ if (accepted.length === 0) return;
158
+
159
+ let next = multiple
160
+ ? [...files, ...accepted]
161
+ : [accepted[accepted.length - 1]];
162
+ if (maxFiles && next.length > maxFiles) {
163
+ onError?.(`최대 ${maxFiles}개까지 업로드 가능합니다.`);
164
+ next = next.slice(0, maxFiles);
165
+ }
166
+ update(next);
167
+ },
168
+ [files, maxSize, maxFiles, multiple, onError, update],
169
+ );
170
+
171
+ const remove = React.useCallback(
172
+ (idx: number) => {
173
+ update(files.filter((_, i) => i !== idx));
174
+ },
175
+ [files, update],
176
+ );
177
+
178
+ const openPicker = React.useCallback(() => {
179
+ if (disabled) return;
180
+ inputRef.current?.click();
181
+ }, [disabled]);
182
+
183
+ const ctx = React.useMemo<FileUploadContextValue>(
184
+ () => ({
185
+ files,
186
+ dragging,
187
+ disabled,
188
+ multiple,
189
+ accept,
190
+ id,
191
+ name,
192
+ inputRef,
193
+ setDragging,
194
+ addFiles,
195
+ remove,
196
+ openPicker,
197
+ }),
198
+ [files, dragging, disabled, multiple, accept, id, name, addFiles, remove, openPicker],
199
+ );
200
+
201
+ return (
202
+ <FileUploadContext.Provider value={ctx}>
203
+ <div className={cx("sh-ui-file-upload", className)} style={style}>
204
+ {/* 공유 네이티브 input. Trigger/Dropzone 모두 이를 통해 파일 선택을 연다. */}
205
+ <input
206
+ ref={inputRef}
207
+ id={id}
208
+ name={name}
209
+ type="file"
210
+ multiple={multiple}
211
+ accept={accept}
212
+ disabled={disabled}
213
+ className="sh-ui-file-upload__input"
214
+ onChange={(e) => {
215
+ if (e.target.files) addFiles(e.target.files);
216
+ e.target.value = ""; // 동일 파일 재선택 허용
217
+ }}
218
+ />
219
+ {children ?? (
220
+ <DefaultLayout
221
+ placeholder={placeholder}
222
+ hint={hint}
223
+ showFileList={showFileList}
224
+ />
225
+ )}
226
+ </div>
227
+ </FileUploadContext.Provider>
228
+ );
229
+ },
230
+ );
231
+ FileUpload.displayName = "FileUpload";
232
+
233
+ /* ───────────── default layout (backward-compat) ───────────── */
234
+
235
+ function DefaultLayout({
236
+ placeholder,
237
+ hint,
238
+ showFileList,
239
+ }: {
240
+ placeholder?: React.ReactNode;
241
+ hint?: React.ReactNode;
242
+ showFileList: boolean;
243
+ }) {
244
+ const { files } = useFileUpload();
245
+ return (
246
+ <>
247
+ <FileUploadDropzone>
248
+ <UploadIcon />
249
+ <div className="sh-ui-file-upload__text">
250
+ {placeholder ?? (
251
+ <>
252
+ <strong>파일을 드래그</strong>하거나 <strong>클릭해서 선택</strong>
253
+ </>
254
+ )}
255
+ </div>
256
+ {hint && <div className="sh-ui-file-upload__hint">{hint}</div>}
257
+ </FileUploadDropzone>
258
+ {showFileList && files.length > 0 && <FileUploadList />}
259
+ </>
260
+ );
261
+ }
262
+
263
+ /* ───────────── parts ───────────── */
264
+
265
+ export interface FileUploadDropzoneProps
266
+ extends Omit<React.HTMLAttributes<HTMLDivElement>, "onDrop" | "onDragOver" | "onDragLeave"> {
267
+ children?: React.ReactNode;
268
+ }
269
+
270
+ /**
271
+ * 파일을 드롭하거나 클릭해 파일 선택창을 여는 영역. 키보드 Enter/Space로도 동작.
272
+ */
273
+ export const FileUploadDropzone = React.forwardRef<
274
+ HTMLDivElement,
275
+ FileUploadDropzoneProps
276
+ >(function FileUploadDropzone({ className, children, onClick, ...rest }, ref) {
277
+ const { dragging, disabled, setDragging, addFiles, openPicker } =
278
+ useFileUpload();
279
+ return (
280
+ <div
281
+ ref={ref}
282
+ role="button"
283
+ tabIndex={disabled ? -1 : 0}
284
+ aria-disabled={disabled || undefined}
285
+ data-dragging={dragging || undefined}
286
+ className={cx(
287
+ "sh-ui-file-upload__dropzone",
288
+ dragging && "sh-ui-file-upload__dropzone--drag",
289
+ disabled && "sh-ui-file-upload__dropzone--disabled",
290
+ className,
291
+ )}
292
+ onClick={(e) => {
293
+ onClick?.(e);
294
+ if (!e.defaultPrevented) openPicker();
295
+ }}
296
+ onKeyDown={(e) => {
297
+ if (disabled) return;
298
+ if (e.key === "Enter" || e.key === " ") {
299
+ e.preventDefault();
300
+ openPicker();
301
+ }
302
+ }}
303
+ onDragOver={(e) => {
304
+ e.preventDefault();
305
+ if (!disabled) setDragging(true);
306
+ }}
307
+ onDragLeave={() => setDragging(false)}
308
+ onDrop={(e) => {
309
+ e.preventDefault();
310
+ setDragging(false);
311
+ if (disabled) return;
312
+ addFiles(e.dataTransfer.files);
313
+ }}
314
+ {...rest}
315
+ >
316
+ {children}
317
+ </div>
318
+ );
319
+ });
320
+
321
+ export interface FileUploadTriggerProps
322
+ extends React.ButtonHTMLAttributes<HTMLButtonElement> {}
323
+
324
+ /** 파일 선택창을 여는 버튼. Dropzone 내부에 두면 클릭 버블링이 자동 차단된다. */
325
+ export const FileUploadTrigger = React.forwardRef<
326
+ HTMLButtonElement,
327
+ FileUploadTriggerProps
328
+ >(function FileUploadTrigger({ className, onClick, children, type, ...rest }, ref) {
329
+ const { disabled, openPicker } = useFileUpload();
330
+ return (
331
+ <button
332
+ ref={ref}
333
+ type={type ?? "button"}
334
+ disabled={disabled || rest.disabled}
335
+ className={cx("sh-ui-file-upload__trigger", className)}
336
+ onClick={(e) => {
337
+ // Dropzone 내부에 있을 때 상위 onClick이 중복 트리거되지 않도록 버블 차단
338
+ e.stopPropagation();
339
+ onClick?.(e);
340
+ if (!e.defaultPrevented) openPicker();
341
+ }}
342
+ {...rest}
343
+ >
344
+ {children}
345
+ </button>
346
+ );
347
+ });
348
+
349
+ export interface FileUploadListProps
350
+ extends Omit<React.HTMLAttributes<HTMLUListElement>, "children"> {
351
+ /**
352
+ * 직접 노드를 넘기거나, 함수를 넘기면 현재 files와 remove 함수를 받아 직접 렌더할 수 있다.
353
+ * 미지정 시 파일당 `FileUploadItem`이 자동 렌더된다.
354
+ *
355
+ * @example
356
+ * <FileUploadList>
357
+ * {({ files, remove }) => files.map((f, i) => (
358
+ * <li key={i}>{f.name} <button onClick={() => remove(i)}>x</button></li>
359
+ * ))}
360
+ * </FileUploadList>
361
+ */
362
+ children?:
363
+ | React.ReactNode
364
+ | ((args: {
365
+ /** 현재 보관 중인 파일 목록. */
366
+ files: File[];
367
+ /** 인덱스 기반 파일 제거. */
368
+ remove: (idx: number) => void;
369
+ }) => React.ReactNode);
370
+ }
371
+
372
+ /**
373
+ * 선택된 파일 목록(`<ul>`). children에 함수를 넘기면 files·remove를 받아 직접 렌더할 수 있고,
374
+ * 생략하면 파일당 FileUploadItem이 자동 렌더된다.
375
+ */
376
+ export const FileUploadList = React.forwardRef<
377
+ HTMLUListElement,
378
+ FileUploadListProps
379
+ >(function FileUploadList({ className, children, ...rest }, ref) {
380
+ const { files, remove } = useFileUpload();
381
+ if (files.length === 0) return null;
382
+
383
+ const content =
384
+ typeof children === "function"
385
+ ? children({ files, remove })
386
+ : (children ??
387
+ files.map((f, i) => (
388
+ <FileUploadItem key={`${f.name}-${i}`} file={f} index={i} />
389
+ )));
390
+
391
+ return (
392
+ <ul
393
+ ref={ref}
394
+ className={cx("sh-ui-file-upload__list", className)}
395
+ {...rest}
396
+ >
397
+ {content}
398
+ </ul>
399
+ );
400
+ });
401
+
402
+ export interface FileUploadItemProps
403
+ extends Omit<React.LiHTMLAttributes<HTMLLIElement>, "children"> {
404
+ /** 표시할 파일 객체. */
405
+ file: File;
406
+ /** files 배열 내 index. 내부 remove 버튼이 사용한다. */
407
+ index: number;
408
+ /** 미지정 시 기본 레이아웃(아이콘 + 이름 + 크기 + 제거 버튼). */
409
+ children?: React.ReactNode;
410
+ }
411
+
412
+ /** 파일 목록의 한 항목. 기본 레이아웃은 아이콘 + 이름 + 크기 + 제거 버튼이다. */
413
+ export const FileUploadItem = React.forwardRef<
414
+ HTMLLIElement,
415
+ FileUploadItemProps
416
+ >(function FileUploadItem({ file, index, className, children, ...rest }, ref) {
417
+ const { disabled, remove } = useFileUpload();
418
+ return (
419
+ <li
420
+ ref={ref}
421
+ className={cx("sh-ui-file-upload__item", className)}
422
+ {...rest}
423
+ >
424
+ {children ?? (
425
+ <>
426
+ <FileIcon />
427
+ <span className="sh-ui-file-upload__name" title={file.name}>
428
+ {file.name}
429
+ </span>
430
+ <span className="sh-ui-file-upload__size">
431
+ {formatBytes(file.size)}
432
+ </span>
433
+ <button
434
+ type="button"
435
+ className="sh-ui-file-upload__remove"
436
+ onClick={() => remove(index)}
437
+ disabled={disabled}
438
+ aria-label={`${file.name} 제거`}
439
+ >
440
+ <XIcon />
441
+ </button>
442
+ </>
443
+ )}
444
+ </li>
445
+ );
446
+ });
447
+
448
+ /* ───────────── icons ───────────── */
449
+
450
+ function UploadIcon() {
451
+ return (
452
+ <svg viewBox="0 0 24 24" width="28" height="28" fill="none" aria-hidden>
453
+ <path
454
+ d="M12 16V4m0 0l-4 4m4-4l4 4M4 16v2a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-2"
455
+ stroke="currentColor"
456
+ strokeWidth="1.5"
457
+ strokeLinecap="round"
458
+ strokeLinejoin="round"
459
+ />
460
+ </svg>
461
+ );
462
+ }
463
+
464
+ function FileIcon() {
465
+ return (
466
+ <svg viewBox="0 0 20 20" width="16" height="16" fill="none" aria-hidden>
467
+ <path
468
+ d="M5 2.5h6l4 4v9a1.5 1.5 0 0 1-1.5 1.5h-8.5A1.5 1.5 0 0 1 3.5 15.5v-11A1.5 1.5 0 0 1 5 3Z"
469
+ stroke="currentColor"
470
+ strokeWidth="1.5"
471
+ strokeLinejoin="round"
472
+ />
473
+ <path d="M11 2.5v4h4" stroke="currentColor" strokeWidth="1.5" strokeLinejoin="round" />
474
+ </svg>
475
+ );
476
+ }
477
+
478
+ function XIcon() {
479
+ return (
480
+ <svg viewBox="0 0 16 16" width="14" height="14" fill="none" aria-hidden>
481
+ <path
482
+ d="M4 4l8 8m0-8l-8 8"
483
+ stroke="currentColor"
484
+ strokeWidth="1.5"
485
+ strokeLinecap="round"
486
+ />
487
+ </svg>
488
+ );
489
+ }
@@ -0,0 +1,170 @@
1
+ .sh-ui-file-upload {
2
+ display: flex;
3
+ flex-direction: column;
4
+ gap: var(--space-3);
5
+ }
6
+
7
+ .sh-ui-file-upload__dropzone {
8
+ position: relative;
9
+ display: flex;
10
+ flex-direction: column;
11
+ align-items: center;
12
+ justify-content: center;
13
+ gap: var(--space-2);
14
+ padding: var(--space-8) var(--space-6);
15
+ min-height: 10rem;
16
+ background: var(--background-subtle);
17
+ color: var(--foreground-muted);
18
+ border: 1.5px dashed var(--border);
19
+ border-radius: var(--radius);
20
+ cursor: pointer;
21
+ text-align: center;
22
+ transition: border-color var(--duration-fast), background-color var(--duration-fast), color var(--duration-fast);
23
+ -webkit-tap-highlight-color: transparent;
24
+ }
25
+
26
+ .sh-ui-file-upload__dropzone:hover {
27
+ border-color: var(--border-strong);
28
+ color: var(--foreground);
29
+ }
30
+
31
+ .sh-ui-file-upload__dropzone:focus-visible {
32
+ outline: var(--border-width-strong) solid var(--foreground);
33
+ outline-offset: 2px;
34
+ border-color: var(--foreground);
35
+ }
36
+
37
+ .sh-ui-file-upload__dropzone--drag {
38
+ border-color: var(--foreground);
39
+ background: var(--background-muted);
40
+ color: var(--foreground);
41
+ }
42
+
43
+ .sh-ui-file-upload__dropzone--disabled {
44
+ opacity: var(--opacity-disabled);
45
+ cursor: not-allowed;
46
+ pointer-events: none;
47
+ }
48
+
49
+ /* 네이티브 input을 시각적으로 숨기되 접근 가능하게 유지 */
50
+ .sh-ui-file-upload__input {
51
+ position: absolute;
52
+ width: 1px;
53
+ height: 1px;
54
+ padding: 0;
55
+ margin: -1px;
56
+ overflow: hidden;
57
+ clip: rect(0 0 0 0);
58
+ white-space: nowrap;
59
+ border: 0;
60
+ }
61
+
62
+ .sh-ui-file-upload__text {
63
+ font-size: var(--text-sm);
64
+ color: var(--foreground);
65
+ }
66
+ .sh-ui-file-upload__text strong {
67
+ font-weight: var(--weight-semibold);
68
+ }
69
+
70
+ .sh-ui-file-upload__hint {
71
+ font-size: var(--text-xs);
72
+ color: var(--foreground-muted);
73
+ }
74
+
75
+ /* Trigger 버튼 */
76
+ .sh-ui-file-upload__trigger {
77
+ display: inline-flex;
78
+ align-items: center;
79
+ justify-content: center;
80
+ gap: var(--space-2);
81
+ padding: var(--space-2) var(--space-3);
82
+ font-size: var(--text-sm);
83
+ font-weight: var(--weight-medium);
84
+ color: var(--foreground);
85
+ background: var(--background);
86
+ border: 1px solid var(--border);
87
+ border-radius: calc(var(--radius) - 2px);
88
+ cursor: pointer;
89
+ transition: background-color var(--duration-fast), border-color var(--duration-fast);
90
+ }
91
+ .sh-ui-file-upload__trigger:hover:not(:disabled) {
92
+ background: var(--background-muted);
93
+ border-color: var(--border-strong);
94
+ }
95
+ .sh-ui-file-upload__trigger:focus-visible {
96
+ outline: var(--border-width-strong) solid var(--foreground);
97
+ outline-offset: 2px;
98
+ }
99
+ .sh-ui-file-upload__trigger:disabled {
100
+ opacity: var(--opacity-disabled);
101
+ cursor: not-allowed;
102
+ }
103
+
104
+ /* 파일 목록 */
105
+ .sh-ui-file-upload__list {
106
+ list-style: none;
107
+ margin: 0;
108
+ padding: 0;
109
+ display: flex;
110
+ flex-direction: column;
111
+ gap: 0.375rem;
112
+ }
113
+
114
+ .sh-ui-file-upload__item {
115
+ display: flex;
116
+ align-items: center;
117
+ gap: 0.625rem;
118
+ padding: var(--space-2) var(--space-3);
119
+ background: var(--background);
120
+ border: 1px solid var(--border);
121
+ border-radius: calc(var(--radius) - 2px);
122
+ font-size: var(--text-sm);
123
+ color: var(--foreground);
124
+ }
125
+
126
+ .sh-ui-file-upload__item > svg {
127
+ color: var(--foreground-muted);
128
+ flex-shrink: 0;
129
+ }
130
+
131
+ .sh-ui-file-upload__name {
132
+ flex: 1 1 auto;
133
+ overflow: hidden;
134
+ text-overflow: ellipsis;
135
+ white-space: nowrap;
136
+ }
137
+
138
+ .sh-ui-file-upload__size {
139
+ font-size: var(--text-xs);
140
+ color: var(--foreground-muted);
141
+ flex-shrink: 0;
142
+ }
143
+
144
+ .sh-ui-file-upload__remove {
145
+ display: inline-flex;
146
+ align-items: center;
147
+ justify-content: center;
148
+ width: 1.5rem;
149
+ height: 1.5rem;
150
+ padding: 0;
151
+ background: transparent;
152
+ border: none;
153
+ border-radius: calc(var(--radius) - 4px);
154
+ color: var(--foreground-muted);
155
+ cursor: pointer;
156
+ transition: color var(--duration-fast), background-color var(--duration-fast);
157
+ flex-shrink: 0;
158
+ }
159
+ .sh-ui-file-upload__remove:hover:not(:disabled) {
160
+ color: var(--foreground);
161
+ background: var(--background-muted);
162
+ }
163
+ .sh-ui-file-upload__remove:focus-visible {
164
+ outline: var(--border-width-strong) solid var(--foreground);
165
+ outline-offset: 2px;
166
+ }
167
+ .sh-ui-file-upload__remove:disabled {
168
+ opacity: var(--opacity-disabled);
169
+ cursor: not-allowed;
170
+ }
@@ -0,0 +1,23 @@
1
+ /* sh-ui focus ring — 모든 인터랙티브 요소에 일관된 키보드 포커스 스타일.
2
+ *
3
+ * 사용 예:
4
+ * .my-button:focus-visible { @apply ... } // (Tailwind 미사용 시 직접 outline 지정)
5
+ * 또는 .sh-ui-focus 클래스를 컴포넌트에 부착.
6
+ */
7
+
8
+ :root {
9
+ --focus-ring-color: var(--foreground);
10
+ --focus-ring-width: 2px;
11
+ --focus-ring-offset: 2px;
12
+ }
13
+
14
+ .sh-ui-focus:focus-visible,
15
+ [data-focus-ring]:focus-visible {
16
+ outline: var(--focus-ring-width) solid var(--focus-ring-color);
17
+ outline-offset: var(--focus-ring-offset);
18
+ }
19
+
20
+ /* 음수 offset이 필요한 경우(예: 메뉴 아이템처럼 부모 안쪽으로) */
21
+ .sh-ui-focus--inset:focus-visible {
22
+ outline-offset: calc(-1 * var(--focus-ring-offset));
23
+ }