@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.
- package/lib/FileUpload-12s-1EmFGBYg.cjs +585 -0
- package/lib/FileUpload-12s-BsPs5ndy.js +569 -0
- package/lib/index.cjs +60 -0
- package/lib/index.d.ts +8012 -0
- package/lib/index.js +43 -0
- package/package.json +48 -0
- package/src/FileUpload.namespace.ts +26 -0
- package/src/FileUpload.tsx +253 -0
- package/src/accept-utils.test.ts +87 -0
- package/src/accept-utils.ts +83 -0
- package/src/index.ts +54 -0
- package/src/types.ts +24 -0
- package/src/useFileUpload.test.tsx +1854 -0
- package/src/useFileUpload.ts +500 -0
- package/src/useFileUploadContext.tsx +50 -0
- package/src/utils.test.ts +46 -0
- package/src/utils.ts +24 -0
|
@@ -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
|
+
}
|