@adamosuiteservices/ui 2.17.1 → 2.18.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1116 @@
1
+ # File Upload V2 Component
2
+
3
+ ## Description
4
+
5
+ Enhanced file upload component with **unique file identification**, **metadata support**, and **server integration** capabilities. Built on the FileUpload component foundation, FileUploadV2 adds automatic ID generation for each file, making it ideal for scenarios requiring server-side file tracking, asynchronous uploads, and delete operations.
6
+
7
+ > **Need a simpler version?** Check out [FileUpload](file-upload.md) for basic file upload without IDs or server integration features.
8
+
9
+ ## Features
10
+
11
+ - ✅ All features from FileUpload component
12
+ - ✅ Automatic unique ID generation for each file
13
+ - ✅ Custom ID generator support
14
+ - ✅ Metadata support for storing additional file information
15
+ - ✅ Server-side file tracking with IDs
16
+ - ✅ Pre-loaded files with server IDs
17
+ - ✅ Callbacks with file references for API integration
18
+ - ✅ Full TypeScript with FileWithMetadata type
19
+ - ✅ Perfect for async upload/delete operations
20
+
21
+ ## When to use
22
+
23
+ Use **FileUploadV2** when you need:
24
+
25
+ - Server-side file tracking with unique identifiers
26
+ - Asynchronous file uploads with progress tracking
27
+ - Delete operations requiring file IDs
28
+ - Pre-loading existing files from server
29
+ - Storing metadata alongside files
30
+ - Complex file management workflows
31
+
32
+ Use **FileUpload** (original) when you need:
33
+
34
+ - Simple file selection without server integration
35
+ - One-time form submission with files
36
+ - No need for file identification or tracking
37
+
38
+ ## Import
39
+
40
+ ```typescript
41
+ import { FileUploadV2 } from "@adamosuiteservices/ui/file-upload-v2";
42
+ import type {
43
+ FileUploadV2Props,
44
+ FileWithMetadata,
45
+ FileUploadV2Labels,
46
+ } from "@adamosuiteservices/ui/file-upload-v2";
47
+ ```
48
+
49
+ ## Basic usage
50
+
51
+ ### Single file with automatic IDs
52
+
53
+ ```tsx
54
+ import { useState } from "react";
55
+ import {
56
+ FileUploadV2,
57
+ FileWithMetadata,
58
+ } from "@adamosuiteservices/ui/file-upload-v2";
59
+
60
+ function MyForm() {
61
+ const [file, setFile] = useState<FileWithMetadata | null>(null);
62
+
63
+ return <FileUploadV2 selectedFile={file} onFileSelect={setFile} />;
64
+ }
65
+ ```
66
+
67
+ ### Multiple files with automatic IDs
68
+
69
+ ```tsx
70
+ import { useState } from "react";
71
+ import {
72
+ FileUploadV2,
73
+ FileWithMetadata,
74
+ } from "@adamosuiteservices/ui/file-upload-v2";
75
+
76
+ function MyForm() {
77
+ const [files, setFiles] = useState<FileWithMetadata[]>([]);
78
+
79
+ return (
80
+ <FileUploadV2 selectedFiles={files} onFilesSelect={setFiles} multiple />
81
+ );
82
+ }
83
+ ```
84
+
85
+ ### With server files (pre-loaded)
86
+
87
+ ```tsx
88
+ import { useState, useEffect } from "react";
89
+ import {
90
+ FileUploadV2,
91
+ FileWithMetadata,
92
+ } from "@adamosuiteservices/ui/file-upload-v2";
93
+
94
+ function MyForm() {
95
+ const [files, setFiles] = useState<FileWithMetadata[]>([]);
96
+
97
+ useEffect(() => {
98
+ // Load existing files from server
99
+ const serverFiles: FileWithMetadata[] = [
100
+ {
101
+ id: "server-file-123",
102
+ file: new File(["content"], "report.xlsx", {
103
+ type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
104
+ }),
105
+ metadata: {
106
+ uploadedAt: "2024-01-15T10:30:00Z",
107
+ uploadedBy: "user@example.com",
108
+ serverUrl: "https://api.example.com/files/server-file-123",
109
+ },
110
+ },
111
+ ];
112
+ setFiles(serverFiles);
113
+ }, []);
114
+
115
+ return (
116
+ <FileUploadV2 selectedFiles={files} onFilesSelect={setFiles} multiple />
117
+ );
118
+ }
119
+ ```
120
+
121
+ ## Props
122
+
123
+ ### FileWithMetadata type
124
+
125
+ ```tsx
126
+ type FileWithMetadata = {
127
+ id: string;
128
+ file: File;
129
+ metadata?: Record<string, unknown>;
130
+ };
131
+ ```
132
+
133
+ - **id**: Unique identifier for the file (auto-generated or provided)
134
+ - **file**: Native browser File object
135
+ - **metadata**: Optional additional data (upload timestamp, server URL, etc.)
136
+
137
+ ### Simple mode (single file)
138
+
139
+ #### selectedFile
140
+
141
+ ```tsx
142
+ selectedFile?: FileWithMetadata | null
143
+ ```
144
+
145
+ State of the currently selected file with ID and metadata. Use `null` when there is no file.
146
+
147
+ #### onFileSelect
148
+
149
+ ```tsx
150
+ onFileSelect?: (file: FileWithMetadata | null) => void
151
+ ```
152
+
153
+ Callback invoked when the user selects or removes a file. Receives the file with ID and metadata, or `null` when removed.
154
+
155
+ #### onFileAdd
156
+
157
+ ```tsx
158
+ onFileAdd?: (file: FileWithMetadata) => void
159
+ ```
160
+
161
+ Callback invoked when a new file is added. Receives the file with auto-generated ID. This fires in addition to `onFileSelect`.
162
+
163
+ **Use case**: Start upload immediately when file is added.
164
+
165
+ **Example**:
166
+
167
+ ```tsx
168
+ const handleFileAdd = async (fileWithMetadata: FileWithMetadata) => {
169
+ console.log("File added with ID:", fileWithMetadata.id);
170
+
171
+ const formData = new FormData();
172
+ formData.append("file", fileWithMetadata.file);
173
+ formData.append("fileId", fileWithMetadata.id);
174
+
175
+ await fetch("/api/upload", {
176
+ method: "POST",
177
+ body: formData,
178
+ });
179
+ };
180
+
181
+ <FileUploadV2
182
+ selectedFile={file}
183
+ onFileSelect={setFile}
184
+ onFileAdd={handleFileAdd}
185
+ />;
186
+ ```
187
+
188
+ #### onFileRemove
189
+
190
+ ```tsx
191
+ onFileRemove?: (file: FileWithMetadata) => void
192
+ ```
193
+
194
+ Callback invoked when a file is removed. Receives the file with its ID, enabling server-side deletion.
195
+
196
+ **Use case**: Call API to delete file from server using the file ID.
197
+
198
+ **Example**:
199
+
200
+ ```tsx
201
+ const handleFileRemove = async (fileWithMetadata: FileWithMetadata) => {
202
+ console.log("File removed with ID:", fileWithMetadata.id);
203
+
204
+ // Only delete from server if it has been uploaded
205
+ if (fileWithMetadata.metadata?.serverUrl) {
206
+ await fetch(`/api/files/${fileWithMetadata.id}`, {
207
+ method: "DELETE",
208
+ });
209
+ }
210
+ };
211
+
212
+ <FileUploadV2
213
+ selectedFile={file}
214
+ onFileSelect={setFile}
215
+ onFileRemove={handleFileRemove}
216
+ />;
217
+ ```
218
+
219
+ ### Multiple mode
220
+
221
+ #### selectedFiles
222
+
223
+ ```tsx
224
+ selectedFiles?: FileWithMetadata[]
225
+ ```
226
+
227
+ Array of selected files with IDs and metadata.
228
+
229
+ #### onFilesSelect
230
+
231
+ ```tsx
232
+ onFilesSelect?: (files: FileWithMetadata[]) => void
233
+ ```
234
+
235
+ Callback invoked when the user selects, adds, or removes files.
236
+
237
+ #### onFilesAdd
238
+
239
+ ```tsx
240
+ onFilesAdd?: (files: FileWithMetadata[]) => void
241
+ ```
242
+
243
+ Callback invoked when new files are added. Receives array of files with auto-generated IDs. This fires in addition to `onFilesSelect`.
244
+
245
+ **Example**:
246
+
247
+ ```tsx
248
+ const handleFilesAdd = async (filesWithMetadata: FileWithMetadata[]) => {
249
+ console.log(
250
+ "Files added:",
251
+ filesWithMetadata.map((f) => f.id),
252
+ );
253
+
254
+ // Upload each file
255
+ await Promise.all(
256
+ filesWithMetadata.map(async (fwm) => {
257
+ const formData = new FormData();
258
+ formData.append("file", fwm.file);
259
+ formData.append("fileId", fwm.id);
260
+
261
+ return fetch("/api/upload", {
262
+ method: "POST",
263
+ body: formData,
264
+ });
265
+ }),
266
+ );
267
+ };
268
+
269
+ <FileUploadV2
270
+ selectedFiles={files}
271
+ onFilesSelect={setFiles}
272
+ onFilesAdd={handleFilesAdd}
273
+ multiple
274
+ />;
275
+ ```
276
+
277
+ #### onFilesRemove
278
+
279
+ ```tsx
280
+ onFilesRemove?: (files: FileWithMetadata[]) => void
281
+ ```
282
+
283
+ Callback invoked when files are removed. Receives array of removed files with their IDs.
284
+
285
+ **Example**:
286
+
287
+ ```tsx
288
+ const handleFilesRemove = async (removedFiles: FileWithMetadata[]) => {
289
+ console.log(
290
+ "Files removed:",
291
+ removedFiles.map((f) => f.id),
292
+ );
293
+
294
+ // Delete from server
295
+ await Promise.all(
296
+ removedFiles
297
+ .filter((f) => f.metadata?.serverUrl)
298
+ .map((f) => fetch(`/api/files/${f.id}`, { method: "DELETE" })),
299
+ );
300
+ };
301
+
302
+ <FileUploadV2
303
+ selectedFiles={files}
304
+ onFilesSelect={setFiles}
305
+ onFilesRemove={handleFilesRemove}
306
+ multiple
307
+ />;
308
+ ```
309
+
310
+ #### multiple
311
+
312
+ ```tsx
313
+ multiple?: boolean
314
+ ```
315
+
316
+ Enables multiple file mode. Default: `false`
317
+
318
+ #### maxFiles
319
+
320
+ ```tsx
321
+ maxFiles?: number
322
+ ```
323
+
324
+ Maximum number of files allowed in multiple mode. Default: `10`
325
+
326
+ #### filesPosition
327
+
328
+ ```tsx
329
+ filesPosition?: "above" | "below"
330
+ ```
331
+
332
+ Position where selected files appear in multiple mode. Default: `"below"`
333
+
334
+ ### ID generation
335
+
336
+ #### generateId
337
+
338
+ ```tsx
339
+ generateId?: (file: File) => string
340
+ ```
341
+
342
+ Custom function to generate unique IDs for files. If not provided, uses default generator.
343
+
344
+ **Default generator format**: `{timestamp}-{random}-{filename}`
345
+
346
+ Example: `1704892800000-x7k2p9q3m-report.xlsx`
347
+
348
+ **Use case**: Use custom ID format for integration with existing systems.
349
+
350
+ **Example**:
351
+
352
+ ```tsx
353
+ const customIdGenerator = (file: File) => {
354
+ return `custom-${Date.now()}-${file.name}`;
355
+ };
356
+
357
+ <FileUploadV2
358
+ selectedFile={file}
359
+ onFileSelect={setFile}
360
+ generateId={customIdGenerator}
361
+ />;
362
+ ```
363
+
364
+ **Example with UUID**:
365
+
366
+ ```tsx
367
+ import { v4 as uuidv4 } from "uuid";
368
+
369
+ <FileUploadV2
370
+ selectedFile={file}
371
+ onFileSelect={setFile}
372
+ generateId={(file) => uuidv4()}
373
+ />;
374
+ ```
375
+
376
+ ### Common props
377
+
378
+ All props from the original FileUpload component are supported:
379
+
380
+ #### acceptedExtensions
381
+
382
+ ```tsx
383
+ acceptedExtensions?: string[]
384
+ ```
385
+
386
+ Array of allowed file extensions. Default: `[".xls", ".xlsx", ".numbers"]`
387
+
388
+ #### maxSizeInMB
389
+
390
+ ```tsx
391
+ maxSizeInMB?: number
392
+ ```
393
+
394
+ Maximum file size in megabytes. Default: `50`
395
+
396
+ #### labels
397
+
398
+ ```tsx
399
+ labels?: FileUploadV2Labels
400
+
401
+ type FileUploadV2Labels = {
402
+ dragDrop?: string
403
+ selectFile?: string
404
+ fileRequirements?: string
405
+ filesSelected?: (count: number) => string
406
+ }
407
+ ```
408
+
409
+ Customizable labels for internationalization.
410
+
411
+ #### aria-invalid / invalid
412
+
413
+ ```tsx
414
+ "aria-invalid"?: boolean
415
+ invalid?: boolean
416
+ ```
417
+
418
+ Marks the component as invalid. Default: `false`
419
+
420
+ #### disabled
421
+
422
+ ```tsx
423
+ disabled?: boolean
424
+ ```
425
+
426
+ Disables the component. Default: `false`
427
+
428
+ #### input
429
+
430
+ ```tsx
431
+ input?: ComponentProps<"input">
432
+ ```
433
+
434
+ Custom props for the internal `<input type="file">` element.
435
+
436
+ #### onInvalidFile
437
+
438
+ ```tsx
439
+ onInvalidFile?: (file: FileWithMetadata, reason: "extension" | "size") => void
440
+ ```
441
+
442
+ Callback invoked when a file does not pass validation.
443
+
444
+ ## Examples
445
+
446
+ ### With Field components
447
+
448
+ ```tsx
449
+ import {
450
+ Field,
451
+ FieldLabel,
452
+ FieldDescription,
453
+ FieldError,
454
+ FieldGroup,
455
+ } from "@adamosuiteservices/ui/field";
456
+ import {
457
+ FileUploadV2,
458
+ FileWithMetadata,
459
+ } from "@adamosuiteservices/ui/file-upload-v2";
460
+
461
+ function FileUploadForm() {
462
+ const [file, setFile] = useState<FileWithMetadata | null>(null);
463
+ const [error, setError] = useState<string>("");
464
+
465
+ const handleInvalidFile = (
466
+ file: FileWithMetadata,
467
+ reason: "extension" | "size",
468
+ ) => {
469
+ if (reason === "extension") {
470
+ setError(`File "${file.file.name}" has an invalid extension.`);
471
+ } else if (reason === "size") {
472
+ setError(`File "${file.file.name}" exceeds the maximum size limit.`);
473
+ }
474
+ };
475
+
476
+ return (
477
+ <FieldGroup className="adm:max-w-xl">
478
+ <Field>
479
+ <FieldLabel>Upload document (PDF or DOCX only, max 2MB)</FieldLabel>
480
+ <FileUploadV2
481
+ selectedFile={file}
482
+ onFileSelect={(newFile) => {
483
+ setFile(newFile);
484
+ if (newFile) setError("");
485
+ }}
486
+ onInvalidFile={handleInvalidFile}
487
+ aria-invalid={!!error}
488
+ acceptedExtensions={[".pdf", ".docx"]}
489
+ maxSizeInMB={2}
490
+ />
491
+ {error ? (
492
+ <FieldError>{error}</FieldError>
493
+ ) : (
494
+ <FieldDescription>
495
+ Supported formats: PDF, DOCX. Maximum 2MB.
496
+ </FieldDescription>
497
+ )}
498
+ </Field>
499
+ </FieldGroup>
500
+ );
501
+ }
502
+ ```
503
+
504
+ ### With API integration (async upload)
505
+
506
+ ```tsx
507
+ import { useState } from "react";
508
+ import {
509
+ FileUploadV2,
510
+ FileWithMetadata,
511
+ } from "@adamosuiteservices/ui/file-upload-v2";
512
+ import { Button } from "@adamosuiteservices/ui/button";
513
+ import { Typography } from "@adamosuiteservices/ui/typography";
514
+
515
+ function AsyncUploadExample() {
516
+ const [files, setFiles] = useState<FileWithMetadata[]>([]);
517
+ const [uploadStatus, setUploadStatus] = useState<
518
+ Record<string, "uploading" | "success" | "error">
519
+ >({});
520
+
521
+ const handleFilesAdd = async (addedFiles: FileWithMetadata[]) => {
522
+ // Mark files as uploading
523
+ const newStatus = { ...uploadStatus };
524
+ addedFiles.forEach((f) => {
525
+ newStatus[f.id] = "uploading";
526
+ });
527
+ setUploadStatus(newStatus);
528
+
529
+ // Upload each file
530
+ await Promise.all(
531
+ addedFiles.map(async (fileWithMetadata) => {
532
+ try {
533
+ const formData = new FormData();
534
+ formData.append("file", fileWithMetadata.file);
535
+ formData.append("fileId", fileWithMetadata.id);
536
+
537
+ const response = await fetch("/api/upload", {
538
+ method: "POST",
539
+ body: formData,
540
+ });
541
+
542
+ if (response.ok) {
543
+ const data = await response.json();
544
+
545
+ // Update file with server metadata
546
+ setFiles((prev) =>
547
+ prev.map((f) =>
548
+ f.id === fileWithMetadata.id
549
+ ? {
550
+ ...f,
551
+ metadata: {
552
+ ...f.metadata,
553
+ serverUrl: data.url,
554
+ uploadedAt: new Date().toISOString(),
555
+ },
556
+ }
557
+ : f,
558
+ ),
559
+ );
560
+
561
+ setUploadStatus((prev) => ({
562
+ ...prev,
563
+ [fileWithMetadata.id]: "success",
564
+ }));
565
+ } else {
566
+ setUploadStatus((prev) => ({
567
+ ...prev,
568
+ [fileWithMetadata.id]: "error",
569
+ }));
570
+ }
571
+ } catch (error) {
572
+ console.error("Upload failed:", error);
573
+ setUploadStatus((prev) => ({
574
+ ...prev,
575
+ [fileWithMetadata.id]: "error",
576
+ }));
577
+ }
578
+ }),
579
+ );
580
+ };
581
+
582
+ const handleFilesRemove = async (removedFiles: FileWithMetadata[]) => {
583
+ // Delete from server if they were uploaded
584
+ await Promise.all(
585
+ removedFiles
586
+ .filter((f) => f.metadata?.serverUrl)
587
+ .map(async (f) => {
588
+ try {
589
+ await fetch(`/api/files/${f.id}`, { method: "DELETE" });
590
+ } catch (error) {
591
+ console.error("Delete failed:", error);
592
+ }
593
+ }),
594
+ );
595
+
596
+ // Clean up upload status
597
+ setUploadStatus((prev) => {
598
+ const newStatus = { ...prev };
599
+ removedFiles.forEach((f) => delete newStatus[f.id]);
600
+ return newStatus;
601
+ });
602
+ };
603
+
604
+ return (
605
+ <div className="adm:max-w-xl adm:space-y-4">
606
+ <FileUploadV2
607
+ selectedFiles={files}
608
+ onFilesSelect={setFiles}
609
+ onFilesAdd={handleFilesAdd}
610
+ onFilesRemove={handleFilesRemove}
611
+ multiple
612
+ />
613
+
614
+ {files.length > 0 && (
615
+ <div className="adm:space-y-2">
616
+ <Typography className="adm:font-medium">Upload status:</Typography>
617
+ {files.map((f) => (
618
+ <div key={f.id} className="adm:flex adm:items-center adm:gap-2">
619
+ <Typography className="adm:text-sm">{f.file.name}</Typography>
620
+ <Typography
621
+ className="adm:text-sm"
622
+ color={
623
+ uploadStatus[f.id] === "success"
624
+ ? "success"
625
+ : uploadStatus[f.id] === "error"
626
+ ? "destructive"
627
+ : "muted"
628
+ }
629
+ >
630
+ {uploadStatus[f.id] === "uploading" && "Uploading..."}
631
+ {uploadStatus[f.id] === "success" && "✓ Uploaded"}
632
+ {uploadStatus[f.id] === "error" && "✗ Failed"}
633
+ </Typography>
634
+ </div>
635
+ ))}
636
+ </div>
637
+ )}
638
+ </div>
639
+ );
640
+ }
641
+ ```
642
+
643
+ ### With pre-loaded server files
644
+
645
+ ```tsx
646
+ import { useState, useEffect } from "react";
647
+ import {
648
+ FileUploadV2,
649
+ FileWithMetadata,
650
+ } from "@adamosuiteservices/ui/file-upload-v2";
651
+
652
+ function PreloadedFilesExample() {
653
+ const [files, setFiles] = useState<FileWithMetadata[]>([]);
654
+
655
+ useEffect(() => {
656
+ // Simulate fetching files from server
657
+ const loadServerFiles = async () => {
658
+ const response = await fetch("/api/files");
659
+ const serverFiles = await response.json();
660
+
661
+ // Convert server files to FileWithMetadata
662
+ const filesWithMetadata: FileWithMetadata[] = serverFiles.map(
663
+ (sf: any) => ({
664
+ id: sf.id,
665
+ file: new File([""], sf.name, { type: sf.mimeType }),
666
+ metadata: {
667
+ serverUrl: sf.url,
668
+ uploadedAt: sf.uploadedAt,
669
+ size: sf.size,
670
+ },
671
+ }),
672
+ );
673
+
674
+ setFiles(filesWithMetadata);
675
+ };
676
+
677
+ loadServerFiles();
678
+ }, []);
679
+
680
+ const handleFilesRemove = async (removedFiles: FileWithMetadata[]) => {
681
+ // Delete from server
682
+ await Promise.all(
683
+ removedFiles.map((f) =>
684
+ fetch(`/api/files/${f.id}`, { method: "DELETE" }),
685
+ ),
686
+ );
687
+ };
688
+
689
+ return (
690
+ <FileUploadV2
691
+ selectedFiles={files}
692
+ onFilesSelect={setFiles}
693
+ onFilesRemove={handleFilesRemove}
694
+ multiple
695
+ className="adm:max-w-xl"
696
+ />
697
+ );
698
+ }
699
+ ```
700
+
701
+ ### With custom ID generator
702
+
703
+ ```tsx
704
+ import { useState } from "react";
705
+ import {
706
+ FileUploadV2,
707
+ FileWithMetadata,
708
+ } from "@adamosuiteservices/ui/file-upload-v2";
709
+ import { v4 as uuidv4 } from "uuid";
710
+
711
+ function CustomIdExample() {
712
+ const [file, setFile] = useState<FileWithMetadata | null>(null);
713
+
714
+ // Use UUID for file IDs
715
+ const generateUUID = () => uuidv4();
716
+
717
+ return (
718
+ <FileUploadV2
719
+ selectedFile={file}
720
+ onFileSelect={setFile}
721
+ generateId={generateUUID}
722
+ className="adm:max-w-xl"
723
+ />
724
+ );
725
+ }
726
+ ```
727
+
728
+ ### With form integration and validation
729
+
730
+ ```tsx
731
+ import { useState } from "react";
732
+ import {
733
+ FileUploadV2,
734
+ FileWithMetadata,
735
+ } from "@adamosuiteservices/ui/file-upload-v2";
736
+ import { Field, FieldLabel, FieldError } from "@adamosuiteservices/ui/field";
737
+ import { Button } from "@adamosuiteservices/ui/button";
738
+
739
+ function FormIntegrationExample() {
740
+ const [files, setFiles] = useState<FileWithMetadata[]>([]);
741
+ const [error, setError] = useState<string>("");
742
+ const [uploadedFileIds, setUploadedFileIds] = useState<string[]>([]);
743
+
744
+ const handleSubmit = async (e: React.FormEvent) => {
745
+ e.preventDefault();
746
+
747
+ if (files.length === 0) {
748
+ setError("Please select at least one file");
749
+ return;
750
+ }
751
+
752
+ setError("");
753
+
754
+ // Upload files that haven't been uploaded yet
755
+ const filesToUpload = files.filter((f) => !uploadedFileIds.includes(f.id));
756
+
757
+ if (filesToUpload.length > 0) {
758
+ try {
759
+ await Promise.all(
760
+ filesToUpload.map(async (f) => {
761
+ const formData = new FormData();
762
+ formData.append("file", f.file);
763
+ formData.append("fileId", f.id);
764
+
765
+ await fetch("/api/upload", {
766
+ method: "POST",
767
+ body: formData,
768
+ });
769
+ }),
770
+ );
771
+
772
+ setUploadedFileIds((prev) => [
773
+ ...prev,
774
+ ...filesToUpload.map((f) => f.id),
775
+ ]);
776
+ alert("Files uploaded successfully!");
777
+ } catch (error) {
778
+ setError("Upload failed. Please try again.");
779
+ }
780
+ } else {
781
+ alert("All files already uploaded!");
782
+ }
783
+ };
784
+
785
+ return (
786
+ <form onSubmit={handleSubmit} className="adm:max-w-xl adm:space-y-4">
787
+ <Field>
788
+ <FieldLabel>Upload documents</FieldLabel>
789
+ <FileUploadV2
790
+ selectedFiles={files}
791
+ onFilesSelect={(newFiles) => {
792
+ setFiles(newFiles);
793
+ setError("");
794
+ }}
795
+ aria-invalid={!!error}
796
+ multiple
797
+ />
798
+ {error && <FieldError>{error}</FieldError>}
799
+ </Field>
800
+
801
+ <Button type="submit">Submit form</Button>
802
+ </form>
803
+ );
804
+ }
805
+ ```
806
+
807
+ ### With event tracking
808
+
809
+ ```tsx
810
+ import { useState } from "react";
811
+ import {
812
+ FileUploadV2,
813
+ FileWithMetadata,
814
+ } from "@adamosuiteservices/ui/file-upload-v2";
815
+ import { Typography } from "@adamosuiteservices/ui/typography";
816
+
817
+ function EventTrackingExample() {
818
+ const [files, setFiles] = useState<FileWithMetadata[]>([]);
819
+ const [events, setEvents] = useState<string[]>([]);
820
+
821
+ const logEvent = (message: string) => {
822
+ setEvents((prev) => [
823
+ ...prev,
824
+ `[${new Date().toLocaleTimeString()}] ${message}`,
825
+ ]);
826
+ };
827
+
828
+ const handleFilesAdd = (addedFiles: FileWithMetadata[]) => {
829
+ logEvent(
830
+ `Added ${addedFiles.length} file(s): ${addedFiles.map((f) => `${f.file.name} (ID: ${f.id})`).join(", ")}`,
831
+ );
832
+ };
833
+
834
+ const handleFilesRemove = (removedFiles: FileWithMetadata[]) => {
835
+ logEvent(
836
+ `Removed ${removedFiles.length} file(s): ${removedFiles.map((f) => `${f.file.name} (ID: ${f.id})`).join(", ")}`,
837
+ );
838
+ };
839
+
840
+ return (
841
+ <div className="adm:max-w-xl adm:space-y-4">
842
+ <FileUploadV2
843
+ selectedFiles={files}
844
+ onFilesSelect={setFiles}
845
+ onFilesAdd={handleFilesAdd}
846
+ onFilesRemove={handleFilesRemove}
847
+ multiple
848
+ />
849
+
850
+ {events.length > 0 && (
851
+ <div className="adm:space-y-2">
852
+ <Typography className="adm:font-medium">Event log:</Typography>
853
+ <div className="adm:max-h-40 adm:overflow-y-auto adm:rounded-lg adm:border adm:bg-muted adm:p-3">
854
+ {events.map((event, index) => (
855
+ <Typography
856
+ key={index}
857
+ className="adm:text-sm adm:font-mono"
858
+ color="muted"
859
+ >
860
+ {event}
861
+ </Typography>
862
+ ))}
863
+ </div>
864
+ </div>
865
+ )}
866
+ </div>
867
+ );
868
+ }
869
+ ```
870
+
871
+ ## Comparison with FileUpload
872
+
873
+ | Feature | FileUpload (original) | FileUploadV2 |
874
+ | ---------------------- | --------------------- | ---------------------- |
875
+ | File type | `File` | `FileWithMetadata` |
876
+ | Unique IDs | ❌ No | ✅ Yes (auto) |
877
+ | Custom ID generator | ❌ No | ✅ Yes (generateId) |
878
+ | Metadata support | ❌ No | ✅ Yes (optional) |
879
+ | Server integration | ⚠️ Manual | ✅ Built-in with IDs |
880
+ | Pre-loaded files | ⚠️ No IDs | ✅ With server IDs |
881
+ | Delete API calls | ⚠️ No file reference | ✅ Uses file.id |
882
+ | Async upload tracking | ⚠️ Manual tracking | ✅ Track by file.id |
883
+ | Callbacks with IDs | ❌ No | ✅ Yes |
884
+ | Use case | Simple forms | Server integration |
885
+ | Backward compatibility | ✅ Original | ❌ Different file type |
886
+
887
+ ## TypeScript types
888
+
889
+ ```typescript
890
+ export type FileWithMetadata = {
891
+ id: string;
892
+ file: File;
893
+ metadata?: Record<string, unknown>;
894
+ };
895
+
896
+ export type FileUploadV2Labels = {
897
+ dragDrop?: string;
898
+ selectFile?: string;
899
+ fileRequirements?: string;
900
+ filesSelected?: (count: number) => string;
901
+ };
902
+
903
+ export type FileUploadV2Props = ComponentProps<"div"> &
904
+ Readonly<{
905
+ // Simple mode
906
+ selectedFile?: FileWithMetadata | null;
907
+ onFileSelect?: (file: FileWithMetadata | null) => void;
908
+
909
+ // Multiple mode
910
+ selectedFiles?: FileWithMetadata[];
911
+ onFilesSelect?: (files: FileWithMetadata[]) => void;
912
+ multiple?: boolean;
913
+ maxFiles?: number;
914
+ filesPosition?: "above" | "below";
915
+
916
+ // Callbacks
917
+ onFileAdd?: (file: FileWithMetadata) => void;
918
+ onFileRemove?: (file: FileWithMetadata) => void;
919
+ onFilesAdd?: (files: FileWithMetadata[]) => void;
920
+ onFilesRemove?: (files: FileWithMetadata[]) => void;
921
+
922
+ // ID generation
923
+ generateId?: (file: File) => string;
924
+
925
+ // Validation
926
+ onInvalidFile?: (
927
+ file: FileWithMetadata,
928
+ reason: "extension" | "size",
929
+ ) => void;
930
+ invalid?: boolean;
931
+ "aria-invalid"?: boolean;
932
+
933
+ // States
934
+ disabled?: boolean;
935
+
936
+ // Common
937
+ acceptedExtensions?: string[];
938
+ maxSizeInMB?: number;
939
+ labels?: FileUploadV2Labels;
940
+ input?: ComponentProps<"input">;
941
+ }>;
942
+ ```
943
+
944
+ ## Default ID generation
945
+
946
+ The default ID generator creates unique IDs using the following format:
947
+
948
+ ```typescript
949
+ const defaultGenerateId = (file: File) => {
950
+ return `${Date.now()}-${Math.random().toString(36).substring(2, 11)}-${file.name}`;
951
+ };
952
+ ```
953
+
954
+ **Format**: `{timestamp}-{random}-{filename}`
955
+
956
+ **Example**: `1704892800000-x7k2p9q3m-report.xlsx`
957
+
958
+ **Components**:
959
+
960
+ - **timestamp**: Current timestamp in milliseconds
961
+ - **random**: Random alphanumeric string (9 characters)
962
+ - **filename**: Original file name
963
+
964
+ This ensures uniqueness even when uploading the same file multiple times.
965
+
966
+ ## Backend integration
967
+
968
+ ### Upload with file ID
969
+
970
+ ```tsx
971
+ const uploadFile = async (fileWithMetadata: FileWithMetadata) => {
972
+ const formData = new FormData();
973
+ formData.append("file", fileWithMetadata.file);
974
+ formData.append("fileId", fileWithMetadata.id);
975
+ formData.append(
976
+ "metadata",
977
+ JSON.stringify({
978
+ uploadedAt: new Date().toISOString(),
979
+ originalName: fileWithMetadata.file.name,
980
+ }),
981
+ );
982
+
983
+ const response = await fetch("/api/upload", {
984
+ method: "POST",
985
+ body: formData,
986
+ });
987
+
988
+ return response.json();
989
+ };
990
+ ```
991
+
992
+ ### Delete from server
993
+
994
+ ```tsx
995
+ const deleteFile = async (fileWithMetadata: FileWithMetadata) => {
996
+ await fetch(`/api/files/${fileWithMetadata.id}`, {
997
+ method: "DELETE",
998
+ });
999
+ };
1000
+ ```
1001
+
1002
+ ### Update file metadata after upload
1003
+
1004
+ ```tsx
1005
+ const handleFileAdd = async (fileWithMetadata: FileWithMetadata) => {
1006
+ const response = await uploadFile(fileWithMetadata);
1007
+
1008
+ // Update file with server metadata
1009
+ setFiles((prev) =>
1010
+ prev.map((f) =>
1011
+ f.id === fileWithMetadata.id
1012
+ ? {
1013
+ ...f,
1014
+ metadata: {
1015
+ serverUrl: response.url,
1016
+ uploadedAt: response.uploadedAt,
1017
+ },
1018
+ }
1019
+ : f,
1020
+ ),
1021
+ );
1022
+ };
1023
+ ```
1024
+
1025
+ ## Best practices
1026
+
1027
+ ### Do
1028
+
1029
+ ```tsx
1030
+ // Use FileUploadV2 when you need server-side tracking
1031
+ <FileUploadV2 selectedFile={file} onFileSelect={setFile} />;
1032
+
1033
+ // Always handle file removal for uploaded files
1034
+ const handleFileRemove = async (file: FileWithMetadata) => {
1035
+ if (file.metadata?.serverUrl) {
1036
+ await deleteFromServer(file.id);
1037
+ }
1038
+ };
1039
+
1040
+ // Store server metadata after successful upload
1041
+ const handleFileAdd = async (file: FileWithMetadata) => {
1042
+ const response = await uploadFile(file);
1043
+ updateFileMetadata(file.id, { serverUrl: response.url });
1044
+ };
1045
+
1046
+ // Use custom ID generator for specific requirements
1047
+ <FileUploadV2
1048
+ selectedFile={file}
1049
+ onFileSelect={setFile}
1050
+ generateId={(file) => uuidv4()}
1051
+ />;
1052
+ ```
1053
+
1054
+ ### Don't
1055
+
1056
+ ```tsx
1057
+ // Don't use FileUploadV2 for simple forms without server integration
1058
+ // Use FileUpload instead
1059
+ <FileUploadV2 selectedFile={file} onFileSelect={setFile} /> // Overkill
1060
+
1061
+ // Don't modify file.id after creation
1062
+ file.id = "new-id"; // ❌ IDs should be immutable
1063
+
1064
+ // Don't forget to clean up server files on remove
1065
+ const handleFileRemove = (file: FileWithMetadata) => {
1066
+ // ❌ Missing server deletion
1067
+ console.log("Removed:", file.id);
1068
+ };
1069
+
1070
+ // Don't store sensitive data in metadata (it's client-side)
1071
+ metadata: {
1072
+ password: "secret123", // ❌ Insecure
1073
+ apiKey: "key", // ❌ Insecure
1074
+ }
1075
+ ```
1076
+
1077
+ ## Notes
1078
+
1079
+ - The component uses the same UI as FileUpload but works with `FileWithMetadata` instead of `File`
1080
+ - IDs are generated automatically when files are added (drag & drop or file selection)
1081
+ - IDs are guaranteed unique within the component instance
1082
+ - For server integration, use the file ID to track uploads and deletions
1083
+ - Metadata is optional and can store any additional information
1084
+ - Pre-loaded files from server should include their server-assigned IDs
1085
+ - The component is fully controlled - the parent manages file state
1086
+ - Use `generateId` prop for custom ID generation strategies
1087
+ - All validation features from FileUpload are preserved
1088
+
1089
+ ## Migration from FileUpload
1090
+
1091
+ If you're using FileUpload and need file IDs:
1092
+
1093
+ **Before** (FileUpload):
1094
+
1095
+ ```tsx
1096
+ const [files, setFiles] = useState<File[]>([]);
1097
+
1098
+ <FileUpload selectedFiles={files} onFilesSelect={setFiles} multiple />;
1099
+ ```
1100
+
1101
+ **After** (FileUploadV2):
1102
+
1103
+ ```tsx
1104
+ const [files, setFiles] = useState<FileWithMetadata[]>([]);
1105
+
1106
+ <FileUploadV2 selectedFiles={files} onFilesSelect={setFiles} multiple />;
1107
+ ```
1108
+
1109
+ **Key changes**:
1110
+
1111
+ 1. Change type from `File[]` to `FileWithMetadata[]`
1112
+ 2. Update component from `FileUpload` to `FileUploadV2`
1113
+ 3. Access the native File object via `fileWithMetadata.file`
1114
+ 4. Use `fileWithMetadata.id` for server operations
1115
+ 5. Store server data in `fileWithMetadata.metadata`
1116
+