@uploadista/react 0.0.20-beta.9 → 0.1.0-beta.5
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/dist/components/index.d.mts +3 -3
- package/dist/components/index.mjs +1 -1
- package/dist/index.d.mts +4 -4
- package/dist/index.mjs +1 -1
- package/dist/upload-zone-CH8B2-hl.mjs +2 -0
- package/dist/upload-zone-CH8B2-hl.mjs.map +1 -0
- package/dist/{uploadista-provider-Cb13AK7Z.d.mts → uploadista-provider-DwKXudoT.d.mts} +558 -15
- package/dist/uploadista-provider-DwKXudoT.d.mts.map +1 -0
- package/dist/use-upload-BgaJmdwF.mjs.map +1 -1
- package/dist/use-uploadista-client-CkzVVmFT.d.mts.map +1 -1
- package/dist/use-uploadista-events-CtDXJYrR.d.mts.map +1 -1
- package/dist/use-uploadista-events-KhJ4knam.mjs.map +1 -1
- package/package.json +12 -9
- package/src/__tests__/event-utils.test.ts +179 -0
- package/src/__tests__/setup.ts +40 -0
- package/src/__tests__/uploadista-provider.test.tsx +316 -0
- package/src/__tests__/use-drag-drop.test.tsx +476 -0
- package/src/__tests__/use-upload.test.tsx +317 -0
- package/src/__tests__/use-uploadista-client.test.tsx +208 -0
- package/src/components/flow-primitives.tsx +3 -8
- package/src/components/index.tsx +37 -6
- package/src/components/upload-primitives.tsx +879 -0
- package/src/components/upload-zone.tsx +1 -2
- package/src/hooks/event-utils.ts +1 -1
- package/src/hooks/use-upload-events.ts +2 -5
- package/src/index.ts +89 -67
- package/vitest.config.ts +16 -0
- package/dist/flow-upload-list-BJSCZ4Ty.mjs +0 -2
- package/dist/flow-upload-list-BJSCZ4Ty.mjs.map +0 -1
- package/dist/uploadista-provider-Cb13AK7Z.d.mts.map +0 -1
|
@@ -0,0 +1,879 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import type { BrowserUploadInput } from "@uploadista/client-browser";
|
|
4
|
+
import type { UploadFile } from "@uploadista/core/types";
|
|
5
|
+
import { createContext, type ReactNode, useCallback, useContext } from "react";
|
|
6
|
+
import {
|
|
7
|
+
type DragDropState,
|
|
8
|
+
type UseDragDropReturn,
|
|
9
|
+
useDragDrop,
|
|
10
|
+
} from "../hooks/use-drag-drop";
|
|
11
|
+
import {
|
|
12
|
+
type MultiUploadState,
|
|
13
|
+
type UploadItem as UploadItemData,
|
|
14
|
+
useMultiUpload,
|
|
15
|
+
} from "../hooks/use-multi-upload";
|
|
16
|
+
import type {
|
|
17
|
+
UploadState,
|
|
18
|
+
UploadStatus as UploadStatusType,
|
|
19
|
+
} from "../hooks/use-upload";
|
|
20
|
+
|
|
21
|
+
// Re-export types for convenience
|
|
22
|
+
export type {
|
|
23
|
+
UploadState,
|
|
24
|
+
UploadStatusType as UploadStatus,
|
|
25
|
+
UploadItemData as UploadItem,
|
|
26
|
+
MultiUploadState,
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
// ============ UPLOAD CONTEXT ============
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Context value provided by the Upload component root.
|
|
33
|
+
* Contains all upload state and actions.
|
|
34
|
+
*/
|
|
35
|
+
export interface UploadContextValue {
|
|
36
|
+
/** Whether in multi-file mode */
|
|
37
|
+
mode: "single" | "multi";
|
|
38
|
+
/** Current multi-upload state (aggregate) */
|
|
39
|
+
state: MultiUploadState;
|
|
40
|
+
/** All upload items */
|
|
41
|
+
items: UploadItemData[];
|
|
42
|
+
/** Whether auto-start is enabled */
|
|
43
|
+
autoStart: boolean;
|
|
44
|
+
|
|
45
|
+
/** Add files to the upload queue */
|
|
46
|
+
addFiles: (files: BrowserUploadInput[]) => void;
|
|
47
|
+
/** Remove an item from the queue */
|
|
48
|
+
removeItem: (id: string) => void;
|
|
49
|
+
/** Start all pending uploads */
|
|
50
|
+
startAll: () => void;
|
|
51
|
+
/** Abort a specific upload by ID */
|
|
52
|
+
abortUpload: (id: string) => void;
|
|
53
|
+
/** Abort all active uploads */
|
|
54
|
+
abortAll: () => void;
|
|
55
|
+
/** Retry a specific failed upload by ID */
|
|
56
|
+
retryUpload: (id: string) => void;
|
|
57
|
+
/** Retry all failed uploads */
|
|
58
|
+
retryFailed: () => void;
|
|
59
|
+
/** Clear all completed uploads */
|
|
60
|
+
clearCompleted: () => void;
|
|
61
|
+
/** Clear all items and reset state */
|
|
62
|
+
clearAll: () => void;
|
|
63
|
+
|
|
64
|
+
/** Internal handler for files received from drop zone */
|
|
65
|
+
handleFilesReceived: (files: File[]) => void;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const UploadContext = createContext<UploadContextValue | null>(null);
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Hook to access upload context from within an Upload component.
|
|
72
|
+
* @throws Error if used outside of an Upload component
|
|
73
|
+
*/
|
|
74
|
+
export function useUploadContext(): UploadContextValue {
|
|
75
|
+
const context = useContext(UploadContext);
|
|
76
|
+
if (!context) {
|
|
77
|
+
throw new Error(
|
|
78
|
+
"useUploadContext must be used within an <Upload> component. " +
|
|
79
|
+
"Wrap your component tree with <Upload onSuccess={...}>",
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
return context;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// ============ UPLOAD ITEM CONTEXT ============
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Context value for a specific upload item within an Upload.
|
|
89
|
+
*/
|
|
90
|
+
export interface UploadItemContextValue {
|
|
91
|
+
/** Item ID */
|
|
92
|
+
id: string;
|
|
93
|
+
/** The file being uploaded */
|
|
94
|
+
file: BrowserUploadInput;
|
|
95
|
+
/** Current upload state */
|
|
96
|
+
state: UploadState;
|
|
97
|
+
/** Abort this upload */
|
|
98
|
+
abort: () => void;
|
|
99
|
+
/** Retry this upload */
|
|
100
|
+
retry: () => void;
|
|
101
|
+
/** Remove this item from the queue */
|
|
102
|
+
remove: () => void;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const UploadItemContext = createContext<UploadItemContextValue | null>(null);
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Hook to access upload item context from within an Upload.Item component.
|
|
109
|
+
* @throws Error if used outside of an Upload.Item component
|
|
110
|
+
*/
|
|
111
|
+
export function useUploadItemContext(): UploadItemContextValue {
|
|
112
|
+
const context = useContext(UploadItemContext);
|
|
113
|
+
if (!context) {
|
|
114
|
+
throw new Error(
|
|
115
|
+
"useUploadItemContext must be used within an <Upload.Item> component. " +
|
|
116
|
+
'Wrap your component with <Upload.Item id="...">',
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
return context;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// ============ UPLOAD ROOT COMPONENT ============
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Props for the Upload root component.
|
|
126
|
+
*/
|
|
127
|
+
export interface UploadProps {
|
|
128
|
+
/** Whether to allow multiple file uploads (default: false) */
|
|
129
|
+
multiple?: boolean;
|
|
130
|
+
/** Maximum concurrent uploads (default: 3, only used in multi mode) */
|
|
131
|
+
maxConcurrent?: number;
|
|
132
|
+
/** Whether to auto-start uploads when files are received (default: true) */
|
|
133
|
+
autoStart?: boolean;
|
|
134
|
+
/** Metadata to attach to uploads */
|
|
135
|
+
metadata?: Record<string, string>;
|
|
136
|
+
/** Called when a single file upload succeeds (single mode) */
|
|
137
|
+
onSuccess?: (result: UploadFile) => void;
|
|
138
|
+
/** Called when an upload fails */
|
|
139
|
+
onError?: (error: Error, item?: UploadItemData) => void;
|
|
140
|
+
/** Called when all uploads complete (multi mode) */
|
|
141
|
+
onComplete?: (results: {
|
|
142
|
+
successful: UploadItemData[];
|
|
143
|
+
failed: UploadItemData[];
|
|
144
|
+
total: number;
|
|
145
|
+
}) => void;
|
|
146
|
+
/** Called when an individual upload starts */
|
|
147
|
+
onUploadStart?: (item: UploadItemData) => void;
|
|
148
|
+
/** Called on upload progress */
|
|
149
|
+
onProgress?: (
|
|
150
|
+
item: UploadItemData,
|
|
151
|
+
progress: number,
|
|
152
|
+
bytesUploaded: number,
|
|
153
|
+
totalBytes: number | null,
|
|
154
|
+
) => void;
|
|
155
|
+
/** Children to render */
|
|
156
|
+
children: ReactNode;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Root component for file uploads.
|
|
161
|
+
* Provides context for all Upload sub-components.
|
|
162
|
+
* Supports both single-file and multi-file modes via the `multiple` prop.
|
|
163
|
+
*
|
|
164
|
+
* @example Single file upload
|
|
165
|
+
* ```tsx
|
|
166
|
+
* <Upload onSuccess={handleSuccess}>
|
|
167
|
+
* <Upload.DropZone accept="image/*">
|
|
168
|
+
* {({ isDragging, getRootProps, getInputProps }) => (
|
|
169
|
+
* <div {...getRootProps()}>
|
|
170
|
+
* <input {...getInputProps()} />
|
|
171
|
+
* {isDragging ? "Drop here" : "Drag or click"}
|
|
172
|
+
* </div>
|
|
173
|
+
* )}
|
|
174
|
+
* </Upload.DropZone>
|
|
175
|
+
* <Upload.Progress>
|
|
176
|
+
* {({ progress }) => <progress value={progress} max={100} />}
|
|
177
|
+
* </Upload.Progress>
|
|
178
|
+
* </Upload>
|
|
179
|
+
* ```
|
|
180
|
+
*
|
|
181
|
+
* @example Multi-file upload
|
|
182
|
+
* ```tsx
|
|
183
|
+
* <Upload multiple maxConcurrent={3} onComplete={handleComplete}>
|
|
184
|
+
* <Upload.DropZone>
|
|
185
|
+
* {(props) => ...}
|
|
186
|
+
* </Upload.DropZone>
|
|
187
|
+
* <Upload.Items>
|
|
188
|
+
* {({ items }) => items.map(item => (
|
|
189
|
+
* <Upload.Item key={item.id} id={item.id}>
|
|
190
|
+
* {({ file, state, abort, remove }) => (
|
|
191
|
+
* <div>{file.name}: {state.progress}%</div>
|
|
192
|
+
* )}
|
|
193
|
+
* </Upload.Item>
|
|
194
|
+
* ))}
|
|
195
|
+
* </Upload.Items>
|
|
196
|
+
* <Upload.StartAll>Upload All</Upload.StartAll>
|
|
197
|
+
* </Upload>
|
|
198
|
+
* ```
|
|
199
|
+
*/
|
|
200
|
+
function UploadRoot({
|
|
201
|
+
multiple = false,
|
|
202
|
+
maxConcurrent = 3,
|
|
203
|
+
autoStart = true,
|
|
204
|
+
metadata,
|
|
205
|
+
onSuccess,
|
|
206
|
+
onError,
|
|
207
|
+
onComplete,
|
|
208
|
+
onUploadStart,
|
|
209
|
+
onProgress,
|
|
210
|
+
children,
|
|
211
|
+
}: UploadProps) {
|
|
212
|
+
const multiUpload = useMultiUpload({
|
|
213
|
+
maxConcurrent,
|
|
214
|
+
metadata,
|
|
215
|
+
onUploadStart,
|
|
216
|
+
onUploadProgress: onProgress,
|
|
217
|
+
onUploadSuccess: (_item, result) => {
|
|
218
|
+
// In single mode, call onSuccess directly
|
|
219
|
+
if (!multiple) {
|
|
220
|
+
onSuccess?.(result);
|
|
221
|
+
}
|
|
222
|
+
},
|
|
223
|
+
onUploadError: (item, error) => {
|
|
224
|
+
onError?.(error, item);
|
|
225
|
+
},
|
|
226
|
+
onComplete,
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
const handleFilesReceived = useCallback(
|
|
230
|
+
(files: File[]) => {
|
|
231
|
+
if (!multiple) {
|
|
232
|
+
// Single mode: clear existing and add new file
|
|
233
|
+
multiUpload.clearAll();
|
|
234
|
+
}
|
|
235
|
+
multiUpload.addFiles(files);
|
|
236
|
+
if (autoStart) {
|
|
237
|
+
// Use setTimeout to ensure state is updated before starting
|
|
238
|
+
setTimeout(() => multiUpload.startAll(), 0);
|
|
239
|
+
}
|
|
240
|
+
},
|
|
241
|
+
[multiple, autoStart, multiUpload],
|
|
242
|
+
);
|
|
243
|
+
|
|
244
|
+
const contextValue: UploadContextValue = {
|
|
245
|
+
mode: multiple ? "multi" : "single",
|
|
246
|
+
state: multiUpload.state,
|
|
247
|
+
items: multiUpload.items,
|
|
248
|
+
autoStart,
|
|
249
|
+
addFiles: multiUpload.addFiles,
|
|
250
|
+
removeItem: multiUpload.removeItem,
|
|
251
|
+
startAll: multiUpload.startAll,
|
|
252
|
+
abortUpload: multiUpload.abortUpload,
|
|
253
|
+
abortAll: multiUpload.abortAll,
|
|
254
|
+
retryUpload: multiUpload.retryUpload,
|
|
255
|
+
retryFailed: multiUpload.retryFailed,
|
|
256
|
+
clearCompleted: multiUpload.clearCompleted,
|
|
257
|
+
clearAll: multiUpload.clearAll,
|
|
258
|
+
handleFilesReceived,
|
|
259
|
+
};
|
|
260
|
+
|
|
261
|
+
return (
|
|
262
|
+
<UploadContext.Provider value={contextValue}>
|
|
263
|
+
{children}
|
|
264
|
+
</UploadContext.Provider>
|
|
265
|
+
);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// ============ DROP ZONE PRIMITIVE ============
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* Render props for Upload.DropZone component.
|
|
272
|
+
*/
|
|
273
|
+
export interface UploadDropZoneRenderProps {
|
|
274
|
+
/** Whether files are being dragged over */
|
|
275
|
+
isDragging: boolean;
|
|
276
|
+
/** Whether drag is over the zone */
|
|
277
|
+
isOver: boolean;
|
|
278
|
+
/** Validation errors */
|
|
279
|
+
errors: string[];
|
|
280
|
+
/** Props to spread on the drop zone container */
|
|
281
|
+
getRootProps: () => UseDragDropReturn["dragHandlers"];
|
|
282
|
+
/** Props to spread on the hidden file input */
|
|
283
|
+
getInputProps: () => UseDragDropReturn["inputProps"];
|
|
284
|
+
/** Open file picker programmatically */
|
|
285
|
+
openFilePicker: () => void;
|
|
286
|
+
/** Current drag-drop state */
|
|
287
|
+
dragDropState: DragDropState;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* Props for Upload.DropZone component.
|
|
292
|
+
*/
|
|
293
|
+
export interface UploadDropZoneProps {
|
|
294
|
+
/** Accepted file types (e.g., "image/*", ".pdf") */
|
|
295
|
+
accept?: string;
|
|
296
|
+
/** Maximum file size in bytes */
|
|
297
|
+
maxFileSize?: number;
|
|
298
|
+
/** Maximum number of files (only in multi mode) */
|
|
299
|
+
maxFiles?: number;
|
|
300
|
+
/** Render function receiving drop zone state */
|
|
301
|
+
children: (props: UploadDropZoneRenderProps) => ReactNode;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* Drop zone for file uploads within an Upload component.
|
|
306
|
+
* Handles drag-and-drop and click-to-select file selection.
|
|
307
|
+
*
|
|
308
|
+
* @example
|
|
309
|
+
* ```tsx
|
|
310
|
+
* <Upload.DropZone accept="image/*">
|
|
311
|
+
* {({ isDragging, getRootProps, getInputProps }) => (
|
|
312
|
+
* <div {...getRootProps()}>
|
|
313
|
+
* <input {...getInputProps()} />
|
|
314
|
+
* {isDragging ? "Drop here" : "Click or drag"}
|
|
315
|
+
* </div>
|
|
316
|
+
* )}
|
|
317
|
+
* </Upload.DropZone>
|
|
318
|
+
* ```
|
|
319
|
+
*/
|
|
320
|
+
function UploadDropZone({
|
|
321
|
+
accept,
|
|
322
|
+
maxFileSize,
|
|
323
|
+
maxFiles,
|
|
324
|
+
children,
|
|
325
|
+
}: UploadDropZoneProps) {
|
|
326
|
+
const upload = useUploadContext();
|
|
327
|
+
|
|
328
|
+
const dragDrop = useDragDrop({
|
|
329
|
+
onFilesReceived: upload.handleFilesReceived,
|
|
330
|
+
accept: accept ? accept.split(",").map((t) => t.trim()) : undefined,
|
|
331
|
+
maxFileSize,
|
|
332
|
+
maxFiles: upload.mode === "multi" ? maxFiles : 1,
|
|
333
|
+
multiple: upload.mode === "multi",
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
const renderProps: UploadDropZoneRenderProps = {
|
|
337
|
+
isDragging: dragDrop.state.isDragging,
|
|
338
|
+
isOver: dragDrop.state.isOver,
|
|
339
|
+
errors: dragDrop.state.errors,
|
|
340
|
+
getRootProps: () => dragDrop.dragHandlers,
|
|
341
|
+
getInputProps: () => dragDrop.inputProps,
|
|
342
|
+
openFilePicker: dragDrop.openFilePicker,
|
|
343
|
+
dragDropState: dragDrop.state,
|
|
344
|
+
};
|
|
345
|
+
|
|
346
|
+
return <>{children(renderProps)}</>;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
// ============ ITEMS PRIMITIVE ============
|
|
350
|
+
|
|
351
|
+
/**
|
|
352
|
+
* Render props for Upload.Items component.
|
|
353
|
+
*/
|
|
354
|
+
export interface UploadItemsRenderProps {
|
|
355
|
+
/** All upload items */
|
|
356
|
+
items: UploadItemData[];
|
|
357
|
+
/** Whether there are any items */
|
|
358
|
+
hasItems: boolean;
|
|
359
|
+
/** Whether items array is empty */
|
|
360
|
+
isEmpty: boolean;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
/**
|
|
364
|
+
* Props for Upload.Items component.
|
|
365
|
+
*/
|
|
366
|
+
export interface UploadItemsProps {
|
|
367
|
+
/** Render function receiving items */
|
|
368
|
+
children: (props: UploadItemsRenderProps) => ReactNode;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
/**
|
|
372
|
+
* Renders the list of upload items via render props.
|
|
373
|
+
*
|
|
374
|
+
* @example
|
|
375
|
+
* ```tsx
|
|
376
|
+
* <Upload.Items>
|
|
377
|
+
* {({ items, isEmpty }) => (
|
|
378
|
+
* isEmpty ? <p>No files</p> : (
|
|
379
|
+
* items.map(item => (
|
|
380
|
+
* <Upload.Item key={item.id} id={item.id}>
|
|
381
|
+
* {(props) => ...}
|
|
382
|
+
* </Upload.Item>
|
|
383
|
+
* ))
|
|
384
|
+
* )
|
|
385
|
+
* )}
|
|
386
|
+
* </Upload.Items>
|
|
387
|
+
* ```
|
|
388
|
+
*/
|
|
389
|
+
function UploadItems({ children }: UploadItemsProps) {
|
|
390
|
+
const upload = useUploadContext();
|
|
391
|
+
|
|
392
|
+
const renderProps: UploadItemsRenderProps = {
|
|
393
|
+
items: upload.items,
|
|
394
|
+
hasItems: upload.items.length > 0,
|
|
395
|
+
isEmpty: upload.items.length === 0,
|
|
396
|
+
};
|
|
397
|
+
|
|
398
|
+
return <>{children(renderProps)}</>;
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
// ============ ITEM PRIMITIVE ============
|
|
402
|
+
|
|
403
|
+
/**
|
|
404
|
+
* Props for Upload.Item component.
|
|
405
|
+
*/
|
|
406
|
+
export interface UploadItemProps {
|
|
407
|
+
/** Item ID */
|
|
408
|
+
id: string;
|
|
409
|
+
/** Children (can be render function or regular children) */
|
|
410
|
+
children: ReactNode | ((props: UploadItemContextValue) => ReactNode);
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
/**
|
|
414
|
+
* Scoped context provider for a specific upload item.
|
|
415
|
+
* Children can access item-specific state via useUploadItemContext().
|
|
416
|
+
*
|
|
417
|
+
* @example
|
|
418
|
+
* ```tsx
|
|
419
|
+
* <Upload.Item id={item.id}>
|
|
420
|
+
* {({ file, state, abort, remove }) => (
|
|
421
|
+
* <div>
|
|
422
|
+
* <span>{file.name}</span>
|
|
423
|
+
* <progress value={state.progress} max={100} />
|
|
424
|
+
* <button onClick={abort}>Cancel</button>
|
|
425
|
+
* <button onClick={remove}>Remove</button>
|
|
426
|
+
* </div>
|
|
427
|
+
* )}
|
|
428
|
+
* </Upload.Item>
|
|
429
|
+
* ```
|
|
430
|
+
*/
|
|
431
|
+
function UploadItem({ id, children }: UploadItemProps) {
|
|
432
|
+
const upload = useUploadContext();
|
|
433
|
+
|
|
434
|
+
const item = upload.items.find((i) => i.id === id);
|
|
435
|
+
|
|
436
|
+
if (!item) {
|
|
437
|
+
// Item not found
|
|
438
|
+
return null;
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
const contextValue: UploadItemContextValue = {
|
|
442
|
+
id,
|
|
443
|
+
file: item.file,
|
|
444
|
+
state: item.state,
|
|
445
|
+
abort: () => upload.abortUpload(id),
|
|
446
|
+
retry: () => upload.retryUpload(id),
|
|
447
|
+
remove: () => upload.removeItem(id),
|
|
448
|
+
};
|
|
449
|
+
|
|
450
|
+
return (
|
|
451
|
+
<UploadItemContext.Provider value={contextValue}>
|
|
452
|
+
{typeof children === "function" ? children(contextValue) : children}
|
|
453
|
+
</UploadItemContext.Provider>
|
|
454
|
+
);
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
// ============ PROGRESS PRIMITIVE ============
|
|
458
|
+
|
|
459
|
+
/**
|
|
460
|
+
* Render props for Upload.Progress component.
|
|
461
|
+
*/
|
|
462
|
+
export interface UploadProgressRenderProps {
|
|
463
|
+
/** Progress percentage (0-100) */
|
|
464
|
+
progress: number;
|
|
465
|
+
/** Bytes uploaded so far */
|
|
466
|
+
bytesUploaded: number;
|
|
467
|
+
/** Total bytes to upload */
|
|
468
|
+
totalBytes: number;
|
|
469
|
+
/** Whether any uploads are active */
|
|
470
|
+
isUploading: boolean;
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
/**
|
|
474
|
+
* Props for Upload.Progress component.
|
|
475
|
+
*/
|
|
476
|
+
export interface UploadProgressProps {
|
|
477
|
+
/** Render function receiving progress state */
|
|
478
|
+
children: (props: UploadProgressRenderProps) => ReactNode;
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
/**
|
|
482
|
+
* Progress display component within an Upload.
|
|
483
|
+
*
|
|
484
|
+
* @example
|
|
485
|
+
* ```tsx
|
|
486
|
+
* <Upload.Progress>
|
|
487
|
+
* {({ progress, isUploading }) => (
|
|
488
|
+
* isUploading && <progress value={progress} max={100} />
|
|
489
|
+
* )}
|
|
490
|
+
* </Upload.Progress>
|
|
491
|
+
* ```
|
|
492
|
+
*/
|
|
493
|
+
function UploadProgress({ children }: UploadProgressProps) {
|
|
494
|
+
const upload = useUploadContext();
|
|
495
|
+
|
|
496
|
+
const renderProps: UploadProgressRenderProps = {
|
|
497
|
+
progress: upload.state.progress,
|
|
498
|
+
bytesUploaded: upload.state.totalBytesUploaded,
|
|
499
|
+
totalBytes: upload.state.totalBytes,
|
|
500
|
+
isUploading: upload.state.isUploading,
|
|
501
|
+
};
|
|
502
|
+
|
|
503
|
+
return <>{children(renderProps)}</>;
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
// ============ STATUS PRIMITIVE ============
|
|
507
|
+
|
|
508
|
+
/**
|
|
509
|
+
* Render props for Upload.Status component.
|
|
510
|
+
*/
|
|
511
|
+
export interface UploadStatusRenderProps {
|
|
512
|
+
/** Overall status */
|
|
513
|
+
status: "idle" | "uploading" | "success" | "error";
|
|
514
|
+
/** Whether idle (no uploads active or completed) */
|
|
515
|
+
isIdle: boolean;
|
|
516
|
+
/** Whether uploading */
|
|
517
|
+
isUploading: boolean;
|
|
518
|
+
/** Whether all uploads succeeded */
|
|
519
|
+
isSuccess: boolean;
|
|
520
|
+
/** Whether any upload failed */
|
|
521
|
+
isError: boolean;
|
|
522
|
+
/** Whether all uploads completed (success or failure) */
|
|
523
|
+
isComplete: boolean;
|
|
524
|
+
/** Number of total items */
|
|
525
|
+
total: number;
|
|
526
|
+
/** Number of successful uploads */
|
|
527
|
+
successful: number;
|
|
528
|
+
/** Number of failed uploads */
|
|
529
|
+
failed: number;
|
|
530
|
+
/** Number of currently uploading */
|
|
531
|
+
uploading: number;
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
/**
|
|
535
|
+
* Props for Upload.Status component.
|
|
536
|
+
*/
|
|
537
|
+
export interface UploadStatusProps {
|
|
538
|
+
/** Render function receiving status state */
|
|
539
|
+
children: (props: UploadStatusRenderProps) => ReactNode;
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
/**
|
|
543
|
+
* Status display component within an Upload.
|
|
544
|
+
*
|
|
545
|
+
* @example
|
|
546
|
+
* ```tsx
|
|
547
|
+
* <Upload.Status>
|
|
548
|
+
* {({ status, total, successful, failed }) => (
|
|
549
|
+
* <div>
|
|
550
|
+
* Status: {status}
|
|
551
|
+
* ({successful}/{total} uploaded, {failed} failed)
|
|
552
|
+
* </div>
|
|
553
|
+
* )}
|
|
554
|
+
* </Upload.Status>
|
|
555
|
+
* ```
|
|
556
|
+
*/
|
|
557
|
+
function UploadStatus({ children }: UploadStatusProps) {
|
|
558
|
+
const upload = useUploadContext();
|
|
559
|
+
const { state } = upload;
|
|
560
|
+
|
|
561
|
+
// Derive overall status
|
|
562
|
+
let status: "idle" | "uploading" | "success" | "error" = "idle";
|
|
563
|
+
if (state.isUploading) {
|
|
564
|
+
status = "uploading";
|
|
565
|
+
} else if (state.isComplete) {
|
|
566
|
+
status = state.failed > 0 ? "error" : "success";
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
const renderProps: UploadStatusRenderProps = {
|
|
570
|
+
status,
|
|
571
|
+
isIdle: status === "idle",
|
|
572
|
+
isUploading: state.isUploading,
|
|
573
|
+
isSuccess: state.isComplete && state.failed === 0,
|
|
574
|
+
isError: state.failed > 0,
|
|
575
|
+
isComplete: state.isComplete,
|
|
576
|
+
total: state.total,
|
|
577
|
+
successful: state.successful,
|
|
578
|
+
failed: state.failed,
|
|
579
|
+
uploading: state.uploading,
|
|
580
|
+
};
|
|
581
|
+
|
|
582
|
+
return <>{children(renderProps)}</>;
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
// ============ ERROR PRIMITIVE ============
|
|
586
|
+
|
|
587
|
+
/**
|
|
588
|
+
* Render props for Upload.Error component.
|
|
589
|
+
*/
|
|
590
|
+
export interface UploadErrorRenderProps {
|
|
591
|
+
/** Whether there are any errors */
|
|
592
|
+
hasError: boolean;
|
|
593
|
+
/** Number of failed uploads */
|
|
594
|
+
failedCount: number;
|
|
595
|
+
/** Failed items */
|
|
596
|
+
failedItems: UploadItemData[];
|
|
597
|
+
/** Reset/clear all errors */
|
|
598
|
+
reset: () => void;
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
/**
|
|
602
|
+
* Props for Upload.Error component.
|
|
603
|
+
*/
|
|
604
|
+
export interface UploadErrorProps {
|
|
605
|
+
/** Render function receiving error state */
|
|
606
|
+
children: (props: UploadErrorRenderProps) => ReactNode;
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
/**
|
|
610
|
+
* Error display component within an Upload.
|
|
611
|
+
*
|
|
612
|
+
* @example
|
|
613
|
+
* ```tsx
|
|
614
|
+
* <Upload.Error>
|
|
615
|
+
* {({ hasError, failedItems, reset }) => (
|
|
616
|
+
* hasError && (
|
|
617
|
+
* <div>
|
|
618
|
+
* {failedItems.map(item => (
|
|
619
|
+
* <p key={item.id}>{item.file.name}: {item.state.error?.message}</p>
|
|
620
|
+
* ))}
|
|
621
|
+
* <button onClick={reset}>Clear</button>
|
|
622
|
+
* </div>
|
|
623
|
+
* )
|
|
624
|
+
* )}
|
|
625
|
+
* </Upload.Error>
|
|
626
|
+
* ```
|
|
627
|
+
*/
|
|
628
|
+
function UploadError({ children }: UploadErrorProps) {
|
|
629
|
+
const upload = useUploadContext();
|
|
630
|
+
|
|
631
|
+
const failedItems = upload.items.filter((item) =>
|
|
632
|
+
["error", "aborted"].includes(item.state.status),
|
|
633
|
+
);
|
|
634
|
+
|
|
635
|
+
const renderProps: UploadErrorRenderProps = {
|
|
636
|
+
hasError: failedItems.length > 0,
|
|
637
|
+
failedCount: failedItems.length,
|
|
638
|
+
failedItems,
|
|
639
|
+
reset: upload.clearCompleted,
|
|
640
|
+
};
|
|
641
|
+
|
|
642
|
+
return <>{children(renderProps)}</>;
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
// ============ ACTION PRIMITIVES ============
|
|
646
|
+
|
|
647
|
+
/**
|
|
648
|
+
* Props for Upload.Cancel component.
|
|
649
|
+
*/
|
|
650
|
+
export interface UploadCancelProps
|
|
651
|
+
extends Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, "onClick"> {
|
|
652
|
+
/** Button content */
|
|
653
|
+
children: ReactNode;
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
/**
|
|
657
|
+
* Cancel button that aborts all active uploads.
|
|
658
|
+
* Automatically disabled when no uploads are active.
|
|
659
|
+
*/
|
|
660
|
+
function UploadCancel({ children, disabled, ...props }: UploadCancelProps) {
|
|
661
|
+
const upload = useUploadContext();
|
|
662
|
+
|
|
663
|
+
const handleClick = useCallback(() => {
|
|
664
|
+
upload.abortAll();
|
|
665
|
+
}, [upload]);
|
|
666
|
+
|
|
667
|
+
return (
|
|
668
|
+
<button
|
|
669
|
+
type="button"
|
|
670
|
+
onClick={handleClick}
|
|
671
|
+
disabled={disabled || !upload.state.isUploading}
|
|
672
|
+
{...props}
|
|
673
|
+
>
|
|
674
|
+
{children}
|
|
675
|
+
</button>
|
|
676
|
+
);
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
/**
|
|
680
|
+
* Props for Upload.Retry component.
|
|
681
|
+
*/
|
|
682
|
+
export interface UploadRetryProps
|
|
683
|
+
extends Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, "onClick"> {
|
|
684
|
+
/** Button content */
|
|
685
|
+
children: ReactNode;
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
/**
|
|
689
|
+
* Retry button that retries all failed uploads.
|
|
690
|
+
* Automatically disabled when no failed uploads exist.
|
|
691
|
+
*/
|
|
692
|
+
function UploadRetry({ children, disabled, ...props }: UploadRetryProps) {
|
|
693
|
+
const upload = useUploadContext();
|
|
694
|
+
|
|
695
|
+
const handleClick = useCallback(() => {
|
|
696
|
+
upload.retryFailed();
|
|
697
|
+
}, [upload]);
|
|
698
|
+
|
|
699
|
+
return (
|
|
700
|
+
<button
|
|
701
|
+
type="button"
|
|
702
|
+
onClick={handleClick}
|
|
703
|
+
disabled={disabled || upload.state.failed === 0}
|
|
704
|
+
{...props}
|
|
705
|
+
>
|
|
706
|
+
{children}
|
|
707
|
+
</button>
|
|
708
|
+
);
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
/**
|
|
712
|
+
* Props for Upload.Reset component.
|
|
713
|
+
*/
|
|
714
|
+
export interface UploadResetProps
|
|
715
|
+
extends Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, "onClick"> {
|
|
716
|
+
/** Button content */
|
|
717
|
+
children: ReactNode;
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
/**
|
|
721
|
+
* Reset button that clears all items and resets state.
|
|
722
|
+
*/
|
|
723
|
+
function UploadReset({ children, ...props }: UploadResetProps) {
|
|
724
|
+
const upload = useUploadContext();
|
|
725
|
+
|
|
726
|
+
const handleClick = useCallback(() => {
|
|
727
|
+
upload.clearAll();
|
|
728
|
+
}, [upload]);
|
|
729
|
+
|
|
730
|
+
return (
|
|
731
|
+
<button type="button" onClick={handleClick} {...props}>
|
|
732
|
+
{children}
|
|
733
|
+
</button>
|
|
734
|
+
);
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
/**
|
|
738
|
+
* Props for Upload.StartAll component.
|
|
739
|
+
*/
|
|
740
|
+
export interface UploadStartAllProps
|
|
741
|
+
extends Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, "onClick"> {
|
|
742
|
+
/** Button content */
|
|
743
|
+
children: ReactNode;
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
/**
|
|
747
|
+
* Start all button that begins all queued uploads.
|
|
748
|
+
* Primarily useful when autoStart is disabled.
|
|
749
|
+
* Automatically disabled when uploads are already active.
|
|
750
|
+
*/
|
|
751
|
+
function UploadStartAll({ children, disabled, ...props }: UploadStartAllProps) {
|
|
752
|
+
const upload = useUploadContext();
|
|
753
|
+
|
|
754
|
+
const handleClick = useCallback(() => {
|
|
755
|
+
upload.startAll();
|
|
756
|
+
}, [upload]);
|
|
757
|
+
|
|
758
|
+
// Count idle items
|
|
759
|
+
const idleCount = upload.items.filter(
|
|
760
|
+
(item) => item.state.status === "idle",
|
|
761
|
+
).length;
|
|
762
|
+
|
|
763
|
+
return (
|
|
764
|
+
<button
|
|
765
|
+
type="button"
|
|
766
|
+
onClick={handleClick}
|
|
767
|
+
disabled={disabled || upload.state.isUploading || idleCount === 0}
|
|
768
|
+
{...props}
|
|
769
|
+
>
|
|
770
|
+
{children}
|
|
771
|
+
</button>
|
|
772
|
+
);
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
/**
|
|
776
|
+
* Props for Upload.ClearCompleted component.
|
|
777
|
+
*/
|
|
778
|
+
export interface UploadClearCompletedProps
|
|
779
|
+
extends Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, "onClick"> {
|
|
780
|
+
/** Button content */
|
|
781
|
+
children: ReactNode;
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
/**
|
|
785
|
+
* Clear completed button that removes all finished uploads from the list.
|
|
786
|
+
* Automatically disabled when no completed uploads exist.
|
|
787
|
+
*/
|
|
788
|
+
function UploadClearCompleted({
|
|
789
|
+
children,
|
|
790
|
+
disabled,
|
|
791
|
+
...props
|
|
792
|
+
}: UploadClearCompletedProps) {
|
|
793
|
+
const upload = useUploadContext();
|
|
794
|
+
|
|
795
|
+
const handleClick = useCallback(() => {
|
|
796
|
+
upload.clearCompleted();
|
|
797
|
+
}, [upload]);
|
|
798
|
+
|
|
799
|
+
return (
|
|
800
|
+
<button
|
|
801
|
+
type="button"
|
|
802
|
+
onClick={handleClick}
|
|
803
|
+
disabled={disabled || upload.state.completed === 0}
|
|
804
|
+
{...props}
|
|
805
|
+
>
|
|
806
|
+
{children}
|
|
807
|
+
</button>
|
|
808
|
+
);
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
// ============ COMPOUND COMPONENT EXPORT ============
|
|
812
|
+
|
|
813
|
+
/**
|
|
814
|
+
* Upload compound component for file uploads.
|
|
815
|
+
*
|
|
816
|
+
* Provides a composable, headless API for building upload interfaces.
|
|
817
|
+
* Supports both single-file and multi-file modes via the `multiple` prop.
|
|
818
|
+
* All sub-components use render props for complete UI control.
|
|
819
|
+
*
|
|
820
|
+
* @example Single file upload
|
|
821
|
+
* ```tsx
|
|
822
|
+
* <Upload onSuccess={handleSuccess}>
|
|
823
|
+
* <Upload.DropZone accept="image/*">
|
|
824
|
+
* {({ isDragging, getRootProps, getInputProps }) => (
|
|
825
|
+
* <div {...getRootProps()}>
|
|
826
|
+
* <input {...getInputProps()} />
|
|
827
|
+
* {isDragging ? "Drop here" : "Drag or click"}
|
|
828
|
+
* </div>
|
|
829
|
+
* )}
|
|
830
|
+
* </Upload.DropZone>
|
|
831
|
+
* <Upload.Progress>
|
|
832
|
+
* {({ progress }) => <progress value={progress} max={100} />}
|
|
833
|
+
* </Upload.Progress>
|
|
834
|
+
* </Upload>
|
|
835
|
+
* ```
|
|
836
|
+
*
|
|
837
|
+
* @example Multi-file upload
|
|
838
|
+
* ```tsx
|
|
839
|
+
* <Upload multiple maxConcurrent={3} onComplete={handleComplete}>
|
|
840
|
+
* <Upload.DropZone>
|
|
841
|
+
* {({ getRootProps, getInputProps }) => (
|
|
842
|
+
* <div {...getRootProps()}>
|
|
843
|
+
* <input {...getInputProps()} />
|
|
844
|
+
* Drop files here
|
|
845
|
+
* </div>
|
|
846
|
+
* )}
|
|
847
|
+
* </Upload.DropZone>
|
|
848
|
+
* <Upload.Items>
|
|
849
|
+
* {({ items }) => items.map(item => (
|
|
850
|
+
* <Upload.Item key={item.id} id={item.id}>
|
|
851
|
+
* {({ file, state, abort, remove }) => (
|
|
852
|
+
* <div>
|
|
853
|
+
* {file.name}: {state.progress}%
|
|
854
|
+
* <button onClick={abort}>Cancel</button>
|
|
855
|
+
* <button onClick={remove}>Remove</button>
|
|
856
|
+
* </div>
|
|
857
|
+
* )}
|
|
858
|
+
* </Upload.Item>
|
|
859
|
+
* ))}
|
|
860
|
+
* </Upload.Items>
|
|
861
|
+
* <Upload.StartAll>Upload All</Upload.StartAll>
|
|
862
|
+
* <Upload.Cancel>Cancel All</Upload.Cancel>
|
|
863
|
+
* <Upload.ClearCompleted>Clear Completed</Upload.ClearCompleted>
|
|
864
|
+
* </Upload>
|
|
865
|
+
* ```
|
|
866
|
+
*/
|
|
867
|
+
export const Upload = Object.assign(UploadRoot, {
|
|
868
|
+
DropZone: UploadDropZone,
|
|
869
|
+
Items: UploadItems,
|
|
870
|
+
Item: UploadItem,
|
|
871
|
+
Progress: UploadProgress,
|
|
872
|
+
Status: UploadStatus,
|
|
873
|
+
Error: UploadError,
|
|
874
|
+
Cancel: UploadCancel,
|
|
875
|
+
Retry: UploadRetry,
|
|
876
|
+
Reset: UploadReset,
|
|
877
|
+
StartAll: UploadStartAll,
|
|
878
|
+
ClearCompleted: UploadClearCompleted,
|
|
879
|
+
});
|