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