uplofile 0.1.1 → 0.1.2

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/README.md CHANGED
@@ -23,6 +23,7 @@ Composable file upload component for React. Build your own UI with small, access
23
23
  Client component (e.g. in Next.js add "use client"):
24
24
 
25
25
  ### 1) Minimal usage
26
+
26
27
  The smallest working setup with a single button that opens the file picker.
27
28
 
28
29
  ```tsx
@@ -32,7 +33,9 @@ import * as FileUploader from "uplofile";
32
33
 
33
34
  export default function Basic() {
34
35
  return (
35
- <FileUploader.Root upload={async (file) => ({ url: URL.createObjectURL(file) })}>
36
+ <FileUploader.Root
37
+ upload={async (file) => ({ url: URL.createObjectURL(file) })}
38
+ >
36
39
  <FileUploader.Trigger>
37
40
  <button type="button">Select file</button>
38
41
  </FileUploader.Trigger>
@@ -47,6 +50,7 @@ export default function Basic() {
47
50
  ---
48
51
 
49
52
  ### 2) Multiple files and a hidden input (form friendly)
53
+
50
54
  Add `multiple` and a `name` so successful uploads are available as JSON in a hidden input for regular form posts.
51
55
 
52
56
  ```tsx
@@ -63,13 +67,18 @@ Add `multiple` and a `name` so successful uploads are available as JSON in a hid
63
67
  ---
64
68
 
65
69
  ### 3) Providing a real upload function
70
+
66
71
  Use fetch for a simple upload, or XHR to report progress. You can keep this in a separate module.
67
72
 
68
73
  ```ts
69
74
  import type { UploadResult } from "uplofile";
70
75
 
71
76
  export function makeFetchUploader(endpoint: string, fieldName = "file") {
72
- return async function upload(file: File, signal: AbortSignal, setProgress?: (pct: number) => void): Promise<UploadResult> {
77
+ return async function upload(
78
+ file: File,
79
+ signal: AbortSignal,
80
+ setProgress?: (pct: number) => void,
81
+ ): Promise<UploadResult> {
73
82
  if (setProgress) {
74
83
  // XHR branch for progress
75
84
  const form = new FormData();
@@ -82,8 +91,12 @@ export function makeFetchUploader(endpoint: string, fieldName = "file") {
82
91
  };
83
92
  xhr.onload = () => {
84
93
  if (xhr.status >= 200 && xhr.status < 300) {
85
- try { const json = JSON.parse(xhr.responseText); resolve({ url: json.url, id: json.id }); }
86
- catch { resolve({ url: xhr.responseText }); }
94
+ try {
95
+ const json = JSON.parse(xhr.responseText);
96
+ resolve({ url: json.url, id: json.id });
97
+ } catch {
98
+ resolve({ url: xhr.responseText });
99
+ }
87
100
  } else reject(new Error(`Upload failed (${xhr.status})`));
88
101
  };
89
102
  xhr.onerror = () => reject(new Error("Network error"));
@@ -100,8 +113,13 @@ export function makeFetchUploader(endpoint: string, fieldName = "file") {
100
113
  form.append(fieldName, file);
101
114
  const res = await fetch(endpoint, { method: "POST", body: form, signal });
102
115
  if (!res.ok) throw new Error(`Upload failed (${res.status})`);
103
- try { const json = await res.json(); return { url: json.url, id: json.id }; }
104
- catch { const text = await res.text(); return { url: text }; }
116
+ try {
117
+ const json = await res.json();
118
+ return { url: json.url, id: json.id };
119
+ } catch {
120
+ const text = await res.text();
121
+ return { url: text };
122
+ }
105
123
  };
106
124
  }
107
125
 
@@ -111,6 +129,7 @@ export const upload = makeFetchUploader("/api/upload");
111
129
  ---
112
130
 
113
131
  ### 4) A nicer trigger with render props
132
+
114
133
  Show live progress and counts without building a full preview.
115
134
 
116
135
  ```tsx
@@ -129,6 +148,7 @@ Show live progress and counts without building a full preview.
129
148
  ---
130
149
 
131
150
  ### 5) Custom preview UI with actions
151
+
132
152
  Render thumbnails, a progress bar, and actions like cancel/retry/remove.
133
153
 
134
154
  ```tsx
@@ -141,20 +161,32 @@ Render thumbnails, a progress bar, and actions like cancel/retry/remove.
141
161
 
142
162
  {item.status === "uploading" && (
143
163
  <div>
144
- <div style={{ width: `${Math.max(0, Math.min(100, item.progress ?? 0))}%` }} />
164
+ <div
165
+ style={{
166
+ width: `${Math.max(0, Math.min(100, item.progress ?? 0))}%`,
167
+ }}
168
+ />
145
169
  </div>
146
170
  )}
147
171
 
148
- {item.status === "error" && <div>{item.error ?? "Upload failed"}</div>}
172
+ {item.status === "error" && (
173
+ <div>{item.error ?? "Upload failed"}</div>
174
+ )}
149
175
 
150
176
  <div>
151
177
  {item.status === "uploading" && (
152
- <button type="button" onClick={() => actions.cancel(item.uid)}>Cancel</button>
178
+ <button type="button" onClick={() => actions.cancel(item.uid)}>
179
+ Cancel
180
+ </button>
153
181
  )}
154
182
  {(item.status === "error" || item.status === "canceled") && (
155
- <button type="button" onClick={() => actions.retry(item.uid)}>Retry</button>
183
+ <button type="button" onClick={() => actions.retry(item.uid)}>
184
+ Retry
185
+ </button>
156
186
  )}
157
- <button type="button" onClick={() => actions.remove(item.uid)}>Remove</button>
187
+ <button type="button" onClick={() => actions.remove(item.uid)}>
188
+ Remove
189
+ </button>
158
190
  </div>
159
191
  </div>
160
192
  ))}
@@ -169,6 +201,7 @@ Render thumbnails, a progress bar, and actions like cancel/retry/remove.
169
201
  ---
170
202
 
171
203
  ### 6) Drag-and-drop (Dropzone)
204
+
172
205
  If your package includes a `Dropzone` primitive, surface it here. If not, you can skip this section.
173
206
 
174
207
  ```tsx
@@ -182,6 +215,7 @@ If your package includes a `Dropzone` primitive, surface it here. If not, you ca
182
215
  ---
183
216
 
184
217
  ### 7) Putting it together (complete example)
218
+
185
219
  A compact, end-to-end example using multiple files, custom trigger, preview, and a hidden input for form submit.
186
220
 
187
221
  ```tsx
@@ -197,7 +231,9 @@ export default function Example() {
197
231
  <FileUploader.Trigger
198
232
  render={({ isUploading, totalProgress, items }) => (
199
233
  <button type="button" data-loading={isUploading || undefined}>
200
- {isUploading ? `Uploading ${totalProgress ?? 0}%` : "Select Images"}
234
+ {isUploading
235
+ ? `Uploading ${totalProgress ?? 0}%`
236
+ : "Select Images"}
201
237
  <span> ({items.length})</span>
202
238
  </button>
203
239
  )}
package/dist/index.cjs CHANGED
@@ -7,7 +7,7 @@ var react = require('react');
7
7
  const uid = ()=>Math.random().toString(36).slice(2, 10) + Date.now().toString(36).slice(-4);
8
8
 
9
9
  const UploaderCtx = /*#__PURE__*/ react.createContext(null);
10
- const Root = ({ multiple = true, initial = [], onChange, upload, removeMode = 'optimistic', onRemove, accept = 'image/*', name = 'images', maxCount, disabled, children })=>{
10
+ const Root = ({ multiple = true, initial = [], onChange, upload, removeMode = "optimistic", onRemove, accept = "image/*", name = "images", maxCount, disabled, children })=>{
11
11
  const [items, setItems] = react.useState([]);
12
12
  const controllers = react.useRef(new Map());
13
13
  const removeControllers = react.useRef(new Map());
@@ -22,7 +22,7 @@ const Root = ({ multiple = true, initial = [], onChange, upload, removeMode = 'o
22
22
  id: it.id,
23
23
  name: it.name,
24
24
  url: it.url,
25
- status: 'done'
25
+ status: "done"
26
26
  };
27
27
  });
28
28
  // Only hydrate if the user hasn't already added/modified items locally
@@ -31,14 +31,14 @@ const Root = ({ multiple = true, initial = [], onChange, upload, removeMode = 'o
31
31
  initial
32
32
  ]);
33
33
  const hiddenInputValue = react.useMemo(()=>{
34
- const done = items.filter((i)=>i.status === 'done' && i.url);
34
+ const done = items.filter((i)=>i.status === "done" && i.url);
35
35
  return JSON.stringify(done.map(({ uid: _u, previewUrl: _p, file: _f, status: _s, progress: _pr, error: _e, ...rest })=>rest));
36
36
  }, [
37
37
  items
38
38
  ]);
39
39
  const emitChange = react.useCallback((next)=>{
40
40
  setItems((prev)=>{
41
- const nextState = typeof next === 'function' ? next(prev) : next;
41
+ const nextState = typeof next === "function" ? next(prev) : next;
42
42
  if (onChange) Promise.resolve(onChange(nextState)).catch(()=>{});
43
43
  return nextState;
44
44
  });
@@ -57,7 +57,7 @@ const Root = ({ multiple = true, initial = [], onChange, upload, removeMode = 'o
57
57
  };
58
58
  emitChange((items)=>items.map((it)=>it.uid === item.uid ? {
59
59
  ...it,
60
- status: 'uploading',
60
+ status: "uploading",
61
61
  error: undefined
62
62
  } : it));
63
63
  try {
@@ -65,7 +65,7 @@ const Root = ({ multiple = true, initial = [], onChange, upload, removeMode = 'o
65
65
  emitChange((items)=>items.map((it)=>{
66
66
  if (it.uid !== item.uid) return it;
67
67
  // Revoke the local objectURL preview to avoid memory leaks
68
- if (it.previewUrl && it.previewUrl.startsWith('blob:')) {
68
+ if (it.previewUrl && it.previewUrl.startsWith("blob:")) {
69
69
  try {
70
70
  URL.revokeObjectURL(it.previewUrl);
71
71
  } catch {
@@ -77,7 +77,7 @@ const Root = ({ multiple = true, initial = [], onChange, upload, removeMode = 'o
77
77
  });
78
78
  return {
79
79
  ...it,
80
- status: 'done',
80
+ status: "done",
81
81
  url: result.url,
82
82
  id: result.id,
83
83
  previewUrl: serverPreview,
@@ -88,8 +88,8 @@ const Root = ({ multiple = true, initial = [], onChange, upload, removeMode = 'o
88
88
  const wasAborted = controller.signal.aborted;
89
89
  emitChange((items)=>items.map((it)=>it.uid === item.uid ? {
90
90
  ...it,
91
- status: wasAborted ? 'canceled' : 'error',
92
- error: wasAborted ? undefined : err?.message || 'Upload failed'
91
+ status: wasAborted ? "canceled" : "error",
92
+ error: wasAborted ? undefined : err?.message || "Upload failed"
93
93
  } : it));
94
94
  } finally{
95
95
  controllers.current.delete(item.uid);
@@ -101,14 +101,14 @@ const Root = ({ multiple = true, initial = [], onChange, upload, removeMode = 'o
101
101
  const selectFiles = react.useCallback((files)=>{
102
102
  if (!files || files.length === 0) return;
103
103
  const selected = Array.from(files);
104
- const remaining = maxCount ? Math.max(0, maxCount - items.filter((i)=>i.status !== 'canceled').length) : undefined;
105
- const toUse = typeof remaining === 'number' ? selected.slice(0, remaining) : selected;
104
+ const remaining = maxCount ? Math.max(0, maxCount - items.filter((i)=>i.status !== "canceled").length) : undefined;
105
+ const toUse = typeof remaining === "number" ? selected.slice(0, remaining) : selected;
106
106
  const newItems = toUse.map((file)=>({
107
107
  uid: uid(),
108
108
  name: file.name,
109
109
  file,
110
110
  previewUrl: URL.createObjectURL(file),
111
- status: 'idle',
111
+ status: "idle",
112
112
  progress: 0
113
113
  }));
114
114
  emitChange([
@@ -124,7 +124,7 @@ const Root = ({ multiple = true, initial = [], onChange, upload, removeMode = 'o
124
124
  ]);
125
125
  const onInputChange = react.useCallback((e)=>{
126
126
  selectFiles(e.target.files);
127
- e.currentTarget.value = '';
127
+ e.currentTarget.value = "";
128
128
  }, [
129
129
  selectFiles
130
130
  ]);
@@ -148,13 +148,13 @@ const Root = ({ multiple = true, initial = [], onChange, upload, removeMode = 'o
148
148
  // abort any in-flight upload first
149
149
  controllers.current.get(uidStr)?.abort();
150
150
  // If no server-side removal needed or not uploaded yet, just remove
151
- if (!onRemove || item.status !== 'done') {
151
+ if (!onRemove || item.status !== "done") {
152
152
  emitChange((list)=>list.filter((i)=>i.uid !== uidStr));
153
153
  return;
154
154
  }
155
155
  const ctrl = new AbortController();
156
156
  removeControllers.current.set(uidStr, ctrl);
157
- if (removeMode === 'optimistic') {
157
+ if (removeMode === "optimistic") {
158
158
  const prev = items;
159
159
  // remove from UI immediately
160
160
  emitChange((list)=>list.filter((i)=>i.uid !== uidStr));
@@ -170,7 +170,7 @@ const Root = ({ multiple = true, initial = [], onChange, upload, removeMode = 'o
170
170
  // strict: mark as removing, wait for API, then remove
171
171
  emitChange((list)=>list.map((it)=>it.uid === uidStr ? {
172
172
  ...it,
173
- status: 'removing'
173
+ status: "removing"
174
174
  } : it));
175
175
  try {
176
176
  await onRemove(item, ctrl.signal);
@@ -179,7 +179,7 @@ const Root = ({ multiple = true, initial = [], onChange, upload, removeMode = 'o
179
179
  // revert to done if delete fails
180
180
  emitChange((list)=>list.map((it)=>it.uid === uidStr ? {
181
181
  ...it,
182
- status: 'done'
182
+ status: "done"
183
183
  } : it));
184
184
  } finally{
185
185
  removeControllers.current.delete(uidStr);
@@ -192,13 +192,13 @@ const Root = ({ multiple = true, initial = [], onChange, upload, removeMode = 'o
192
192
  if (item.file) {
193
193
  void startUpload({
194
194
  ...item,
195
- status: 'idle',
195
+ status: "idle",
196
196
  error: undefined,
197
197
  progress: 0
198
198
  });
199
199
  emitChange((items)=>items.map((it)=>it.uid === uidStr ? {
200
200
  ...it,
201
- status: 'idle',
201
+ status: "idle",
202
202
  error: undefined,
203
203
  progress: 0
204
204
  } : it));
@@ -230,16 +230,16 @@ const Root = ({ multiple = true, initial = [], onChange, upload, removeMode = 'o
230
230
  disabled
231
231
  },
232
232
  getDropzoneProps: ()=>({
233
- role: 'button',
233
+ role: "button",
234
234
  tabIndex: 0,
235
235
  onDrop,
236
236
  onDragOver,
237
237
  onKeyDown: (e)=>{
238
238
  if (disabled) return;
239
- if (e.key === 'Enter' || e.key === ' ') inputRef.current?.click();
239
+ if (e.key === "Enter" || e.key === " ") inputRef.current?.click();
240
240
  },
241
- 'data-disabled': disabled ? '' : undefined,
242
- 'data-multiple': multiple ? '' : undefined
241
+ "data-disabled": disabled ? "" : undefined,
242
+ "data-multiple": multiple ? "" : undefined
243
243
  }),
244
244
  hiddenInputValue,
245
245
  name
@@ -278,7 +278,7 @@ const Dropzone = ({ asChild, ...rest })=>{
278
278
 
279
279
  const Preview = ({ render, className })=>{
280
280
  const { items, actions } = useImageUploader();
281
- if (render && typeof render === 'function') {
281
+ if (render && typeof render === "function") {
282
282
  return render({
283
283
  items,
284
284
  actions
@@ -302,7 +302,7 @@ const Preview = ({ render, className })=>{
302
302
  className: "flex h-32 w-full items-center justify-center text-xs text-gray-500",
303
303
  children: "No preview"
304
304
  }),
305
- item.status === 'uploading' && /*#__PURE__*/ jsxRuntime.jsx("div", {
305
+ item.status === "uploading" && /*#__PURE__*/ jsxRuntime.jsx("div", {
306
306
  className: "absolute bottom-0 left-0 right-0 h-1 bg-gray-200",
307
307
  children: /*#__PURE__*/ jsxRuntime.jsx("div", {
308
308
  className: "h-full bg-black/80",
@@ -314,13 +314,13 @@ const Preview = ({ render, className })=>{
314
314
  /*#__PURE__*/ jsxRuntime.jsxs("div", {
315
315
  className: "absolute inset-x-0 bottom-0 flex justify-end gap-2 bg-gradient-to-t from-black/60 to-transparent p-2",
316
316
  children: [
317
- item.status === 'uploading' && /*#__PURE__*/ jsxRuntime.jsx("button", {
317
+ item.status === "uploading" && /*#__PURE__*/ jsxRuntime.jsx("button", {
318
318
  type: "button",
319
319
  className: "rounded-xl bg-black/50 px-2 py-1 text-xs text-white",
320
320
  onClick: ()=>actions.cancel(item.uid),
321
321
  children: "Cancel"
322
322
  }),
323
- (item.status === 'error' || item.status === 'canceled') && /*#__PURE__*/ jsxRuntime.jsx("button", {
323
+ (item.status === "error" || item.status === "canceled") && /*#__PURE__*/ jsxRuntime.jsx("button", {
324
324
  type: "button",
325
325
  className: "rounded-xl bg-black/50 px-2 py-1 text-xs text-white",
326
326
  onClick: ()=>actions.retry(item.uid),
@@ -349,7 +349,7 @@ const HiddenInput = ({ name })=>{
349
349
  };
350
350
  const Cancel = ({ uid, asChild, ...rest })=>{
351
351
  const { actions } = useImageUploader();
352
- const Comp = asChild ? reactSlot.Slot : 'button';
352
+ const Comp = asChild ? reactSlot.Slot : "button";
353
353
  return /*#__PURE__*/ jsxRuntime.jsx(Comp, {
354
354
  onClick: (e)=>{
355
355
  e.stopPropagation();
@@ -360,7 +360,7 @@ const Cancel = ({ uid, asChild, ...rest })=>{
360
360
  };
361
361
  const Retry = ({ uid, asChild, ...rest })=>{
362
362
  const { actions } = useImageUploader();
363
- const Comp = asChild ? reactSlot.Slot : 'button';
363
+ const Comp = asChild ? reactSlot.Slot : "button";
364
364
  return /*#__PURE__*/ jsxRuntime.jsx(Comp, {
365
365
  onClick: (e)=>{
366
366
  e.stopPropagation();
@@ -371,7 +371,7 @@ const Retry = ({ uid, asChild, ...rest })=>{
371
371
  };
372
372
  const Remove = ({ uid, asChild, ...rest })=>{
373
373
  const { actions } = useImageUploader();
374
- const Comp = asChild ? reactSlot.Slot : 'button';
374
+ const Comp = asChild ? reactSlot.Slot : "button";
375
375
  return /*#__PURE__*/ jsxRuntime.jsx(Comp, {
376
376
  onClick: (e)=>{
377
377
  e.stopPropagation();
@@ -383,12 +383,12 @@ const Remove = ({ uid, asChild, ...rest })=>{
383
383
 
384
384
  const Trigger = ({ asChild, children, render, ...rest })=>{
385
385
  const { openFileDialog, disabled, items } = useImageUploader();
386
- const Comp = asChild ? reactSlot.Slot : 'button';
387
- const uploading = items.filter((i)=>i.status === 'uploading');
386
+ const Comp = asChild ? reactSlot.Slot : "button";
387
+ const uploading = items.filter((i)=>i.status === "uploading");
388
388
  const uploadingCount = uploading.length;
389
- const doneCount = items.filter((i)=>i.status === 'done').length;
390
- const errorCount = items.filter((i)=>i.status === 'error').length;
391
- const totalProgress = uploadingCount ? Math.round(uploading.reduce((acc, it)=>acc + (typeof it.progress === 'number' ? it.progress : 0), 0) / uploadingCount) : undefined;
389
+ const doneCount = items.filter((i)=>i.status === "done").length;
390
+ const errorCount = items.filter((i)=>i.status === "error").length;
391
+ const totalProgress = uploadingCount ? Math.round(uploading.reduce((acc, it)=>acc + (typeof it.progress === "number" ? it.progress : 0), 0) / uploadingCount) : undefined;
392
392
  const api = {
393
393
  items,
394
394
  isUploading: uploadingCount > 0,
@@ -399,7 +399,7 @@ const Trigger = ({ asChild, children, render, ...rest })=>{
399
399
  open: openFileDialog
400
400
  };
401
401
  return /*#__PURE__*/ jsxRuntime.jsx(Comp, {
402
- type: asChild ? undefined : 'button',
402
+ type: asChild ? undefined : "button",
403
403
  "aria-disabled": disabled,
404
404
  "data-part": "trigger",
405
405
  onClick: (e)=>{
package/dist/index.d.mts CHANGED
@@ -87,7 +87,7 @@ type Props = {
87
87
  render?: (api: PreviewRenderProps) => React.ReactNode;
88
88
  className?: string;
89
89
  };
90
- declare const Preview: ({ render, className }: Props) => string | number | boolean | react_jsx_runtime.JSX.Element | Iterable<React.ReactNode> | null | undefined;
90
+ declare const Preview: ({ render, className }: Props) => string | number | boolean | Iterable<React.ReactNode> | react_jsx_runtime.JSX.Element | null | undefined;
91
91
  declare const HiddenInput: ({ name }: {
92
92
  name?: string;
93
93
  }) => react_jsx_runtime.JSX.Element;
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.mts","sources":["../src/components/dropzone.tsx","../src/types.ts","../src/components/preview.tsx","../src/components/trigger.tsx","../src/context.tsx","../src/hook.ts"],"sourcesContent":["import { Slot } from \"@radix-ui/react-slot\";\nimport { HTMLAttributes } from \"react\";\n\nimport { useImageUploader } from \"../hook\";\n\nexport const Dropzone = ({\n asChild,\n ...rest\n}: { asChild?: boolean } & HTMLAttributes<HTMLElement>) => {\n const { getDropzoneProps } = useImageUploader();\n const Comp: any = asChild ? Slot : \"div\";\n return <Comp data-part=\"dropzone\" {...getDropzoneProps()} {...rest} />;\n};\n","import type {\n ChangeEvent,\n DragEvent,\n PropsWithChildren,\n RefObject,\n} from \"react\";\n\nexport type UploadStatus =\n | \"idle\"\n | \"uploading\"\n | \"done\"\n | \"error\"\n | \"canceled\"\n | \"removing\";\n\nexport type UploadFileItem = {\n uid: string;\n id?: string;\n name: string;\n url?: string;\n previewUrl?: string;\n file?: File;\n status: UploadStatus;\n progress?: number;\n error?: string;\n data?: any;\n};\n\nexport type UploadResult = { url: string; id?: string };\n\nexport type RootProps = PropsWithChildren<{\n multiple?: boolean;\n initial?: Array<Pick<UploadFileItem, \"uid\" | \"id\" | \"name\" | \"url\">>;\n /**\n * optimistic (default): remove from UI immediately, call onRemove in the background; if it fails, restore the item and show error.\n * strict: call onRemove first; only remove from UI if it succeeds.\n */\n removeMode?: \"optimistic\" | \"strict\";\n name?: string;\n maxCount?: number;\n disabled?: boolean;\n accept?: string;\n onChange?: (items: UploadFileItem[]) => Promise<void> | void;\n upload: (\n file: File,\n signal: AbortSignal,\n setProgress?: (pct: number) => void,\n ) => Promise<UploadResult>;\n onRemove?: (item: UploadFileItem, signal: AbortSignal) => Promise<void>;\n}>;\n\nexport type ItemActions = {\n cancel: (uid: string) => void;\n remove: (uid: string) => void;\n retry: (uid: string) => void;\n};\n\nexport type ImageUploaderContextValue = {\n items: UploadFileItem[];\n disabled?: boolean;\n multiple: boolean;\n accept: string;\n actions: ItemActions;\n openFileDialog: () => void;\n fileInputProps: {\n ref: RefObject<HTMLInputElement>;\n onChange: (e: ChangeEvent<HTMLInputElement>) => void;\n accept: string;\n multiple: boolean;\n disabled?: boolean;\n };\n getDropzoneProps: () => {\n role: string;\n tabIndex: number;\n onDrop: (e: DragEvent) => void;\n onDragOver: (e: DragEvent) => void;\n onKeyDown: (e: KeyboardEvent) => void;\n \"data-disabled\"?: string;\n \"data-multiple\"?: string;\n };\n hiddenInputValue: string;\n name: string;\n};\n\nexport type TriggerRenderProps = {\n items: UploadFileItem[];\n isUploading: boolean;\n uploadingCount: number;\n doneCount: number;\n errorCount: number;\n totalProgress?: number;\n open: () => void;\n};\n\nexport type PreviewRenderProps = {\n items: UploadFileItem[];\n actions: ItemActions;\n};\n","import { Slot } from '@radix-ui/react-slot'\nimport React, { ButtonHTMLAttributes } from 'react'\n\nimport { useImageUploader } from '../hook'\n\nimport type { PreviewRenderProps } from '../types'\n\ntype Props = {\n render?: (api: PreviewRenderProps) => React.ReactNode\n className?: string\n}\n\nexport const Preview = ({ render, className }: Props) => {\n const { items, actions } = useImageUploader()\n\n if (render && typeof render === 'function') {\n return render({ items, actions })\n }\n\n return (\n <div data-part=\"preview\" className={className}>\n <div className=\"grid grid-cols-2 gap-3 sm:grid-cols-3 md:grid-cols-4\">\n {items.map((item) => (\n <div\n key={item.uid}\n onClick={(e) => e.stopPropagation()}\n className=\"relative overflow-hidden rounded-xl border\"\n data-state={item.status}\n >\n {item.url || item.previewUrl ? (\n <img\n src={item.url || item.previewUrl}\n alt={item.name}\n className=\"h-32 w-full object-cover\"\n />\n ) : (\n <div className=\"flex h-32 w-full items-center justify-center text-xs text-gray-500\">\n No preview\n </div>\n )}\n {item.status === 'uploading' && (\n <div className=\"absolute bottom-0 left-0 right-0 h-1 bg-gray-200\">\n <div\n className=\"h-full bg-black/80\"\n style={{ width: `${Math.max(0, Math.min(100, item.progress ?? 0))}%` }}\n />\n </div>\n )}\n <div className=\"absolute inset-x-0 bottom-0 flex justify-end gap-2 bg-gradient-to-t from-black/60 to-transparent p-2\">\n {item.status === 'uploading' && (\n <button\n type=\"button\"\n className=\"rounded-xl bg-black/50 px-2 py-1 text-xs text-white\"\n onClick={() => actions.cancel(item.uid)}\n >\n Cancel\n </button>\n )}\n {(item.status === 'error' || item.status === 'canceled') && (\n <button\n type=\"button\"\n className=\"rounded-xl bg-black/50 px-2 py-1 text-xs text-white\"\n onClick={() => actions.retry(item.uid)}\n >\n Retry\n </button>\n )}\n <button\n type=\"button\"\n className=\"rounded-xl bg-black/50 px-2 py-1 text-xs text-white\"\n onClick={() => actions.remove(item.uid)}\n >\n Remove\n </button>\n </div>\n </div>\n ))}\n </div>\n </div>\n )\n}\n\nexport const HiddenInput = ({ name }: { name?: string }) => {\n const { hiddenInputValue, name: defaultName } = useImageUploader()\n return <input type=\"hidden\" name={name ?? defaultName} value={hiddenInputValue} />\n}\n\ntype ButtonProps = {\n uid: string\n asChild?: boolean\n} & ButtonHTMLAttributes<HTMLButtonElement>\n\nexport const Cancel = ({ uid, asChild, ...rest }: ButtonProps) => {\n const { actions } = useImageUploader()\n const Comp: any = asChild ? Slot : 'button'\n return (\n <Comp\n onClick={(e: { stopPropagation: () => void }) => {\n e.stopPropagation()\n actions.cancel(uid)\n }}\n {...rest}\n />\n )\n}\n\nexport const Retry = ({ uid, asChild, ...rest }: ButtonProps) => {\n const { actions } = useImageUploader()\n const Comp: any = asChild ? Slot : 'button'\n return (\n <Comp\n onClick={(e: { stopPropagation: () => void }) => {\n e.stopPropagation()\n actions.retry(uid)\n }}\n {...rest}\n />\n )\n}\n\nexport const Remove = ({ uid, asChild, ...rest }: ButtonProps) => {\n const { actions } = useImageUploader()\n const Comp: any = asChild ? Slot : 'button'\n return (\n <Comp\n onClick={(e: { stopPropagation: () => void }) => {\n e.stopPropagation()\n actions.remove(uid)\n }}\n {...rest}\n />\n )\n}\n","import { Slot } from '@radix-ui/react-slot'\nimport React, { PropsWithChildren } from 'react'\n\nimport { useImageUploader } from '../hook'\nimport type { TriggerRenderProps } from '../types'\n\nexport const Trigger = ({\n asChild,\n children,\n render,\n ...rest\n}: PropsWithChildren<\n {\n asChild?: boolean\n render?: (api: TriggerRenderProps) => React.ReactNode\n children?: React.ReactNode | ((api: TriggerRenderProps) => React.ReactNode)\n } & React.HTMLAttributes<HTMLElement>\n>) => {\n const { openFileDialog, disabled, items } = useImageUploader()\n const Comp: any = asChild ? Slot : 'button'\n\n const uploading = items.filter((i) => i.status === 'uploading')\n const uploadingCount = uploading.length\n const doneCount = items.filter((i) => i.status === 'done').length\n const errorCount = items.filter((i) => i.status === 'error').length\n const totalProgress = uploadingCount\n ? Math.round(\n uploading.reduce(\n (acc, it) => acc + (typeof it.progress === 'number' ? it.progress : 0),\n 0\n ) / uploadingCount\n )\n : undefined\n\n const api: TriggerRenderProps = {\n items,\n isUploading: uploadingCount > 0,\n uploadingCount,\n doneCount,\n errorCount,\n totalProgress,\n open: openFileDialog,\n }\n\n return (\n <Comp\n type={asChild ? undefined : 'button'}\n aria-disabled={disabled}\n data-part=\"trigger\"\n onClick={(e: any) => {\n if (disabled) return\n ;(rest as any).onClick?.(e)\n openFileDialog()\n }}\n {...rest}\n >\n {render ? render(api) : children}\n </Comp>\n )\n}\n","import type { DragEvent, RefObject } from 'react'\nimport React, { createContext, useCallback, useEffect, useMemo, useRef, useState } from 'react'\n\nimport type { ImageUploaderContextValue, ItemActions, RootProps, UploadFileItem } from './types'\nimport { uid } from './utils'\n\nexport const UploaderCtx = createContext<ImageUploaderContextValue | null>(null)\n\nexport const Root = ({\n multiple = true,\n initial = [],\n onChange,\n upload,\n removeMode = 'optimistic',\n onRemove,\n accept = 'image/*',\n name = 'images',\n maxCount,\n disabled,\n children,\n}: RootProps) => {\n const [items, setItems] = useState<UploadFileItem[]>([])\n const controllers = useRef(new Map<string, AbortController>())\n const removeControllers = useRef(new Map<string, AbortController>())\n const inputRef = useRef<HTMLInputElement | null>(null)\n\n // Hydrate initial items from the server and keep them marked as done\n useEffect(() => {\n const arr = initial ?? []\n if (!Array.isArray(arr)) return\n\n const mapped: UploadFileItem[] = arr.map((it) => {\n return {\n uid: it.uid || it.id,\n id: it.id,\n name: it.name,\n url: it.url,\n status: 'done',\n } as UploadFileItem\n })\n\n // Only hydrate if the user hasn't already added/modified items locally\n setItems((prev) => (prev.length === 0 ? mapped : prev))\n }, [initial])\n\n const hiddenInputValue = useMemo(() => {\n const done = items.filter((i) => i.status === 'done' && i.url)\n return JSON.stringify(\n done.map(\n ({ uid: _u, previewUrl: _p, file: _f, status: _s, progress: _pr, error: _e, ...rest }) =>\n rest\n )\n )\n }, [items])\n\n const emitChange = useCallback(\n (next: UploadFileItem[] | ((prev: UploadFileItem[]) => UploadFileItem[])) => {\n setItems((prev) => {\n const nextState = typeof next === 'function' ? (next as any)(prev) : next\n if (onChange) Promise.resolve(onChange(nextState)).catch(() => {})\n return nextState\n })\n },\n [onChange]\n )\n\n const startUpload = useCallback(\n async (item: UploadFileItem) => {\n if (!item.file) return\n const controller = new AbortController()\n controllers.current.set(item.uid, controller)\n\n const setProgress = (pct: number) => {\n emitChange((items) =>\n items.map((it) =>\n it.uid === item.uid ? { ...it, progress: Math.max(0, Math.min(100, pct)) } : it\n )\n )\n }\n\n emitChange((items) =>\n items.map((it) =>\n it.uid === item.uid ? { ...it, status: 'uploading', error: undefined } : it\n )\n )\n\n try {\n const result = await upload(item.file, controller.signal, setProgress)\n emitChange((items) =>\n items.map((it) => {\n if (it.uid !== item.uid) return it\n // Revoke the local objectURL preview to avoid memory leaks\n if (it.previewUrl && it.previewUrl.startsWith('blob:')) {\n try {\n URL.revokeObjectURL(it.previewUrl)\n } catch {\n /*fail silently*/\n }\n }\n const serverPreview = (result as any).preview || result.url\n console.log({ result })\n return {\n ...it,\n status: 'done',\n url: result.url,\n id: result.id,\n previewUrl: serverPreview,\n progress: 100,\n }\n })\n )\n } catch (err: any) {\n const wasAborted = controller.signal.aborted\n emitChange((items) =>\n items.map((it) =>\n it.uid === item.uid\n ? {\n ...it,\n status: wasAborted ? 'canceled' : 'error',\n error: wasAborted ? undefined : err?.message || 'Upload failed',\n }\n : it\n )\n )\n } finally {\n controllers.current.delete(item.uid)\n }\n },\n [emitChange, upload]\n )\n\n const selectFiles = useCallback(\n (files: FileList | null) => {\n if (!files || files.length === 0) return\n const selected = Array.from(files)\n const remaining = maxCount\n ? Math.max(0, maxCount - items.filter((i) => i.status !== 'canceled').length)\n : undefined\n const toUse = typeof remaining === 'number' ? selected.slice(0, remaining) : selected\n\n const newItems: UploadFileItem[] = toUse.map((file) => ({\n uid: uid(),\n name: file.name,\n file,\n previewUrl: URL.createObjectURL(file),\n status: 'idle',\n progress: 0,\n }))\n\n emitChange([...items, ...newItems])\n newItems.forEach((it) => startUpload(it))\n },\n [emitChange, items, maxCount, startUpload]\n )\n\n const onInputChange = useCallback(\n (e: React.ChangeEvent<HTMLInputElement>) => {\n selectFiles(e.target.files)\n e.currentTarget.value = ''\n },\n [selectFiles]\n )\n\n const onDrop = useCallback(\n (e: DragEvent) => {\n e.preventDefault()\n if (disabled) return\n selectFiles(e.dataTransfer.files)\n },\n [disabled, selectFiles]\n )\n\n const onDragOver = useCallback((e: DragEvent) => e.preventDefault(), [])\n\n const actions: ItemActions = useMemo(\n () => ({\n cancel: (uidStr: string) => {\n const ctrl = controllers.current.get(uidStr)\n ctrl?.abort()\n },\n remove: async (uidStr: string) => {\n const item = items.find((i) => i.uid === uidStr)\n if (!item) return\n\n // abort any in-flight upload first\n controllers.current.get(uidStr)?.abort()\n\n // If no server-side removal needed or not uploaded yet, just remove\n if (!onRemove || item.status !== 'done') {\n emitChange((list) => list.filter((i) => i.uid !== uidStr))\n return\n }\n\n const ctrl = new AbortController()\n removeControllers.current.set(uidStr, ctrl)\n\n if (removeMode === 'optimistic') {\n const prev = items\n // remove from UI immediately\n emitChange((list) => list.filter((i) => i.uid !== uidStr))\n try {\n await onRemove(item, ctrl.signal)\n } catch {\n // rollback UI if server delete fails\n emitChange(prev)\n } finally {\n removeControllers.current.delete(uidStr)\n }\n } else {\n // strict: mark as removing, wait for API, then remove\n emitChange((list) =>\n list.map((it) => (it.uid === uidStr ? { ...it, status: 'removing' as const } : it))\n )\n try {\n await onRemove(item, ctrl.signal)\n emitChange((list) => list.filter((i) => i.uid !== uidStr))\n } catch {\n // revert to done if delete fails\n emitChange((list) =>\n list.map((it) => (it.uid === uidStr ? { ...it, status: 'done' as const } : it))\n )\n } finally {\n removeControllers.current.delete(uidStr)\n }\n }\n },\n retry: (uidStr: string) => {\n const item = items.find((i) => i.uid === uidStr)\n if (!item) return\n if (item.file) {\n void startUpload({ ...item, status: 'idle', error: undefined, progress: 0 })\n emitChange((items) =>\n items.map((it) =>\n it.uid === uidStr ? { ...it, status: 'idle', error: undefined, progress: 0 } : it\n )\n )\n }\n },\n }),\n [emitChange, items, onRemove, removeMode, startUpload]\n )\n\n useEffect(\n () => () => {\n items.forEach((i) => i.previewUrl && URL.revokeObjectURL(i.previewUrl))\n controllers.current.forEach((c) => c.abort())\n },\n []\n )\n\n const ctx: ImageUploaderContextValue = {\n items,\n disabled,\n multiple,\n accept,\n actions,\n openFileDialog: () => inputRef.current?.click(),\n fileInputProps: {\n ref: inputRef as RefObject<HTMLInputElement>,\n onChange: onInputChange,\n accept,\n multiple,\n disabled,\n },\n getDropzoneProps: () => ({\n role: 'button',\n tabIndex: 0,\n onDrop,\n onDragOver,\n onKeyDown: (e) => {\n if (disabled) return\n if (e.key === 'Enter' || e.key === ' ') inputRef.current?.click()\n },\n 'data-disabled': disabled ? '' : undefined,\n 'data-multiple': multiple ? '' : undefined,\n }),\n hiddenInputValue,\n name,\n }\n\n return (\n <UploaderCtx.Provider value={ctx}>\n <div data-part=\"root\">\n <input type=\"file\" hidden {...ctx.fileInputProps} />\n {children}\n </div>\n </UploaderCtx.Provider>\n )\n}\n","import { useContext } from 'react'\n\nimport { UploaderCtx } from './context'\n\nexport const useImageUploader = () => {\n const ctx = useContext(UploaderCtx);\n if (!ctx) throw new Error(\"ImageUploader components must be used within <ImageUploader.Root>\");\n return ctx;\n};"],"names":[],"mappings":";;;AACO;AACP;AACA;;ACFO;AACA;AACP;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACO;AACP;AACA;AACA;AACO;AACP;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACO;AACP;AACA;AACA;AACA;AACO;AACP;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACO;AACP;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACO;AACP;AACA;AACA;;AC3EA;AACA;AACA;AACA;AACO;AACA;AACP;AACA;AACA;AACA;AACA;AACA;AACO;AACA;AACA;;ACdA;AACP;AACA;AACA;AACA;;ACHO;;ACHA;;;"}
1
+ {"version":3,"file":"index.d.mts","sources":["../src/components/dropzone.tsx","../src/types.ts","../src/components/preview.tsx","../src/components/trigger.tsx","../src/context.tsx","../src/hook.ts"],"sourcesContent":["import { Slot } from \"@radix-ui/react-slot\";\nimport { HTMLAttributes } from \"react\";\n\nimport { useImageUploader } from \"../hook\";\n\nexport const Dropzone = ({\n asChild,\n ...rest\n}: { asChild?: boolean } & HTMLAttributes<HTMLElement>) => {\n const { getDropzoneProps } = useImageUploader();\n const Comp: any = asChild ? Slot : \"div\";\n return <Comp data-part=\"dropzone\" {...getDropzoneProps()} {...rest} />;\n};\n","import type {\n ChangeEvent,\n DragEvent,\n PropsWithChildren,\n RefObject,\n} from \"react\";\n\nexport type UploadStatus =\n | \"idle\"\n | \"uploading\"\n | \"done\"\n | \"error\"\n | \"canceled\"\n | \"removing\";\n\nexport type UploadFileItem = {\n uid: string;\n id?: string;\n name: string;\n url?: string;\n previewUrl?: string;\n file?: File;\n status: UploadStatus;\n progress?: number;\n error?: string;\n data?: any;\n};\n\nexport type UploadResult = { url: string; id?: string };\n\nexport type RootProps = PropsWithChildren<{\n multiple?: boolean;\n initial?: Array<Pick<UploadFileItem, \"uid\" | \"id\" | \"name\" | \"url\">>;\n /**\n * optimistic (default): remove from UI immediately, call onRemove in the background; if it fails, restore the item and show error.\n * strict: call onRemove first; only remove from UI if it succeeds.\n */\n removeMode?: \"optimistic\" | \"strict\";\n name?: string;\n maxCount?: number;\n disabled?: boolean;\n accept?: string;\n onChange?: (items: UploadFileItem[]) => Promise<void> | void;\n upload: (\n file: File,\n signal: AbortSignal,\n setProgress?: (pct: number) => void,\n ) => Promise<UploadResult>;\n onRemove?: (item: UploadFileItem, signal: AbortSignal) => Promise<void>;\n}>;\n\nexport type ItemActions = {\n cancel: (uid: string) => void;\n remove: (uid: string) => void;\n retry: (uid: string) => void;\n};\n\nexport type ImageUploaderContextValue = {\n items: UploadFileItem[];\n disabled?: boolean;\n multiple: boolean;\n accept: string;\n actions: ItemActions;\n openFileDialog: () => void;\n fileInputProps: {\n ref: RefObject<HTMLInputElement>;\n onChange: (e: ChangeEvent<HTMLInputElement>) => void;\n accept: string;\n multiple: boolean;\n disabled?: boolean;\n };\n getDropzoneProps: () => {\n role: string;\n tabIndex: number;\n onDrop: (e: DragEvent) => void;\n onDragOver: (e: DragEvent) => void;\n onKeyDown: (e: KeyboardEvent) => void;\n \"data-disabled\"?: string;\n \"data-multiple\"?: string;\n };\n hiddenInputValue: string;\n name: string;\n};\n\nexport type TriggerRenderProps = {\n items: UploadFileItem[];\n isUploading: boolean;\n uploadingCount: number;\n doneCount: number;\n errorCount: number;\n totalProgress?: number;\n open: () => void;\n};\n\nexport type PreviewRenderProps = {\n items: UploadFileItem[];\n actions: ItemActions;\n};\n","import { Slot } from \"@radix-ui/react-slot\";\nimport React, { ButtonHTMLAttributes } from \"react\";\n\nimport { useImageUploader } from \"../hook\";\n\nimport type { PreviewRenderProps } from \"../types\";\n\ntype Props = {\n render?: (api: PreviewRenderProps) => React.ReactNode;\n className?: string;\n};\n\nexport const Preview = ({ render, className }: Props) => {\n const { items, actions } = useImageUploader();\n\n if (render && typeof render === \"function\") {\n return render({ items, actions });\n }\n\n return (\n <div data-part=\"preview\" className={className}>\n <div className=\"grid grid-cols-2 gap-3 sm:grid-cols-3 md:grid-cols-4\">\n {items.map((item) => (\n <div\n key={item.uid}\n onClick={(e) => e.stopPropagation()}\n className=\"relative overflow-hidden rounded-xl border\"\n data-state={item.status}\n >\n {item.url || item.previewUrl ? (\n <img\n src={item.url || item.previewUrl}\n alt={item.name}\n className=\"h-32 w-full object-cover\"\n />\n ) : (\n <div className=\"flex h-32 w-full items-center justify-center text-xs text-gray-500\">\n No preview\n </div>\n )}\n {item.status === \"uploading\" && (\n <div className=\"absolute bottom-0 left-0 right-0 h-1 bg-gray-200\">\n <div\n className=\"h-full bg-black/80\"\n style={{\n width: `${Math.max(0, Math.min(100, item.progress ?? 0))}%`,\n }}\n />\n </div>\n )}\n <div className=\"absolute inset-x-0 bottom-0 flex justify-end gap-2 bg-gradient-to-t from-black/60 to-transparent p-2\">\n {item.status === \"uploading\" && (\n <button\n type=\"button\"\n className=\"rounded-xl bg-black/50 px-2 py-1 text-xs text-white\"\n onClick={() => actions.cancel(item.uid)}\n >\n Cancel\n </button>\n )}\n {(item.status === \"error\" || item.status === \"canceled\") && (\n <button\n type=\"button\"\n className=\"rounded-xl bg-black/50 px-2 py-1 text-xs text-white\"\n onClick={() => actions.retry(item.uid)}\n >\n Retry\n </button>\n )}\n <button\n type=\"button\"\n className=\"rounded-xl bg-black/50 px-2 py-1 text-xs text-white\"\n onClick={() => actions.remove(item.uid)}\n >\n Remove\n </button>\n </div>\n </div>\n ))}\n </div>\n </div>\n );\n};\n\nexport const HiddenInput = ({ name }: { name?: string }) => {\n const { hiddenInputValue, name: defaultName } = useImageUploader();\n return (\n <input type=\"hidden\" name={name ?? defaultName} value={hiddenInputValue} />\n );\n};\n\ntype ButtonProps = {\n uid: string;\n asChild?: boolean;\n} & ButtonHTMLAttributes<HTMLButtonElement>;\n\nexport const Cancel = ({ uid, asChild, ...rest }: ButtonProps) => {\n const { actions } = useImageUploader();\n const Comp: any = asChild ? Slot : \"button\";\n return (\n <Comp\n onClick={(e: { stopPropagation: () => void }) => {\n e.stopPropagation();\n actions.cancel(uid);\n }}\n {...rest}\n />\n );\n};\n\nexport const Retry = ({ uid, asChild, ...rest }: ButtonProps) => {\n const { actions } = useImageUploader();\n const Comp: any = asChild ? Slot : \"button\";\n return (\n <Comp\n onClick={(e: { stopPropagation: () => void }) => {\n e.stopPropagation();\n actions.retry(uid);\n }}\n {...rest}\n />\n );\n};\n\nexport const Remove = ({ uid, asChild, ...rest }: ButtonProps) => {\n const { actions } = useImageUploader();\n const Comp: any = asChild ? Slot : \"button\";\n return (\n <Comp\n onClick={(e: { stopPropagation: () => void }) => {\n e.stopPropagation();\n actions.remove(uid);\n }}\n {...rest}\n />\n );\n};\n","import { Slot } from \"@radix-ui/react-slot\";\nimport React, { PropsWithChildren } from \"react\";\n\nimport { useImageUploader } from \"../hook\";\nimport type { TriggerRenderProps } from \"../types\";\n\nexport const Trigger = ({\n asChild,\n children,\n render,\n ...rest\n}: PropsWithChildren<\n {\n asChild?: boolean;\n render?: (api: TriggerRenderProps) => React.ReactNode;\n children?: React.ReactNode | ((api: TriggerRenderProps) => React.ReactNode);\n } & React.HTMLAttributes<HTMLElement>\n>) => {\n const { openFileDialog, disabled, items } = useImageUploader();\n const Comp: any = asChild ? Slot : \"button\";\n\n const uploading = items.filter((i) => i.status === \"uploading\");\n const uploadingCount = uploading.length;\n const doneCount = items.filter((i) => i.status === \"done\").length;\n const errorCount = items.filter((i) => i.status === \"error\").length;\n const totalProgress = uploadingCount\n ? Math.round(\n uploading.reduce(\n (acc, it) =>\n acc + (typeof it.progress === \"number\" ? it.progress : 0),\n 0,\n ) / uploadingCount,\n )\n : undefined;\n\n const api: TriggerRenderProps = {\n items,\n isUploading: uploadingCount > 0,\n uploadingCount,\n doneCount,\n errorCount,\n totalProgress,\n open: openFileDialog,\n };\n\n return (\n <Comp\n type={asChild ? undefined : \"button\"}\n aria-disabled={disabled}\n data-part=\"trigger\"\n onClick={(e: any) => {\n if (disabled) return;\n (rest as any).onClick?.(e);\n openFileDialog();\n }}\n {...rest}\n >\n {render ? render(api) : children}\n </Comp>\n );\n};\n","import type { DragEvent, RefObject } from \"react\";\nimport React, {\n createContext,\n useCallback,\n useEffect,\n useMemo,\n useRef,\n useState,\n} from \"react\";\n\nimport type {\n ImageUploaderContextValue,\n ItemActions,\n RootProps,\n UploadFileItem,\n} from \"./types\";\nimport { uid } from \"./utils\";\n\nexport const UploaderCtx = createContext<ImageUploaderContextValue | null>(\n null,\n);\n\nexport const Root = ({\n multiple = true,\n initial = [],\n onChange,\n upload,\n removeMode = \"optimistic\",\n onRemove,\n accept = \"image/*\",\n name = \"images\",\n maxCount,\n disabled,\n children,\n}: RootProps) => {\n const [items, setItems] = useState<UploadFileItem[]>([]);\n const controllers = useRef(new Map<string, AbortController>());\n const removeControllers = useRef(new Map<string, AbortController>());\n const inputRef = useRef<HTMLInputElement | null>(null);\n\n // Hydrate initial items from the server and keep them marked as done\n useEffect(() => {\n const arr = initial ?? [];\n if (!Array.isArray(arr)) return;\n\n const mapped: UploadFileItem[] = arr.map((it) => {\n return {\n uid: it.uid || it.id,\n id: it.id,\n name: it.name,\n url: it.url,\n status: \"done\",\n } as UploadFileItem;\n });\n\n // Only hydrate if the user hasn't already added/modified items locally\n setItems((prev) => (prev.length === 0 ? mapped : prev));\n }, [initial]);\n\n const hiddenInputValue = useMemo(() => {\n const done = items.filter((i) => i.status === \"done\" && i.url);\n return JSON.stringify(\n done.map(\n ({\n uid: _u,\n previewUrl: _p,\n file: _f,\n status: _s,\n progress: _pr,\n error: _e,\n ...rest\n }) => rest,\n ),\n );\n }, [items]);\n\n const emitChange = useCallback(\n (\n next: UploadFileItem[] | ((prev: UploadFileItem[]) => UploadFileItem[]),\n ) => {\n setItems((prev) => {\n const nextState =\n typeof next === \"function\" ? (next as any)(prev) : next;\n if (onChange) Promise.resolve(onChange(nextState)).catch(() => {});\n return nextState;\n });\n },\n [onChange],\n );\n\n const startUpload = useCallback(\n async (item: UploadFileItem) => {\n if (!item.file) return;\n const controller = new AbortController();\n controllers.current.set(item.uid, controller);\n\n const setProgress = (pct: number) => {\n emitChange((items) =>\n items.map((it) =>\n it.uid === item.uid\n ? { ...it, progress: Math.max(0, Math.min(100, pct)) }\n : it,\n ),\n );\n };\n\n emitChange((items) =>\n items.map((it) =>\n it.uid === item.uid\n ? { ...it, status: \"uploading\", error: undefined }\n : it,\n ),\n );\n\n try {\n const result = await upload(item.file, controller.signal, setProgress);\n emitChange((items) =>\n items.map((it) => {\n if (it.uid !== item.uid) return it;\n // Revoke the local objectURL preview to avoid memory leaks\n if (it.previewUrl && it.previewUrl.startsWith(\"blob:\")) {\n try {\n URL.revokeObjectURL(it.previewUrl);\n } catch {\n /*fail silently*/\n }\n }\n const serverPreview = (result as any).preview || result.url;\n console.log({ result });\n return {\n ...it,\n status: \"done\",\n url: result.url,\n id: result.id,\n previewUrl: serverPreview,\n progress: 100,\n };\n }),\n );\n } catch (err: any) {\n const wasAborted = controller.signal.aborted;\n emitChange((items) =>\n items.map((it) =>\n it.uid === item.uid\n ? {\n ...it,\n status: wasAborted ? \"canceled\" : \"error\",\n error: wasAborted\n ? undefined\n : err?.message || \"Upload failed\",\n }\n : it,\n ),\n );\n } finally {\n controllers.current.delete(item.uid);\n }\n },\n [emitChange, upload],\n );\n\n const selectFiles = useCallback(\n (files: FileList | null) => {\n if (!files || files.length === 0) return;\n const selected = Array.from(files);\n const remaining = maxCount\n ? Math.max(\n 0,\n maxCount - items.filter((i) => i.status !== \"canceled\").length,\n )\n : undefined;\n const toUse =\n typeof remaining === \"number\" ? selected.slice(0, remaining) : selected;\n\n const newItems: UploadFileItem[] = toUse.map((file) => ({\n uid: uid(),\n name: file.name,\n file,\n previewUrl: URL.createObjectURL(file),\n status: \"idle\",\n progress: 0,\n }));\n\n emitChange([...items, ...newItems]);\n newItems.forEach((it) => startUpload(it));\n },\n [emitChange, items, maxCount, startUpload],\n );\n\n const onInputChange = useCallback(\n (e: React.ChangeEvent<HTMLInputElement>) => {\n selectFiles(e.target.files);\n e.currentTarget.value = \"\";\n },\n [selectFiles],\n );\n\n const onDrop = useCallback(\n (e: DragEvent) => {\n e.preventDefault();\n if (disabled) return;\n selectFiles(e.dataTransfer.files);\n },\n [disabled, selectFiles],\n );\n\n const onDragOver = useCallback((e: DragEvent) => e.preventDefault(), []);\n\n const actions: ItemActions = useMemo(\n () => ({\n cancel: (uidStr: string) => {\n const ctrl = controllers.current.get(uidStr);\n ctrl?.abort();\n },\n remove: async (uidStr: string) => {\n const item = items.find((i) => i.uid === uidStr);\n if (!item) return;\n\n // abort any in-flight upload first\n controllers.current.get(uidStr)?.abort();\n\n // If no server-side removal needed or not uploaded yet, just remove\n if (!onRemove || item.status !== \"done\") {\n emitChange((list) => list.filter((i) => i.uid !== uidStr));\n return;\n }\n\n const ctrl = new AbortController();\n removeControllers.current.set(uidStr, ctrl);\n\n if (removeMode === \"optimistic\") {\n const prev = items;\n // remove from UI immediately\n emitChange((list) => list.filter((i) => i.uid !== uidStr));\n try {\n await onRemove(item, ctrl.signal);\n } catch {\n // rollback UI if server delete fails\n emitChange(prev);\n } finally {\n removeControllers.current.delete(uidStr);\n }\n } else {\n // strict: mark as removing, wait for API, then remove\n emitChange((list) =>\n list.map((it) =>\n it.uid === uidStr ? { ...it, status: \"removing\" as const } : it,\n ),\n );\n try {\n await onRemove(item, ctrl.signal);\n emitChange((list) => list.filter((i) => i.uid !== uidStr));\n } catch {\n // revert to done if delete fails\n emitChange((list) =>\n list.map((it) =>\n it.uid === uidStr ? { ...it, status: \"done\" as const } : it,\n ),\n );\n } finally {\n removeControllers.current.delete(uidStr);\n }\n }\n },\n retry: (uidStr: string) => {\n const item = items.find((i) => i.uid === uidStr);\n if (!item) return;\n if (item.file) {\n void startUpload({\n ...item,\n status: \"idle\",\n error: undefined,\n progress: 0,\n });\n emitChange((items) =>\n items.map((it) =>\n it.uid === uidStr\n ? { ...it, status: \"idle\", error: undefined, progress: 0 }\n : it,\n ),\n );\n }\n },\n }),\n [emitChange, items, onRemove, removeMode, startUpload],\n );\n\n useEffect(\n () => () => {\n items.forEach((i) => i.previewUrl && URL.revokeObjectURL(i.previewUrl));\n controllers.current.forEach((c) => c.abort());\n },\n [],\n );\n\n const ctx: ImageUploaderContextValue = {\n items,\n disabled,\n multiple,\n accept,\n actions,\n openFileDialog: () => inputRef.current?.click(),\n fileInputProps: {\n ref: inputRef as RefObject<HTMLInputElement>,\n onChange: onInputChange,\n accept,\n multiple,\n disabled,\n },\n getDropzoneProps: () => ({\n role: \"button\",\n tabIndex: 0,\n onDrop,\n onDragOver,\n onKeyDown: (e) => {\n if (disabled) return;\n if (e.key === \"Enter\" || e.key === \" \") inputRef.current?.click();\n },\n \"data-disabled\": disabled ? \"\" : undefined,\n \"data-multiple\": multiple ? \"\" : undefined,\n }),\n hiddenInputValue,\n name,\n };\n\n return (\n <UploaderCtx.Provider value={ctx}>\n <div data-part=\"root\">\n <input type=\"file\" hidden {...ctx.fileInputProps} />\n {children}\n </div>\n </UploaderCtx.Provider>\n );\n};\n","import { useContext } from \"react\";\n\nimport { UploaderCtx } from \"./context\";\n\nexport const useImageUploader = () => {\n const ctx = useContext(UploaderCtx);\n if (!ctx)\n throw new Error(\n \"ImageUploader components must be used within <ImageUploader.Root>\",\n );\n return ctx;\n};\n"],"names":[],"mappings":";;;AACO;AACP;AACA;;ACFO;AACA;AACP;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACO;AACP;AACA;AACA;AACO;AACP;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACO;AACP;AACA;AACA;AACA;AACO;AACP;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACO;AACP;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACO;AACP;AACA;AACA;;AC3EA;AACA;AACA;AACA;AACO;AACA;AACP;AACA;AACA;AACA;AACA;AACA;AACO;AACA;AACA;;ACdA;AACP;AACA;AACA;AACA;;ACHO;;ACHA;;;"}
package/dist/index.d.ts CHANGED
@@ -87,7 +87,7 @@ type Props = {
87
87
  render?: (api: PreviewRenderProps) => React.ReactNode;
88
88
  className?: string;
89
89
  };
90
- declare const Preview: ({ render, className }: Props) => string | number | boolean | react_jsx_runtime.JSX.Element | Iterable<React.ReactNode> | null | undefined;
90
+ declare const Preview: ({ render, className }: Props) => string | number | boolean | Iterable<React.ReactNode> | react_jsx_runtime.JSX.Element | null | undefined;
91
91
  declare const HiddenInput: ({ name }: {
92
92
  name?: string;
93
93
  }) => react_jsx_runtime.JSX.Element;
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sources":["../src/components/dropzone.tsx","../src/types.ts","../src/components/preview.tsx","../src/components/trigger.tsx","../src/context.tsx","../src/hook.ts"],"sourcesContent":["import { Slot } from \"@radix-ui/react-slot\";\nimport { HTMLAttributes } from \"react\";\n\nimport { useImageUploader } from \"../hook\";\n\nexport const Dropzone = ({\n asChild,\n ...rest\n}: { asChild?: boolean } & HTMLAttributes<HTMLElement>) => {\n const { getDropzoneProps } = useImageUploader();\n const Comp: any = asChild ? Slot : \"div\";\n return <Comp data-part=\"dropzone\" {...getDropzoneProps()} {...rest} />;\n};\n","import type {\n ChangeEvent,\n DragEvent,\n PropsWithChildren,\n RefObject,\n} from \"react\";\n\nexport type UploadStatus =\n | \"idle\"\n | \"uploading\"\n | \"done\"\n | \"error\"\n | \"canceled\"\n | \"removing\";\n\nexport type UploadFileItem = {\n uid: string;\n id?: string;\n name: string;\n url?: string;\n previewUrl?: string;\n file?: File;\n status: UploadStatus;\n progress?: number;\n error?: string;\n data?: any;\n};\n\nexport type UploadResult = { url: string; id?: string };\n\nexport type RootProps = PropsWithChildren<{\n multiple?: boolean;\n initial?: Array<Pick<UploadFileItem, \"uid\" | \"id\" | \"name\" | \"url\">>;\n /**\n * optimistic (default): remove from UI immediately, call onRemove in the background; if it fails, restore the item and show error.\n * strict: call onRemove first; only remove from UI if it succeeds.\n */\n removeMode?: \"optimistic\" | \"strict\";\n name?: string;\n maxCount?: number;\n disabled?: boolean;\n accept?: string;\n onChange?: (items: UploadFileItem[]) => Promise<void> | void;\n upload: (\n file: File,\n signal: AbortSignal,\n setProgress?: (pct: number) => void,\n ) => Promise<UploadResult>;\n onRemove?: (item: UploadFileItem, signal: AbortSignal) => Promise<void>;\n}>;\n\nexport type ItemActions = {\n cancel: (uid: string) => void;\n remove: (uid: string) => void;\n retry: (uid: string) => void;\n};\n\nexport type ImageUploaderContextValue = {\n items: UploadFileItem[];\n disabled?: boolean;\n multiple: boolean;\n accept: string;\n actions: ItemActions;\n openFileDialog: () => void;\n fileInputProps: {\n ref: RefObject<HTMLInputElement>;\n onChange: (e: ChangeEvent<HTMLInputElement>) => void;\n accept: string;\n multiple: boolean;\n disabled?: boolean;\n };\n getDropzoneProps: () => {\n role: string;\n tabIndex: number;\n onDrop: (e: DragEvent) => void;\n onDragOver: (e: DragEvent) => void;\n onKeyDown: (e: KeyboardEvent) => void;\n \"data-disabled\"?: string;\n \"data-multiple\"?: string;\n };\n hiddenInputValue: string;\n name: string;\n};\n\nexport type TriggerRenderProps = {\n items: UploadFileItem[];\n isUploading: boolean;\n uploadingCount: number;\n doneCount: number;\n errorCount: number;\n totalProgress?: number;\n open: () => void;\n};\n\nexport type PreviewRenderProps = {\n items: UploadFileItem[];\n actions: ItemActions;\n};\n","import { Slot } from '@radix-ui/react-slot'\nimport React, { ButtonHTMLAttributes } from 'react'\n\nimport { useImageUploader } from '../hook'\n\nimport type { PreviewRenderProps } from '../types'\n\ntype Props = {\n render?: (api: PreviewRenderProps) => React.ReactNode\n className?: string\n}\n\nexport const Preview = ({ render, className }: Props) => {\n const { items, actions } = useImageUploader()\n\n if (render && typeof render === 'function') {\n return render({ items, actions })\n }\n\n return (\n <div data-part=\"preview\" className={className}>\n <div className=\"grid grid-cols-2 gap-3 sm:grid-cols-3 md:grid-cols-4\">\n {items.map((item) => (\n <div\n key={item.uid}\n onClick={(e) => e.stopPropagation()}\n className=\"relative overflow-hidden rounded-xl border\"\n data-state={item.status}\n >\n {item.url || item.previewUrl ? (\n <img\n src={item.url || item.previewUrl}\n alt={item.name}\n className=\"h-32 w-full object-cover\"\n />\n ) : (\n <div className=\"flex h-32 w-full items-center justify-center text-xs text-gray-500\">\n No preview\n </div>\n )}\n {item.status === 'uploading' && (\n <div className=\"absolute bottom-0 left-0 right-0 h-1 bg-gray-200\">\n <div\n className=\"h-full bg-black/80\"\n style={{ width: `${Math.max(0, Math.min(100, item.progress ?? 0))}%` }}\n />\n </div>\n )}\n <div className=\"absolute inset-x-0 bottom-0 flex justify-end gap-2 bg-gradient-to-t from-black/60 to-transparent p-2\">\n {item.status === 'uploading' && (\n <button\n type=\"button\"\n className=\"rounded-xl bg-black/50 px-2 py-1 text-xs text-white\"\n onClick={() => actions.cancel(item.uid)}\n >\n Cancel\n </button>\n )}\n {(item.status === 'error' || item.status === 'canceled') && (\n <button\n type=\"button\"\n className=\"rounded-xl bg-black/50 px-2 py-1 text-xs text-white\"\n onClick={() => actions.retry(item.uid)}\n >\n Retry\n </button>\n )}\n <button\n type=\"button\"\n className=\"rounded-xl bg-black/50 px-2 py-1 text-xs text-white\"\n onClick={() => actions.remove(item.uid)}\n >\n Remove\n </button>\n </div>\n </div>\n ))}\n </div>\n </div>\n )\n}\n\nexport const HiddenInput = ({ name }: { name?: string }) => {\n const { hiddenInputValue, name: defaultName } = useImageUploader()\n return <input type=\"hidden\" name={name ?? defaultName} value={hiddenInputValue} />\n}\n\ntype ButtonProps = {\n uid: string\n asChild?: boolean\n} & ButtonHTMLAttributes<HTMLButtonElement>\n\nexport const Cancel = ({ uid, asChild, ...rest }: ButtonProps) => {\n const { actions } = useImageUploader()\n const Comp: any = asChild ? Slot : 'button'\n return (\n <Comp\n onClick={(e: { stopPropagation: () => void }) => {\n e.stopPropagation()\n actions.cancel(uid)\n }}\n {...rest}\n />\n )\n}\n\nexport const Retry = ({ uid, asChild, ...rest }: ButtonProps) => {\n const { actions } = useImageUploader()\n const Comp: any = asChild ? Slot : 'button'\n return (\n <Comp\n onClick={(e: { stopPropagation: () => void }) => {\n e.stopPropagation()\n actions.retry(uid)\n }}\n {...rest}\n />\n )\n}\n\nexport const Remove = ({ uid, asChild, ...rest }: ButtonProps) => {\n const { actions } = useImageUploader()\n const Comp: any = asChild ? Slot : 'button'\n return (\n <Comp\n onClick={(e: { stopPropagation: () => void }) => {\n e.stopPropagation()\n actions.remove(uid)\n }}\n {...rest}\n />\n )\n}\n","import { Slot } from '@radix-ui/react-slot'\nimport React, { PropsWithChildren } from 'react'\n\nimport { useImageUploader } from '../hook'\nimport type { TriggerRenderProps } from '../types'\n\nexport const Trigger = ({\n asChild,\n children,\n render,\n ...rest\n}: PropsWithChildren<\n {\n asChild?: boolean\n render?: (api: TriggerRenderProps) => React.ReactNode\n children?: React.ReactNode | ((api: TriggerRenderProps) => React.ReactNode)\n } & React.HTMLAttributes<HTMLElement>\n>) => {\n const { openFileDialog, disabled, items } = useImageUploader()\n const Comp: any = asChild ? Slot : 'button'\n\n const uploading = items.filter((i) => i.status === 'uploading')\n const uploadingCount = uploading.length\n const doneCount = items.filter((i) => i.status === 'done').length\n const errorCount = items.filter((i) => i.status === 'error').length\n const totalProgress = uploadingCount\n ? Math.round(\n uploading.reduce(\n (acc, it) => acc + (typeof it.progress === 'number' ? it.progress : 0),\n 0\n ) / uploadingCount\n )\n : undefined\n\n const api: TriggerRenderProps = {\n items,\n isUploading: uploadingCount > 0,\n uploadingCount,\n doneCount,\n errorCount,\n totalProgress,\n open: openFileDialog,\n }\n\n return (\n <Comp\n type={asChild ? undefined : 'button'}\n aria-disabled={disabled}\n data-part=\"trigger\"\n onClick={(e: any) => {\n if (disabled) return\n ;(rest as any).onClick?.(e)\n openFileDialog()\n }}\n {...rest}\n >\n {render ? render(api) : children}\n </Comp>\n )\n}\n","import type { DragEvent, RefObject } from 'react'\nimport React, { createContext, useCallback, useEffect, useMemo, useRef, useState } from 'react'\n\nimport type { ImageUploaderContextValue, ItemActions, RootProps, UploadFileItem } from './types'\nimport { uid } from './utils'\n\nexport const UploaderCtx = createContext<ImageUploaderContextValue | null>(null)\n\nexport const Root = ({\n multiple = true,\n initial = [],\n onChange,\n upload,\n removeMode = 'optimistic',\n onRemove,\n accept = 'image/*',\n name = 'images',\n maxCount,\n disabled,\n children,\n}: RootProps) => {\n const [items, setItems] = useState<UploadFileItem[]>([])\n const controllers = useRef(new Map<string, AbortController>())\n const removeControllers = useRef(new Map<string, AbortController>())\n const inputRef = useRef<HTMLInputElement | null>(null)\n\n // Hydrate initial items from the server and keep them marked as done\n useEffect(() => {\n const arr = initial ?? []\n if (!Array.isArray(arr)) return\n\n const mapped: UploadFileItem[] = arr.map((it) => {\n return {\n uid: it.uid || it.id,\n id: it.id,\n name: it.name,\n url: it.url,\n status: 'done',\n } as UploadFileItem\n })\n\n // Only hydrate if the user hasn't already added/modified items locally\n setItems((prev) => (prev.length === 0 ? mapped : prev))\n }, [initial])\n\n const hiddenInputValue = useMemo(() => {\n const done = items.filter((i) => i.status === 'done' && i.url)\n return JSON.stringify(\n done.map(\n ({ uid: _u, previewUrl: _p, file: _f, status: _s, progress: _pr, error: _e, ...rest }) =>\n rest\n )\n )\n }, [items])\n\n const emitChange = useCallback(\n (next: UploadFileItem[] | ((prev: UploadFileItem[]) => UploadFileItem[])) => {\n setItems((prev) => {\n const nextState = typeof next === 'function' ? (next as any)(prev) : next\n if (onChange) Promise.resolve(onChange(nextState)).catch(() => {})\n return nextState\n })\n },\n [onChange]\n )\n\n const startUpload = useCallback(\n async (item: UploadFileItem) => {\n if (!item.file) return\n const controller = new AbortController()\n controllers.current.set(item.uid, controller)\n\n const setProgress = (pct: number) => {\n emitChange((items) =>\n items.map((it) =>\n it.uid === item.uid ? { ...it, progress: Math.max(0, Math.min(100, pct)) } : it\n )\n )\n }\n\n emitChange((items) =>\n items.map((it) =>\n it.uid === item.uid ? { ...it, status: 'uploading', error: undefined } : it\n )\n )\n\n try {\n const result = await upload(item.file, controller.signal, setProgress)\n emitChange((items) =>\n items.map((it) => {\n if (it.uid !== item.uid) return it\n // Revoke the local objectURL preview to avoid memory leaks\n if (it.previewUrl && it.previewUrl.startsWith('blob:')) {\n try {\n URL.revokeObjectURL(it.previewUrl)\n } catch {\n /*fail silently*/\n }\n }\n const serverPreview = (result as any).preview || result.url\n console.log({ result })\n return {\n ...it,\n status: 'done',\n url: result.url,\n id: result.id,\n previewUrl: serverPreview,\n progress: 100,\n }\n })\n )\n } catch (err: any) {\n const wasAborted = controller.signal.aborted\n emitChange((items) =>\n items.map((it) =>\n it.uid === item.uid\n ? {\n ...it,\n status: wasAborted ? 'canceled' : 'error',\n error: wasAborted ? undefined : err?.message || 'Upload failed',\n }\n : it\n )\n )\n } finally {\n controllers.current.delete(item.uid)\n }\n },\n [emitChange, upload]\n )\n\n const selectFiles = useCallback(\n (files: FileList | null) => {\n if (!files || files.length === 0) return\n const selected = Array.from(files)\n const remaining = maxCount\n ? Math.max(0, maxCount - items.filter((i) => i.status !== 'canceled').length)\n : undefined\n const toUse = typeof remaining === 'number' ? selected.slice(0, remaining) : selected\n\n const newItems: UploadFileItem[] = toUse.map((file) => ({\n uid: uid(),\n name: file.name,\n file,\n previewUrl: URL.createObjectURL(file),\n status: 'idle',\n progress: 0,\n }))\n\n emitChange([...items, ...newItems])\n newItems.forEach((it) => startUpload(it))\n },\n [emitChange, items, maxCount, startUpload]\n )\n\n const onInputChange = useCallback(\n (e: React.ChangeEvent<HTMLInputElement>) => {\n selectFiles(e.target.files)\n e.currentTarget.value = ''\n },\n [selectFiles]\n )\n\n const onDrop = useCallback(\n (e: DragEvent) => {\n e.preventDefault()\n if (disabled) return\n selectFiles(e.dataTransfer.files)\n },\n [disabled, selectFiles]\n )\n\n const onDragOver = useCallback((e: DragEvent) => e.preventDefault(), [])\n\n const actions: ItemActions = useMemo(\n () => ({\n cancel: (uidStr: string) => {\n const ctrl = controllers.current.get(uidStr)\n ctrl?.abort()\n },\n remove: async (uidStr: string) => {\n const item = items.find((i) => i.uid === uidStr)\n if (!item) return\n\n // abort any in-flight upload first\n controllers.current.get(uidStr)?.abort()\n\n // If no server-side removal needed or not uploaded yet, just remove\n if (!onRemove || item.status !== 'done') {\n emitChange((list) => list.filter((i) => i.uid !== uidStr))\n return\n }\n\n const ctrl = new AbortController()\n removeControllers.current.set(uidStr, ctrl)\n\n if (removeMode === 'optimistic') {\n const prev = items\n // remove from UI immediately\n emitChange((list) => list.filter((i) => i.uid !== uidStr))\n try {\n await onRemove(item, ctrl.signal)\n } catch {\n // rollback UI if server delete fails\n emitChange(prev)\n } finally {\n removeControllers.current.delete(uidStr)\n }\n } else {\n // strict: mark as removing, wait for API, then remove\n emitChange((list) =>\n list.map((it) => (it.uid === uidStr ? { ...it, status: 'removing' as const } : it))\n )\n try {\n await onRemove(item, ctrl.signal)\n emitChange((list) => list.filter((i) => i.uid !== uidStr))\n } catch {\n // revert to done if delete fails\n emitChange((list) =>\n list.map((it) => (it.uid === uidStr ? { ...it, status: 'done' as const } : it))\n )\n } finally {\n removeControllers.current.delete(uidStr)\n }\n }\n },\n retry: (uidStr: string) => {\n const item = items.find((i) => i.uid === uidStr)\n if (!item) return\n if (item.file) {\n void startUpload({ ...item, status: 'idle', error: undefined, progress: 0 })\n emitChange((items) =>\n items.map((it) =>\n it.uid === uidStr ? { ...it, status: 'idle', error: undefined, progress: 0 } : it\n )\n )\n }\n },\n }),\n [emitChange, items, onRemove, removeMode, startUpload]\n )\n\n useEffect(\n () => () => {\n items.forEach((i) => i.previewUrl && URL.revokeObjectURL(i.previewUrl))\n controllers.current.forEach((c) => c.abort())\n },\n []\n )\n\n const ctx: ImageUploaderContextValue = {\n items,\n disabled,\n multiple,\n accept,\n actions,\n openFileDialog: () => inputRef.current?.click(),\n fileInputProps: {\n ref: inputRef as RefObject<HTMLInputElement>,\n onChange: onInputChange,\n accept,\n multiple,\n disabled,\n },\n getDropzoneProps: () => ({\n role: 'button',\n tabIndex: 0,\n onDrop,\n onDragOver,\n onKeyDown: (e) => {\n if (disabled) return\n if (e.key === 'Enter' || e.key === ' ') inputRef.current?.click()\n },\n 'data-disabled': disabled ? '' : undefined,\n 'data-multiple': multiple ? '' : undefined,\n }),\n hiddenInputValue,\n name,\n }\n\n return (\n <UploaderCtx.Provider value={ctx}>\n <div data-part=\"root\">\n <input type=\"file\" hidden {...ctx.fileInputProps} />\n {children}\n </div>\n </UploaderCtx.Provider>\n )\n}\n","import { useContext } from 'react'\n\nimport { UploaderCtx } from './context'\n\nexport const useImageUploader = () => {\n const ctx = useContext(UploaderCtx);\n if (!ctx) throw new Error(\"ImageUploader components must be used within <ImageUploader.Root>\");\n return ctx;\n};"],"names":[],"mappings":";;;AACO;AACP;AACA;;ACFO;AACA;AACP;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACO;AACP;AACA;AACA;AACO;AACP;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACO;AACP;AACA;AACA;AACA;AACO;AACP;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACO;AACP;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACO;AACP;AACA;AACA;;AC3EA;AACA;AACA;AACA;AACO;AACA;AACP;AACA;AACA;AACA;AACA;AACA;AACO;AACA;AACA;;ACdA;AACP;AACA;AACA;AACA;;ACHO;;ACHA;;;"}
1
+ {"version":3,"file":"index.d.ts","sources":["../src/components/dropzone.tsx","../src/types.ts","../src/components/preview.tsx","../src/components/trigger.tsx","../src/context.tsx","../src/hook.ts"],"sourcesContent":["import { Slot } from \"@radix-ui/react-slot\";\nimport { HTMLAttributes } from \"react\";\n\nimport { useImageUploader } from \"../hook\";\n\nexport const Dropzone = ({\n asChild,\n ...rest\n}: { asChild?: boolean } & HTMLAttributes<HTMLElement>) => {\n const { getDropzoneProps } = useImageUploader();\n const Comp: any = asChild ? Slot : \"div\";\n return <Comp data-part=\"dropzone\" {...getDropzoneProps()} {...rest} />;\n};\n","import type {\n ChangeEvent,\n DragEvent,\n PropsWithChildren,\n RefObject,\n} from \"react\";\n\nexport type UploadStatus =\n | \"idle\"\n | \"uploading\"\n | \"done\"\n | \"error\"\n | \"canceled\"\n | \"removing\";\n\nexport type UploadFileItem = {\n uid: string;\n id?: string;\n name: string;\n url?: string;\n previewUrl?: string;\n file?: File;\n status: UploadStatus;\n progress?: number;\n error?: string;\n data?: any;\n};\n\nexport type UploadResult = { url: string; id?: string };\n\nexport type RootProps = PropsWithChildren<{\n multiple?: boolean;\n initial?: Array<Pick<UploadFileItem, \"uid\" | \"id\" | \"name\" | \"url\">>;\n /**\n * optimistic (default): remove from UI immediately, call onRemove in the background; if it fails, restore the item and show error.\n * strict: call onRemove first; only remove from UI if it succeeds.\n */\n removeMode?: \"optimistic\" | \"strict\";\n name?: string;\n maxCount?: number;\n disabled?: boolean;\n accept?: string;\n onChange?: (items: UploadFileItem[]) => Promise<void> | void;\n upload: (\n file: File,\n signal: AbortSignal,\n setProgress?: (pct: number) => void,\n ) => Promise<UploadResult>;\n onRemove?: (item: UploadFileItem, signal: AbortSignal) => Promise<void>;\n}>;\n\nexport type ItemActions = {\n cancel: (uid: string) => void;\n remove: (uid: string) => void;\n retry: (uid: string) => void;\n};\n\nexport type ImageUploaderContextValue = {\n items: UploadFileItem[];\n disabled?: boolean;\n multiple: boolean;\n accept: string;\n actions: ItemActions;\n openFileDialog: () => void;\n fileInputProps: {\n ref: RefObject<HTMLInputElement>;\n onChange: (e: ChangeEvent<HTMLInputElement>) => void;\n accept: string;\n multiple: boolean;\n disabled?: boolean;\n };\n getDropzoneProps: () => {\n role: string;\n tabIndex: number;\n onDrop: (e: DragEvent) => void;\n onDragOver: (e: DragEvent) => void;\n onKeyDown: (e: KeyboardEvent) => void;\n \"data-disabled\"?: string;\n \"data-multiple\"?: string;\n };\n hiddenInputValue: string;\n name: string;\n};\n\nexport type TriggerRenderProps = {\n items: UploadFileItem[];\n isUploading: boolean;\n uploadingCount: number;\n doneCount: number;\n errorCount: number;\n totalProgress?: number;\n open: () => void;\n};\n\nexport type PreviewRenderProps = {\n items: UploadFileItem[];\n actions: ItemActions;\n};\n","import { Slot } from \"@radix-ui/react-slot\";\nimport React, { ButtonHTMLAttributes } from \"react\";\n\nimport { useImageUploader } from \"../hook\";\n\nimport type { PreviewRenderProps } from \"../types\";\n\ntype Props = {\n render?: (api: PreviewRenderProps) => React.ReactNode;\n className?: string;\n};\n\nexport const Preview = ({ render, className }: Props) => {\n const { items, actions } = useImageUploader();\n\n if (render && typeof render === \"function\") {\n return render({ items, actions });\n }\n\n return (\n <div data-part=\"preview\" className={className}>\n <div className=\"grid grid-cols-2 gap-3 sm:grid-cols-3 md:grid-cols-4\">\n {items.map((item) => (\n <div\n key={item.uid}\n onClick={(e) => e.stopPropagation()}\n className=\"relative overflow-hidden rounded-xl border\"\n data-state={item.status}\n >\n {item.url || item.previewUrl ? (\n <img\n src={item.url || item.previewUrl}\n alt={item.name}\n className=\"h-32 w-full object-cover\"\n />\n ) : (\n <div className=\"flex h-32 w-full items-center justify-center text-xs text-gray-500\">\n No preview\n </div>\n )}\n {item.status === \"uploading\" && (\n <div className=\"absolute bottom-0 left-0 right-0 h-1 bg-gray-200\">\n <div\n className=\"h-full bg-black/80\"\n style={{\n width: `${Math.max(0, Math.min(100, item.progress ?? 0))}%`,\n }}\n />\n </div>\n )}\n <div className=\"absolute inset-x-0 bottom-0 flex justify-end gap-2 bg-gradient-to-t from-black/60 to-transparent p-2\">\n {item.status === \"uploading\" && (\n <button\n type=\"button\"\n className=\"rounded-xl bg-black/50 px-2 py-1 text-xs text-white\"\n onClick={() => actions.cancel(item.uid)}\n >\n Cancel\n </button>\n )}\n {(item.status === \"error\" || item.status === \"canceled\") && (\n <button\n type=\"button\"\n className=\"rounded-xl bg-black/50 px-2 py-1 text-xs text-white\"\n onClick={() => actions.retry(item.uid)}\n >\n Retry\n </button>\n )}\n <button\n type=\"button\"\n className=\"rounded-xl bg-black/50 px-2 py-1 text-xs text-white\"\n onClick={() => actions.remove(item.uid)}\n >\n Remove\n </button>\n </div>\n </div>\n ))}\n </div>\n </div>\n );\n};\n\nexport const HiddenInput = ({ name }: { name?: string }) => {\n const { hiddenInputValue, name: defaultName } = useImageUploader();\n return (\n <input type=\"hidden\" name={name ?? defaultName} value={hiddenInputValue} />\n );\n};\n\ntype ButtonProps = {\n uid: string;\n asChild?: boolean;\n} & ButtonHTMLAttributes<HTMLButtonElement>;\n\nexport const Cancel = ({ uid, asChild, ...rest }: ButtonProps) => {\n const { actions } = useImageUploader();\n const Comp: any = asChild ? Slot : \"button\";\n return (\n <Comp\n onClick={(e: { stopPropagation: () => void }) => {\n e.stopPropagation();\n actions.cancel(uid);\n }}\n {...rest}\n />\n );\n};\n\nexport const Retry = ({ uid, asChild, ...rest }: ButtonProps) => {\n const { actions } = useImageUploader();\n const Comp: any = asChild ? Slot : \"button\";\n return (\n <Comp\n onClick={(e: { stopPropagation: () => void }) => {\n e.stopPropagation();\n actions.retry(uid);\n }}\n {...rest}\n />\n );\n};\n\nexport const Remove = ({ uid, asChild, ...rest }: ButtonProps) => {\n const { actions } = useImageUploader();\n const Comp: any = asChild ? Slot : \"button\";\n return (\n <Comp\n onClick={(e: { stopPropagation: () => void }) => {\n e.stopPropagation();\n actions.remove(uid);\n }}\n {...rest}\n />\n );\n};\n","import { Slot } from \"@radix-ui/react-slot\";\nimport React, { PropsWithChildren } from \"react\";\n\nimport { useImageUploader } from \"../hook\";\nimport type { TriggerRenderProps } from \"../types\";\n\nexport const Trigger = ({\n asChild,\n children,\n render,\n ...rest\n}: PropsWithChildren<\n {\n asChild?: boolean;\n render?: (api: TriggerRenderProps) => React.ReactNode;\n children?: React.ReactNode | ((api: TriggerRenderProps) => React.ReactNode);\n } & React.HTMLAttributes<HTMLElement>\n>) => {\n const { openFileDialog, disabled, items } = useImageUploader();\n const Comp: any = asChild ? Slot : \"button\";\n\n const uploading = items.filter((i) => i.status === \"uploading\");\n const uploadingCount = uploading.length;\n const doneCount = items.filter((i) => i.status === \"done\").length;\n const errorCount = items.filter((i) => i.status === \"error\").length;\n const totalProgress = uploadingCount\n ? Math.round(\n uploading.reduce(\n (acc, it) =>\n acc + (typeof it.progress === \"number\" ? it.progress : 0),\n 0,\n ) / uploadingCount,\n )\n : undefined;\n\n const api: TriggerRenderProps = {\n items,\n isUploading: uploadingCount > 0,\n uploadingCount,\n doneCount,\n errorCount,\n totalProgress,\n open: openFileDialog,\n };\n\n return (\n <Comp\n type={asChild ? undefined : \"button\"}\n aria-disabled={disabled}\n data-part=\"trigger\"\n onClick={(e: any) => {\n if (disabled) return;\n (rest as any).onClick?.(e);\n openFileDialog();\n }}\n {...rest}\n >\n {render ? render(api) : children}\n </Comp>\n );\n};\n","import type { DragEvent, RefObject } from \"react\";\nimport React, {\n createContext,\n useCallback,\n useEffect,\n useMemo,\n useRef,\n useState,\n} from \"react\";\n\nimport type {\n ImageUploaderContextValue,\n ItemActions,\n RootProps,\n UploadFileItem,\n} from \"./types\";\nimport { uid } from \"./utils\";\n\nexport const UploaderCtx = createContext<ImageUploaderContextValue | null>(\n null,\n);\n\nexport const Root = ({\n multiple = true,\n initial = [],\n onChange,\n upload,\n removeMode = \"optimistic\",\n onRemove,\n accept = \"image/*\",\n name = \"images\",\n maxCount,\n disabled,\n children,\n}: RootProps) => {\n const [items, setItems] = useState<UploadFileItem[]>([]);\n const controllers = useRef(new Map<string, AbortController>());\n const removeControllers = useRef(new Map<string, AbortController>());\n const inputRef = useRef<HTMLInputElement | null>(null);\n\n // Hydrate initial items from the server and keep them marked as done\n useEffect(() => {\n const arr = initial ?? [];\n if (!Array.isArray(arr)) return;\n\n const mapped: UploadFileItem[] = arr.map((it) => {\n return {\n uid: it.uid || it.id,\n id: it.id,\n name: it.name,\n url: it.url,\n status: \"done\",\n } as UploadFileItem;\n });\n\n // Only hydrate if the user hasn't already added/modified items locally\n setItems((prev) => (prev.length === 0 ? mapped : prev));\n }, [initial]);\n\n const hiddenInputValue = useMemo(() => {\n const done = items.filter((i) => i.status === \"done\" && i.url);\n return JSON.stringify(\n done.map(\n ({\n uid: _u,\n previewUrl: _p,\n file: _f,\n status: _s,\n progress: _pr,\n error: _e,\n ...rest\n }) => rest,\n ),\n );\n }, [items]);\n\n const emitChange = useCallback(\n (\n next: UploadFileItem[] | ((prev: UploadFileItem[]) => UploadFileItem[]),\n ) => {\n setItems((prev) => {\n const nextState =\n typeof next === \"function\" ? (next as any)(prev) : next;\n if (onChange) Promise.resolve(onChange(nextState)).catch(() => {});\n return nextState;\n });\n },\n [onChange],\n );\n\n const startUpload = useCallback(\n async (item: UploadFileItem) => {\n if (!item.file) return;\n const controller = new AbortController();\n controllers.current.set(item.uid, controller);\n\n const setProgress = (pct: number) => {\n emitChange((items) =>\n items.map((it) =>\n it.uid === item.uid\n ? { ...it, progress: Math.max(0, Math.min(100, pct)) }\n : it,\n ),\n );\n };\n\n emitChange((items) =>\n items.map((it) =>\n it.uid === item.uid\n ? { ...it, status: \"uploading\", error: undefined }\n : it,\n ),\n );\n\n try {\n const result = await upload(item.file, controller.signal, setProgress);\n emitChange((items) =>\n items.map((it) => {\n if (it.uid !== item.uid) return it;\n // Revoke the local objectURL preview to avoid memory leaks\n if (it.previewUrl && it.previewUrl.startsWith(\"blob:\")) {\n try {\n URL.revokeObjectURL(it.previewUrl);\n } catch {\n /*fail silently*/\n }\n }\n const serverPreview = (result as any).preview || result.url;\n console.log({ result });\n return {\n ...it,\n status: \"done\",\n url: result.url,\n id: result.id,\n previewUrl: serverPreview,\n progress: 100,\n };\n }),\n );\n } catch (err: any) {\n const wasAborted = controller.signal.aborted;\n emitChange((items) =>\n items.map((it) =>\n it.uid === item.uid\n ? {\n ...it,\n status: wasAborted ? \"canceled\" : \"error\",\n error: wasAborted\n ? undefined\n : err?.message || \"Upload failed\",\n }\n : it,\n ),\n );\n } finally {\n controllers.current.delete(item.uid);\n }\n },\n [emitChange, upload],\n );\n\n const selectFiles = useCallback(\n (files: FileList | null) => {\n if (!files || files.length === 0) return;\n const selected = Array.from(files);\n const remaining = maxCount\n ? Math.max(\n 0,\n maxCount - items.filter((i) => i.status !== \"canceled\").length,\n )\n : undefined;\n const toUse =\n typeof remaining === \"number\" ? selected.slice(0, remaining) : selected;\n\n const newItems: UploadFileItem[] = toUse.map((file) => ({\n uid: uid(),\n name: file.name,\n file,\n previewUrl: URL.createObjectURL(file),\n status: \"idle\",\n progress: 0,\n }));\n\n emitChange([...items, ...newItems]);\n newItems.forEach((it) => startUpload(it));\n },\n [emitChange, items, maxCount, startUpload],\n );\n\n const onInputChange = useCallback(\n (e: React.ChangeEvent<HTMLInputElement>) => {\n selectFiles(e.target.files);\n e.currentTarget.value = \"\";\n },\n [selectFiles],\n );\n\n const onDrop = useCallback(\n (e: DragEvent) => {\n e.preventDefault();\n if (disabled) return;\n selectFiles(e.dataTransfer.files);\n },\n [disabled, selectFiles],\n );\n\n const onDragOver = useCallback((e: DragEvent) => e.preventDefault(), []);\n\n const actions: ItemActions = useMemo(\n () => ({\n cancel: (uidStr: string) => {\n const ctrl = controllers.current.get(uidStr);\n ctrl?.abort();\n },\n remove: async (uidStr: string) => {\n const item = items.find((i) => i.uid === uidStr);\n if (!item) return;\n\n // abort any in-flight upload first\n controllers.current.get(uidStr)?.abort();\n\n // If no server-side removal needed or not uploaded yet, just remove\n if (!onRemove || item.status !== \"done\") {\n emitChange((list) => list.filter((i) => i.uid !== uidStr));\n return;\n }\n\n const ctrl = new AbortController();\n removeControllers.current.set(uidStr, ctrl);\n\n if (removeMode === \"optimistic\") {\n const prev = items;\n // remove from UI immediately\n emitChange((list) => list.filter((i) => i.uid !== uidStr));\n try {\n await onRemove(item, ctrl.signal);\n } catch {\n // rollback UI if server delete fails\n emitChange(prev);\n } finally {\n removeControllers.current.delete(uidStr);\n }\n } else {\n // strict: mark as removing, wait for API, then remove\n emitChange((list) =>\n list.map((it) =>\n it.uid === uidStr ? { ...it, status: \"removing\" as const } : it,\n ),\n );\n try {\n await onRemove(item, ctrl.signal);\n emitChange((list) => list.filter((i) => i.uid !== uidStr));\n } catch {\n // revert to done if delete fails\n emitChange((list) =>\n list.map((it) =>\n it.uid === uidStr ? { ...it, status: \"done\" as const } : it,\n ),\n );\n } finally {\n removeControllers.current.delete(uidStr);\n }\n }\n },\n retry: (uidStr: string) => {\n const item = items.find((i) => i.uid === uidStr);\n if (!item) return;\n if (item.file) {\n void startUpload({\n ...item,\n status: \"idle\",\n error: undefined,\n progress: 0,\n });\n emitChange((items) =>\n items.map((it) =>\n it.uid === uidStr\n ? { ...it, status: \"idle\", error: undefined, progress: 0 }\n : it,\n ),\n );\n }\n },\n }),\n [emitChange, items, onRemove, removeMode, startUpload],\n );\n\n useEffect(\n () => () => {\n items.forEach((i) => i.previewUrl && URL.revokeObjectURL(i.previewUrl));\n controllers.current.forEach((c) => c.abort());\n },\n [],\n );\n\n const ctx: ImageUploaderContextValue = {\n items,\n disabled,\n multiple,\n accept,\n actions,\n openFileDialog: () => inputRef.current?.click(),\n fileInputProps: {\n ref: inputRef as RefObject<HTMLInputElement>,\n onChange: onInputChange,\n accept,\n multiple,\n disabled,\n },\n getDropzoneProps: () => ({\n role: \"button\",\n tabIndex: 0,\n onDrop,\n onDragOver,\n onKeyDown: (e) => {\n if (disabled) return;\n if (e.key === \"Enter\" || e.key === \" \") inputRef.current?.click();\n },\n \"data-disabled\": disabled ? \"\" : undefined,\n \"data-multiple\": multiple ? \"\" : undefined,\n }),\n hiddenInputValue,\n name,\n };\n\n return (\n <UploaderCtx.Provider value={ctx}>\n <div data-part=\"root\">\n <input type=\"file\" hidden {...ctx.fileInputProps} />\n {children}\n </div>\n </UploaderCtx.Provider>\n );\n};\n","import { useContext } from \"react\";\n\nimport { UploaderCtx } from \"./context\";\n\nexport const useImageUploader = () => {\n const ctx = useContext(UploaderCtx);\n if (!ctx)\n throw new Error(\n \"ImageUploader components must be used within <ImageUploader.Root>\",\n );\n return ctx;\n};\n"],"names":[],"mappings":";;;AACO;AACP;AACA;;ACFO;AACA;AACP;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACO;AACP;AACA;AACA;AACO;AACP;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACO;AACP;AACA;AACA;AACA;AACO;AACP;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACO;AACP;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACO;AACP;AACA;AACA;;AC3EA;AACA;AACA;AACA;AACO;AACA;AACP;AACA;AACA;AACA;AACA;AACA;AACO;AACA;AACA;;ACdA;AACP;AACA;AACA;AACA;;ACHO;;ACHA;;;"}
package/dist/index.js CHANGED
@@ -5,7 +5,7 @@ import { useState, useRef, useEffect, useMemo, useCallback, createContext, useCo
5
5
  const uid = ()=>Math.random().toString(36).slice(2, 10) + Date.now().toString(36).slice(-4);
6
6
 
7
7
  const UploaderCtx = /*#__PURE__*/ createContext(null);
8
- const Root = ({ multiple = true, initial = [], onChange, upload, removeMode = 'optimistic', onRemove, accept = 'image/*', name = 'images', maxCount, disabled, children })=>{
8
+ const Root = ({ multiple = true, initial = [], onChange, upload, removeMode = "optimistic", onRemove, accept = "image/*", name = "images", maxCount, disabled, children })=>{
9
9
  const [items, setItems] = useState([]);
10
10
  const controllers = useRef(new Map());
11
11
  const removeControllers = useRef(new Map());
@@ -20,7 +20,7 @@ const Root = ({ multiple = true, initial = [], onChange, upload, removeMode = 'o
20
20
  id: it.id,
21
21
  name: it.name,
22
22
  url: it.url,
23
- status: 'done'
23
+ status: "done"
24
24
  };
25
25
  });
26
26
  // Only hydrate if the user hasn't already added/modified items locally
@@ -29,14 +29,14 @@ const Root = ({ multiple = true, initial = [], onChange, upload, removeMode = 'o
29
29
  initial
30
30
  ]);
31
31
  const hiddenInputValue = useMemo(()=>{
32
- const done = items.filter((i)=>i.status === 'done' && i.url);
32
+ const done = items.filter((i)=>i.status === "done" && i.url);
33
33
  return JSON.stringify(done.map(({ uid: _u, previewUrl: _p, file: _f, status: _s, progress: _pr, error: _e, ...rest })=>rest));
34
34
  }, [
35
35
  items
36
36
  ]);
37
37
  const emitChange = useCallback((next)=>{
38
38
  setItems((prev)=>{
39
- const nextState = typeof next === 'function' ? next(prev) : next;
39
+ const nextState = typeof next === "function" ? next(prev) : next;
40
40
  if (onChange) Promise.resolve(onChange(nextState)).catch(()=>{});
41
41
  return nextState;
42
42
  });
@@ -55,7 +55,7 @@ const Root = ({ multiple = true, initial = [], onChange, upload, removeMode = 'o
55
55
  };
56
56
  emitChange((items)=>items.map((it)=>it.uid === item.uid ? {
57
57
  ...it,
58
- status: 'uploading',
58
+ status: "uploading",
59
59
  error: undefined
60
60
  } : it));
61
61
  try {
@@ -63,7 +63,7 @@ const Root = ({ multiple = true, initial = [], onChange, upload, removeMode = 'o
63
63
  emitChange((items)=>items.map((it)=>{
64
64
  if (it.uid !== item.uid) return it;
65
65
  // Revoke the local objectURL preview to avoid memory leaks
66
- if (it.previewUrl && it.previewUrl.startsWith('blob:')) {
66
+ if (it.previewUrl && it.previewUrl.startsWith("blob:")) {
67
67
  try {
68
68
  URL.revokeObjectURL(it.previewUrl);
69
69
  } catch {
@@ -75,7 +75,7 @@ const Root = ({ multiple = true, initial = [], onChange, upload, removeMode = 'o
75
75
  });
76
76
  return {
77
77
  ...it,
78
- status: 'done',
78
+ status: "done",
79
79
  url: result.url,
80
80
  id: result.id,
81
81
  previewUrl: serverPreview,
@@ -86,8 +86,8 @@ const Root = ({ multiple = true, initial = [], onChange, upload, removeMode = 'o
86
86
  const wasAborted = controller.signal.aborted;
87
87
  emitChange((items)=>items.map((it)=>it.uid === item.uid ? {
88
88
  ...it,
89
- status: wasAborted ? 'canceled' : 'error',
90
- error: wasAborted ? undefined : err?.message || 'Upload failed'
89
+ status: wasAborted ? "canceled" : "error",
90
+ error: wasAborted ? undefined : err?.message || "Upload failed"
91
91
  } : it));
92
92
  } finally{
93
93
  controllers.current.delete(item.uid);
@@ -99,14 +99,14 @@ const Root = ({ multiple = true, initial = [], onChange, upload, removeMode = 'o
99
99
  const selectFiles = useCallback((files)=>{
100
100
  if (!files || files.length === 0) return;
101
101
  const selected = Array.from(files);
102
- const remaining = maxCount ? Math.max(0, maxCount - items.filter((i)=>i.status !== 'canceled').length) : undefined;
103
- const toUse = typeof remaining === 'number' ? selected.slice(0, remaining) : selected;
102
+ const remaining = maxCount ? Math.max(0, maxCount - items.filter((i)=>i.status !== "canceled").length) : undefined;
103
+ const toUse = typeof remaining === "number" ? selected.slice(0, remaining) : selected;
104
104
  const newItems = toUse.map((file)=>({
105
105
  uid: uid(),
106
106
  name: file.name,
107
107
  file,
108
108
  previewUrl: URL.createObjectURL(file),
109
- status: 'idle',
109
+ status: "idle",
110
110
  progress: 0
111
111
  }));
112
112
  emitChange([
@@ -122,7 +122,7 @@ const Root = ({ multiple = true, initial = [], onChange, upload, removeMode = 'o
122
122
  ]);
123
123
  const onInputChange = useCallback((e)=>{
124
124
  selectFiles(e.target.files);
125
- e.currentTarget.value = '';
125
+ e.currentTarget.value = "";
126
126
  }, [
127
127
  selectFiles
128
128
  ]);
@@ -146,13 +146,13 @@ const Root = ({ multiple = true, initial = [], onChange, upload, removeMode = 'o
146
146
  // abort any in-flight upload first
147
147
  controllers.current.get(uidStr)?.abort();
148
148
  // If no server-side removal needed or not uploaded yet, just remove
149
- if (!onRemove || item.status !== 'done') {
149
+ if (!onRemove || item.status !== "done") {
150
150
  emitChange((list)=>list.filter((i)=>i.uid !== uidStr));
151
151
  return;
152
152
  }
153
153
  const ctrl = new AbortController();
154
154
  removeControllers.current.set(uidStr, ctrl);
155
- if (removeMode === 'optimistic') {
155
+ if (removeMode === "optimistic") {
156
156
  const prev = items;
157
157
  // remove from UI immediately
158
158
  emitChange((list)=>list.filter((i)=>i.uid !== uidStr));
@@ -168,7 +168,7 @@ const Root = ({ multiple = true, initial = [], onChange, upload, removeMode = 'o
168
168
  // strict: mark as removing, wait for API, then remove
169
169
  emitChange((list)=>list.map((it)=>it.uid === uidStr ? {
170
170
  ...it,
171
- status: 'removing'
171
+ status: "removing"
172
172
  } : it));
173
173
  try {
174
174
  await onRemove(item, ctrl.signal);
@@ -177,7 +177,7 @@ const Root = ({ multiple = true, initial = [], onChange, upload, removeMode = 'o
177
177
  // revert to done if delete fails
178
178
  emitChange((list)=>list.map((it)=>it.uid === uidStr ? {
179
179
  ...it,
180
- status: 'done'
180
+ status: "done"
181
181
  } : it));
182
182
  } finally{
183
183
  removeControllers.current.delete(uidStr);
@@ -190,13 +190,13 @@ const Root = ({ multiple = true, initial = [], onChange, upload, removeMode = 'o
190
190
  if (item.file) {
191
191
  void startUpload({
192
192
  ...item,
193
- status: 'idle',
193
+ status: "idle",
194
194
  error: undefined,
195
195
  progress: 0
196
196
  });
197
197
  emitChange((items)=>items.map((it)=>it.uid === uidStr ? {
198
198
  ...it,
199
- status: 'idle',
199
+ status: "idle",
200
200
  error: undefined,
201
201
  progress: 0
202
202
  } : it));
@@ -228,16 +228,16 @@ const Root = ({ multiple = true, initial = [], onChange, upload, removeMode = 'o
228
228
  disabled
229
229
  },
230
230
  getDropzoneProps: ()=>({
231
- role: 'button',
231
+ role: "button",
232
232
  tabIndex: 0,
233
233
  onDrop,
234
234
  onDragOver,
235
235
  onKeyDown: (e)=>{
236
236
  if (disabled) return;
237
- if (e.key === 'Enter' || e.key === ' ') inputRef.current?.click();
237
+ if (e.key === "Enter" || e.key === " ") inputRef.current?.click();
238
238
  },
239
- 'data-disabled': disabled ? '' : undefined,
240
- 'data-multiple': multiple ? '' : undefined
239
+ "data-disabled": disabled ? "" : undefined,
240
+ "data-multiple": multiple ? "" : undefined
241
241
  }),
242
242
  hiddenInputValue,
243
243
  name
@@ -276,7 +276,7 @@ const Dropzone = ({ asChild, ...rest })=>{
276
276
 
277
277
  const Preview = ({ render, className })=>{
278
278
  const { items, actions } = useImageUploader();
279
- if (render && typeof render === 'function') {
279
+ if (render && typeof render === "function") {
280
280
  return render({
281
281
  items,
282
282
  actions
@@ -300,7 +300,7 @@ const Preview = ({ render, className })=>{
300
300
  className: "flex h-32 w-full items-center justify-center text-xs text-gray-500",
301
301
  children: "No preview"
302
302
  }),
303
- item.status === 'uploading' && /*#__PURE__*/ jsx("div", {
303
+ item.status === "uploading" && /*#__PURE__*/ jsx("div", {
304
304
  className: "absolute bottom-0 left-0 right-0 h-1 bg-gray-200",
305
305
  children: /*#__PURE__*/ jsx("div", {
306
306
  className: "h-full bg-black/80",
@@ -312,13 +312,13 @@ const Preview = ({ render, className })=>{
312
312
  /*#__PURE__*/ jsxs("div", {
313
313
  className: "absolute inset-x-0 bottom-0 flex justify-end gap-2 bg-gradient-to-t from-black/60 to-transparent p-2",
314
314
  children: [
315
- item.status === 'uploading' && /*#__PURE__*/ jsx("button", {
315
+ item.status === "uploading" && /*#__PURE__*/ jsx("button", {
316
316
  type: "button",
317
317
  className: "rounded-xl bg-black/50 px-2 py-1 text-xs text-white",
318
318
  onClick: ()=>actions.cancel(item.uid),
319
319
  children: "Cancel"
320
320
  }),
321
- (item.status === 'error' || item.status === 'canceled') && /*#__PURE__*/ jsx("button", {
321
+ (item.status === "error" || item.status === "canceled") && /*#__PURE__*/ jsx("button", {
322
322
  type: "button",
323
323
  className: "rounded-xl bg-black/50 px-2 py-1 text-xs text-white",
324
324
  onClick: ()=>actions.retry(item.uid),
@@ -347,7 +347,7 @@ const HiddenInput = ({ name })=>{
347
347
  };
348
348
  const Cancel = ({ uid, asChild, ...rest })=>{
349
349
  const { actions } = useImageUploader();
350
- const Comp = asChild ? Slot : 'button';
350
+ const Comp = asChild ? Slot : "button";
351
351
  return /*#__PURE__*/ jsx(Comp, {
352
352
  onClick: (e)=>{
353
353
  e.stopPropagation();
@@ -358,7 +358,7 @@ const Cancel = ({ uid, asChild, ...rest })=>{
358
358
  };
359
359
  const Retry = ({ uid, asChild, ...rest })=>{
360
360
  const { actions } = useImageUploader();
361
- const Comp = asChild ? Slot : 'button';
361
+ const Comp = asChild ? Slot : "button";
362
362
  return /*#__PURE__*/ jsx(Comp, {
363
363
  onClick: (e)=>{
364
364
  e.stopPropagation();
@@ -369,7 +369,7 @@ const Retry = ({ uid, asChild, ...rest })=>{
369
369
  };
370
370
  const Remove = ({ uid, asChild, ...rest })=>{
371
371
  const { actions } = useImageUploader();
372
- const Comp = asChild ? Slot : 'button';
372
+ const Comp = asChild ? Slot : "button";
373
373
  return /*#__PURE__*/ jsx(Comp, {
374
374
  onClick: (e)=>{
375
375
  e.stopPropagation();
@@ -381,12 +381,12 @@ const Remove = ({ uid, asChild, ...rest })=>{
381
381
 
382
382
  const Trigger = ({ asChild, children, render, ...rest })=>{
383
383
  const { openFileDialog, disabled, items } = useImageUploader();
384
- const Comp = asChild ? Slot : 'button';
385
- const uploading = items.filter((i)=>i.status === 'uploading');
384
+ const Comp = asChild ? Slot : "button";
385
+ const uploading = items.filter((i)=>i.status === "uploading");
386
386
  const uploadingCount = uploading.length;
387
- const doneCount = items.filter((i)=>i.status === 'done').length;
388
- const errorCount = items.filter((i)=>i.status === 'error').length;
389
- const totalProgress = uploadingCount ? Math.round(uploading.reduce((acc, it)=>acc + (typeof it.progress === 'number' ? it.progress : 0), 0) / uploadingCount) : undefined;
387
+ const doneCount = items.filter((i)=>i.status === "done").length;
388
+ const errorCount = items.filter((i)=>i.status === "error").length;
389
+ const totalProgress = uploadingCount ? Math.round(uploading.reduce((acc, it)=>acc + (typeof it.progress === "number" ? it.progress : 0), 0) / uploadingCount) : undefined;
390
390
  const api = {
391
391
  items,
392
392
  isUploading: uploadingCount > 0,
@@ -397,7 +397,7 @@ const Trigger = ({ asChild, children, render, ...rest })=>{
397
397
  open: openFileDialog
398
398
  };
399
399
  return /*#__PURE__*/ jsx(Comp, {
400
- type: asChild ? undefined : 'button',
400
+ type: asChild ? undefined : "button",
401
401
  "aria-disabled": disabled,
402
402
  "data-part": "trigger",
403
403
  onClick: (e)=>{
package/dist/index.mjs CHANGED
@@ -5,7 +5,7 @@ import { useState, useRef, useEffect, useMemo, useCallback, createContext, useCo
5
5
  const uid = ()=>Math.random().toString(36).slice(2, 10) + Date.now().toString(36).slice(-4);
6
6
 
7
7
  const UploaderCtx = /*#__PURE__*/ createContext(null);
8
- const Root = ({ multiple = true, initial = [], onChange, upload, removeMode = 'optimistic', onRemove, accept = 'image/*', name = 'images', maxCount, disabled, children })=>{
8
+ const Root = ({ multiple = true, initial = [], onChange, upload, removeMode = "optimistic", onRemove, accept = "image/*", name = "images", maxCount, disabled, children })=>{
9
9
  const [items, setItems] = useState([]);
10
10
  const controllers = useRef(new Map());
11
11
  const removeControllers = useRef(new Map());
@@ -20,7 +20,7 @@ const Root = ({ multiple = true, initial = [], onChange, upload, removeMode = 'o
20
20
  id: it.id,
21
21
  name: it.name,
22
22
  url: it.url,
23
- status: 'done'
23
+ status: "done"
24
24
  };
25
25
  });
26
26
  // Only hydrate if the user hasn't already added/modified items locally
@@ -29,14 +29,14 @@ const Root = ({ multiple = true, initial = [], onChange, upload, removeMode = 'o
29
29
  initial
30
30
  ]);
31
31
  const hiddenInputValue = useMemo(()=>{
32
- const done = items.filter((i)=>i.status === 'done' && i.url);
32
+ const done = items.filter((i)=>i.status === "done" && i.url);
33
33
  return JSON.stringify(done.map(({ uid: _u, previewUrl: _p, file: _f, status: _s, progress: _pr, error: _e, ...rest })=>rest));
34
34
  }, [
35
35
  items
36
36
  ]);
37
37
  const emitChange = useCallback((next)=>{
38
38
  setItems((prev)=>{
39
- const nextState = typeof next === 'function' ? next(prev) : next;
39
+ const nextState = typeof next === "function" ? next(prev) : next;
40
40
  if (onChange) Promise.resolve(onChange(nextState)).catch(()=>{});
41
41
  return nextState;
42
42
  });
@@ -55,7 +55,7 @@ const Root = ({ multiple = true, initial = [], onChange, upload, removeMode = 'o
55
55
  };
56
56
  emitChange((items)=>items.map((it)=>it.uid === item.uid ? {
57
57
  ...it,
58
- status: 'uploading',
58
+ status: "uploading",
59
59
  error: undefined
60
60
  } : it));
61
61
  try {
@@ -63,7 +63,7 @@ const Root = ({ multiple = true, initial = [], onChange, upload, removeMode = 'o
63
63
  emitChange((items)=>items.map((it)=>{
64
64
  if (it.uid !== item.uid) return it;
65
65
  // Revoke the local objectURL preview to avoid memory leaks
66
- if (it.previewUrl && it.previewUrl.startsWith('blob:')) {
66
+ if (it.previewUrl && it.previewUrl.startsWith("blob:")) {
67
67
  try {
68
68
  URL.revokeObjectURL(it.previewUrl);
69
69
  } catch {
@@ -75,7 +75,7 @@ const Root = ({ multiple = true, initial = [], onChange, upload, removeMode = 'o
75
75
  });
76
76
  return {
77
77
  ...it,
78
- status: 'done',
78
+ status: "done",
79
79
  url: result.url,
80
80
  id: result.id,
81
81
  previewUrl: serverPreview,
@@ -86,8 +86,8 @@ const Root = ({ multiple = true, initial = [], onChange, upload, removeMode = 'o
86
86
  const wasAborted = controller.signal.aborted;
87
87
  emitChange((items)=>items.map((it)=>it.uid === item.uid ? {
88
88
  ...it,
89
- status: wasAborted ? 'canceled' : 'error',
90
- error: wasAborted ? undefined : err?.message || 'Upload failed'
89
+ status: wasAborted ? "canceled" : "error",
90
+ error: wasAborted ? undefined : err?.message || "Upload failed"
91
91
  } : it));
92
92
  } finally{
93
93
  controllers.current.delete(item.uid);
@@ -99,14 +99,14 @@ const Root = ({ multiple = true, initial = [], onChange, upload, removeMode = 'o
99
99
  const selectFiles = useCallback((files)=>{
100
100
  if (!files || files.length === 0) return;
101
101
  const selected = Array.from(files);
102
- const remaining = maxCount ? Math.max(0, maxCount - items.filter((i)=>i.status !== 'canceled').length) : undefined;
103
- const toUse = typeof remaining === 'number' ? selected.slice(0, remaining) : selected;
102
+ const remaining = maxCount ? Math.max(0, maxCount - items.filter((i)=>i.status !== "canceled").length) : undefined;
103
+ const toUse = typeof remaining === "number" ? selected.slice(0, remaining) : selected;
104
104
  const newItems = toUse.map((file)=>({
105
105
  uid: uid(),
106
106
  name: file.name,
107
107
  file,
108
108
  previewUrl: URL.createObjectURL(file),
109
- status: 'idle',
109
+ status: "idle",
110
110
  progress: 0
111
111
  }));
112
112
  emitChange([
@@ -122,7 +122,7 @@ const Root = ({ multiple = true, initial = [], onChange, upload, removeMode = 'o
122
122
  ]);
123
123
  const onInputChange = useCallback((e)=>{
124
124
  selectFiles(e.target.files);
125
- e.currentTarget.value = '';
125
+ e.currentTarget.value = "";
126
126
  }, [
127
127
  selectFiles
128
128
  ]);
@@ -146,13 +146,13 @@ const Root = ({ multiple = true, initial = [], onChange, upload, removeMode = 'o
146
146
  // abort any in-flight upload first
147
147
  controllers.current.get(uidStr)?.abort();
148
148
  // If no server-side removal needed or not uploaded yet, just remove
149
- if (!onRemove || item.status !== 'done') {
149
+ if (!onRemove || item.status !== "done") {
150
150
  emitChange((list)=>list.filter((i)=>i.uid !== uidStr));
151
151
  return;
152
152
  }
153
153
  const ctrl = new AbortController();
154
154
  removeControllers.current.set(uidStr, ctrl);
155
- if (removeMode === 'optimistic') {
155
+ if (removeMode === "optimistic") {
156
156
  const prev = items;
157
157
  // remove from UI immediately
158
158
  emitChange((list)=>list.filter((i)=>i.uid !== uidStr));
@@ -168,7 +168,7 @@ const Root = ({ multiple = true, initial = [], onChange, upload, removeMode = 'o
168
168
  // strict: mark as removing, wait for API, then remove
169
169
  emitChange((list)=>list.map((it)=>it.uid === uidStr ? {
170
170
  ...it,
171
- status: 'removing'
171
+ status: "removing"
172
172
  } : it));
173
173
  try {
174
174
  await onRemove(item, ctrl.signal);
@@ -177,7 +177,7 @@ const Root = ({ multiple = true, initial = [], onChange, upload, removeMode = 'o
177
177
  // revert to done if delete fails
178
178
  emitChange((list)=>list.map((it)=>it.uid === uidStr ? {
179
179
  ...it,
180
- status: 'done'
180
+ status: "done"
181
181
  } : it));
182
182
  } finally{
183
183
  removeControllers.current.delete(uidStr);
@@ -190,13 +190,13 @@ const Root = ({ multiple = true, initial = [], onChange, upload, removeMode = 'o
190
190
  if (item.file) {
191
191
  void startUpload({
192
192
  ...item,
193
- status: 'idle',
193
+ status: "idle",
194
194
  error: undefined,
195
195
  progress: 0
196
196
  });
197
197
  emitChange((items)=>items.map((it)=>it.uid === uidStr ? {
198
198
  ...it,
199
- status: 'idle',
199
+ status: "idle",
200
200
  error: undefined,
201
201
  progress: 0
202
202
  } : it));
@@ -228,16 +228,16 @@ const Root = ({ multiple = true, initial = [], onChange, upload, removeMode = 'o
228
228
  disabled
229
229
  },
230
230
  getDropzoneProps: ()=>({
231
- role: 'button',
231
+ role: "button",
232
232
  tabIndex: 0,
233
233
  onDrop,
234
234
  onDragOver,
235
235
  onKeyDown: (e)=>{
236
236
  if (disabled) return;
237
- if (e.key === 'Enter' || e.key === ' ') inputRef.current?.click();
237
+ if (e.key === "Enter" || e.key === " ") inputRef.current?.click();
238
238
  },
239
- 'data-disabled': disabled ? '' : undefined,
240
- 'data-multiple': multiple ? '' : undefined
239
+ "data-disabled": disabled ? "" : undefined,
240
+ "data-multiple": multiple ? "" : undefined
241
241
  }),
242
242
  hiddenInputValue,
243
243
  name
@@ -276,7 +276,7 @@ const Dropzone = ({ asChild, ...rest })=>{
276
276
 
277
277
  const Preview = ({ render, className })=>{
278
278
  const { items, actions } = useImageUploader();
279
- if (render && typeof render === 'function') {
279
+ if (render && typeof render === "function") {
280
280
  return render({
281
281
  items,
282
282
  actions
@@ -300,7 +300,7 @@ const Preview = ({ render, className })=>{
300
300
  className: "flex h-32 w-full items-center justify-center text-xs text-gray-500",
301
301
  children: "No preview"
302
302
  }),
303
- item.status === 'uploading' && /*#__PURE__*/ jsx("div", {
303
+ item.status === "uploading" && /*#__PURE__*/ jsx("div", {
304
304
  className: "absolute bottom-0 left-0 right-0 h-1 bg-gray-200",
305
305
  children: /*#__PURE__*/ jsx("div", {
306
306
  className: "h-full bg-black/80",
@@ -312,13 +312,13 @@ const Preview = ({ render, className })=>{
312
312
  /*#__PURE__*/ jsxs("div", {
313
313
  className: "absolute inset-x-0 bottom-0 flex justify-end gap-2 bg-gradient-to-t from-black/60 to-transparent p-2",
314
314
  children: [
315
- item.status === 'uploading' && /*#__PURE__*/ jsx("button", {
315
+ item.status === "uploading" && /*#__PURE__*/ jsx("button", {
316
316
  type: "button",
317
317
  className: "rounded-xl bg-black/50 px-2 py-1 text-xs text-white",
318
318
  onClick: ()=>actions.cancel(item.uid),
319
319
  children: "Cancel"
320
320
  }),
321
- (item.status === 'error' || item.status === 'canceled') && /*#__PURE__*/ jsx("button", {
321
+ (item.status === "error" || item.status === "canceled") && /*#__PURE__*/ jsx("button", {
322
322
  type: "button",
323
323
  className: "rounded-xl bg-black/50 px-2 py-1 text-xs text-white",
324
324
  onClick: ()=>actions.retry(item.uid),
@@ -347,7 +347,7 @@ const HiddenInput = ({ name })=>{
347
347
  };
348
348
  const Cancel = ({ uid, asChild, ...rest })=>{
349
349
  const { actions } = useImageUploader();
350
- const Comp = asChild ? Slot : 'button';
350
+ const Comp = asChild ? Slot : "button";
351
351
  return /*#__PURE__*/ jsx(Comp, {
352
352
  onClick: (e)=>{
353
353
  e.stopPropagation();
@@ -358,7 +358,7 @@ const Cancel = ({ uid, asChild, ...rest })=>{
358
358
  };
359
359
  const Retry = ({ uid, asChild, ...rest })=>{
360
360
  const { actions } = useImageUploader();
361
- const Comp = asChild ? Slot : 'button';
361
+ const Comp = asChild ? Slot : "button";
362
362
  return /*#__PURE__*/ jsx(Comp, {
363
363
  onClick: (e)=>{
364
364
  e.stopPropagation();
@@ -369,7 +369,7 @@ const Retry = ({ uid, asChild, ...rest })=>{
369
369
  };
370
370
  const Remove = ({ uid, asChild, ...rest })=>{
371
371
  const { actions } = useImageUploader();
372
- const Comp = asChild ? Slot : 'button';
372
+ const Comp = asChild ? Slot : "button";
373
373
  return /*#__PURE__*/ jsx(Comp, {
374
374
  onClick: (e)=>{
375
375
  e.stopPropagation();
@@ -381,12 +381,12 @@ const Remove = ({ uid, asChild, ...rest })=>{
381
381
 
382
382
  const Trigger = ({ asChild, children, render, ...rest })=>{
383
383
  const { openFileDialog, disabled, items } = useImageUploader();
384
- const Comp = asChild ? Slot : 'button';
385
- const uploading = items.filter((i)=>i.status === 'uploading');
384
+ const Comp = asChild ? Slot : "button";
385
+ const uploading = items.filter((i)=>i.status === "uploading");
386
386
  const uploadingCount = uploading.length;
387
- const doneCount = items.filter((i)=>i.status === 'done').length;
388
- const errorCount = items.filter((i)=>i.status === 'error').length;
389
- const totalProgress = uploadingCount ? Math.round(uploading.reduce((acc, it)=>acc + (typeof it.progress === 'number' ? it.progress : 0), 0) / uploadingCount) : undefined;
387
+ const doneCount = items.filter((i)=>i.status === "done").length;
388
+ const errorCount = items.filter((i)=>i.status === "error").length;
389
+ const totalProgress = uploadingCount ? Math.round(uploading.reduce((acc, it)=>acc + (typeof it.progress === "number" ? it.progress : 0), 0) / uploadingCount) : undefined;
390
390
  const api = {
391
391
  items,
392
392
  isUploading: uploadingCount > 0,
@@ -397,7 +397,7 @@ const Trigger = ({ asChild, children, render, ...rest })=>{
397
397
  open: openFileDialog
398
398
  };
399
399
  return /*#__PURE__*/ jsx(Comp, {
400
- type: asChild ? undefined : 'button',
400
+ type: asChild ? undefined : "button",
401
401
  "aria-disabled": disabled,
402
402
  "data-part": "trigger",
403
403
  onClick: (e)=>{
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "uplofile",
3
- "version": "0.1.1",
3
+ "version": "0.1.2",
4
4
  "description": "Composable file upload component for React.",
5
5
  "license": "MIT",
6
6
  "author": "Chris Josh <KristofaJosh>",
@@ -8,6 +8,7 @@
8
8
  "type": "git",
9
9
  "url": "git+https://github.com/KristofaJosh/uplofile.git"
10
10
  },
11
+ "homepage": "https://uplofile.kristofajosh.dev",
11
12
  "keywords": [
12
13
  "react",
13
14
  "file-upload",