sh-ui-cli 0.52.0 → 0.52.2

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 (89) hide show
  1. package/data/changelog/versions.json +25 -0
  2. package/data/registry/react/components/_smoke/vanilla-extract.test.ts +33 -0
  3. package/data/registry/react/components/input/styles.css.ts +6 -6
  4. package/data/registry/react/registry.json +35 -852
  5. package/package.json +2 -2
  6. package/src/api.d.ts +3 -4
  7. package/src/constants.js +9 -5
  8. package/src/create/plugins/pluginSchema.js +5 -3
  9. package/src/mcp.mjs +4 -3
  10. package/data/registry/react/components/accordion/index.vanilla-extract.tsx +0 -97
  11. package/data/registry/react/components/accordion/styles.css.ts +0 -131
  12. package/data/registry/react/components/avatar/index.vanilla-extract.tsx +0 -73
  13. package/data/registry/react/components/avatar/styles.css.ts +0 -68
  14. package/data/registry/react/components/badge/index.vanilla-extract.tsx +0 -40
  15. package/data/registry/react/components/badge/styles.css.ts +0 -71
  16. package/data/registry/react/components/breadcrumb/index.vanilla-extract.tsx +0 -152
  17. package/data/registry/react/components/breadcrumb/styles.css.ts +0 -95
  18. package/data/registry/react/components/calendar/index.vanilla-extract.tsx +0 -806
  19. package/data/registry/react/components/calendar/styles.css.ts +0 -250
  20. package/data/registry/react/components/carousel/index.vanilla-extract.tsx +0 -430
  21. package/data/registry/react/components/carousel/styles.css.ts +0 -169
  22. package/data/registry/react/components/checkbox/index.vanilla-extract.tsx +0 -96
  23. package/data/registry/react/components/checkbox/styles.css.ts +0 -74
  24. package/data/registry/react/components/code-editor/index.vanilla-extract.tsx +0 -230
  25. package/data/registry/react/components/code-editor/styles.css.ts +0 -97
  26. package/data/registry/react/components/code-panel/index.vanilla-extract.tsx +0 -191
  27. package/data/registry/react/components/code-panel/styles.css.ts +0 -151
  28. package/data/registry/react/components/color-picker/index.vanilla-extract.tsx +0 -467
  29. package/data/registry/react/components/color-picker/styles.css.ts +0 -169
  30. package/data/registry/react/components/combobox/index.vanilla-extract.tsx +0 -165
  31. package/data/registry/react/components/combobox/styles.css.ts +0 -174
  32. package/data/registry/react/components/context-menu/index.vanilla-extract.tsx +0 -251
  33. package/data/registry/react/components/context-menu/styles.css.ts +0 -167
  34. package/data/registry/react/components/date-picker/index.vanilla-extract.tsx +0 -520
  35. package/data/registry/react/components/date-picker/styles.css.ts +0 -111
  36. package/data/registry/react/components/dialog/index.vanilla-extract.tsx +0 -95
  37. package/data/registry/react/components/dialog/styles.css.ts +0 -140
  38. package/data/registry/react/components/dropdown-menu/index.vanilla-extract.tsx +0 -255
  39. package/data/registry/react/components/dropdown-menu/styles.css.ts +0 -175
  40. package/data/registry/react/components/file-upload/index.vanilla-extract.tsx +0 -487
  41. package/data/registry/react/components/file-upload/styles.css.ts +0 -193
  42. package/data/registry/react/components/form/index.vanilla-extract.tsx +0 -61
  43. package/data/registry/react/components/form/styles.css.ts +0 -56
  44. package/data/registry/react/components/header/index.vanilla-extract.tsx +0 -805
  45. package/data/registry/react/components/header/styles.css.ts +0 -413
  46. package/data/registry/react/components/label/index.vanilla-extract.tsx +0 -52
  47. package/data/registry/react/components/label/styles.css.ts +0 -141
  48. package/data/registry/react/components/markdown-editor/index.vanilla-extract.tsx +0 -119
  49. package/data/registry/react/components/markdown-editor/styles.css.ts +0 -231
  50. package/data/registry/react/components/menubar/index.vanilla-extract.tsx +0 -32
  51. package/data/registry/react/components/menubar/styles.css.ts +0 -53
  52. package/data/registry/react/components/numeric-input/index.vanilla-extract.tsx +0 -148
  53. package/data/registry/react/components/numeric-input/styles.css.ts +0 -65
  54. package/data/registry/react/components/page-toc/index.vanilla-extract.tsx +0 -174
  55. package/data/registry/react/components/page-toc/styles.css.ts +0 -97
  56. package/data/registry/react/components/pagination/index.vanilla-extract.tsx +0 -269
  57. package/data/registry/react/components/pagination/styles.css.ts +0 -113
  58. package/data/registry/react/components/popover/index.vanilla-extract.tsx +0 -113
  59. package/data/registry/react/components/popover/styles.css.ts +0 -78
  60. package/data/registry/react/components/progress/index.vanilla-extract.tsx +0 -54
  61. package/data/registry/react/components/progress/styles.css.ts +0 -53
  62. package/data/registry/react/components/radio/index.vanilla-extract.tsx +0 -65
  63. package/data/registry/react/components/radio/styles.css.ts +0 -79
  64. package/data/registry/react/components/rich-text-editor/index.vanilla-extract.tsx +0 -348
  65. package/data/registry/react/components/rich-text-editor/styles.css.ts +0 -243
  66. package/data/registry/react/components/select/index.vanilla-extract.tsx +0 -234
  67. package/data/registry/react/components/select/styles.css.ts +0 -225
  68. package/data/registry/react/components/separator/index.vanilla-extract.tsx +0 -46
  69. package/data/registry/react/components/separator/styles.css.ts +0 -24
  70. package/data/registry/react/components/sidebar/index.vanilla-extract.tsx +0 -1067
  71. package/data/registry/react/components/sidebar/styles.css.ts +0 -578
  72. package/data/registry/react/components/skeleton/index.vanilla-extract.tsx +0 -22
  73. package/data/registry/react/components/skeleton/styles.css.ts +0 -30
  74. package/data/registry/react/components/slider/index.vanilla-extract.tsx +0 -298
  75. package/data/registry/react/components/slider/styles.css.ts +0 -75
  76. package/data/registry/react/components/spinner/index.vanilla-extract.tsx +0 -38
  77. package/data/registry/react/components/spinner/styles.css.ts +0 -60
  78. package/data/registry/react/components/switch/index.vanilla-extract.tsx +0 -39
  79. package/data/registry/react/components/switch/styles.css.ts +0 -87
  80. package/data/registry/react/components/tabs/index.vanilla-extract.tsx +0 -91
  81. package/data/registry/react/components/tabs/styles.css.ts +0 -145
  82. package/data/registry/react/components/textarea/index.vanilla-extract.tsx +0 -23
  83. package/data/registry/react/components/textarea/styles.css.ts +0 -55
  84. package/data/registry/react/components/toast/index.vanilla-extract.tsx +0 -258
  85. package/data/registry/react/components/toast/styles.css.ts +0 -307
  86. package/data/registry/react/components/toggle/index.vanilla-extract.tsx +0 -131
  87. package/data/registry/react/components/toggle/styles.css.ts +0 -109
  88. package/data/registry/react/components/tooltip/index.vanilla-extract.tsx +0 -83
  89. package/data/registry/react/components/tooltip/styles.css.ts +0 -59
@@ -1,175 +0,0 @@
1
- import { style, keyframes } from "@vanilla-extract/css";
2
-
3
- export const shUiDmIn = keyframes({
4
- "from": {
5
- opacity: 0,
6
- transform: "scale(0.96)",
7
- },
8
- "to": {
9
- opacity: 1,
10
- transform: "scale(1)",
11
- },
12
- });
13
-
14
- export const shUiDmOut = keyframes({
15
- "from": {
16
- opacity: 1,
17
- transform: "scale(1)",
18
- },
19
- "to": {
20
- opacity: 0,
21
- transform: "scale(0.96)",
22
- },
23
- });
24
-
25
- export const dm__trigger = style({
26
- font: "inherit",
27
- cursor: "pointer",
28
- WebkitTapHighlightColor: "transparent",
29
- selectors: {
30
- "&:focus-visible": {
31
- outline: "var(--border-width-strong) solid var(--foreground)",
32
- outlineOffset: "2px",
33
- },
34
- },
35
- });
36
-
37
- export const dm__positioner = style({
38
- outline: "none",
39
- zIndex: "var(--z-dropdown)",
40
- });
41
-
42
- export const dm__content = style({
43
- minWidth: "10rem",
44
- maxHeight: "min(24rem, var(--available-height, 24rem))",
45
- overflowY: "auto",
46
- padding: "var(--space-1)",
47
- background: "var(--background)",
48
- color: "var(--foreground)",
49
- border: "1px solid var(--border)",
50
- borderRadius: "var(--radius)",
51
- boxShadow: "0 4px 6px -1px rgba(0, 0, 0, 0.08),\n 0 2px 4px -2px rgba(0, 0, 0, 0.05)",
52
- fontSize: "var(--text-sm)",
53
- transformOrigin: "var(--transform-origin)",
54
- animation: "sh-ui-dm-in 140ms ease-out",
55
- outline: "none",
56
- selectors: {
57
- "&[data-ending-style]": {
58
- animation: "sh-ui-dm-out 100ms ease-in forwards",
59
- },
60
- },
61
- "@media": {
62
- "(prefers-reduced-motion: reduce)": {
63
- animation: "none",
64
- selectors: {
65
- "&[data-ending-style]": {
66
- animation: "none",
67
- },
68
- },
69
- },
70
- },
71
- });
72
-
73
- export const dm__item = style({
74
- position: "relative",
75
- display: "flex",
76
- alignItems: "center",
77
- gap: "var(--space-2)",
78
- padding: "0.5rem 0.75rem",
79
- borderRadius: "calc(var(--radius) - 2px)",
80
- cursor: "pointer",
81
- outline: "none",
82
- userSelect: "none",
83
- transition: "background-color 80ms",
84
- selectors: {
85
- "&[data-highlighted]": {
86
- background: "var(--background-muted)",
87
- },
88
- "&:hover": {
89
- background: "var(--background-muted)",
90
- },
91
- "&[data-disabled]": {
92
- opacity: "var(--opacity-disabled)",
93
- pointerEvents: "none",
94
- },
95
- },
96
- "@media": {
97
- "(prefers-reduced-motion: reduce)": {
98
- transition: "none",
99
- },
100
- },
101
- });
102
-
103
- export const dmItemText = style({
104
- flex: 1,
105
- minWidth: 0,
106
- overflow: "hidden",
107
- textOverflow: "ellipsis",
108
- whiteSpace: "nowrap",
109
- });
110
-
111
- export const dmItemCheck = style({
112
- paddingLeft: "1.75rem",
113
- });
114
-
115
- export const dmItemIndicator = style({
116
- position: "absolute",
117
- left: "0.5rem",
118
- display: "inline-flex",
119
- alignItems: "center",
120
- justifyContent: "center",
121
- width: "1rem",
122
- height: "1rem",
123
- color: "var(--foreground)",
124
- });
125
-
126
- export const dm__group = style({
127
- padding: 0,
128
- });
129
-
130
- export const dm__label = style({
131
- padding: "var(--space-2) var(--space-2) var(--space-1)",
132
- fontSize: "var(--text-xs)",
133
- fontWeight: "var(--weight-semibold)",
134
- color: "var(--foreground-muted)",
135
- textTransform: "uppercase",
136
- letterSpacing: "0.04em",
137
- });
138
-
139
- export const dm__separator = style({
140
- height: "1px",
141
- background: "var(--border)",
142
- margin: "var(--space-1) 0",
143
- });
144
-
145
- export const dmSubArrow = style({
146
- display: "inline-flex",
147
- alignItems: "center",
148
- justifyContent: "center",
149
- marginLeft: "auto",
150
- color: "var(--foreground-muted)",
151
- });
152
-
153
- export const dmSubTrigger = style({
154
- selectors: {
155
- "&[data-popup-open]": {
156
- background: "var(--background-muted)",
157
- },
158
- },
159
- });
160
-
161
- /** 동적 키로 클래스 참조용 — `byKey[\`badge--${variant}\`]` 같은 패턴 지원. */
162
- export const byKey: Record<string, string> = {
163
- "dm__trigger": dm__trigger,
164
- "dm__positioner": dm__positioner,
165
- "dm__content": dm__content,
166
- "dm__item": dm__item,
167
- "dm__item-text": dmItemText,
168
- "dm__item--check": dmItemCheck,
169
- "dm__item-indicator": dmItemIndicator,
170
- "dm__group": dm__group,
171
- "dm__label": dm__label,
172
- "dm__separator": dm__separator,
173
- "dm__sub-arrow": dmSubArrow,
174
- "dm__sub-trigger": dmSubTrigger,
175
- };
@@ -1,487 +0,0 @@
1
- "use client";
2
-
3
- import * as React from "react";
4
- import { byKey, fileUpload, fileUploadDropzone, fileUploadDropzoneDrag, fileUploadDropzoneDisabled, fileUploadInput, fileUploadText, fileUploadHint, fileUploadTrigger, fileUploadList, fileUploadItem, fileUploadName, fileUploadSize, fileUploadRemove } from "./styles.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(fileUpload, 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={fileUploadInput}
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={fileUploadText}>
248
- {placeholder ?? (
249
- <>
250
- <strong>파일을 드래그</strong>하거나 <strong>클릭해서 선택</strong>
251
- </>
252
- )}
253
- </div>
254
- {hint && <div className={fileUploadHint}>{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
- fileUploadDropzone,
286
- dragging && fileUploadDropzoneDrag,
287
- disabled && fileUploadDropzoneDisabled,
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(fileUploadTrigger, 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(fileUploadList, 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(fileUploadItem, className)}
420
- {...rest}
421
- >
422
- {children ?? (
423
- <>
424
- <FileIcon />
425
- <span className={fileUploadName} title={file.name}>
426
- {file.name}
427
- </span>
428
- <span className={fileUploadSize}>
429
- {formatBytes(file.size)}
430
- </span>
431
- <button
432
- type="button"
433
- className={fileUploadRemove}
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
- }