XspecT 0.4.0__py3-none-any.whl → 0.5.0__py3-none-any.whl

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.

Potentially problematic release.


This version of XspecT might be problematic. Click here for more details.

Files changed (80) hide show
  1. xspect/classify.py +32 -0
  2. xspect/file_io.py +3 -9
  3. xspect/filter_sequences.py +56 -0
  4. xspect/main.py +52 -30
  5. xspect/mlst_feature/mlst_helper.py +102 -13
  6. xspect/mlst_feature/pub_mlst_handler.py +32 -6
  7. xspect/model_management.py +1 -15
  8. xspect/models/probabilistic_filter_mlst_model.py +160 -32
  9. xspect/models/probabilistic_filter_model.py +1 -0
  10. xspect/models/result.py +18 -6
  11. xspect/ncbi.py +8 -6
  12. xspect/train.py +13 -5
  13. xspect/web.py +173 -0
  14. xspect/xspect-web/.gitignore +24 -0
  15. xspect/xspect-web/README.md +54 -0
  16. xspect/xspect-web/components.json +21 -0
  17. xspect/xspect-web/dist/assets/index-CMG4V7fZ.js +290 -0
  18. xspect/xspect-web/dist/assets/index-jIKg1HIy.css +1 -0
  19. xspect/xspect-web/dist/index.html +14 -0
  20. xspect/xspect-web/dist/vite.svg +1 -0
  21. xspect/xspect-web/eslint.config.js +28 -0
  22. xspect/xspect-web/index.html +13 -0
  23. xspect/xspect-web/package-lock.json +6865 -0
  24. xspect/xspect-web/package.json +58 -0
  25. xspect/xspect-web/pnpm-lock.yaml +4317 -0
  26. xspect/xspect-web/public/vite.svg +1 -0
  27. xspect/xspect-web/src/App.tsx +29 -0
  28. xspect/xspect-web/src/api.tsx +62 -0
  29. xspect/xspect-web/src/assets/react.svg +1 -0
  30. xspect/xspect-web/src/components/classification-form.tsx +284 -0
  31. xspect/xspect-web/src/components/classify.tsx +18 -0
  32. xspect/xspect-web/src/components/data-table.tsx +78 -0
  33. xspect/xspect-web/src/components/dropdown-checkboxes.tsx +63 -0
  34. xspect/xspect-web/src/components/dropdown-slider.tsx +42 -0
  35. xspect/xspect-web/src/components/filter-form.tsx +423 -0
  36. xspect/xspect-web/src/components/filter.tsx +15 -0
  37. xspect/xspect-web/src/components/header.tsx +46 -0
  38. xspect/xspect-web/src/components/landing.tsx +7 -0
  39. xspect/xspect-web/src/components/models-details.tsx +138 -0
  40. xspect/xspect-web/src/components/models.tsx +53 -0
  41. xspect/xspect-web/src/components/result-chart.tsx +44 -0
  42. xspect/xspect-web/src/components/result.tsx +155 -0
  43. xspect/xspect-web/src/components/spinner.tsx +30 -0
  44. xspect/xspect-web/src/components/ui/accordion.tsx +64 -0
  45. xspect/xspect-web/src/components/ui/button.tsx +59 -0
  46. xspect/xspect-web/src/components/ui/card.tsx +92 -0
  47. xspect/xspect-web/src/components/ui/chart.tsx +351 -0
  48. xspect/xspect-web/src/components/ui/command.tsx +175 -0
  49. xspect/xspect-web/src/components/ui/dialog.tsx +135 -0
  50. xspect/xspect-web/src/components/ui/dropdown-menu.tsx +255 -0
  51. xspect/xspect-web/src/components/ui/file-upload.tsx +1459 -0
  52. xspect/xspect-web/src/components/ui/form.tsx +165 -0
  53. xspect/xspect-web/src/components/ui/input.tsx +21 -0
  54. xspect/xspect-web/src/components/ui/label.tsx +24 -0
  55. xspect/xspect-web/src/components/ui/navigation-menu.tsx +168 -0
  56. xspect/xspect-web/src/components/ui/popover.tsx +46 -0
  57. xspect/xspect-web/src/components/ui/select.tsx +183 -0
  58. xspect/xspect-web/src/components/ui/separator.tsx +26 -0
  59. xspect/xspect-web/src/components/ui/slider.tsx +61 -0
  60. xspect/xspect-web/src/components/ui/switch.tsx +29 -0
  61. xspect/xspect-web/src/components/ui/table.tsx +113 -0
  62. xspect/xspect-web/src/components/ui/tabs.tsx +64 -0
  63. xspect/xspect-web/src/index.css +120 -0
  64. xspect/xspect-web/src/lib/utils.ts +6 -0
  65. xspect/xspect-web/src/main.tsx +10 -0
  66. xspect/xspect-web/src/types.tsx +34 -0
  67. xspect/xspect-web/src/utils.tsx +6 -0
  68. xspect/xspect-web/src/vite-env.d.ts +1 -0
  69. xspect/xspect-web/tsconfig.app.json +32 -0
  70. xspect/xspect-web/tsconfig.json +13 -0
  71. xspect/xspect-web/tsconfig.node.json +24 -0
  72. xspect/xspect-web/vite.config.ts +24 -0
  73. {xspect-0.4.0.dist-info → xspect-0.5.0.dist-info}/METADATA +7 -8
  74. xspect-0.5.0.dist-info/RECORD +85 -0
  75. {xspect-0.4.0.dist-info → xspect-0.5.0.dist-info}/WHEEL +1 -1
  76. xspect/fastapi.py +0 -102
  77. xspect-0.4.0.dist-info/RECORD +0 -24
  78. {xspect-0.4.0.dist-info → xspect-0.5.0.dist-info}/entry_points.txt +0 -0
  79. {xspect-0.4.0.dist-info → xspect-0.5.0.dist-info}/licenses/LICENSE +0 -0
  80. {xspect-0.4.0.dist-info → xspect-0.5.0.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,1459 @@
1
+ "use client";
2
+
3
+ import { cn } from "@/lib/utils";
4
+ import { Slot } from "@radix-ui/react-slot";
5
+ import {
6
+ FileArchiveIcon,
7
+ FileAudioIcon,
8
+ FileCodeIcon,
9
+ FileCogIcon,
10
+ FileIcon,
11
+ FileTextIcon,
12
+ FileVideoIcon,
13
+ } from "lucide-react";
14
+ import * as React from "react";
15
+
16
+ const ROOT_NAME = "FileUpload";
17
+ const DROPZONE_NAME = "FileUploadDropzone";
18
+ const TRIGGER_NAME = "FileUploadTrigger";
19
+ const LIST_NAME = "FileUploadList";
20
+ const ITEM_NAME = "FileUploadItem";
21
+ const ITEM_PREVIEW_NAME = "FileUploadItemPreview";
22
+ const ITEM_METADATA_NAME = "FileUploadItemMetadata";
23
+ const ITEM_PROGRESS_NAME = "FileUploadItemProgress";
24
+ const ITEM_DELETE_NAME = "FileUploadItemDelete";
25
+ const CLEAR_NAME = "FileUploadClear";
26
+
27
+ const FILE_UPLOAD_ERRORS = {
28
+ [ROOT_NAME]: `\`${ROOT_NAME}\` must be used as root component`,
29
+ [DROPZONE_NAME]: `\`${DROPZONE_NAME}\` must be within \`${ROOT_NAME}\``,
30
+ [TRIGGER_NAME]: `\`${TRIGGER_NAME}\` must be within \`${ROOT_NAME}\``,
31
+ [LIST_NAME]: `\`${LIST_NAME}\` must be within \`${ROOT_NAME}\``,
32
+ [ITEM_NAME]: `\`${ITEM_NAME}\` must be within \`${ROOT_NAME}\``,
33
+ [ITEM_PREVIEW_NAME]: `\`${ITEM_PREVIEW_NAME}\` must be within \`${ITEM_NAME}\``,
34
+ [ITEM_METADATA_NAME]: `\`${ITEM_METADATA_NAME}\` must be within \`${ITEM_NAME}\``,
35
+ [ITEM_PROGRESS_NAME]: `\`${ITEM_PROGRESS_NAME}\` must be within \`${ITEM_NAME}\``,
36
+ [ITEM_DELETE_NAME]: `\`${ITEM_DELETE_NAME}\` must be within \`${ITEM_NAME}\``,
37
+ [CLEAR_NAME]: `\`${CLEAR_NAME}\` must be within \`${ROOT_NAME}\``,
38
+ } as const;
39
+
40
+ const useIsomorphicLayoutEffect =
41
+ typeof window !== "undefined" ? React.useLayoutEffect : React.useEffect;
42
+
43
+ function useAsRef<T>(data: T) {
44
+ const ref = React.useRef<T>(data);
45
+ useIsomorphicLayoutEffect(() => {
46
+ ref.current = data;
47
+ });
48
+ return ref;
49
+ }
50
+
51
+ function useLazyRef<T>(fn: () => T) {
52
+ const ref = React.useRef<T | null>(null);
53
+ if (ref.current === null) {
54
+ ref.current = fn();
55
+ }
56
+ return ref as React.RefObject<T>;
57
+ }
58
+
59
+ type Direction = "ltr" | "rtl";
60
+
61
+ const DirectionContext = React.createContext<Direction | undefined>(undefined);
62
+
63
+ function useDirection(dirProp?: Direction): Direction {
64
+ const contextDir = React.useContext(DirectionContext);
65
+ return dirProp ?? contextDir ?? "ltr";
66
+ }
67
+
68
+ interface FileState {
69
+ file: File;
70
+ progress: number;
71
+ error?: string;
72
+ status: "idle" | "uploading" | "error" | "success";
73
+ }
74
+
75
+ interface StoreState {
76
+ files: Map<File, FileState>;
77
+ dragOver: boolean;
78
+ invalid: boolean;
79
+ }
80
+
81
+ type StoreAction =
82
+ | { variant: "ADD_FILES"; files: File[] }
83
+ | { variant: "SET_FILES"; files: File[] }
84
+ | { variant: "SET_PROGRESS"; file: File; progress: number }
85
+ | { variant: "SET_SUCCESS"; file: File }
86
+ | { variant: "SET_ERROR"; file: File; error: string }
87
+ | { variant: "REMOVE_FILE"; file: File }
88
+ | { variant: "SET_DRAG_OVER"; dragOver: boolean }
89
+ | { variant: "SET_INVALID"; invalid: boolean }
90
+ | { variant: "CLEAR" };
91
+
92
+ function createStore(
93
+ listeners: Set<() => void>,
94
+ files: Map<File, FileState>,
95
+ onValueChange?: (files: File[]) => void,
96
+ invalid?: boolean,
97
+ ) {
98
+ const initialState: StoreState = {
99
+ files,
100
+ dragOver: false,
101
+ invalid: invalid ?? false,
102
+ };
103
+
104
+ let state = initialState;
105
+
106
+ function reducer(state: StoreState, action: StoreAction): StoreState {
107
+ switch (action.variant) {
108
+ case "ADD_FILES": {
109
+ for (const file of action.files) {
110
+ files.set(file, {
111
+ file,
112
+ progress: 0,
113
+ status: "idle",
114
+ });
115
+ }
116
+
117
+ if (onValueChange) {
118
+ const fileList = Array.from(files.values()).map(
119
+ (fileState) => fileState.file,
120
+ );
121
+ onValueChange(fileList);
122
+ }
123
+ return { ...state, files };
124
+ }
125
+
126
+ case "SET_FILES": {
127
+ const newFileSet = new Set(action.files);
128
+ for (const existingFile of files.keys()) {
129
+ if (!newFileSet.has(existingFile)) {
130
+ files.delete(existingFile);
131
+ }
132
+ }
133
+
134
+ for (const file of action.files) {
135
+ const existingState = files.get(file);
136
+ if (!existingState) {
137
+ files.set(file, {
138
+ file,
139
+ progress: 0,
140
+ status: "idle",
141
+ });
142
+ }
143
+ }
144
+ return { ...state, files };
145
+ }
146
+
147
+ case "SET_PROGRESS": {
148
+ const fileState = files.get(action.file);
149
+ if (fileState) {
150
+ files.set(action.file, {
151
+ ...fileState,
152
+ progress: action.progress,
153
+ status: "uploading",
154
+ });
155
+ }
156
+ return { ...state, files };
157
+ }
158
+
159
+ case "SET_SUCCESS": {
160
+ const fileState = files.get(action.file);
161
+ if (fileState) {
162
+ files.set(action.file, {
163
+ ...fileState,
164
+ progress: 100,
165
+ status: "success",
166
+ });
167
+ }
168
+ return { ...state, files };
169
+ }
170
+
171
+ case "SET_ERROR": {
172
+ const fileState = files.get(action.file);
173
+ if (fileState) {
174
+ files.set(action.file, {
175
+ ...fileState,
176
+ error: action.error,
177
+ status: "error",
178
+ });
179
+ }
180
+ return { ...state, files };
181
+ }
182
+
183
+ case "REMOVE_FILE": {
184
+ files.delete(action.file);
185
+
186
+ if (onValueChange) {
187
+ const fileList = Array.from(files.values()).map(
188
+ (fileState) => fileState.file,
189
+ );
190
+ onValueChange(fileList);
191
+ }
192
+ return { ...state, files };
193
+ }
194
+
195
+ case "SET_DRAG_OVER": {
196
+ return { ...state, dragOver: action.dragOver };
197
+ }
198
+
199
+ case "SET_INVALID": {
200
+ return { ...state, invalid: action.invalid };
201
+ }
202
+
203
+ case "CLEAR": {
204
+ files.clear();
205
+ if (onValueChange) {
206
+ onValueChange([]);
207
+ }
208
+ return { ...state, files, invalid: false };
209
+ }
210
+
211
+ default:
212
+ return state;
213
+ }
214
+ }
215
+
216
+ function getState() {
217
+ return state;
218
+ }
219
+
220
+ function dispatch(action: StoreAction) {
221
+ state = reducer(state, action);
222
+ for (const listener of listeners) {
223
+ listener();
224
+ }
225
+ }
226
+
227
+ function subscribe(listener: () => void) {
228
+ listeners.add(listener);
229
+ return () => listeners.delete(listener);
230
+ }
231
+
232
+ return { getState, dispatch, subscribe };
233
+ }
234
+
235
+ const StoreContext = React.createContext<ReturnType<typeof createStore> | null>(
236
+ null,
237
+ );
238
+ StoreContext.displayName = ROOT_NAME;
239
+
240
+ function useStoreContext(name: keyof typeof FILE_UPLOAD_ERRORS) {
241
+ const context = React.useContext(StoreContext);
242
+ if (!context) {
243
+ throw new Error(FILE_UPLOAD_ERRORS[name]);
244
+ }
245
+ return context;
246
+ }
247
+
248
+ function useStore<T>(selector: (state: StoreState) => T): T {
249
+ const store = useStoreContext(ROOT_NAME);
250
+
251
+ const lastValueRef = useLazyRef<{ value: T; state: StoreState } | null>(
252
+ () => null,
253
+ );
254
+
255
+ const getSnapshot = React.useCallback(() => {
256
+ const state = store.getState();
257
+ const prevValue = lastValueRef.current;
258
+
259
+ if (prevValue && prevValue.state === state) {
260
+ return prevValue.value;
261
+ }
262
+
263
+ const nextValue = selector(state);
264
+ lastValueRef.current = { value: nextValue, state };
265
+ return nextValue;
266
+ }, [store, selector, lastValueRef]);
267
+
268
+ return React.useSyncExternalStore(store.subscribe, getSnapshot, getSnapshot);
269
+ }
270
+
271
+ interface FileUploadContextValue {
272
+ inputId: string;
273
+ dropzoneId: string;
274
+ listId: string;
275
+ labelId: string;
276
+ disabled: boolean;
277
+ dir: Direction;
278
+ inputRef: React.RefObject<HTMLInputElement | null>;
279
+ }
280
+
281
+ const FileUploadContext = React.createContext<FileUploadContextValue | null>(
282
+ null,
283
+ );
284
+
285
+ function useFileUploadContext(name: keyof typeof FILE_UPLOAD_ERRORS) {
286
+ const context = React.useContext(FileUploadContext);
287
+ if (!context) {
288
+ throw new Error(FILE_UPLOAD_ERRORS[name]);
289
+ }
290
+ return context;
291
+ }
292
+
293
+ interface FileUploadRootProps
294
+ extends Omit<
295
+ React.ComponentPropsWithoutRef<"div">,
296
+ "defaultValue" | "onChange"
297
+ > {
298
+ value?: File[];
299
+ defaultValue?: File[];
300
+ onValueChange?: (files: File[]) => void;
301
+ onAccept?: (files: File[]) => void;
302
+ onFileAccept?: (file: File) => void;
303
+ onFileReject?: (file: File, message: string) => void;
304
+ onFileValidate?: (file: File) => string | null | undefined;
305
+ onUpload?: (
306
+ files: File[],
307
+ options: {
308
+ onProgress: (file: File, progress: number) => void;
309
+ onSuccess: (file: File) => void;
310
+ onError: (file: File, error: Error) => void;
311
+ },
312
+ ) => Promise<void> | void;
313
+ accept?: string;
314
+ maxFiles?: number;
315
+ maxSize?: number;
316
+ dir?: Direction;
317
+ label?: string;
318
+ name?: string;
319
+ asChild?: boolean;
320
+ disabled?: boolean;
321
+ invalid?: boolean;
322
+ multiple?: boolean;
323
+ required?: boolean;
324
+ }
325
+
326
+ const FileUploadRoot = React.forwardRef<HTMLDivElement, FileUploadRootProps>(
327
+ (props, forwardedRef) => {
328
+ const {
329
+ value,
330
+ defaultValue,
331
+ onValueChange,
332
+ onAccept,
333
+ onFileAccept,
334
+ onFileReject,
335
+ onFileValidate,
336
+ onUpload,
337
+ accept,
338
+ maxFiles,
339
+ maxSize,
340
+ dir: dirProp,
341
+ label,
342
+ name,
343
+ asChild,
344
+ disabled = false,
345
+ invalid = false,
346
+ multiple = false,
347
+ required = false,
348
+ children,
349
+ className,
350
+ ...rootProps
351
+ } = props;
352
+
353
+ const inputId = React.useId();
354
+ const dropzoneId = React.useId();
355
+ const listId = React.useId();
356
+ const labelId = React.useId();
357
+
358
+ const dir = useDirection(dirProp);
359
+ const propsRef = useAsRef(props);
360
+ const listeners = useLazyRef(() => new Set<() => void>()).current;
361
+ const files = useLazyRef<Map<File, FileState>>(() => new Map()).current;
362
+ const inputRef = React.useRef<HTMLInputElement>(null);
363
+ const isControlled = value !== undefined;
364
+
365
+ const store = React.useMemo(
366
+ () => createStore(listeners, files, onValueChange, invalid),
367
+ [listeners, files, onValueChange, invalid],
368
+ );
369
+
370
+ const contextValue = React.useMemo<FileUploadContextValue>(
371
+ () => ({
372
+ dropzoneId,
373
+ inputId,
374
+ listId,
375
+ labelId,
376
+ dir,
377
+ disabled,
378
+ inputRef,
379
+ }),
380
+ [dropzoneId, inputId, listId, labelId, dir, disabled],
381
+ );
382
+
383
+ React.useEffect(() => {
384
+ if (isControlled) {
385
+ store.dispatch({ variant: "SET_FILES", files: value });
386
+ } else if (
387
+ defaultValue &&
388
+ defaultValue.length > 0 &&
389
+ !store.getState().files.size
390
+ ) {
391
+ store.dispatch({ variant: "SET_FILES", files: defaultValue });
392
+ }
393
+ }, [value, defaultValue, isControlled, store]);
394
+
395
+ const onFilesChange = React.useCallback(
396
+ (originalFiles: File[]) => {
397
+ if (propsRef.current.disabled) return;
398
+
399
+ let filesToProcess = [...originalFiles];
400
+ let invalid = false;
401
+
402
+ if (propsRef.current.maxFiles) {
403
+ const currentCount = store.getState().files.size;
404
+ const remainingSlotCount = Math.max(
405
+ 0,
406
+ propsRef.current.maxFiles - currentCount,
407
+ );
408
+
409
+ if (remainingSlotCount < filesToProcess.length) {
410
+ const rejectedFiles = filesToProcess.slice(remainingSlotCount);
411
+ invalid = true;
412
+
413
+ filesToProcess = filesToProcess.slice(0, remainingSlotCount);
414
+
415
+ for (const file of rejectedFiles) {
416
+ let rejectionMessage = `Maximum ${propsRef.current.maxFiles} files allowed`;
417
+
418
+ if (propsRef.current.onFileValidate) {
419
+ const validationMessage = propsRef.current.onFileValidate(file);
420
+ if (validationMessage) {
421
+ rejectionMessage = validationMessage;
422
+ }
423
+ }
424
+
425
+ propsRef.current.onFileReject?.(file, rejectionMessage);
426
+ }
427
+ }
428
+ }
429
+
430
+ const acceptedFiles: File[] = [];
431
+ const rejectedFiles: { file: File; message: string }[] = [];
432
+
433
+ for (const file of filesToProcess) {
434
+ let rejected = false;
435
+ let rejectionMessage = "";
436
+
437
+ if (propsRef.current.onFileValidate) {
438
+ const validationMessage = propsRef.current.onFileValidate(file);
439
+ if (validationMessage) {
440
+ rejectionMessage = validationMessage;
441
+ propsRef.current.onFileReject?.(file, rejectionMessage);
442
+ rejected = true;
443
+ invalid = true;
444
+ continue;
445
+ }
446
+ }
447
+
448
+ if (propsRef.current.accept) {
449
+ const acceptTypes = propsRef.current.accept
450
+ .split(",")
451
+ .map((t) => t.trim());
452
+ const fileType = file.type;
453
+ const fileExtension = `.${file.name.split(".").pop()}`;
454
+
455
+ if (
456
+ !acceptTypes.some(
457
+ (type) =>
458
+ type === fileType ||
459
+ type === fileExtension ||
460
+ (type.includes("/*") &&
461
+ fileType.startsWith(type.replace("/*", "/"))),
462
+ )
463
+ ) {
464
+ rejectionMessage = "File type not accepted";
465
+ propsRef.current.onFileReject?.(file, rejectionMessage);
466
+ rejected = true;
467
+ invalid = true;
468
+ }
469
+ }
470
+
471
+ if (
472
+ propsRef.current.maxSize &&
473
+ file.size > propsRef.current.maxSize
474
+ ) {
475
+ rejectionMessage = "File too large";
476
+ propsRef.current.onFileReject?.(file, rejectionMessage);
477
+ rejected = true;
478
+ invalid = true;
479
+ }
480
+
481
+ if (!rejected) {
482
+ acceptedFiles.push(file);
483
+ } else {
484
+ rejectedFiles.push({ file, message: rejectionMessage });
485
+ }
486
+ }
487
+
488
+ if (invalid) {
489
+ store.dispatch({ variant: "SET_INVALID", invalid });
490
+ setTimeout(() => {
491
+ store.dispatch({ variant: "SET_INVALID", invalid: false });
492
+ }, 2000);
493
+ }
494
+
495
+ if (acceptedFiles.length > 0) {
496
+ store.dispatch({ variant: "ADD_FILES", files: acceptedFiles });
497
+
498
+ if (isControlled && propsRef.current.onValueChange) {
499
+ const currentFiles = Array.from(
500
+ store.getState().files.values(),
501
+ ).map((f) => f.file);
502
+ propsRef.current.onValueChange([...currentFiles]);
503
+ }
504
+
505
+ if (propsRef.current.onAccept) {
506
+ propsRef.current.onAccept(acceptedFiles);
507
+ }
508
+
509
+ for (const file of acceptedFiles) {
510
+ propsRef.current.onFileAccept?.(file);
511
+ }
512
+
513
+ if (propsRef.current.onUpload) {
514
+ requestAnimationFrame(() => {
515
+ onFilesUpload(acceptedFiles);
516
+ });
517
+ }
518
+ }
519
+ },
520
+ [store, isControlled, propsRef],
521
+ );
522
+
523
+ const onFilesUpload = React.useCallback(
524
+ async (files: File[]) => {
525
+ try {
526
+ for (const file of files) {
527
+ store.dispatch({ variant: "SET_PROGRESS", file, progress: 0 });
528
+ }
529
+
530
+ if (propsRef.current.onUpload) {
531
+ await propsRef.current.onUpload(files, {
532
+ onProgress: (file, progress) => {
533
+ store.dispatch({
534
+ variant: "SET_PROGRESS",
535
+ file,
536
+ progress: Math.min(Math.max(0, progress), 100),
537
+ });
538
+ },
539
+ onSuccess: (file) => {
540
+ store.dispatch({ variant: "SET_SUCCESS", file });
541
+ },
542
+ onError: (file, error) => {
543
+ store.dispatch({
544
+ variant: "SET_ERROR",
545
+ file,
546
+ error: error.message ?? "Upload failed",
547
+ });
548
+ },
549
+ });
550
+ } else {
551
+ for (const file of files) {
552
+ store.dispatch({ variant: "SET_SUCCESS", file });
553
+ }
554
+ }
555
+ } catch (error) {
556
+ const errorMessage =
557
+ error instanceof Error ? error.message : "Upload failed";
558
+ for (const file of files) {
559
+ store.dispatch({
560
+ variant: "SET_ERROR",
561
+ file,
562
+ error: errorMessage,
563
+ });
564
+ }
565
+ }
566
+ },
567
+ [store, propsRef.current.onUpload],
568
+ );
569
+
570
+ const onInputChange = React.useCallback(
571
+ (event: React.ChangeEvent<HTMLInputElement>) => {
572
+ const files = Array.from(event.target.files ?? []);
573
+ onFilesChange(files);
574
+ event.target.value = "";
575
+ },
576
+ [onFilesChange],
577
+ );
578
+
579
+ const RootPrimitive = asChild ? Slot : "div";
580
+
581
+ return (
582
+ <DirectionContext.Provider value={dir}>
583
+ <StoreContext.Provider value={store}>
584
+ <FileUploadContext.Provider value={contextValue}>
585
+ <RootPrimitive
586
+ data-disabled={disabled ? "" : undefined}
587
+ data-slot="file-upload"
588
+ dir={dir}
589
+ {...rootProps}
590
+ ref={forwardedRef}
591
+ className={cn("relative flex flex-col gap-2", className)}
592
+ >
593
+ {children}
594
+ <input
595
+ type="file"
596
+ id={inputId}
597
+ aria-labelledby={labelId}
598
+ aria-describedby={dropzoneId}
599
+ ref={inputRef}
600
+ tabIndex={-1}
601
+ accept={accept}
602
+ name={name}
603
+ disabled={disabled}
604
+ multiple={multiple}
605
+ required={required}
606
+ className="sr-only"
607
+ onChange={onInputChange}
608
+ />
609
+ <span id={labelId} className="sr-only">
610
+ {label ?? "File upload"}
611
+ </span>
612
+ </RootPrimitive>
613
+ </FileUploadContext.Provider>
614
+ </StoreContext.Provider>
615
+ </DirectionContext.Provider>
616
+ );
617
+ },
618
+ );
619
+ FileUploadRoot.displayName = ROOT_NAME;
620
+
621
+ interface FileUploadDropzoneProps
622
+ extends React.ComponentPropsWithoutRef<"div"> {
623
+ asChild?: boolean;
624
+ }
625
+
626
+ const FileUploadDropzone = React.forwardRef<
627
+ HTMLDivElement,
628
+ FileUploadDropzoneProps
629
+ >((props, forwardedRef) => {
630
+ const { asChild, className, ...dropzoneProps } = props;
631
+
632
+ const context = useFileUploadContext(DROPZONE_NAME);
633
+ const store = useStoreContext(DROPZONE_NAME);
634
+ const dragOver = useStore((state) => state.dragOver);
635
+ const invalid = useStore((state) => state.invalid);
636
+ const propsRef = useAsRef(dropzoneProps);
637
+
638
+ const onClick = React.useCallback(
639
+ (event: React.MouseEvent<HTMLDivElement>) => {
640
+ propsRef.current?.onClick?.(event);
641
+
642
+ if (event.defaultPrevented) return;
643
+
644
+ const target = event.target;
645
+
646
+ const isFromTrigger =
647
+ target instanceof HTMLElement &&
648
+ target.closest('[data-slot="file-upload-trigger"]');
649
+
650
+ if (!isFromTrigger) {
651
+ context.inputRef.current?.click();
652
+ }
653
+ },
654
+ [context.inputRef, propsRef],
655
+ );
656
+
657
+ const onDragOver = React.useCallback(
658
+ (event: React.DragEvent<HTMLDivElement>) => {
659
+ propsRef.current?.onDragOver?.(event);
660
+
661
+ if (event.defaultPrevented) return;
662
+
663
+ event.preventDefault();
664
+ store.dispatch({ variant: "SET_DRAG_OVER", dragOver: true });
665
+ },
666
+ [store, propsRef.current.onDragOver],
667
+ );
668
+
669
+ const onDragEnter = React.useCallback(
670
+ (event: React.DragEvent<HTMLDivElement>) => {
671
+ propsRef.current?.onDragEnter?.(event);
672
+
673
+ if (event.defaultPrevented) return;
674
+
675
+ event.preventDefault();
676
+ store.dispatch({ variant: "SET_DRAG_OVER", dragOver: true });
677
+ },
678
+ [store, propsRef.current.onDragEnter],
679
+ );
680
+
681
+ const onDragLeave = React.useCallback(
682
+ (event: React.DragEvent<HTMLDivElement>) => {
683
+ propsRef.current?.onDragLeave?.(event);
684
+
685
+ if (event.defaultPrevented) return;
686
+
687
+ const relatedTarget = event.relatedTarget;
688
+ if (
689
+ relatedTarget &&
690
+ relatedTarget instanceof Node &&
691
+ event.currentTarget.contains(relatedTarget)
692
+ ) {
693
+ return;
694
+ }
695
+
696
+ event.preventDefault();
697
+ store.dispatch({ variant: "SET_DRAG_OVER", dragOver: false });
698
+ },
699
+ [store, propsRef.current.onDragLeave],
700
+ );
701
+
702
+ const onDrop = React.useCallback(
703
+ (event: React.DragEvent<HTMLDivElement>) => {
704
+ propsRef.current?.onDrop?.(event);
705
+
706
+ if (event.defaultPrevented) return;
707
+
708
+ event.preventDefault();
709
+ store.dispatch({ variant: "SET_DRAG_OVER", dragOver: false });
710
+
711
+ const files = Array.from(event.dataTransfer.files);
712
+ const inputElement = context.inputRef.current;
713
+ if (!inputElement) return;
714
+
715
+ const dataTransfer = new DataTransfer();
716
+ for (const file of files) {
717
+ dataTransfer.items.add(file);
718
+ }
719
+
720
+ inputElement.files = dataTransfer.files;
721
+ inputElement.dispatchEvent(new Event("change", { bubbles: true }));
722
+ },
723
+ [store, context.inputRef, propsRef.current.onDrop],
724
+ );
725
+
726
+ const onPaste = React.useCallback(
727
+ (event: React.ClipboardEvent<HTMLDivElement>) => {
728
+ propsRef.current?.onPaste?.(event);
729
+
730
+ if (event.defaultPrevented) return;
731
+
732
+ event.preventDefault();
733
+ store.dispatch({ variant: "SET_DRAG_OVER", dragOver: false });
734
+
735
+ const items = event.clipboardData?.items;
736
+ if (!items) return;
737
+
738
+ const files: File[] = [];
739
+ for (let i = 0; i < items.length; i++) {
740
+ const item = items[i];
741
+ if (item?.kind === "file") {
742
+ const file = item.getAsFile();
743
+ if (file) {
744
+ files.push(file);
745
+ }
746
+ }
747
+ }
748
+
749
+ if (files.length === 0) return;
750
+
751
+ const inputElement = context.inputRef.current;
752
+ if (!inputElement) return;
753
+
754
+ const dataTransfer = new DataTransfer();
755
+ for (const file of files) {
756
+ dataTransfer.items.add(file);
757
+ }
758
+
759
+ inputElement.files = dataTransfer.files;
760
+ inputElement.dispatchEvent(new Event("change", { bubbles: true }));
761
+ },
762
+ [store, context.inputRef, propsRef],
763
+ );
764
+
765
+ const onKeyDown = React.useCallback(
766
+ (event: React.KeyboardEvent<HTMLDivElement>) => {
767
+ propsRef.current?.onKeyDown?.(event);
768
+
769
+ if (
770
+ !event.defaultPrevented &&
771
+ (event.key === "Enter" || event.key === " ")
772
+ ) {
773
+ event.preventDefault();
774
+ context.inputRef.current?.click();
775
+ }
776
+ },
777
+ [context.inputRef, propsRef.current.onKeyDown],
778
+ );
779
+
780
+ const DropzonePrimitive = asChild ? Slot : "div";
781
+
782
+ return (
783
+ <DropzonePrimitive
784
+ role="region"
785
+ id={context.dropzoneId}
786
+ aria-controls={`${context.inputId} ${context.listId}`}
787
+ aria-disabled={context.disabled}
788
+ aria-invalid={invalid}
789
+ data-disabled={context.disabled ? "" : undefined}
790
+ data-dragging={dragOver ? "" : undefined}
791
+ data-invalid={invalid ? "" : undefined}
792
+ data-slot="file-upload-dropzone"
793
+ dir={context.dir}
794
+ tabIndex={context.disabled ? undefined : 0}
795
+ {...dropzoneProps}
796
+ ref={forwardedRef}
797
+ className={cn(
798
+ "relative flex select-none flex-col items-center justify-center gap-2 rounded-lg border-2 border-dashed p-6 outline-none transition-colors hover:bg-accent/30 focus-visible:border-ring/50 data-[disabled]:pointer-events-none data-[dragging]:border-primary data-[invalid]:border-destructive data-[invalid]:ring-destructive/20",
799
+ className,
800
+ )}
801
+ onClick={onClick}
802
+ onDragEnter={onDragEnter}
803
+ onDragLeave={onDragLeave}
804
+ onDragOver={onDragOver}
805
+ onDrop={onDrop}
806
+ onKeyDown={onKeyDown}
807
+ onPaste={onPaste}
808
+ />
809
+ );
810
+ });
811
+ FileUploadDropzone.displayName = DROPZONE_NAME;
812
+
813
+ interface FileUploadTriggerProps
814
+ extends React.ComponentPropsWithoutRef<"button"> {
815
+ asChild?: boolean;
816
+ }
817
+
818
+ const FileUploadTrigger = React.forwardRef<
819
+ HTMLButtonElement,
820
+ FileUploadTriggerProps
821
+ >((props, forwardedRef) => {
822
+ const { asChild, ...triggerProps } = props;
823
+ const context = useFileUploadContext(TRIGGER_NAME);
824
+ const propsRef = useAsRef(triggerProps);
825
+
826
+ const onClick = React.useCallback(
827
+ (event: React.MouseEvent<HTMLButtonElement>) => {
828
+ propsRef.current?.onClick?.(event);
829
+
830
+ if (event.defaultPrevented) return;
831
+
832
+ context.inputRef.current?.click();
833
+ },
834
+ [context.inputRef, propsRef.current],
835
+ );
836
+
837
+ const TriggerPrimitive = asChild ? Slot : "button";
838
+
839
+ return (
840
+ <TriggerPrimitive
841
+ type="button"
842
+ aria-controls={context.inputId}
843
+ data-disabled={context.disabled ? "" : undefined}
844
+ data-slot="file-upload-trigger"
845
+ {...triggerProps}
846
+ ref={forwardedRef}
847
+ disabled={context.disabled}
848
+ onClick={onClick}
849
+ />
850
+ );
851
+ });
852
+ FileUploadTrigger.displayName = TRIGGER_NAME;
853
+
854
+ interface FileUploadListProps extends React.ComponentPropsWithoutRef<"div"> {
855
+ orientation?: "horizontal" | "vertical";
856
+ asChild?: boolean;
857
+ forceMount?: boolean;
858
+ }
859
+
860
+ const FileUploadList = React.forwardRef<HTMLDivElement, FileUploadListProps>(
861
+ (props, forwardedRef) => {
862
+ const {
863
+ className,
864
+ orientation = "vertical",
865
+ asChild,
866
+ forceMount,
867
+ ...listProps
868
+ } = props;
869
+
870
+ const context = useFileUploadContext(LIST_NAME);
871
+
872
+ const shouldRender =
873
+ forceMount || useStore((state) => state.files.size > 0);
874
+
875
+ if (!shouldRender) return null;
876
+
877
+ const ListPrimitive = asChild ? Slot : "div";
878
+
879
+ return (
880
+ <ListPrimitive
881
+ role="list"
882
+ id={context.listId}
883
+ aria-orientation={orientation}
884
+ data-orientation={orientation}
885
+ data-slot="file-upload-list"
886
+ data-state={shouldRender ? "active" : "inactive"}
887
+ dir={context.dir}
888
+ {...listProps}
889
+ ref={forwardedRef}
890
+ className={cn(
891
+ "data-[state=inactive]:fade-out-0 data-[state=active]:fade-in-0 data-[state=inactive]:slide-out-to-top-2 data-[state=active]:slide-in-from-top-2 flex flex-col gap-2 data-[state=active]:animate-in data-[state=inactive]:animate-out",
892
+ orientation === "horizontal" && "flex-row overflow-x-auto p-1.5",
893
+ className,
894
+ )}
895
+ />
896
+ );
897
+ },
898
+ );
899
+ FileUploadList.displayName = LIST_NAME;
900
+
901
+ interface FileUploadItemContextValue {
902
+ id: string;
903
+ fileState: FileState | undefined;
904
+ nameId: string;
905
+ sizeId: string;
906
+ statusId: string;
907
+ messageId: string;
908
+ }
909
+
910
+ const FileUploadItemContext =
911
+ React.createContext<FileUploadItemContextValue | null>(null);
912
+
913
+ function useFileUploadItemContext(name: keyof typeof FILE_UPLOAD_ERRORS) {
914
+ const context = React.useContext(FileUploadItemContext);
915
+ if (!context) {
916
+ throw new Error(FILE_UPLOAD_ERRORS[name]);
917
+ }
918
+ return context;
919
+ }
920
+
921
+ interface FileUploadItemProps extends React.ComponentPropsWithoutRef<"div"> {
922
+ value: File;
923
+ asChild?: boolean;
924
+ }
925
+
926
+ const FileUploadItem = React.forwardRef<HTMLDivElement, FileUploadItemProps>(
927
+ (props, forwardedRef) => {
928
+ const { value, asChild, className, ...itemProps } = props;
929
+
930
+ const id = React.useId();
931
+ const statusId = `${id}-status`;
932
+ const nameId = `${id}-name`;
933
+ const sizeId = `${id}-size`;
934
+ const messageId = `${id}-message`;
935
+
936
+ const context = useFileUploadContext(ITEM_NAME);
937
+ const fileState = useStore((state) => state.files.get(value));
938
+ const fileCount = useStore((state) => state.files.size);
939
+ const fileIndex = useStore((state) => {
940
+ const files = Array.from(state.files.keys());
941
+ return files.indexOf(value) + 1;
942
+ });
943
+
944
+ const itemContext = React.useMemo(
945
+ () => ({
946
+ id,
947
+ fileState,
948
+ nameId,
949
+ sizeId,
950
+ statusId,
951
+ messageId,
952
+ }),
953
+ [id, fileState, statusId, nameId, sizeId, messageId],
954
+ );
955
+
956
+ if (!fileState) return null;
957
+
958
+ const statusText = fileState.error
959
+ ? `Error: ${fileState.error}`
960
+ : fileState.status === "uploading"
961
+ ? `Uploading: ${fileState.progress}% complete`
962
+ : fileState.status === "success"
963
+ ? "Upload complete"
964
+ : "Ready to upload";
965
+
966
+ const ItemPrimitive = asChild ? Slot : "div";
967
+
968
+ return (
969
+ <FileUploadItemContext.Provider value={itemContext}>
970
+ <ItemPrimitive
971
+ role="listitem"
972
+ id={id}
973
+ aria-setsize={fileCount}
974
+ aria-posinset={fileIndex}
975
+ aria-describedby={`${nameId} ${sizeId} ${statusId} ${
976
+ fileState.error ? messageId : ""
977
+ }`}
978
+ aria-labelledby={nameId}
979
+ data-slot="file-upload-item"
980
+ dir={context.dir}
981
+ {...itemProps}
982
+ ref={forwardedRef}
983
+ className={cn(
984
+ "relative flex items-center gap-2.5 rounded-md border p-3",
985
+ className,
986
+ )}
987
+ >
988
+ {props.children}
989
+ <span id={statusId} className="sr-only">
990
+ {statusText}
991
+ </span>
992
+ </ItemPrimitive>
993
+ </FileUploadItemContext.Provider>
994
+ );
995
+ },
996
+ );
997
+ FileUploadItem.displayName = ITEM_NAME;
998
+
999
+ function formatBytes(bytes: number) {
1000
+ if (bytes === 0) return "0 B";
1001
+ const sizes = ["B", "KB", "MB", "GB", "TB"];
1002
+ const i = Math.floor(Math.log(bytes) / Math.log(1024));
1003
+ return `${(bytes / 1024 ** i).toFixed(i ? 1 : 0)} ${sizes[i]}`;
1004
+ }
1005
+
1006
+ function getFileIcon(file: File) {
1007
+ const type = file.type;
1008
+ const extension = file.name.split(".").pop()?.toLowerCase() ?? "";
1009
+
1010
+ if (type.startsWith("video/")) {
1011
+ return <FileVideoIcon />;
1012
+ }
1013
+
1014
+ if (type.startsWith("audio/")) {
1015
+ return <FileAudioIcon />;
1016
+ }
1017
+
1018
+ if (
1019
+ type.startsWith("text/") ||
1020
+ ["txt", "md", "rtf", "pdf"].includes(extension)
1021
+ ) {
1022
+ return <FileTextIcon />;
1023
+ }
1024
+
1025
+ if (
1026
+ [
1027
+ "html",
1028
+ "css",
1029
+ "js",
1030
+ "jsx",
1031
+ "ts",
1032
+ "tsx",
1033
+ "json",
1034
+ "xml",
1035
+ "php",
1036
+ "py",
1037
+ "rb",
1038
+ "java",
1039
+ "c",
1040
+ "cpp",
1041
+ "cs",
1042
+ ].includes(extension)
1043
+ ) {
1044
+ return <FileCodeIcon />;
1045
+ }
1046
+
1047
+ if (["zip", "rar", "7z", "tar", "gz", "bz2"].includes(extension)) {
1048
+ return <FileArchiveIcon />;
1049
+ }
1050
+
1051
+ if (
1052
+ ["exe", "msi", "app", "apk", "deb", "rpm"].includes(extension) ||
1053
+ type.startsWith("application/")
1054
+ ) {
1055
+ return <FileCogIcon />;
1056
+ }
1057
+
1058
+ return <FileIcon />;
1059
+ }
1060
+
1061
+ interface FileUploadItemPreviewProps
1062
+ extends React.ComponentPropsWithoutRef<"div"> {
1063
+ render?: (file: File) => React.ReactNode;
1064
+ asChild?: boolean;
1065
+ }
1066
+
1067
+ const FileUploadItemPreview = React.forwardRef<
1068
+ HTMLDivElement,
1069
+ FileUploadItemPreviewProps
1070
+ >((props, forwardedRef) => {
1071
+ const { render, asChild, children, className, ...previewProps } = props;
1072
+
1073
+ const itemContext = useFileUploadItemContext(ITEM_PREVIEW_NAME);
1074
+
1075
+ const onPreviewRender = React.useCallback(
1076
+ (file: File) => {
1077
+ if (render) return render(file);
1078
+
1079
+ if (itemContext.fileState?.file.type.startsWith("image/")) {
1080
+ return (
1081
+ <img
1082
+ src={URL.createObjectURL(file)}
1083
+ alt={file.name}
1084
+ className="size-full object-cover"
1085
+ onLoad={(event) => {
1086
+ if (!(event.target instanceof HTMLImageElement)) return;
1087
+ URL.revokeObjectURL(event.target.src);
1088
+ }}
1089
+ />
1090
+ );
1091
+ }
1092
+
1093
+ return getFileIcon(file);
1094
+ },
1095
+ [render, itemContext.fileState?.file.type],
1096
+ );
1097
+
1098
+ if (!itemContext.fileState) return null;
1099
+
1100
+ const ItemPreviewPrimitive = asChild ? Slot : "div";
1101
+
1102
+ return (
1103
+ <ItemPreviewPrimitive
1104
+ aria-labelledby={itemContext.nameId}
1105
+ data-slot="file-upload-preview"
1106
+ {...previewProps}
1107
+ ref={forwardedRef}
1108
+ className={cn(
1109
+ "relative flex size-10 shrink-0 items-center justify-center overflow-hidden rounded border bg-accent/50 [&>svg]:size-10",
1110
+ className,
1111
+ )}
1112
+ >
1113
+ {onPreviewRender(itemContext.fileState.file)}
1114
+ {children}
1115
+ </ItemPreviewPrimitive>
1116
+ );
1117
+ });
1118
+ FileUploadItemPreview.displayName = ITEM_PREVIEW_NAME;
1119
+
1120
+ interface FileUploadItemMetadataProps
1121
+ extends React.ComponentPropsWithoutRef<"div"> {
1122
+ asChild?: boolean;
1123
+ size?: "default" | "sm";
1124
+ }
1125
+
1126
+ const FileUploadItemMetadata = React.forwardRef<
1127
+ HTMLDivElement,
1128
+ FileUploadItemMetadataProps
1129
+ >((props, forwardedRef) => {
1130
+ const {
1131
+ asChild,
1132
+ size = "default",
1133
+ children,
1134
+ className,
1135
+ ...metadataProps
1136
+ } = props;
1137
+
1138
+ const context = useFileUploadContext(ITEM_METADATA_NAME);
1139
+ const itemContext = useFileUploadItemContext(ITEM_METADATA_NAME);
1140
+
1141
+ if (!itemContext.fileState) return null;
1142
+
1143
+ const ItemMetadataPrimitive = asChild ? Slot : "div";
1144
+
1145
+ return (
1146
+ <ItemMetadataPrimitive
1147
+ data-slot="file-upload-metadata"
1148
+ dir={context.dir}
1149
+ {...metadataProps}
1150
+ ref={forwardedRef}
1151
+ className={cn("flex min-w-0 flex-1 flex-col", className)}
1152
+ >
1153
+ {children ?? (
1154
+ <>
1155
+ <span
1156
+ id={itemContext.nameId}
1157
+ className={cn(
1158
+ "truncate font-medium text-sm",
1159
+ size === "sm" && "font-normal text-[13px] leading-snug",
1160
+ )}
1161
+ >
1162
+ {itemContext.fileState.file.name}
1163
+ </span>
1164
+ <span
1165
+ id={itemContext.sizeId}
1166
+ className={cn(
1167
+ "truncate text-muted-foreground text-xs",
1168
+ size === "sm" && "text-[11px]",
1169
+ )}
1170
+ >
1171
+ {formatBytes(itemContext.fileState.file.size)}
1172
+ </span>
1173
+ {itemContext.fileState.error && (
1174
+ <span
1175
+ id={itemContext.messageId}
1176
+ className="text-destructive text-xs"
1177
+ >
1178
+ {itemContext.fileState.error}
1179
+ </span>
1180
+ )}
1181
+ </>
1182
+ )}
1183
+ </ItemMetadataPrimitive>
1184
+ );
1185
+ });
1186
+ FileUploadItemMetadata.displayName = ITEM_METADATA_NAME;
1187
+
1188
+ interface FileUploadItemProgressProps
1189
+ extends React.ComponentPropsWithoutRef<"div"> {
1190
+ asChild?: boolean;
1191
+ variant?: "linear" | "circular" | "fill";
1192
+ size?: number;
1193
+ forceMount?: boolean;
1194
+ }
1195
+
1196
+ const FileUploadItemProgress = React.forwardRef<
1197
+ HTMLDivElement,
1198
+ FileUploadItemProgressProps
1199
+ >((props, forwardedRef) => {
1200
+ const {
1201
+ variant = "linear",
1202
+ size = 40,
1203
+ asChild,
1204
+ forceMount,
1205
+ className,
1206
+ ...progressProps
1207
+ } = props;
1208
+
1209
+ const itemContext = useFileUploadItemContext(ITEM_PROGRESS_NAME);
1210
+
1211
+ if (!itemContext.fileState) return null;
1212
+
1213
+ const shouldRender = forceMount || itemContext.fileState.progress !== 100;
1214
+
1215
+ if (!shouldRender) return null;
1216
+
1217
+ const ItemProgressPrimitive = asChild ? Slot : "div";
1218
+
1219
+ switch (variant) {
1220
+ case "circular": {
1221
+ const circumference = 2 * Math.PI * ((size - 4) / 2);
1222
+ const strokeDashoffset =
1223
+ circumference - (itemContext.fileState.progress / 100) * circumference;
1224
+
1225
+ return (
1226
+ <ItemProgressPrimitive
1227
+ role="progressbar"
1228
+ aria-valuemin={0}
1229
+ aria-valuemax={100}
1230
+ aria-valuenow={itemContext.fileState.progress}
1231
+ aria-valuetext={`${itemContext.fileState.progress}%`}
1232
+ aria-labelledby={itemContext.nameId}
1233
+ data-slot="file-upload-progress"
1234
+ {...progressProps}
1235
+ ref={forwardedRef}
1236
+ className={cn(
1237
+ "-translate-x-1/2 -translate-y-1/2 absolute top-1/2 left-1/2",
1238
+ className,
1239
+ )}
1240
+ >
1241
+ <svg
1242
+ className="rotate-[-90deg] transform"
1243
+ width={size}
1244
+ height={size}
1245
+ viewBox={`0 0 ${size} ${size}`}
1246
+ fill="none"
1247
+ stroke="currentColor"
1248
+ >
1249
+ <circle
1250
+ className="text-primary/20"
1251
+ strokeWidth="2"
1252
+ cx={size / 2}
1253
+ cy={size / 2}
1254
+ r={(size - 4) / 2}
1255
+ />
1256
+ <circle
1257
+ className="text-primary transition-[stroke-dashoffset] duration-300 ease-linear"
1258
+ strokeWidth="2"
1259
+ strokeLinecap="round"
1260
+ strokeDasharray={circumference}
1261
+ strokeDashoffset={strokeDashoffset}
1262
+ cx={size / 2}
1263
+ cy={size / 2}
1264
+ r={(size - 4) / 2}
1265
+ />
1266
+ </svg>
1267
+ </ItemProgressPrimitive>
1268
+ );
1269
+ }
1270
+
1271
+ case "fill": {
1272
+ const progressPercentage = itemContext.fileState.progress;
1273
+ const topInset = 100 - progressPercentage;
1274
+
1275
+ return (
1276
+ <ItemProgressPrimitive
1277
+ role="progressbar"
1278
+ aria-valuemin={0}
1279
+ aria-valuemax={100}
1280
+ aria-valuenow={progressPercentage}
1281
+ aria-valuetext={`${progressPercentage}%`}
1282
+ aria-labelledby={itemContext.nameId}
1283
+ data-slot="file-upload-progress"
1284
+ {...progressProps}
1285
+ ref={forwardedRef}
1286
+ className={cn(
1287
+ "absolute inset-0 bg-primary/50 transition-[clip-path] duration-300 ease-linear",
1288
+ className,
1289
+ )}
1290
+ style={{
1291
+ clipPath: `inset(${topInset}% 0% 0% 0%)`,
1292
+ }}
1293
+ />
1294
+ );
1295
+ }
1296
+
1297
+ default:
1298
+ return (
1299
+ <ItemProgressPrimitive
1300
+ role="progressbar"
1301
+ aria-valuemin={0}
1302
+ aria-valuemax={100}
1303
+ aria-valuenow={itemContext.fileState.progress}
1304
+ aria-valuetext={`${itemContext.fileState.progress}%`}
1305
+ aria-labelledby={itemContext.nameId}
1306
+ data-slot="file-upload-progress"
1307
+ {...progressProps}
1308
+ ref={forwardedRef}
1309
+ className={cn(
1310
+ "relative h-1.5 w-full overflow-hidden rounded-full bg-primary/20",
1311
+ className,
1312
+ )}
1313
+ >
1314
+ <div
1315
+ className="h-full w-full flex-1 bg-primary transition-transform duration-300 ease-linear"
1316
+ style={{
1317
+ transform: `translateX(-${100 - itemContext.fileState.progress}%)`,
1318
+ }}
1319
+ />
1320
+ </ItemProgressPrimitive>
1321
+ );
1322
+ }
1323
+ });
1324
+ FileUploadItemProgress.displayName = ITEM_PROGRESS_NAME;
1325
+
1326
+ interface FileUploadItemDeleteProps
1327
+ extends React.ComponentPropsWithoutRef<"button"> {
1328
+ asChild?: boolean;
1329
+ }
1330
+
1331
+ const FileUploadItemDelete = React.forwardRef<
1332
+ HTMLButtonElement,
1333
+ FileUploadItemDeleteProps
1334
+ >((props, forwardedRef) => {
1335
+ const { asChild, ...deleteProps } = props;
1336
+
1337
+ const store = useStoreContext(ITEM_DELETE_NAME);
1338
+ const itemContext = useFileUploadItemContext(ITEM_DELETE_NAME);
1339
+ const propsRef = useAsRef(deleteProps);
1340
+
1341
+ const onClick = React.useCallback(
1342
+ (event: React.MouseEvent<HTMLButtonElement>) => {
1343
+ propsRef.current?.onClick?.(event);
1344
+
1345
+ if (!itemContext.fileState || event.defaultPrevented) return;
1346
+
1347
+ store.dispatch({
1348
+ variant: "REMOVE_FILE",
1349
+ file: itemContext.fileState.file,
1350
+ });
1351
+ },
1352
+ [store, itemContext.fileState, propsRef.current?.onClick],
1353
+ );
1354
+
1355
+ if (!itemContext.fileState) return null;
1356
+
1357
+ const ItemDeletePrimitive = asChild ? Slot : "button";
1358
+
1359
+ return (
1360
+ <ItemDeletePrimitive
1361
+ type="button"
1362
+ aria-controls={itemContext.id}
1363
+ aria-describedby={itemContext.nameId}
1364
+ data-slot="file-upload-item-delete"
1365
+ {...deleteProps}
1366
+ ref={forwardedRef}
1367
+ onClick={onClick}
1368
+ />
1369
+ );
1370
+ });
1371
+ FileUploadItemDelete.displayName = ITEM_DELETE_NAME;
1372
+
1373
+ interface FileUploadClearProps
1374
+ extends React.ComponentPropsWithoutRef<"button"> {
1375
+ forceMount?: boolean;
1376
+ asChild?: boolean;
1377
+ }
1378
+
1379
+ const FileUploadClear = React.forwardRef<
1380
+ HTMLButtonElement,
1381
+ FileUploadClearProps
1382
+ >((props, forwardedRef) => {
1383
+ const { asChild, forceMount, disabled, ...clearProps } = props;
1384
+
1385
+ const context = useFileUploadContext(CLEAR_NAME);
1386
+ const store = useStoreContext(CLEAR_NAME);
1387
+ const propsRef = useAsRef(clearProps);
1388
+
1389
+ const isDisabled = disabled || context.disabled;
1390
+
1391
+ const onClick = React.useCallback(
1392
+ (event: React.MouseEvent<HTMLButtonElement>) => {
1393
+ propsRef.current?.onClick?.(event);
1394
+
1395
+ if (event.defaultPrevented) return;
1396
+
1397
+ store.dispatch({ variant: "CLEAR" });
1398
+ },
1399
+ [store, propsRef],
1400
+ );
1401
+
1402
+ const shouldRender = forceMount || useStore((state) => state.files.size > 0);
1403
+
1404
+ if (!shouldRender) return null;
1405
+
1406
+ const ClearPrimitive = asChild ? Slot : "button";
1407
+
1408
+ return (
1409
+ <ClearPrimitive
1410
+ type="button"
1411
+ aria-controls={context.listId}
1412
+ data-slot="file-upload-clear"
1413
+ data-disabled={isDisabled ? "" : undefined}
1414
+ {...clearProps}
1415
+ ref={forwardedRef}
1416
+ disabled={isDisabled}
1417
+ onClick={onClick}
1418
+ />
1419
+ );
1420
+ });
1421
+ FileUploadClear.displayName = CLEAR_NAME;
1422
+
1423
+ const FileUpload = FileUploadRoot;
1424
+ const Root = FileUploadRoot;
1425
+ const Trigger = FileUploadTrigger;
1426
+ const Dropzone = FileUploadDropzone;
1427
+ const List = FileUploadList;
1428
+ const Item = FileUploadItem;
1429
+ const ItemPreview = FileUploadItemPreview;
1430
+ const ItemMetadata = FileUploadItemMetadata;
1431
+ const ItemProgress = FileUploadItemProgress;
1432
+ const ItemDelete = FileUploadItemDelete;
1433
+ const Clear = FileUploadClear;
1434
+
1435
+ export {
1436
+ FileUpload,
1437
+ FileUploadDropzone,
1438
+ FileUploadTrigger,
1439
+ FileUploadList,
1440
+ FileUploadItem,
1441
+ FileUploadItemPreview,
1442
+ FileUploadItemMetadata,
1443
+ FileUploadItemProgress,
1444
+ FileUploadItemDelete,
1445
+ FileUploadClear,
1446
+ //
1447
+ Root,
1448
+ Dropzone,
1449
+ Trigger,
1450
+ List,
1451
+ Item,
1452
+ ItemPreview,
1453
+ ItemMetadata,
1454
+ ItemProgress,
1455
+ ItemDelete,
1456
+ Clear,
1457
+ //
1458
+ useStore as useFileUpload,
1459
+ };