@thangnqdev/ui 0.2.0 → 0.2.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 (190) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +144 -142
  3. package/bin/cli.js +125 -0
  4. package/dist/bento/index.cjs.map +1 -1
  5. package/dist/bento/index.js +2 -2
  6. package/dist/buttons/index.cjs.map +1 -1
  7. package/dist/buttons/index.js +3 -3
  8. package/dist/{chunk-77KTYOMQ.js → chunk-2OXZK6IP.js} +3 -3
  9. package/dist/chunk-2OXZK6IP.js.map +1 -0
  10. package/dist/{chunk-4CRFNALF.js → chunk-4GNFT2ZJ.js} +7 -7
  11. package/dist/chunk-4GNFT2ZJ.js.map +1 -0
  12. package/dist/{chunk-2YJBW4E2.js → chunk-4S6L5V7X.js} +2 -2
  13. package/dist/chunk-4S6L5V7X.js.map +1 -0
  14. package/dist/{chunk-I2FQBMVW.js → chunk-66VWUJEI.js} +3 -3
  15. package/dist/chunk-66VWUJEI.js.map +1 -0
  16. package/dist/{chunk-YOCGBORV.js → chunk-7NMB2Y64.js} +4 -4
  17. package/dist/chunk-7NMB2Y64.js.map +1 -0
  18. package/dist/{chunk-V52JOSDF.js → chunk-A7P3O5Z5.js} +3 -3
  19. package/dist/chunk-A7P3O5Z5.js.map +1 -0
  20. package/dist/{chunk-ME4XILG5.js → chunk-ATYGDTMK.js} +5 -5
  21. package/dist/chunk-ATYGDTMK.js.map +1 -0
  22. package/dist/{chunk-S6XJMYME.js → chunk-C4SUXMI3.js} +3 -3
  23. package/dist/chunk-C4SUXMI3.js.map +1 -0
  24. package/dist/{chunk-EXFR2BFJ.js → chunk-DUPPPGXY.js} +7 -7
  25. package/dist/chunk-DUPPPGXY.js.map +1 -0
  26. package/dist/{chunk-I5O2WCGF.js → chunk-EU2LPUYP.js} +3 -3
  27. package/dist/chunk-EU2LPUYP.js.map +1 -0
  28. package/dist/{chunk-D7FB3TCA.js → chunk-GXI2H5XA.js} +3 -3
  29. package/dist/chunk-GXI2H5XA.js.map +1 -0
  30. package/dist/{chunk-ADO3SVUA.js → chunk-IABPUCAQ.js} +2 -2
  31. package/dist/chunk-IABPUCAQ.js.map +1 -0
  32. package/dist/{chunk-PC5SZFZC.js → chunk-JOKEKOP3.js} +8 -8
  33. package/dist/chunk-JOKEKOP3.js.map +1 -0
  34. package/dist/{chunk-GXWB6BSY.js → chunk-KUQWUTZA.js} +4 -4
  35. package/dist/chunk-KUQWUTZA.js.map +1 -0
  36. package/dist/{chunk-ZEDXARXW.js → chunk-U3J4UPV3.js} +2 -2
  37. package/dist/chunk-U3J4UPV3.js.map +1 -0
  38. package/dist/{chunk-DSEL7ELZ.js → chunk-VE2PNSDQ.js} +4 -4
  39. package/dist/chunk-VE2PNSDQ.js.map +1 -0
  40. package/dist/{chunk-YJ64RI7J.js → chunk-WAFHC4DQ.js} +3 -3
  41. package/dist/chunk-WAFHC4DQ.js.map +1 -0
  42. package/dist/{chunk-W5WNWTQC.js → chunk-YJ67WYVW.js} +5 -5
  43. package/dist/chunk-YJ67WYVW.js.map +1 -0
  44. package/dist/{chunk-4MS6ZHFQ.js → chunk-Z427NPMU.js} +7 -7
  45. package/dist/chunk-Z427NPMU.js.map +1 -0
  46. package/dist/core/index.cjs +4 -4
  47. package/dist/core/index.cjs.map +1 -1
  48. package/dist/core/index.js +2 -2
  49. package/dist/data-display/index.cjs.map +1 -1
  50. package/dist/data-display/index.js +5 -5
  51. package/dist/feedback/index.cjs.map +1 -1
  52. package/dist/feedback/index.js +2 -2
  53. package/dist/forms/index.cjs.map +1 -1
  54. package/dist/forms/index.js +8 -8
  55. package/dist/glass/index.cjs.map +1 -1
  56. package/dist/glass/index.js +2 -2
  57. package/dist/index.cjs +4 -4
  58. package/dist/index.cjs.map +1 -1
  59. package/dist/index.js +19 -19
  60. package/dist/layout/index.cjs.map +1 -1
  61. package/dist/layout/index.js +2 -2
  62. package/dist/navigation/index.cjs.map +1 -1
  63. package/dist/navigation/index.js +6 -6
  64. package/dist/overlays/index.cjs.map +1 -1
  65. package/dist/overlays/index.js +7 -7
  66. package/dist/reset.css +45 -45
  67. package/dist/tokens.css +11 -11
  68. package/dist/typography/index.cjs.map +1 -1
  69. package/dist/typography/index.js +4 -4
  70. package/llms.txt +3 -3
  71. package/package.json +166 -159
  72. package/scripts/postinstall.js +125 -0
  73. package/src/bento/BentoCard.tsx +140 -140
  74. package/src/bento/BentoGrid.tsx +50 -50
  75. package/src/bento/index.ts +2 -2
  76. package/src/bento/tokens.css +46 -46
  77. package/src/buttons/Button.tsx +104 -104
  78. package/src/buttons/ButtonGroup.tsx +43 -43
  79. package/src/buttons/IconButton.tsx +79 -79
  80. package/src/buttons/SplitButton.tsx +186 -186
  81. package/src/buttons/ToggleButton.tsx +157 -157
  82. package/src/buttons/index.ts +5 -5
  83. package/src/buttons/tokens.css +6 -6
  84. package/src/core/ThemeProvider.tsx +133 -133
  85. package/src/core/index.ts +2 -2
  86. package/src/core/tokens.css +134 -134
  87. package/src/data-display/Accordion.tsx +147 -147
  88. package/src/data-display/Avatar.tsx +134 -134
  89. package/src/data-display/Card.tsx +112 -112
  90. package/src/data-display/Chip.tsx +74 -74
  91. package/src/data-display/DataTablePro.tsx +534 -534
  92. package/src/data-display/StatCard.tsx +60 -60
  93. package/src/data-display/Table.tsx +74 -74
  94. package/src/data-display/Timeline.tsx +62 -62
  95. package/src/data-display/TreeView.tsx +279 -279
  96. package/src/data-display/tokens.css +4 -4
  97. package/src/feedback/Alert.tsx +80 -80
  98. package/src/feedback/EmptyState.tsx +45 -45
  99. package/src/feedback/Skeleton.tsx +66 -66
  100. package/src/feedback/Spinner.tsx +65 -65
  101. package/src/feedback/index.ts +5 -5
  102. package/src/feedback/tokens.css +4 -4
  103. package/src/forms/Checkbox.tsx +149 -149
  104. package/src/forms/CheckboxGroup.tsx +76 -76
  105. package/src/forms/Combobox.tsx +512 -512
  106. package/src/forms/DatePicker.tsx +784 -784
  107. package/src/forms/FileUpload.tsx +266 -266
  108. package/src/forms/FormField.tsx +115 -115
  109. package/src/forms/Input.tsx +151 -151
  110. package/src/forms/NumberInput.tsx +262 -262
  111. package/src/forms/PasswordInput.tsx +35 -35
  112. package/src/forms/PinInput.tsx +139 -139
  113. package/src/forms/Radio.tsx +142 -142
  114. package/src/forms/RangeSlider.tsx +237 -237
  115. package/src/forms/SearchInput.tsx +34 -34
  116. package/src/forms/Select.tsx +103 -103
  117. package/src/forms/Slider.tsx +85 -85
  118. package/src/forms/Textarea.tsx +114 -114
  119. package/src/forms/tokens.css +6 -6
  120. package/src/glass/GlassButton.tsx +59 -59
  121. package/src/glass/GlassCard.tsx +71 -71
  122. package/src/glass/index.ts +3 -3
  123. package/src/glass/tokens.css +81 -81
  124. package/src/index.ts +26 -26
  125. package/src/layout/AspectRatio.tsx +39 -39
  126. package/src/layout/Container.tsx +53 -53
  127. package/src/layout/Divider.tsx +61 -61
  128. package/src/layout/Flex.tsx +79 -79
  129. package/src/layout/Grid.tsx +124 -124
  130. package/src/layout/PageLayout.tsx +59 -59
  131. package/src/layout/Section.tsx +47 -47
  132. package/src/layout/Spacer.tsx +30 -30
  133. package/src/layout/Stack.tsx +88 -88
  134. package/src/layout/tokens.css +8 -8
  135. package/src/navigation/Breadcrumb.tsx +73 -73
  136. package/src/navigation/CommandPalette.tsx +142 -142
  137. package/src/navigation/DropdownMenu.tsx +244 -244
  138. package/src/navigation/Pagination.tsx +114 -114
  139. package/src/navigation/Stepper.tsx +93 -93
  140. package/src/navigation/Tabs.tsx +265 -265
  141. package/src/navigation/index.ts +6 -6
  142. package/src/navigation/tokens.css +4 -4
  143. package/src/overlays/AlertDialog.tsx +63 -63
  144. package/src/overlays/BottomSheet.tsx +143 -143
  145. package/src/overlays/ContextMenu.tsx +161 -161
  146. package/src/overlays/Drawer.tsx +160 -160
  147. package/src/overlays/FocusTrap.tsx +122 -122
  148. package/src/overlays/Modal.tsx +296 -296
  149. package/src/overlays/Popover.tsx +156 -156
  150. package/src/overlays/Portal.tsx +23 -23
  151. package/src/overlays/Toast.tsx +175 -175
  152. package/src/overlays/Tooltip.tsx +125 -125
  153. package/src/overlays/tokens.css +8 -8
  154. package/src/registry/components.ts +1368 -1368
  155. package/src/styles/components.css +39 -39
  156. package/src/styles/index.css +5 -5
  157. package/src/styles/reset.css +45 -45
  158. package/src/styles/tokens.css +11 -11
  159. package/src/typography/Blockquote.tsx +32 -32
  160. package/src/typography/Code.tsx +89 -89
  161. package/src/typography/Heading.tsx +56 -56
  162. package/src/typography/Kbd.tsx +25 -25
  163. package/src/typography/Label.tsx +47 -47
  164. package/src/typography/Link.tsx +56 -56
  165. package/src/typography/Text.tsx +62 -62
  166. package/src/typography/index.ts +7 -7
  167. package/src/typography/tokens.css +5 -5
  168. package/src/utils/cn.ts +6 -6
  169. package/src/utils/scroll-lock.ts +19 -19
  170. package/src/utils/useControllableState.ts +54 -54
  171. package/ui-contract.json +1 -1
  172. package/dist/chunk-2YJBW4E2.js.map +0 -1
  173. package/dist/chunk-4CRFNALF.js.map +0 -1
  174. package/dist/chunk-4MS6ZHFQ.js.map +0 -1
  175. package/dist/chunk-77KTYOMQ.js.map +0 -1
  176. package/dist/chunk-ADO3SVUA.js.map +0 -1
  177. package/dist/chunk-D7FB3TCA.js.map +0 -1
  178. package/dist/chunk-DSEL7ELZ.js.map +0 -1
  179. package/dist/chunk-EXFR2BFJ.js.map +0 -1
  180. package/dist/chunk-GXWB6BSY.js.map +0 -1
  181. package/dist/chunk-I2FQBMVW.js.map +0 -1
  182. package/dist/chunk-I5O2WCGF.js.map +0 -1
  183. package/dist/chunk-ME4XILG5.js.map +0 -1
  184. package/dist/chunk-PC5SZFZC.js.map +0 -1
  185. package/dist/chunk-S6XJMYME.js.map +0 -1
  186. package/dist/chunk-V52JOSDF.js.map +0 -1
  187. package/dist/chunk-W5WNWTQC.js.map +0 -1
  188. package/dist/chunk-YJ64RI7J.js.map +0 -1
  189. package/dist/chunk-YOCGBORV.js.map +0 -1
  190. package/dist/chunk-ZEDXARXW.js.map +0 -1
@@ -1,266 +1,266 @@
1
- import React, { forwardRef, useState, useRef, useEffect } from 'react';
2
- import { UploadCloud, File, FileText, Image as ImageIcon, X, CheckCircle2, AlertCircle } from 'lucide-react';
3
- import { cn } from '../utils/cn';
4
- import { ProgressBar } from '../feedback/ProgressBar';
5
- import { Button } from '../buttons/Button';
6
-
7
- export interface UploadedFile {
8
- id: string;
9
- file: File;
10
- name: string;
11
- size: number;
12
- type: string;
13
- progress?: number;
14
- status?: 'pending' | 'uploading' | 'completed' | 'error';
15
- previewUrl?: string;
16
- errorMessage?: string;
17
- }
18
-
19
- export interface FileUploadProps {
20
- accept?: string;
21
- maxSizeMB?: number;
22
- multiple?: boolean;
23
- maxFiles?: number;
24
- disabled?: boolean;
25
- onFilesSelected?: (files: File[]) => void;
26
- onFileRemove?: (fileId: string) => void;
27
- className?: string;
28
- label?: string;
29
- description?: string;
30
- }
31
-
32
- export function formatFileSize(bytes: number): string {
33
- if (bytes === 0) return '0 B';
34
- const k = 1024;
35
- const sizes = ['B', 'KB', 'MB', 'GB'];
36
- const i = Math.floor(Math.log(bytes) / Math.log(k));
37
- return `${parseFloat((bytes / Math.pow(k, i)).toFixed(1))} ${sizes[i]}`;
38
- }
39
-
40
- export const FileUpload = forwardRef<HTMLDivElement, FileUploadProps>(
41
- (
42
- {
43
- accept,
44
- maxSizeMB = 10,
45
- multiple = true,
46
- maxFiles = 5,
47
- disabled = false,
48
- onFilesSelected,
49
- onFileRemove,
50
- className,
51
- label = 'Drag & drop files here, or browse',
52
- description = 'Supports images, documents, and media up to 10MB',
53
- },
54
- ref
55
- ) => {
56
- const [isDragOver, setIsDragOver] = useState(false);
57
- const [fileList, setFileList] = useState<UploadedFile[]>([]);
58
- const inputRef = useRef<HTMLInputElement | null>(null);
59
- const fileListRef = useRef<UploadedFile[]>(fileList);
60
- fileListRef.current = fileList;
61
-
62
- // Cleanup object URLs on unmount
63
- useEffect(() => {
64
- return () => {
65
- fileListRef.current.forEach((item) => {
66
- if (item.previewUrl) {
67
- URL.revokeObjectURL(item.previewUrl);
68
- }
69
- });
70
- };
71
- }, []);
72
-
73
- const matchesAccept = (file: File, acceptStr?: string): boolean => {
74
- if (!acceptStr) return true;
75
- const patterns = acceptStr.split(',').map((p) => p.trim().toLowerCase());
76
- const fileName = file.name.toLowerCase();
77
- const fileType = file.type.toLowerCase();
78
-
79
- return patterns.some((p) => {
80
- if (p.startsWith('.')) {
81
- return fileName.endsWith(p);
82
- }
83
- if (p.endsWith('/*')) {
84
- const baseType = p.replace('/*', '');
85
- return fileType.startsWith(baseType);
86
- }
87
- return fileType === p;
88
- });
89
- };
90
-
91
- const processFiles = (incoming: FileList | null) => {
92
- if (!incoming) return;
93
-
94
- const validFiles: File[] = [];
95
- const newUploaded: UploadedFile[] = [];
96
-
97
- Array.from(incoming).forEach((file) => {
98
- if (fileList.length + newUploaded.length >= maxFiles) return;
99
-
100
- const isAccepted = matchesAccept(file, accept);
101
- const isOversized = file.size > maxSizeMB * 1024 * 1024;
102
- const hasError = !isAccepted || isOversized;
103
-
104
- let errorMsg: string | undefined;
105
- if (!isAccepted) errorMsg = `File type not supported (${accept})`;
106
- else if (isOversized) errorMsg = `File exceeds ${maxSizeMB}MB limit`;
107
-
108
- const fileObj: UploadedFile = {
109
- id: `${file.name}-${Date.now()}-${Math.random()}`,
110
- file,
111
- name: file.name,
112
- size: file.size,
113
- type: file.type,
114
- progress: hasError ? 0 : 100,
115
- status: hasError ? 'error' : 'completed',
116
- errorMessage: errorMsg,
117
- previewUrl: file.type.startsWith('image/') && !hasError ? URL.createObjectURL(file) : undefined,
118
- };
119
-
120
- newUploaded.push(fileObj);
121
- if (!hasError) validFiles.push(file);
122
- });
123
-
124
- setFileList((prev) => [...prev, ...newUploaded]);
125
- if (validFiles.length > 0) {
126
- onFilesSelected?.(validFiles);
127
- }
128
- };
129
-
130
- const handleDragOver = (e: React.DragEvent) => {
131
- e.preventDefault();
132
- if (!disabled) setIsDragOver(true);
133
- };
134
-
135
- const handleDragLeave = (e: React.DragEvent) => {
136
- e.preventDefault();
137
- setIsDragOver(false);
138
- };
139
-
140
- const handleDrop = (e: React.DragEvent) => {
141
- e.preventDefault();
142
- setIsDragOver(false);
143
- if (disabled) return;
144
- processFiles(e.dataTransfer.files);
145
- };
146
-
147
- const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
148
- processFiles(e.target.files);
149
- if (inputRef.current) inputRef.current.value = '';
150
- };
151
-
152
- const removeFile = (id: string) => {
153
- setFileList((prev) => {
154
- const item = prev.find((f) => f.id === id);
155
- if (item?.previewUrl) {
156
- URL.revokeObjectURL(item.previewUrl);
157
- }
158
- return prev.filter((f) => f.id !== id);
159
- });
160
- onFileRemove?.(id);
161
- };
162
-
163
- return (
164
- <div ref={ref} className={cn('w-full space-y-4', className)}>
165
- {/* Dropzone Box */}
166
- <div
167
- onDragOver={handleDragOver}
168
- onDragLeave={handleDragLeave}
169
- onDrop={handleDrop}
170
- onClick={() => !disabled && inputRef.current?.click()}
171
- className={cn(
172
- 'relative rounded-2xl p-6 sm:p-8 text-center border-2 border-dashed transition-all cursor-pointer flex flex-col items-center justify-center gap-3 select-none',
173
- disabled
174
- ? 'opacity-50 cursor-not-allowed border-[var(--ui-border-subtle)] bg-[var(--ui-bg-secondary)]/30'
175
- : isDragOver
176
- ? 'border-[var(--ui-primary)] bg-[var(--ui-primary)]/10 shadow-lg shadow-blue-500/20 scale-[0.99]'
177
- : 'border-[var(--ui-border-default)] hover:border-[var(--ui-border-strong)] bg-[var(--ui-bg-surface)]/60 hover:bg-[var(--ui-bg-secondary)]'
178
- )}
179
- >
180
- <input
181
- ref={inputRef}
182
- type="file"
183
- accept={accept}
184
- multiple={multiple}
185
- disabled={disabled}
186
- onChange={handleFileChange}
187
- className="hidden"
188
- />
189
-
190
- <div className="w-12 h-12 rounded-2xl bg-[var(--ui-primary)]/15 text-[var(--ui-primary)] flex items-center justify-center shadow-inner">
191
- <UploadCloud className="w-6 h-6" />
192
- </div>
193
-
194
- <div className="space-y-1">
195
- <h4 className="text-sm font-bold text-[var(--ui-text-primary)]">{label}</h4>
196
- <p className="text-xs text-[var(--ui-text-muted)]">{description}</p>
197
- </div>
198
-
199
- <Button size="xs" variant="outline" className="rounded-full px-4 mt-1 pointer-events-none">
200
- Browse Files
201
- </Button>
202
- </div>
203
-
204
- {/* Uploaded Files List */}
205
- {fileList.length > 0 && (
206
- <div className="space-y-2">
207
- <span className="text-xs font-mono font-bold text-[var(--ui-text-muted)] uppercase tracking-wider">
208
- Files ({fileList.length}/{maxFiles})
209
- </span>
210
-
211
- <div className="space-y-2">
212
- {fileList.map((item) => (
213
- <div
214
- key={item.id}
215
- className="p-3 rounded-2xl bg-[var(--ui-bg-surface)] border border-[var(--ui-border-default)] flex items-center justify-between gap-3 shadow-xs animate-in fade-in duration-200"
216
- >
217
- <div className="flex items-center gap-3 truncate">
218
- {item.previewUrl ? (
219
- <img
220
- src={item.previewUrl}
221
- alt={item.name}
222
- className="w-10 h-10 rounded-xl object-cover border border-[var(--ui-border-subtle)] shrink-0"
223
- />
224
- ) : (
225
- <div className="w-10 h-10 rounded-xl bg-[var(--ui-bg-tertiary)] flex items-center justify-center text-[var(--ui-text-muted)] shrink-0">
226
- {item.type.includes('pdf') ? <FileText className="w-5 h-5" /> : <File className="w-5 h-5" />}
227
- </div>
228
- )}
229
-
230
- <div className="truncate space-y-0.5">
231
- <span className="text-xs font-semibold text-[var(--ui-text-primary)] block truncate">
232
- {item.name}
233
- </span>
234
- <div className="flex items-center gap-2 text-[11px] text-[var(--ui-text-muted)] font-mono">
235
- <span>{formatFileSize(item.size)}</span>
236
- {item.status === 'completed' && (
237
- <span className="text-emerald-500 font-semibold flex items-center gap-0.5">
238
- <CheckCircle2 className="w-3 h-3" /> Ready
239
- </span>
240
- )}
241
- {item.status === 'error' && (
242
- <span className="text-rose-500 font-semibold flex items-center gap-0.5">
243
- <AlertCircle className="w-3 h-3" /> {item.errorMessage}
244
- </span>
245
- )}
246
- </div>
247
- </div>
248
- </div>
249
-
250
- <button
251
- type="button"
252
- onClick={() => removeFile(item.id)}
253
- className="p-1.5 rounded-full hover:bg-[var(--ui-bg-tertiary)] text-[var(--ui-text-muted)] hover:text-rose-500 transition-colors"
254
- >
255
- <X className="w-4 h-4" />
256
- </button>
257
- </div>
258
- ))}
259
- </div>
260
- </div>
261
- )}
262
- </div>
263
- );
264
- }
265
- );
266
- FileUpload.displayName = 'FileUpload';
1
+ import React, { forwardRef, useState, useRef, useEffect } from 'react';
2
+ import { UploadCloud, File, FileText, Image as ImageIcon, X, CheckCircle2, AlertCircle } from 'lucide-react';
3
+ import { cn } from '../utils/cn';
4
+ import { ProgressBar } from '../feedback/ProgressBar';
5
+ import { Button } from '../buttons/Button';
6
+
7
+ export interface UploadedFile {
8
+ id: string;
9
+ file: File;
10
+ name: string;
11
+ size: number;
12
+ type: string;
13
+ progress?: number;
14
+ status?: 'pending' | 'uploading' | 'completed' | 'error';
15
+ previewUrl?: string;
16
+ errorMessage?: string;
17
+ }
18
+
19
+ export interface FileUploadProps {
20
+ accept?: string;
21
+ maxSizeMB?: number;
22
+ multiple?: boolean;
23
+ maxFiles?: number;
24
+ disabled?: boolean;
25
+ onFilesSelected?: (files: File[]) => void;
26
+ onFileRemove?: (fileId: string) => void;
27
+ className?: string;
28
+ label?: string;
29
+ description?: string;
30
+ }
31
+
32
+ export function formatFileSize(bytes: number): string {
33
+ if (bytes === 0) return '0 B';
34
+ const k = 1024;
35
+ const sizes = ['B', 'KB', 'MB', 'GB'];
36
+ const i = Math.floor(Math.log(bytes) / Math.log(k));
37
+ return `${parseFloat((bytes / Math.pow(k, i)).toFixed(1))} ${sizes[i]}`;
38
+ }
39
+
40
+ export const FileUpload = forwardRef<HTMLDivElement, FileUploadProps>(
41
+ (
42
+ {
43
+ accept,
44
+ maxSizeMB = 10,
45
+ multiple = true,
46
+ maxFiles = 5,
47
+ disabled = false,
48
+ onFilesSelected,
49
+ onFileRemove,
50
+ className,
51
+ label = 'Drag & drop files here, or browse',
52
+ description = 'Supports images, documents, and media up to 10MB',
53
+ },
54
+ ref
55
+ ) => {
56
+ const [isDragOver, setIsDragOver] = useState(false);
57
+ const [fileList, setFileList] = useState<UploadedFile[]>([]);
58
+ const inputRef = useRef<HTMLInputElement | null>(null);
59
+ const fileListRef = useRef<UploadedFile[]>(fileList);
60
+ fileListRef.current = fileList;
61
+
62
+ // Cleanup object URLs on unmount
63
+ useEffect(() => {
64
+ return () => {
65
+ fileListRef.current.forEach((item) => {
66
+ if (item.previewUrl) {
67
+ URL.revokeObjectURL(item.previewUrl);
68
+ }
69
+ });
70
+ };
71
+ }, []);
72
+
73
+ const matchesAccept = (file: File, acceptStr?: string): boolean => {
74
+ if (!acceptStr) return true;
75
+ const patterns = acceptStr.split(',').map((p) => p.trim().toLowerCase());
76
+ const fileName = file.name.toLowerCase();
77
+ const fileType = file.type.toLowerCase();
78
+
79
+ return patterns.some((p) => {
80
+ if (p.startsWith('.')) {
81
+ return fileName.endsWith(p);
82
+ }
83
+ if (p.endsWith('/*')) {
84
+ const baseType = p.replace('/*', '');
85
+ return fileType.startsWith(baseType);
86
+ }
87
+ return fileType === p;
88
+ });
89
+ };
90
+
91
+ const processFiles = (incoming: FileList | null) => {
92
+ if (!incoming) return;
93
+
94
+ const validFiles: File[] = [];
95
+ const newUploaded: UploadedFile[] = [];
96
+
97
+ Array.from(incoming).forEach((file) => {
98
+ if (fileList.length + newUploaded.length >= maxFiles) return;
99
+
100
+ const isAccepted = matchesAccept(file, accept);
101
+ const isOversized = file.size > maxSizeMB * 1024 * 1024;
102
+ const hasError = !isAccepted || isOversized;
103
+
104
+ let errorMsg: string | undefined;
105
+ if (!isAccepted) errorMsg = `File type not supported (${accept})`;
106
+ else if (isOversized) errorMsg = `File exceeds ${maxSizeMB}MB limit`;
107
+
108
+ const fileObj: UploadedFile = {
109
+ id: `${file.name}-${Date.now()}-${Math.random()}`,
110
+ file,
111
+ name: file.name,
112
+ size: file.size,
113
+ type: file.type,
114
+ progress: hasError ? 0 : 100,
115
+ status: hasError ? 'error' : 'completed',
116
+ errorMessage: errorMsg,
117
+ previewUrl: file.type.startsWith('image/') && !hasError ? URL.createObjectURL(file) : undefined,
118
+ };
119
+
120
+ newUploaded.push(fileObj);
121
+ if (!hasError) validFiles.push(file);
122
+ });
123
+
124
+ setFileList((prev) => [...prev, ...newUploaded]);
125
+ if (validFiles.length > 0) {
126
+ onFilesSelected?.(validFiles);
127
+ }
128
+ };
129
+
130
+ const handleDragOver = (e: React.DragEvent) => {
131
+ e.preventDefault();
132
+ if (!disabled) setIsDragOver(true);
133
+ };
134
+
135
+ const handleDragLeave = (e: React.DragEvent) => {
136
+ e.preventDefault();
137
+ setIsDragOver(false);
138
+ };
139
+
140
+ const handleDrop = (e: React.DragEvent) => {
141
+ e.preventDefault();
142
+ setIsDragOver(false);
143
+ if (disabled) return;
144
+ processFiles(e.dataTransfer.files);
145
+ };
146
+
147
+ const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
148
+ processFiles(e.target.files);
149
+ if (inputRef.current) inputRef.current.value = '';
150
+ };
151
+
152
+ const removeFile = (id: string) => {
153
+ setFileList((prev) => {
154
+ const item = prev.find((f) => f.id === id);
155
+ if (item?.previewUrl) {
156
+ URL.revokeObjectURL(item.previewUrl);
157
+ }
158
+ return prev.filter((f) => f.id !== id);
159
+ });
160
+ onFileRemove?.(id);
161
+ };
162
+
163
+ return (
164
+ <div ref={ref} className={cn('w-full space-y-4', className)}>
165
+ {/* Dropzone Box */}
166
+ <div
167
+ onDragOver={handleDragOver}
168
+ onDragLeave={handleDragLeave}
169
+ onDrop={handleDrop}
170
+ onClick={() => !disabled && inputRef.current?.click()}
171
+ className={cn(
172
+ 'relative rounded-2xl p-6 sm:p-8 text-center border-2 border-dashed transition-all cursor-pointer flex flex-col items-center justify-center gap-3 select-none',
173
+ disabled
174
+ ? 'opacity-50 cursor-not-allowed border-[var(--ui-border-subtle)] bg-[var(--ui-bg-secondary)]/30'
175
+ : isDragOver
176
+ ? 'border-[var(--ui-primary)] bg-[var(--ui-primary)]/10 shadow-lg shadow-blue-500/20 scale-[0.99]'
177
+ : 'border-[var(--ui-border-default)] hover:border-[var(--ui-border-strong)] bg-[var(--ui-bg-surface)]/60 hover:bg-[var(--ui-bg-secondary)]'
178
+ )}
179
+ >
180
+ <input
181
+ ref={inputRef}
182
+ type="file"
183
+ accept={accept}
184
+ multiple={multiple}
185
+ disabled={disabled}
186
+ onChange={handleFileChange}
187
+ className="hidden"
188
+ />
189
+
190
+ <div className="w-12 h-12 rounded-2xl bg-[var(--ui-primary)]/15 text-[var(--ui-primary)] flex items-center justify-center shadow-inner">
191
+ <UploadCloud className="w-6 h-6" />
192
+ </div>
193
+
194
+ <div className="space-y-1">
195
+ <h4 className="text-sm font-bold text-[var(--ui-text-primary)]">{label}</h4>
196
+ <p className="text-xs text-[var(--ui-text-muted)]">{description}</p>
197
+ </div>
198
+
199
+ <Button size="xs" variant="outline" className="rounded-full px-4 mt-1 pointer-events-none">
200
+ Browse Files
201
+ </Button>
202
+ </div>
203
+
204
+ {/* Uploaded Files List */}
205
+ {fileList.length > 0 && (
206
+ <div className="space-y-2">
207
+ <span className="text-xs font-mono font-bold text-[var(--ui-text-muted)] uppercase tracking-wider">
208
+ Files ({fileList.length}/{maxFiles})
209
+ </span>
210
+
211
+ <div className="space-y-2">
212
+ {fileList.map((item) => (
213
+ <div
214
+ key={item.id}
215
+ className="p-3 rounded-2xl bg-[var(--ui-bg-surface)] border border-[var(--ui-border-default)] flex items-center justify-between gap-3 shadow-xs animate-in fade-in duration-200"
216
+ >
217
+ <div className="flex items-center gap-3 truncate">
218
+ {item.previewUrl ? (
219
+ <img
220
+ src={item.previewUrl}
221
+ alt={item.name}
222
+ className="w-10 h-10 rounded-xl object-cover border border-[var(--ui-border-subtle)] shrink-0"
223
+ />
224
+ ) : (
225
+ <div className="w-10 h-10 rounded-xl bg-[var(--ui-bg-tertiary)] flex items-center justify-center text-[var(--ui-text-muted)] shrink-0">
226
+ {item.type.includes('pdf') ? <FileText className="w-5 h-5" /> : <File className="w-5 h-5" />}
227
+ </div>
228
+ )}
229
+
230
+ <div className="truncate space-y-0.5">
231
+ <span className="text-xs font-semibold text-[var(--ui-text-primary)] block truncate">
232
+ {item.name}
233
+ </span>
234
+ <div className="flex items-center gap-2 text-[11px] text-[var(--ui-text-muted)] font-mono">
235
+ <span>{formatFileSize(item.size)}</span>
236
+ {item.status === 'completed' && (
237
+ <span className="text-emerald-500 font-semibold flex items-center gap-0.5">
238
+ <CheckCircle2 className="w-3 h-3" /> Ready
239
+ </span>
240
+ )}
241
+ {item.status === 'error' && (
242
+ <span className="text-rose-500 font-semibold flex items-center gap-0.5">
243
+ <AlertCircle className="w-3 h-3" /> {item.errorMessage}
244
+ </span>
245
+ )}
246
+ </div>
247
+ </div>
248
+ </div>
249
+
250
+ <button
251
+ type="button"
252
+ onClick={() => removeFile(item.id)}
253
+ className="p-1.5 rounded-full hover:bg-[var(--ui-bg-tertiary)] text-[var(--ui-text-muted)] hover:text-rose-500 transition-colors"
254
+ >
255
+ <X className="w-4 h-4" />
256
+ </button>
257
+ </div>
258
+ ))}
259
+ </div>
260
+ </div>
261
+ )}
262
+ </div>
263
+ );
264
+ }
265
+ );
266
+ FileUpload.displayName = 'FileUpload';