@seed-design/react-file-upload 0.0.0-alpha-20260416134952

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,500 @@
1
+ import { useControllableState } from "@radix-ui/react-use-controllable-state";
2
+ import {
3
+ ariaAttr,
4
+ dataAttr,
5
+ elementProps,
6
+ inputProps,
7
+ buttonProps,
8
+ visuallyHidden,
9
+ } from "@seed-design/dom-utils";
10
+ import { useState, useCallback, useRef, useEffect } from "react";
11
+ import type { FileRejection, FileError, FileEntry, FileStatusDetails } from "./types";
12
+ import { getFileAcceptType } from "./accept-utils";
13
+ import { useFileUploadContext } from "./useFileUploadContext";
14
+
15
+ function syncInputFiles(inputEl: HTMLInputElement, files: File[]) {
16
+ const dataTransfer = new DataTransfer();
17
+
18
+ for (const file of files) {
19
+ dataTransfer.items.add(file);
20
+ }
21
+
22
+ inputEl.files = dataTransfer.files;
23
+ }
24
+
25
+ interface UseFileUploadStateProps {
26
+ acceptedFileEntries?: FileEntry[];
27
+ defaultAcceptedFileEntries?: FileEntry[];
28
+ onAcceptedFileEntriesChange?: (fileEntries: FileEntry[]) => void;
29
+ onFileReject?: (rejections: FileRejection[]) => void;
30
+ onFileAccept?: (
31
+ acceptedEntries: FileEntry[],
32
+ helpers: { updateFileEntryStatus: (id: string, details: FileStatusDetails) => void },
33
+ ) => void;
34
+ }
35
+
36
+ function useFileUploadState(props: UseFileUploadStateProps) {
37
+ const [acceptedFileEntries, setAcceptedFileEntries] = useControllableState<FileEntry[]>({
38
+ prop: props.acceptedFileEntries,
39
+ defaultProp: props.defaultAcceptedFileEntries ?? [],
40
+ onChange: (filesWithStatus) => {
41
+ props.onAcceptedFileEntriesChange?.(filesWithStatus);
42
+ },
43
+ });
44
+
45
+ const [isDragging, setIsDragging] = useState(false);
46
+
47
+ return {
48
+ acceptedFileEntries: acceptedFileEntries ?? [],
49
+ isDragging,
50
+
51
+ setAcceptedFileEntries,
52
+ setIsDragging,
53
+ };
54
+ }
55
+
56
+ export interface UseFileUploadProps extends UseFileUploadStateProps {
57
+ /**
58
+ * Accepted file types (e.g., "image/*", ".pdf", "image/png")
59
+ */
60
+ accept?: string | string[];
61
+
62
+ /**
63
+ * @default false
64
+ */
65
+ disabled?: boolean;
66
+ /**
67
+ * @default false
68
+ */
69
+ required?: boolean;
70
+ /**
71
+ * @default false
72
+ */
73
+ invalid?: boolean;
74
+
75
+ /**
76
+ * NOTE: this currently is a no-op since the UI doesn't have a readOnly state
77
+ * @default false
78
+ */
79
+ readOnly?: boolean;
80
+
81
+ /**
82
+ * @default 1
83
+ */
84
+ maxFiles?: number;
85
+ /**
86
+ * @default Infinity
87
+ */
88
+ maxFileSize?: number;
89
+ /**
90
+ * @default 0
91
+ */
92
+ minFileSize?: number;
93
+
94
+ name?: string;
95
+
96
+ /**
97
+ * Custom validation function.
98
+ */
99
+ validate?: (file: File) => FileError[] | null;
100
+
101
+ /**
102
+ * Whether to prevent the browser's default behavior when files are dropped
103
+ * outside the dropzone (e.g., opening the file in a new tab).
104
+ * @default true
105
+ */
106
+ preventDocumentDrop?: boolean;
107
+ }
108
+
109
+ export type UseFileUploadReturn = ReturnType<typeof useFileUpload>;
110
+
111
+ export function useFileUpload({
112
+ accept,
113
+ disabled = false,
114
+ required = false,
115
+ invalid = false,
116
+ readOnly = false,
117
+ maxFiles = 1,
118
+ maxFileSize = Number.POSITIVE_INFINITY,
119
+ minFileSize = 0,
120
+ name,
121
+ validate,
122
+ preventDocumentDrop = true,
123
+ onFileReject,
124
+ onFileAccept,
125
+ ...props
126
+ }: UseFileUploadProps = {}) {
127
+ const inputRef = useRef<HTMLInputElement>(null);
128
+ const dropzoneRef = useRef<HTMLDivElement>(null);
129
+ const idCounterRef = useRef(0);
130
+
131
+ const { acceptedFileEntries, isDragging, setAcceptedFileEntries, setIsDragging } =
132
+ useFileUploadState(props);
133
+
134
+ const multiple = maxFiles > 1;
135
+ const maxFilesReached = acceptedFileEntries.length >= maxFiles;
136
+ const triggerDisabled = disabled || maxFilesReached;
137
+
138
+ const acceptString = Array.isArray(accept) ? accept.join(",") : accept;
139
+
140
+ const acceptType = getFileAcceptType(accept);
141
+
142
+ const isValidFileType = useCallback(
143
+ (file: File): boolean => {
144
+ if (!accept) return true;
145
+
146
+ const acceptList = Array.isArray(accept) ? accept : accept.split(",").map((s) => s.trim());
147
+
148
+ return acceptList.some((acceptPattern) => {
149
+ // .pdf
150
+ if (acceptPattern.startsWith("."))
151
+ return file.name.toLowerCase().endsWith(acceptPattern.toLowerCase());
152
+
153
+ // image/* or image/png
154
+ if (acceptPattern.includes("*")) {
155
+ const [type] = acceptPattern.split("/");
156
+
157
+ return file.type.startsWith(`${type}/`);
158
+ }
159
+
160
+ return file.type === acceptPattern;
161
+ });
162
+ },
163
+ [accept],
164
+ );
165
+
166
+ const validateFiles = useCallback(
167
+ (files: File[]): { accepted: File[]; rejected: FileRejection[] } => {
168
+ const accepted: File[] = [];
169
+ const rejected: FileRejection[] = [];
170
+
171
+ const currentCount = acceptedFileEntries.length;
172
+ let addedCount = 0;
173
+
174
+ for (const file of files) {
175
+ const errors: FileError[] = [];
176
+
177
+ if (currentCount + addedCount >= maxFiles) {
178
+ errors.push("TOO_MANY_FILES");
179
+ }
180
+
181
+ if (!isValidFileType(file)) {
182
+ errors.push("INVALID_TYPE");
183
+ }
184
+
185
+ if (file.size > maxFileSize) {
186
+ errors.push("FILE_TOO_LARGE");
187
+ }
188
+
189
+ if (file.size < minFileSize) {
190
+ errors.push("FILE_TOO_SMALL");
191
+ }
192
+
193
+ if (validate) {
194
+ const customErrors = validate(file);
195
+ if (customErrors) {
196
+ errors.push(...customErrors);
197
+ }
198
+ }
199
+
200
+ if (errors.length > 0) {
201
+ rejected.push({ file, errors });
202
+ } else {
203
+ accepted.push(file);
204
+ addedCount++;
205
+ }
206
+ }
207
+
208
+ return { accepted, rejected };
209
+ },
210
+ [maxFileSize, minFileSize, maxFiles, acceptedFileEntries.length, validate, isValidFileType],
211
+ );
212
+
213
+ const openFilePicker = useCallback(() => {
214
+ if (triggerDisabled) return;
215
+ if (!inputRef.current) return;
216
+
217
+ // Reset value before opening the picker so re-selecting the same file still fires a change event
218
+ // see: https://github.com/chakra-ui/zag/blob/main/packages/machines/file-upload/src/file-upload.connect.ts
219
+ inputRef.current.value = "";
220
+ inputRef.current.click();
221
+ }, [triggerDisabled]);
222
+
223
+ const updateFileEntryStatus = useCallback(
224
+ (id: string, details: FileStatusDetails) => {
225
+ setAcceptedFileEntries((prev) =>
226
+ (prev ?? []).map((f) => (f.id === id ? { ...f, ...details } : f)),
227
+ );
228
+ },
229
+ [setAcceptedFileEntries],
230
+ );
231
+
232
+ const setFileEntries = useCallback(
233
+ (files: File[]) => {
234
+ if (disabled) return;
235
+
236
+ const { accepted, rejected } = validateFiles(files);
237
+
238
+ const acceptedEntries: FileEntry[] = accepted.map((file) => ({
239
+ id: `file-${Date.now()}-${++idCounterRef.current}`,
240
+ file,
241
+ status: "pending",
242
+ }));
243
+
244
+ if (acceptedEntries.length > 0) {
245
+ const finalEntries = multiple ? acceptedEntries : [acceptedEntries[0]];
246
+ if (multiple) {
247
+ setAcceptedFileEntries((prev) => [...(prev ?? []), ...finalEntries]);
248
+ } else {
249
+ setAcceptedFileEntries(finalEntries);
250
+ }
251
+ onFileAccept?.(finalEntries, { updateFileEntryStatus });
252
+ }
253
+
254
+ if (rejected.length > 0) {
255
+ onFileReject?.(rejected);
256
+ }
257
+ },
258
+ [
259
+ disabled,
260
+ multiple,
261
+ validateFiles,
262
+ setAcceptedFileEntries,
263
+ onFileReject,
264
+ onFileAccept,
265
+ updateFileEntryStatus,
266
+ ],
267
+ );
268
+
269
+ const reorderFileEntry = useCallback(
270
+ (fromIndex: number, toIndex: number) => {
271
+ if (disabled) return;
272
+ setAcceptedFileEntries((prev) => {
273
+ const files = [...(prev ?? [])];
274
+
275
+ if (fromIndex < 0 || fromIndex >= files.length) return prev;
276
+ if (toIndex < 0 || toIndex >= files.length) return prev;
277
+
278
+ const [removed] = files.splice(fromIndex, 1);
279
+ files.splice(toIndex, 0, removed);
280
+
281
+ return files;
282
+ });
283
+ },
284
+ [disabled, setAcceptedFileEntries],
285
+ );
286
+
287
+ const createFileUrl = useCallback((file: File, callback: (url: string) => void) => {
288
+ const url = URL.createObjectURL(file);
289
+ callback(url);
290
+ return () => URL.revokeObjectURL(url);
291
+ }, []);
292
+
293
+ const removeFileEntry = useCallback(
294
+ (id: string) => {
295
+ setAcceptedFileEntries((prev) => (prev ?? []).filter((f) => f.id !== id));
296
+ },
297
+ [setAcceptedFileEntries],
298
+ );
299
+
300
+ const clearFileEntries = useCallback(() => {
301
+ setAcceptedFileEntries([]);
302
+ }, [setAcceptedFileEntries]);
303
+
304
+ const stateProps = elementProps({
305
+ "data-dragging-over": dataAttr(isDragging),
306
+ "data-disabled": dataAttr(triggerDisabled),
307
+ "data-invalid": dataAttr(invalid),
308
+ });
309
+
310
+ useEffect(() => {
311
+ if (!inputRef.current) return;
312
+ syncInputFiles(
313
+ inputRef.current,
314
+ acceptedFileEntries.map((e) => e.file),
315
+ );
316
+ }, [acceptedFileEntries]);
317
+
318
+ useEffect(() => {
319
+ if (!preventDocumentDrop) return;
320
+ if (disabled) return;
321
+
322
+ const onDragOver = (event: Event) => {
323
+ if (!dropzoneRef.current) return;
324
+
325
+ event.preventDefault();
326
+ };
327
+
328
+ const onDrop = (event: Event) => {
329
+ if (!dropzoneRef.current) return;
330
+
331
+ const target = event.target as Node | null;
332
+ if (dropzoneRef.current.contains(target)) return;
333
+
334
+ event.preventDefault();
335
+ };
336
+
337
+ document.addEventListener("dragover", onDragOver);
338
+ document.addEventListener("drop", onDrop);
339
+
340
+ return () => {
341
+ document.removeEventListener("dragover", onDragOver);
342
+ document.removeEventListener("drop", onDrop);
343
+ };
344
+ }, [preventDocumentDrop, disabled]);
345
+
346
+ return {
347
+ inputRef,
348
+ dropzoneRef,
349
+
350
+ acceptedFileEntries,
351
+ dragging: isDragging,
352
+ disabled,
353
+ invalid,
354
+ required,
355
+ maxFiles,
356
+ currentFileEntryCount: acceptedFileEntries.length,
357
+ acceptType,
358
+
359
+ openFilePicker,
360
+ setFileEntries,
361
+ reorderFileEntry,
362
+ createFileUrl,
363
+ updateFileEntryStatus,
364
+ removeFileEntry,
365
+ clearFileEntries,
366
+
367
+ stateProps,
368
+
369
+ dropzoneProps: elementProps({
370
+ ...stateProps,
371
+ onDragOver: (event) => {
372
+ if (triggerDisabled) return;
373
+
374
+ event.preventDefault();
375
+ event.stopPropagation();
376
+
377
+ setIsDragging(true);
378
+ },
379
+ onDragEnter: (event) => {
380
+ if (triggerDisabled) return;
381
+
382
+ event.preventDefault();
383
+ event.stopPropagation();
384
+
385
+ setIsDragging(true);
386
+ },
387
+ onDragLeave: (event) => {
388
+ if (triggerDisabled) return;
389
+
390
+ event.preventDefault();
391
+ event.stopPropagation();
392
+
393
+ // Only set dragging to false if we're leaving the dropzone entirely
394
+ const relatedTarget = event.relatedTarget as Node | null;
395
+ if (!event.currentTarget.contains(relatedTarget)) {
396
+ setIsDragging(false);
397
+ }
398
+ },
399
+ onDrop: (event) => {
400
+ if (disabled) return;
401
+
402
+ event.preventDefault();
403
+ event.stopPropagation();
404
+
405
+ setIsDragging(false);
406
+
407
+ const files = event.dataTransfer?.files;
408
+
409
+ if (files && files.length > 0) {
410
+ setFileEntries(Array.from(files));
411
+ }
412
+ },
413
+ }),
414
+
415
+ triggerProps: buttonProps({
416
+ ...stateProps,
417
+
418
+ type: "button",
419
+ disabled: triggerDisabled,
420
+ onClick: openFilePicker,
421
+
422
+ // escape hatch: triggerProps is meant to be spread on ActionButton,
423
+ // but ActionButton overrides HTML `color` attribute for its fg color
424
+ }) as Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, "color">,
425
+
426
+ hiddenInputProps: inputProps({
427
+ type: "file",
428
+ name,
429
+
430
+ accept: acceptString,
431
+ multiple,
432
+
433
+ disabled,
434
+ "aria-required": ariaAttr(required),
435
+
436
+ tabIndex: -1,
437
+ style: visuallyHidden,
438
+
439
+ onChange: (event) => {
440
+ const files = event.target.files;
441
+
442
+ if (files) {
443
+ setFileEntries(Array.from(files));
444
+ }
445
+
446
+ event.target.value = "";
447
+ },
448
+ }),
449
+ };
450
+ }
451
+
452
+ export type UseFileUploadItemReturn = ReturnType<typeof useFileUploadItem>;
453
+
454
+ export function useFileUploadItem(fileEntry: FileEntry) {
455
+ const { createFileUrl, removeFileEntry, acceptType } = useFileUploadContext();
456
+
457
+ const [isOverlayRendered, setIsOverlayRendered] = useState(false);
458
+ const overlayRef = useCallback((node: HTMLElement | null) => {
459
+ setIsOverlayRendered(!!node);
460
+ }, []);
461
+
462
+ // Image blob URL management
463
+ const [imageSrc, setImageSrc] = useState<string>();
464
+
465
+ useEffect(() => {
466
+ if (acceptType !== "image") return;
467
+ if (!fileEntry.file) return;
468
+
469
+ return createFileUrl(fileEntry.file, setImageSrc);
470
+ }, [fileEntry.file, createFileUrl, acceptType]);
471
+
472
+ const overlayStateProps = elementProps({
473
+ "data-has-overlay": dataAttr(isOverlayRendered),
474
+ });
475
+
476
+ return {
477
+ ...fileEntry,
478
+
479
+ refs: { overlay: overlayRef },
480
+
481
+ ...(acceptType === "image" &&
482
+ imageSrc && {
483
+ imageProps: {
484
+ src: imageSrc,
485
+ alt: fileEntry.file.name,
486
+ } satisfies React.ImgHTMLAttributes<HTMLImageElement>,
487
+ }),
488
+
489
+ thumbnailProps: overlayStateProps,
490
+ metadataProps: overlayStateProps,
491
+
492
+ // NOTE: `disabled` of item remove button works separately from the overall `disabled` state so we don't have stateProps here
493
+ removeButtonProps: buttonProps({
494
+ type: "button",
495
+ onClick: () => {
496
+ removeFileEntry(fileEntry.id);
497
+ },
498
+ }),
499
+ };
500
+ }
@@ -0,0 +1,50 @@
1
+ import { createContext, useContext } from "react";
2
+ import type { UseFileUploadReturn } from "./useFileUpload";
3
+ import type { UseFileUploadItemReturn } from "./useFileUpload";
4
+ import type { FileEntry } from "./types";
5
+
6
+ // =============================================================================
7
+ // FileUpload Context
8
+ // =============================================================================
9
+
10
+ export type UseFileUploadContext = UseFileUploadReturn;
11
+
12
+ const FileUploadContext = createContext<UseFileUploadContext | null>(null);
13
+
14
+ export const FileUploadProvider = FileUploadContext.Provider;
15
+
16
+ export function useFileUploadContext<T extends boolean | undefined = true>({
17
+ strict = true,
18
+ }: {
19
+ strict?: T;
20
+ } = {}): T extends false ? UseFileUploadContext | null : UseFileUploadContext {
21
+ const context = useContext(FileUploadContext);
22
+ if (!context && strict) {
23
+ throw new Error("useFileUploadContext must be used within a FileUpload");
24
+ }
25
+
26
+ return context as UseFileUploadContext;
27
+ }
28
+
29
+ // =============================================================================
30
+ // FileUploadItem Context
31
+ // =============================================================================
32
+
33
+ export type FileUploadItemContext = FileEntry & UseFileUploadItemReturn;
34
+
35
+ const ItemContext = createContext<FileUploadItemContext | null>(null);
36
+
37
+ export const FileUploadItemProvider = ItemContext.Provider;
38
+
39
+ export function useFileUploadItemContext<T extends boolean | undefined = true>({
40
+ strict = true,
41
+ }: {
42
+ strict?: T;
43
+ } = {}): T extends false ? FileUploadItemContext | null : FileUploadItemContext {
44
+ const context = useContext(ItemContext);
45
+ if (!context && strict) {
46
+ throw new Error("useFileUploadItemContext must be used within a FileUploadItem");
47
+ }
48
+
49
+ return context as FileUploadItemContext;
50
+ }
@@ -0,0 +1,46 @@
1
+ import { describe, it, expect } from "bun:test";
2
+ import { splitFileName } from "./utils";
3
+
4
+ describe("splitFileName", () => {
5
+ it("should split a normal filename", () => {
6
+ expect(splitFileName("document.pdf")).toEqual({
7
+ basename: "document",
8
+ extension: ".pdf",
9
+ });
10
+ });
11
+
12
+ it("should handle multiple dots", () => {
13
+ expect(splitFileName("my.file.name.txt")).toEqual({
14
+ basename: "my.file.name",
15
+ extension: ".txt",
16
+ });
17
+ });
18
+
19
+ it("should handle no extension", () => {
20
+ expect(splitFileName("README")).toEqual({
21
+ basename: "README",
22
+ extension: "",
23
+ });
24
+ });
25
+
26
+ it("should handle dotfile", () => {
27
+ expect(splitFileName(".gitignore")).toEqual({
28
+ basename: ".gitignore",
29
+ extension: "",
30
+ });
31
+ });
32
+
33
+ it("should handle empty string", () => {
34
+ expect(splitFileName("")).toEqual({
35
+ basename: "",
36
+ extension: "",
37
+ });
38
+ });
39
+
40
+ it("should handle long extension", () => {
41
+ expect(splitFileName("archive.tar.gz")).toEqual({
42
+ basename: "archive.tar",
43
+ extension: ".gz",
44
+ });
45
+ });
46
+ });
package/src/utils.ts ADDED
@@ -0,0 +1,24 @@
1
+ export interface SplitFileNameResult {
2
+ basename: string;
3
+ extension: string;
4
+ }
5
+
6
+ /**
7
+ * Split a filename into basename and extension.
8
+ *
9
+ * "document.pdf" -> { basename: "document", extension: ".pdf" }
10
+ * ".gitignore" -> { basename: ".gitignore", extension: "" }
11
+ * "README" -> { basename: "README", extension: "" }
12
+ */
13
+ export function splitFileName(name: string): SplitFileNameResult {
14
+ if (!name) return { basename: "", extension: "" };
15
+
16
+ const lastDot = name.lastIndexOf(".");
17
+ // No dot, or dot is first character (dotfile)
18
+ if (lastDot <= 0) return { basename: name, extension: "" };
19
+
20
+ return {
21
+ basename: name.slice(0, lastDot),
22
+ extension: name.slice(lastDot),
23
+ };
24
+ }