@seed-design/react-file-upload 0.0.0-alpha-20260511052324 → 1.0.0

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/index.js CHANGED
@@ -1,5 +1,5 @@
1
- import { F as FileUploadContext, a as FileUploadDropzone, b as FileUploadHiddenInput, c as FileUploadItemBackdrop, d as FileUploadItemImage, e as FileUploadItemMetadata, f as FileUploadItemName, g as FileUploadItemRemoveButton, h as FileUploadItemSize, i as FileUploadItemThumbnail, j as FileUploadRoot, k as FileUploadTrigger } from './FileUpload-12s-BsPs5ndy.js';
2
- export { l as FileUploadItemProvider, n as useFileUpload, u as useFileUploadContext, o as useFileUploadItem, m as useFileUploadItemContext } from './FileUpload-12s-BsPs5ndy.js';
1
+ import { F as FileUploadContext, a as FileUploadDropzone, b as FileUploadHiddenInput, c as FileUploadItemBackdrop, d as FileUploadItemImage, e as FileUploadItemName, f as FileUploadItemRemoveButton, g as FileUploadItemSize, h as FileUploadRoot, i as FileUploadTrigger } from './FileUpload-12s-BJqUtE7a.js';
2
+ export { j as FileUploadItemProvider, l as useFileUpload, u as useFileUploadContext, m as useFileUploadItem, k as useFileUploadItemContext } from './FileUpload-12s-BJqUtE7a.js';
3
3
 
4
4
  var FileUpload_namespace = {
5
5
  __proto__: null,
@@ -8,11 +8,9 @@ var FileUpload_namespace = {
8
8
  HiddenInput: FileUploadHiddenInput,
9
9
  ItemBackdrop: FileUploadItemBackdrop,
10
10
  ItemImage: FileUploadItemImage,
11
- ItemMetadata: FileUploadItemMetadata,
12
11
  ItemName: FileUploadItemName,
13
12
  ItemRemoveButton: FileUploadItemRemoveButton,
14
13
  ItemSize: FileUploadItemSize,
15
- ItemThumbnail: FileUploadItemThumbnail,
16
14
  Root: FileUploadRoot,
17
15
  Trigger: FileUploadTrigger
18
16
  };
@@ -40,4 +38,4 @@ var FileUpload_namespace = {
40
38
  };
41
39
  }
42
40
 
43
- export { FileUpload_namespace as FileUpload, FileUploadContext, FileUploadDropzone, FileUploadHiddenInput, FileUploadItemBackdrop, FileUploadItemImage, FileUploadItemMetadata, FileUploadItemName, FileUploadItemRemoveButton, FileUploadItemSize, FileUploadItemThumbnail, FileUploadRoot, FileUploadTrigger, splitFileName };
41
+ export { FileUpload_namespace as FileUpload, FileUploadContext, FileUploadDropzone, FileUploadHiddenInput, FileUploadItemBackdrop, FileUploadItemImage, FileUploadItemName, FileUploadItemRemoveButton, FileUploadItemSize, FileUploadRoot, FileUploadTrigger, splitFileName };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@seed-design/react-file-upload",
3
- "version": "0.0.0-alpha-20260511052324",
3
+ "version": "1.0.0",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/daangn/seed-design.git",
@@ -29,9 +29,9 @@
29
29
  "dependencies": {
30
30
  "@radix-ui/react-compose-refs": "1.1.2",
31
31
  "@radix-ui/react-use-controllable-state": "1.2.2",
32
- "@seed-design/dom-utils": "1.0.0",
33
- "@seed-design/react-primitive": "0.0.0-alpha-20260511052324",
34
- "@seed-design/react-supports": "0.0.1"
32
+ "@seed-design/dom-utils": "^2.0.0",
33
+ "@seed-design/react-primitive": "^2.0.0",
34
+ "@seed-design/react-supports": "^1.0.0"
35
35
  },
36
36
  "devDependencies": {
37
37
  "@types/react": "^19.1.6",
@@ -7,8 +7,6 @@ export {
7
7
  FileUploadItemSize as ItemSize,
8
8
  FileUploadItemRemoveButton as ItemRemoveButton,
9
9
  FileUploadItemImage as ItemImage,
10
- FileUploadItemThumbnail as ItemThumbnail,
11
- FileUploadItemMetadata as ItemMetadata,
12
10
  FileUploadItemBackdrop as ItemBackdrop,
13
11
  FileUploadContext as Context,
14
12
  type FileUploadRootProps as RootProps,
@@ -19,8 +17,6 @@ export {
19
17
  type FileUploadItemSizeProps as ItemSizeProps,
20
18
  type FileUploadItemRemoveButtonProps as ItemRemoveButtonProps,
21
19
  type FileUploadItemImageProps as ItemImageProps,
22
- type FileUploadItemThumbnailProps as ItemThumbnailProps,
23
- type FileUploadItemMetadataProps as ItemMetadataProps,
24
20
  type FileUploadItemBackdropProps as ItemBackdropProps,
25
21
  type FileUploadContextProps as ContextProps,
26
22
  } from "./FileUpload";
@@ -192,36 +192,6 @@ export const FileUploadItemImage = forwardRef<HTMLImageElement, FileUploadItemIm
192
192
  );
193
193
  FileUploadItemImage.displayName = "FileUploadItemImage";
194
194
 
195
- export interface FileUploadItemThumbnailProps
196
- extends PrimitiveProps,
197
- React.HTMLAttributes<HTMLDivElement> {}
198
-
199
- export const FileUploadItemThumbnail = forwardRef<HTMLDivElement, FileUploadItemThumbnailProps>(
200
- (props, ref) => {
201
- const { thumbnailProps } = useFileUploadItemContext();
202
-
203
- const mergedProps = mergeProps(thumbnailProps, props);
204
-
205
- return <Primitive.div ref={ref} {...mergedProps} />;
206
- },
207
- );
208
- FileUploadItemThumbnail.displayName = "FileUploadItemThumbnail";
209
-
210
- export interface FileUploadItemMetadataProps
211
- extends PrimitiveProps,
212
- React.HTMLAttributes<HTMLDivElement> {}
213
-
214
- export const FileUploadItemMetadata = forwardRef<HTMLDivElement, FileUploadItemMetadataProps>(
215
- (props, ref) => {
216
- const { metadataProps } = useFileUploadItemContext();
217
-
218
- const mergedProps = mergeProps(metadataProps, props);
219
-
220
- return <Primitive.div ref={ref} {...mergedProps} />;
221
- },
222
- );
223
- FileUploadItemMetadata.displayName = "FileUploadItemMetadata";
224
-
225
195
  export interface FileUploadItemBackdropProps
226
196
  extends PrimitiveProps,
227
197
  Omit<React.HTMLAttributes<HTMLDivElement>, "children"> {
@@ -236,7 +206,7 @@ export const FileUploadItemBackdrop = forwardRef<HTMLDivElement, FileUploadItemB
236
206
  if (entry.status !== status) return null;
237
207
 
238
208
  return (
239
- <Primitive.div ref={composeRefs(entry.refs.overlay, ref)} {...props}>
209
+ <Primitive.div ref={ref} {...props}>
240
210
  {typeof children === "function" ? children(entry) : children}
241
211
  </Primitive.div>
242
212
  );
package/src/index.ts CHANGED
@@ -7,8 +7,6 @@ export {
7
7
  FileUploadItemSize,
8
8
  FileUploadItemRemoveButton,
9
9
  FileUploadItemImage,
10
- FileUploadItemThumbnail,
11
- FileUploadItemMetadata,
12
10
  FileUploadItemBackdrop,
13
11
  FileUploadContext,
14
12
  type FileUploadRootProps,
@@ -19,8 +17,6 @@ export {
19
17
  type FileUploadItemSizeProps,
20
18
  type FileUploadItemRemoveButtonProps,
21
19
  type FileUploadItemImageProps,
22
- type FileUploadItemThumbnailProps,
23
- type FileUploadItemMetadataProps,
24
20
  type FileUploadItemBackdropProps,
25
21
  type FileUploadContextProps,
26
22
  } from "./FileUpload";
@@ -87,6 +87,7 @@ describe("useFileUpload", () => {
87
87
  expect(input.type).toBe("file");
88
88
  expect(input.name).toBe("files");
89
89
  expect(input.tabIndex).toBe(-1);
90
+ expect(input.getAttribute("aria-hidden")).toBe("true");
90
91
  });
91
92
 
92
93
  it("should render with multiple attribute when maxFiles > 1", () => {
@@ -479,6 +480,149 @@ describe("useFileUpload", () => {
479
480
  });
480
481
  });
481
482
 
483
+ describe("readonly state", () => {
484
+ const seedFiles: FileEntry[] = [
485
+ { id: "seed-1", file: createMockFile("seed.txt", 100, "text/plain"), status: "success" },
486
+ ];
487
+
488
+ it("should have data-readonly attribute on dropzone when readOnly", () => {
489
+ const { getByTestId } = setUp(<BasicFileUpload readOnly />);
490
+ const dropzone = getByTestId("dropzone");
491
+
492
+ expect(dropzone.getAttribute("data-readonly")).toBe("");
493
+ });
494
+
495
+ it("should disable trigger when readOnly", () => {
496
+ const { getByText } = setUp(<BasicFileUpload readOnly />);
497
+ const trigger = getByText("Choose files") as HTMLButtonElement;
498
+
499
+ expect(trigger.disabled).toBe(true);
500
+ });
501
+
502
+ it("should NOT disable hidden input when readOnly so the form value is preserved", () => {
503
+ const { getByTestId } = setUp(
504
+ <BasicFileUpload readOnly defaultAcceptedFileEntries={seedFiles} />,
505
+ );
506
+ const input = getByTestId("hidden-input") as HTMLInputElement;
507
+
508
+ expect(input.disabled).toBe(false);
509
+ });
510
+
511
+ it("should keep input.files in sync when readOnly (form submission carries the values)", async () => {
512
+ const { getByTestId } = setUp(
513
+ <BasicFileUpload readOnly defaultAcceptedFileEntries={seedFiles} />,
514
+ );
515
+ const input = getByTestId("hidden-input") as HTMLInputElement;
516
+
517
+ await waitFor(() => {
518
+ expect(input.files?.length).toBe(1);
519
+ expect(input.files?.[0]?.name).toBe("seed.txt");
520
+ });
521
+ });
522
+
523
+ it("should not accept files via input change when readOnly", async () => {
524
+ const onAcceptedFileEntriesChange = mock(() => {});
525
+ const { getByTestId } = setUp(
526
+ <BasicFileUpload readOnly onAcceptedFileEntriesChange={onAcceptedFileEntriesChange} />,
527
+ );
528
+
529
+ const input = getByTestId("hidden-input") as HTMLInputElement;
530
+ fireEvent.change(input, {
531
+ target: { files: [createMockFile("new.txt", 100, "text/plain")] },
532
+ });
533
+
534
+ await new Promise((resolve) => setTimeout(resolve, 50));
535
+ expect(onAcceptedFileEntriesChange).not.toHaveBeenCalled();
536
+ });
537
+
538
+ it("should not accept dropped files when readOnly", async () => {
539
+ const onAcceptedFileEntriesChange = mock(() => {});
540
+ const { getByTestId } = setUp(
541
+ <BasicFileUpload readOnly onAcceptedFileEntriesChange={onAcceptedFileEntriesChange} />,
542
+ );
543
+
544
+ const dropzone = getByTestId("dropzone");
545
+ fireEvent.drop(dropzone, {
546
+ dataTransfer: { files: [createMockFile("dropped.txt", 100, "text/plain")] },
547
+ });
548
+
549
+ await new Promise((resolve) => setTimeout(resolve, 50));
550
+ expect(onAcceptedFileEntriesChange).not.toHaveBeenCalled();
551
+ });
552
+
553
+ it("should disable remove button on items when readOnly", () => {
554
+ const { getByTestId } = setUp(
555
+ <BasicFileUpload readOnly defaultAcceptedFileEntries={seedFiles} />,
556
+ );
557
+ const deleteButton = getByTestId("delete-0") as HTMLButtonElement;
558
+
559
+ expect(deleteButton.disabled).toBe(true);
560
+ });
561
+
562
+ it("should not remove items when readOnly", async () => {
563
+ const onAcceptedFileEntriesChange = mock(() => {});
564
+ const { getByTestId, user } = setUp(
565
+ <BasicFileUpload
566
+ readOnly
567
+ defaultAcceptedFileEntries={seedFiles}
568
+ onAcceptedFileEntriesChange={onAcceptedFileEntriesChange}
569
+ />,
570
+ );
571
+
572
+ const deleteButton = getByTestId("delete-0");
573
+ await user.click(deleteButton);
574
+
575
+ await new Promise((resolve) => setTimeout(resolve, 50));
576
+ expect(onAcceptedFileEntriesChange).not.toHaveBeenCalled();
577
+ expect(getByTestId("item-0")).toBeDefined();
578
+ });
579
+
580
+ it("should not reorder when readOnly", async () => {
581
+ const reorderSeed: FileEntry[] = [
582
+ { id: "r-1", file: createMockFile("a.txt", 100, "text/plain"), status: "success" },
583
+ { id: "r-2", file: createMockFile("b.txt", 100, "text/plain"), status: "success" },
584
+ ];
585
+
586
+ const ReadOnlyReorder = () => (
587
+ <FileUploadRoot maxFiles={5} readOnly defaultAcceptedFileEntries={reorderSeed}>
588
+ <FileUploadContext>
589
+ {({ acceptedFileEntries, reorderFileEntry }) => (
590
+ <>
591
+ <ul>
592
+ {acceptedFileEntries.map((f, i) => (
593
+ <li key={f.id} data-testid={`f-${i}`}>
594
+ {f.file.name}
595
+ </li>
596
+ ))}
597
+ </ul>
598
+ <button type="button" data-testid="reorder" onClick={() => reorderFileEntry(0, 1)}>
599
+ Reorder
600
+ </button>
601
+ </>
602
+ )}
603
+ </FileUploadContext>
604
+ </FileUploadRoot>
605
+ );
606
+
607
+ const { getByTestId, user } = setUp(<ReadOnlyReorder />);
608
+
609
+ await user.click(getByTestId("reorder"));
610
+
611
+ await waitFor(() => {
612
+ expect(getByTestId("f-0").textContent).toBe("a.txt");
613
+ expect(getByTestId("f-1").textContent).toBe("b.txt");
614
+ });
615
+ });
616
+
617
+ it("should still have data-readonly when both disabled and readOnly are set", () => {
618
+ const { getByTestId } = setUp(<BasicFileUpload disabled readOnly />);
619
+ const dropzone = getByTestId("dropzone");
620
+
621
+ expect(dropzone.getAttribute("data-readonly")).toBe("");
622
+ expect(dropzone.getAttribute("data-disabled")).toBe("");
623
+ });
624
+ });
625
+
482
626
  describe("status change", () => {
483
627
  it("should call onAcceptedFileEntriesChange when file status changes", async () => {
484
628
  const onAcceptedFileEntriesChange = mock(() => {});
@@ -73,7 +73,6 @@ export interface UseFileUploadProps extends UseFileUploadStateProps {
73
73
  invalid?: boolean;
74
74
 
75
75
  /**
76
- * NOTE: this currently is a no-op since the UI doesn't have a readOnly state
77
76
  * @default false
78
77
  */
79
78
  readOnly?: boolean;
@@ -133,7 +132,7 @@ export function useFileUpload({
133
132
 
134
133
  const multiple = maxFiles > 1;
135
134
  const maxFilesReached = acceptedFileEntries.length >= maxFiles;
136
- const triggerDisabled = disabled || maxFilesReached;
135
+ const triggerDisabled = disabled || maxFilesReached || readOnly;
137
136
 
138
137
  const acceptString = Array.isArray(accept) ? accept.join(",") : accept;
139
138
 
@@ -231,7 +230,7 @@ export function useFileUpload({
231
230
 
232
231
  const setFileEntries = useCallback(
233
232
  (files: File[]) => {
234
- if (disabled) return;
233
+ if (disabled || readOnly) return;
235
234
 
236
235
  const { accepted, rejected } = validateFiles(files);
237
236
 
@@ -257,6 +256,7 @@ export function useFileUpload({
257
256
  },
258
257
  [
259
258
  disabled,
259
+ readOnly,
260
260
  multiple,
261
261
  validateFiles,
262
262
  setAcceptedFileEntries,
@@ -268,7 +268,7 @@ export function useFileUpload({
268
268
 
269
269
  const reorderFileEntry = useCallback(
270
270
  (fromIndex: number, toIndex: number) => {
271
- if (disabled) return;
271
+ if (disabled || readOnly) return;
272
272
  setAcceptedFileEntries((prev) => {
273
273
  const files = [...(prev ?? [])];
274
274
 
@@ -281,7 +281,7 @@ export function useFileUpload({
281
281
  return files;
282
282
  });
283
283
  },
284
- [disabled, setAcceptedFileEntries],
284
+ [disabled, readOnly, setAcceptedFileEntries],
285
285
  );
286
286
 
287
287
  const createFileUrl = useCallback((file: File, callback: (url: string) => void) => {
@@ -290,20 +290,25 @@ export function useFileUpload({
290
290
  return () => URL.revokeObjectURL(url);
291
291
  }, []);
292
292
 
293
+ // Root `disabled` intentionally does NOT block removal — disabled trigger should still allow
294
+ // pruning what's already accepted. Root `readOnly` does block it: readOnly preserves the form value.
293
295
  const removeFileEntry = useCallback(
294
296
  (id: string) => {
297
+ if (readOnly) return;
295
298
  setAcceptedFileEntries((prev) => (prev ?? []).filter((f) => f.id !== id));
296
299
  },
297
- [setAcceptedFileEntries],
300
+ [readOnly, setAcceptedFileEntries],
298
301
  );
299
302
 
300
303
  const clearFileEntries = useCallback(() => {
304
+ if (readOnly) return;
301
305
  setAcceptedFileEntries([]);
302
- }, [setAcceptedFileEntries]);
306
+ }, [readOnly, setAcceptedFileEntries]);
303
307
 
304
308
  const stateProps = elementProps({
305
309
  "data-dragging-over": dataAttr(isDragging),
306
310
  "data-disabled": dataAttr(triggerDisabled),
311
+ "data-readonly": dataAttr(readOnly),
307
312
  "data-invalid": dataAttr(invalid),
308
313
  });
309
314
 
@@ -350,6 +355,7 @@ export function useFileUpload({
350
355
  acceptedFileEntries,
351
356
  dragging: isDragging,
352
357
  disabled,
358
+ readOnly,
353
359
  invalid,
354
360
  required,
355
361
  maxFiles,
@@ -435,6 +441,10 @@ export function useFileUpload({
435
441
 
436
442
  tabIndex: -1,
437
443
  style: visuallyHidden,
444
+ // exclude from the a11y tree — the trigger/dropzone provides the accessible interface,
445
+ // and tabIndex: -1 alone does not stop AT browse-mode cursors from reaching the input.
446
+ // ref: https://github.com/chakra-ui/zag/commit/e847201a3d0b98d3b109d702f9942f0bfa25b97f
447
+ "aria-hidden": true,
438
448
 
439
449
  onChange: (event) => {
440
450
  const files = event.target.files;
@@ -452,12 +462,7 @@ export function useFileUpload({
452
462
  export type UseFileUploadItemReturn = ReturnType<typeof useFileUploadItem>;
453
463
 
454
464
  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
- }, []);
465
+ const { createFileUrl, removeFileEntry, acceptType, readOnly } = useFileUploadContext();
461
466
 
462
467
  // Image blob URL management
463
468
  const [imageSrc, setImageSrc] = useState<string>();
@@ -469,15 +474,9 @@ export function useFileUploadItem(fileEntry: FileEntry) {
469
474
  return createFileUrl(fileEntry.file, setImageSrc);
470
475
  }, [fileEntry.file, createFileUrl, acceptType]);
471
476
 
472
- const overlayStateProps = elementProps({
473
- "data-has-overlay": dataAttr(isOverlayRendered),
474
- });
475
-
476
477
  return {
477
478
  ...fileEntry,
478
479
 
479
- refs: { overlay: overlayRef },
480
-
481
480
  ...(acceptType === "image" &&
482
481
  imageSrc && {
483
482
  imageProps: {
@@ -486,13 +485,13 @@ export function useFileUploadItem(fileEntry: FileEntry) {
486
485
  } satisfies React.ImgHTMLAttributes<HTMLImageElement>,
487
486
  }),
488
487
 
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
488
+ // Root `disabled` is intentionally NOT propagated here — disabled still allows pruning
489
+ // already-accepted files. Root `readOnly` does block removal so the value is preserved.
493
490
  removeButtonProps: buttonProps({
494
491
  type: "button",
492
+ disabled: readOnly,
495
493
  onClick: () => {
494
+ if (readOnly) return;
496
495
  removeFileEntry(fileEntry.id);
497
496
  },
498
497
  }),