@uploadista/react-native-core 0.0.20-beta.8 → 0.0.20
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/index.d.mts +700 -93
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +1 -1
- package/dist/index.mjs.map +1 -1
- package/package.json +4 -4
- package/src/components/index.ts +34 -0
- package/src/components/upload-primitives.tsx +971 -0
- package/src/index.ts +31 -0
|
@@ -0,0 +1,971 @@
|
|
|
1
|
+
import type { UploadFile } from "@uploadista/core/types";
|
|
2
|
+
import { createContext, type ReactNode, useCallback, useContext } from "react";
|
|
3
|
+
import {
|
|
4
|
+
type MultiUploadState,
|
|
5
|
+
type UploadItemState,
|
|
6
|
+
useMultiUpload,
|
|
7
|
+
} from "../hooks/use-multi-upload";
|
|
8
|
+
import { useUploadistaContext } from "../hooks/use-uploadista-context";
|
|
9
|
+
import type { FilePickResult } from "../types";
|
|
10
|
+
|
|
11
|
+
// Re-export types for convenience
|
|
12
|
+
export type { MultiUploadState, UploadItemState };
|
|
13
|
+
|
|
14
|
+
// ============ UPLOAD CONTEXT ============
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Context value provided by the Upload component root.
|
|
18
|
+
* Contains all upload state and actions.
|
|
19
|
+
*/
|
|
20
|
+
export interface UploadContextValue {
|
|
21
|
+
/** Whether in multi-file mode */
|
|
22
|
+
mode: "single" | "multi";
|
|
23
|
+
/** Current multi-upload state (aggregate) */
|
|
24
|
+
state: MultiUploadState;
|
|
25
|
+
/** Whether auto-start is enabled */
|
|
26
|
+
autoStart: boolean;
|
|
27
|
+
|
|
28
|
+
/** Add files to the upload queue */
|
|
29
|
+
addFiles: (files: FilePickResult[]) => string[];
|
|
30
|
+
/** Remove an item from the queue */
|
|
31
|
+
removeItem: (id: string) => void;
|
|
32
|
+
/** Start all pending uploads */
|
|
33
|
+
startAll: (itemIds?: string[]) => Promise<void>;
|
|
34
|
+
/** Abort a specific upload by ID */
|
|
35
|
+
abortItem: (id: string) => void;
|
|
36
|
+
/** Retry a specific failed upload by ID */
|
|
37
|
+
retryItem: (id: string) => Promise<void>;
|
|
38
|
+
/** Clear all items and reset state */
|
|
39
|
+
clear: () => void;
|
|
40
|
+
|
|
41
|
+
/** Internal handler for files received from picker */
|
|
42
|
+
handleFilesReceived: (files: FilePickResult[]) => void;
|
|
43
|
+
/** Pick a file using the file system provider */
|
|
44
|
+
pickFile: () => Promise<FilePickResult | null>;
|
|
45
|
+
/** Pick an image using the file system provider */
|
|
46
|
+
pickImage: () => Promise<FilePickResult | null>;
|
|
47
|
+
/** Take a photo using the camera */
|
|
48
|
+
takePhoto: () => Promise<FilePickResult | null>;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const UploadContext = createContext<UploadContextValue | null>(null);
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Hook to access upload context from within an Upload component.
|
|
55
|
+
* @throws Error if used outside of an Upload component
|
|
56
|
+
*/
|
|
57
|
+
export function useUploadContext(): UploadContextValue {
|
|
58
|
+
const context = useContext(UploadContext);
|
|
59
|
+
if (!context) {
|
|
60
|
+
throw new Error(
|
|
61
|
+
"useUploadContext must be used within an <Upload> component. " +
|
|
62
|
+
"Wrap your component tree with <Upload>",
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
return context;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// ============ UPLOAD ITEM CONTEXT ============
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Context value for a specific upload item within an Upload.
|
|
72
|
+
*/
|
|
73
|
+
export interface UploadItemContextValue {
|
|
74
|
+
/** Item ID */
|
|
75
|
+
id: string;
|
|
76
|
+
/** The file being uploaded */
|
|
77
|
+
file: Extract<FilePickResult, { status: "success" }>;
|
|
78
|
+
/** Current upload state */
|
|
79
|
+
state: {
|
|
80
|
+
status: UploadItemState["status"];
|
|
81
|
+
progress: number;
|
|
82
|
+
bytesUploaded: number;
|
|
83
|
+
totalBytes: number;
|
|
84
|
+
error: Error | null;
|
|
85
|
+
result: UploadFile | null;
|
|
86
|
+
};
|
|
87
|
+
/** Abort this upload */
|
|
88
|
+
abort: () => void;
|
|
89
|
+
/** Retry this upload */
|
|
90
|
+
retry: () => Promise<void>;
|
|
91
|
+
/** Remove this item from the queue */
|
|
92
|
+
remove: () => void;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const UploadItemContext = createContext<UploadItemContextValue | null>(null);
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Hook to access upload item context from within an Upload.Item component.
|
|
99
|
+
* @throws Error if used outside of an Upload.Item component
|
|
100
|
+
*/
|
|
101
|
+
export function useUploadItemContext(): UploadItemContextValue {
|
|
102
|
+
const context = useContext(UploadItemContext);
|
|
103
|
+
if (!context) {
|
|
104
|
+
throw new Error(
|
|
105
|
+
"useUploadItemContext must be used within an <Upload.Item> component. " +
|
|
106
|
+
'Wrap your component with <Upload.Item id="...">',
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
return context;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// ============ UPLOAD ROOT COMPONENT ============
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Render props for the Upload root component.
|
|
116
|
+
*/
|
|
117
|
+
export interface UploadRenderProps extends UploadContextValue {}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Props for the Upload root component.
|
|
121
|
+
*/
|
|
122
|
+
export interface UploadProps {
|
|
123
|
+
/** Whether to allow multiple file uploads (default: false) */
|
|
124
|
+
multiple?: boolean;
|
|
125
|
+
/** Maximum concurrent uploads (default: 3, only used in multi mode) */
|
|
126
|
+
maxConcurrent?: number;
|
|
127
|
+
/** Whether to auto-start uploads when files are received (default: true) */
|
|
128
|
+
autoStart?: boolean;
|
|
129
|
+
/** Metadata to attach to uploads */
|
|
130
|
+
metadata?: Record<string, string>;
|
|
131
|
+
/** Called when a single file upload succeeds */
|
|
132
|
+
onSuccess?: (result: UploadFile) => void;
|
|
133
|
+
/** Called when an upload fails */
|
|
134
|
+
onError?: (error: Error) => void;
|
|
135
|
+
/** Called when all uploads complete (multi mode) */
|
|
136
|
+
onComplete?: (results: { successful: number; failed: number; total: number }) => void;
|
|
137
|
+
/** Children to render (can be render function or ReactNode) */
|
|
138
|
+
children: ReactNode | ((props: UploadRenderProps) => ReactNode);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Root component for file uploads on React Native.
|
|
143
|
+
* Provides context for all Upload sub-components.
|
|
144
|
+
* Supports both single-file and multi-file modes via the `multiple` prop.
|
|
145
|
+
*
|
|
146
|
+
* @example Single file upload
|
|
147
|
+
* ```tsx
|
|
148
|
+
* <Upload onSuccess={handleSuccess}>
|
|
149
|
+
* <Upload.FilePicker>
|
|
150
|
+
* {({ pick, isLoading }) => (
|
|
151
|
+
* <Pressable onPress={pick}>
|
|
152
|
+
* <Text>Select File</Text>
|
|
153
|
+
* </Pressable>
|
|
154
|
+
* )}
|
|
155
|
+
* </Upload.FilePicker>
|
|
156
|
+
* <Upload.Progress>
|
|
157
|
+
* {({ progress, isUploading }) => (
|
|
158
|
+
* isUploading && <Text>{progress}%</Text>
|
|
159
|
+
* )}
|
|
160
|
+
* </Upload.Progress>
|
|
161
|
+
* </Upload>
|
|
162
|
+
* ```
|
|
163
|
+
*
|
|
164
|
+
* @example Multi-file upload
|
|
165
|
+
* ```tsx
|
|
166
|
+
* <Upload multiple maxConcurrent={3} onComplete={handleComplete}>
|
|
167
|
+
* <Upload.GalleryPicker>
|
|
168
|
+
* {({ pick }) => (
|
|
169
|
+
* <Pressable onPress={pick}>
|
|
170
|
+
* <Text>Select Photos</Text>
|
|
171
|
+
* </Pressable>
|
|
172
|
+
* )}
|
|
173
|
+
* </Upload.GalleryPicker>
|
|
174
|
+
* <Upload.Items>
|
|
175
|
+
* {({ items }) => items.map(item => (
|
|
176
|
+
* <Upload.Item key={item.id} id={item.id}>
|
|
177
|
+
* {({ file, state, abort, remove }) => (
|
|
178
|
+
* <View>
|
|
179
|
+
* <Text>{file.data.name}: {state.progress}%</Text>
|
|
180
|
+
* </View>
|
|
181
|
+
* )}
|
|
182
|
+
* </Upload.Item>
|
|
183
|
+
* ))}
|
|
184
|
+
* </Upload.Items>
|
|
185
|
+
* <Upload.StartAll>
|
|
186
|
+
* {({ start, disabled }) => (
|
|
187
|
+
* <Pressable onPress={start} disabled={disabled}>
|
|
188
|
+
* <Text>Upload All</Text>
|
|
189
|
+
* </Pressable>
|
|
190
|
+
* )}
|
|
191
|
+
* </Upload.StartAll>
|
|
192
|
+
* </Upload>
|
|
193
|
+
* ```
|
|
194
|
+
*/
|
|
195
|
+
function UploadRoot({
|
|
196
|
+
multiple = false,
|
|
197
|
+
maxConcurrent = 3,
|
|
198
|
+
autoStart = true,
|
|
199
|
+
metadata,
|
|
200
|
+
onSuccess,
|
|
201
|
+
onError,
|
|
202
|
+
onComplete,
|
|
203
|
+
children,
|
|
204
|
+
}: UploadProps) {
|
|
205
|
+
const { fileSystemProvider } = useUploadistaContext();
|
|
206
|
+
|
|
207
|
+
const multiUpload = useMultiUpload({
|
|
208
|
+
maxConcurrent,
|
|
209
|
+
metadata,
|
|
210
|
+
// Cast to unknown since the type definition uses unknown but implementation uses UploadFile
|
|
211
|
+
onSuccess: onSuccess as ((result: unknown) => void) | undefined,
|
|
212
|
+
onError,
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
// Track completion
|
|
216
|
+
const checkComplete = useCallback(() => {
|
|
217
|
+
const { items } = multiUpload.state;
|
|
218
|
+
const allComplete = items.length > 0 && items.every(
|
|
219
|
+
(item) => item.status === "success" || item.status === "error" || item.status === "aborted"
|
|
220
|
+
);
|
|
221
|
+
if (allComplete && onComplete) {
|
|
222
|
+
const successful = items.filter((item) => item.status === "success").length;
|
|
223
|
+
const failed = items.filter((item) => item.status === "error" || item.status === "aborted").length;
|
|
224
|
+
onComplete({ successful, failed, total: items.length });
|
|
225
|
+
}
|
|
226
|
+
}, [multiUpload.state, onComplete]);
|
|
227
|
+
|
|
228
|
+
const handleFilesReceived = useCallback(
|
|
229
|
+
(files: FilePickResult[]) => {
|
|
230
|
+
if (!multiple) {
|
|
231
|
+
// Single mode: clear existing
|
|
232
|
+
multiUpload.clear();
|
|
233
|
+
}
|
|
234
|
+
const ids = multiUpload.addFiles(files);
|
|
235
|
+
if (autoStart && ids.length > 0) {
|
|
236
|
+
multiUpload.startUploads(ids).then(checkComplete);
|
|
237
|
+
}
|
|
238
|
+
},
|
|
239
|
+
[multiple, autoStart, multiUpload, checkComplete],
|
|
240
|
+
);
|
|
241
|
+
|
|
242
|
+
const pickFile = useCallback(async (): Promise<FilePickResult | null> => {
|
|
243
|
+
if (!fileSystemProvider?.pickDocument) {
|
|
244
|
+
throw new Error("File picker not available");
|
|
245
|
+
}
|
|
246
|
+
const result = await fileSystemProvider.pickDocument();
|
|
247
|
+
if (result.status === "success") {
|
|
248
|
+
handleFilesReceived([result]);
|
|
249
|
+
return result;
|
|
250
|
+
}
|
|
251
|
+
return null;
|
|
252
|
+
}, [fileSystemProvider, handleFilesReceived]);
|
|
253
|
+
|
|
254
|
+
const pickImage = useCallback(async (): Promise<FilePickResult | null> => {
|
|
255
|
+
if (!fileSystemProvider?.pickImage) {
|
|
256
|
+
throw new Error("Image picker not available");
|
|
257
|
+
}
|
|
258
|
+
const result = await fileSystemProvider.pickImage({ allowMultiple: multiple });
|
|
259
|
+
if (result.status === "success") {
|
|
260
|
+
handleFilesReceived([result]);
|
|
261
|
+
return result;
|
|
262
|
+
}
|
|
263
|
+
return null;
|
|
264
|
+
}, [fileSystemProvider, handleFilesReceived, multiple]);
|
|
265
|
+
|
|
266
|
+
const takePhoto = useCallback(async (): Promise<FilePickResult | null> => {
|
|
267
|
+
if (!fileSystemProvider?.pickCamera) {
|
|
268
|
+
throw new Error("Camera not available");
|
|
269
|
+
}
|
|
270
|
+
const result = await fileSystemProvider.pickCamera();
|
|
271
|
+
if (result.status === "success") {
|
|
272
|
+
handleFilesReceived([result]);
|
|
273
|
+
return result;
|
|
274
|
+
}
|
|
275
|
+
return null;
|
|
276
|
+
}, [fileSystemProvider, handleFilesReceived]);
|
|
277
|
+
|
|
278
|
+
const contextValue: UploadContextValue = {
|
|
279
|
+
mode: multiple ? "multi" : "single",
|
|
280
|
+
state: multiUpload.state,
|
|
281
|
+
autoStart,
|
|
282
|
+
addFiles: multiUpload.addFiles,
|
|
283
|
+
removeItem: multiUpload.removeItem,
|
|
284
|
+
startAll: async (ids) => {
|
|
285
|
+
await multiUpload.startUploads(ids);
|
|
286
|
+
checkComplete();
|
|
287
|
+
},
|
|
288
|
+
abortItem: multiUpload.abortItem,
|
|
289
|
+
retryItem: multiUpload.retryItem,
|
|
290
|
+
clear: multiUpload.clear,
|
|
291
|
+
handleFilesReceived,
|
|
292
|
+
pickFile,
|
|
293
|
+
pickImage,
|
|
294
|
+
takePhoto,
|
|
295
|
+
};
|
|
296
|
+
|
|
297
|
+
return (
|
|
298
|
+
<UploadContext.Provider value={contextValue}>
|
|
299
|
+
{typeof children === "function" ? children(contextValue) : children}
|
|
300
|
+
</UploadContext.Provider>
|
|
301
|
+
);
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// ============ FILE PICKER PRIMITIVE ============
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* Render props for Upload.FilePicker component.
|
|
308
|
+
*/
|
|
309
|
+
export interface UploadFilePickerRenderProps {
|
|
310
|
+
/** Pick a file */
|
|
311
|
+
pick: () => Promise<void>;
|
|
312
|
+
/** Whether a pick operation is in progress */
|
|
313
|
+
isLoading: boolean;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/**
|
|
317
|
+
* Props for Upload.FilePicker component.
|
|
318
|
+
*/
|
|
319
|
+
export interface UploadFilePickerProps {
|
|
320
|
+
/** Render function receiving picker state */
|
|
321
|
+
children: (props: UploadFilePickerRenderProps) => ReactNode;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/**
|
|
325
|
+
* File picker component for document selection.
|
|
326
|
+
*
|
|
327
|
+
* @example
|
|
328
|
+
* ```tsx
|
|
329
|
+
* <Upload.FilePicker>
|
|
330
|
+
* {({ pick }) => (
|
|
331
|
+
* <Pressable onPress={pick}>
|
|
332
|
+
* <Text>Select Document</Text>
|
|
333
|
+
* </Pressable>
|
|
334
|
+
* )}
|
|
335
|
+
* </Upload.FilePicker>
|
|
336
|
+
* ```
|
|
337
|
+
*/
|
|
338
|
+
function UploadFilePicker({ children }: UploadFilePickerProps) {
|
|
339
|
+
const upload = useUploadContext();
|
|
340
|
+
|
|
341
|
+
const pick = useCallback(async () => {
|
|
342
|
+
await upload.pickFile();
|
|
343
|
+
}, [upload]);
|
|
344
|
+
|
|
345
|
+
const renderProps: UploadFilePickerRenderProps = {
|
|
346
|
+
pick,
|
|
347
|
+
isLoading: upload.state.activeCount > 0,
|
|
348
|
+
};
|
|
349
|
+
|
|
350
|
+
return <>{children(renderProps)}</>;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
// ============ GALLERY PICKER PRIMITIVE ============
|
|
354
|
+
|
|
355
|
+
/**
|
|
356
|
+
* Render props for Upload.GalleryPicker component.
|
|
357
|
+
*/
|
|
358
|
+
export interface UploadGalleryPickerRenderProps {
|
|
359
|
+
/** Pick from gallery */
|
|
360
|
+
pick: () => Promise<void>;
|
|
361
|
+
/** Whether a pick operation is in progress */
|
|
362
|
+
isLoading: boolean;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
/**
|
|
366
|
+
* Props for Upload.GalleryPicker component.
|
|
367
|
+
*/
|
|
368
|
+
export interface UploadGalleryPickerProps {
|
|
369
|
+
/** Render function receiving picker state */
|
|
370
|
+
children: (props: UploadGalleryPickerRenderProps) => ReactNode;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
/**
|
|
374
|
+
* Gallery picker component for image selection.
|
|
375
|
+
*
|
|
376
|
+
* @example
|
|
377
|
+
* ```tsx
|
|
378
|
+
* <Upload.GalleryPicker>
|
|
379
|
+
* {({ pick }) => (
|
|
380
|
+
* <Pressable onPress={pick}>
|
|
381
|
+
* <Text>Select Photos</Text>
|
|
382
|
+
* </Pressable>
|
|
383
|
+
* )}
|
|
384
|
+
* </Upload.GalleryPicker>
|
|
385
|
+
* ```
|
|
386
|
+
*/
|
|
387
|
+
function UploadGalleryPicker({ children }: UploadGalleryPickerProps) {
|
|
388
|
+
const upload = useUploadContext();
|
|
389
|
+
|
|
390
|
+
const pick = useCallback(async () => {
|
|
391
|
+
await upload.pickImage();
|
|
392
|
+
}, [upload]);
|
|
393
|
+
|
|
394
|
+
const renderProps: UploadGalleryPickerRenderProps = {
|
|
395
|
+
pick,
|
|
396
|
+
isLoading: upload.state.activeCount > 0,
|
|
397
|
+
};
|
|
398
|
+
|
|
399
|
+
return <>{children(renderProps)}</>;
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
// ============ CAMERA PICKER PRIMITIVE ============
|
|
403
|
+
|
|
404
|
+
/**
|
|
405
|
+
* Render props for Upload.CameraPicker component.
|
|
406
|
+
*/
|
|
407
|
+
export interface UploadCameraPickerRenderProps {
|
|
408
|
+
/** Take a photo */
|
|
409
|
+
take: () => Promise<void>;
|
|
410
|
+
/** Whether a capture is in progress */
|
|
411
|
+
isLoading: boolean;
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
/**
|
|
415
|
+
* Props for Upload.CameraPicker component.
|
|
416
|
+
*/
|
|
417
|
+
export interface UploadCameraPickerProps {
|
|
418
|
+
/** Render function receiving picker state */
|
|
419
|
+
children: (props: UploadCameraPickerRenderProps) => ReactNode;
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
/**
|
|
423
|
+
* Camera picker component for photo capture.
|
|
424
|
+
*
|
|
425
|
+
* @example
|
|
426
|
+
* ```tsx
|
|
427
|
+
* <Upload.CameraPicker>
|
|
428
|
+
* {({ take }) => (
|
|
429
|
+
* <Pressable onPress={take}>
|
|
430
|
+
* <Text>Take Photo</Text>
|
|
431
|
+
* </Pressable>
|
|
432
|
+
* )}
|
|
433
|
+
* </Upload.CameraPicker>
|
|
434
|
+
* ```
|
|
435
|
+
*/
|
|
436
|
+
function UploadCameraPicker({ children }: UploadCameraPickerProps) {
|
|
437
|
+
const upload = useUploadContext();
|
|
438
|
+
|
|
439
|
+
const take = useCallback(async () => {
|
|
440
|
+
await upload.takePhoto();
|
|
441
|
+
}, [upload]);
|
|
442
|
+
|
|
443
|
+
const renderProps: UploadCameraPickerRenderProps = {
|
|
444
|
+
take,
|
|
445
|
+
isLoading: upload.state.activeCount > 0,
|
|
446
|
+
};
|
|
447
|
+
|
|
448
|
+
return <>{children(renderProps)}</>;
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
// ============ ITEMS PRIMITIVE ============
|
|
452
|
+
|
|
453
|
+
/**
|
|
454
|
+
* Render props for Upload.Items component.
|
|
455
|
+
*/
|
|
456
|
+
export interface UploadItemsRenderProps {
|
|
457
|
+
/** All upload items */
|
|
458
|
+
items: UploadItemState[];
|
|
459
|
+
/** Whether there are any items */
|
|
460
|
+
hasItems: boolean;
|
|
461
|
+
/** Whether items array is empty */
|
|
462
|
+
isEmpty: boolean;
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
/**
|
|
466
|
+
* Props for Upload.Items component.
|
|
467
|
+
*/
|
|
468
|
+
export interface UploadItemsProps {
|
|
469
|
+
/** Render function receiving items */
|
|
470
|
+
children: (props: UploadItemsRenderProps) => ReactNode;
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
/**
|
|
474
|
+
* Renders the list of upload items via render props.
|
|
475
|
+
*
|
|
476
|
+
* @example
|
|
477
|
+
* ```tsx
|
|
478
|
+
* <Upload.Items>
|
|
479
|
+
* {({ items, isEmpty }) => (
|
|
480
|
+
* isEmpty ? <Text>No files</Text> : (
|
|
481
|
+
* items.map(item => (
|
|
482
|
+
* <Upload.Item key={item.id} id={item.id}>
|
|
483
|
+
* {(props) => ...}
|
|
484
|
+
* </Upload.Item>
|
|
485
|
+
* ))
|
|
486
|
+
* )
|
|
487
|
+
* )}
|
|
488
|
+
* </Upload.Items>
|
|
489
|
+
* ```
|
|
490
|
+
*/
|
|
491
|
+
function UploadItems({ children }: UploadItemsProps) {
|
|
492
|
+
const upload = useUploadContext();
|
|
493
|
+
|
|
494
|
+
const renderProps: UploadItemsRenderProps = {
|
|
495
|
+
items: upload.state.items,
|
|
496
|
+
hasItems: upload.state.items.length > 0,
|
|
497
|
+
isEmpty: upload.state.items.length === 0,
|
|
498
|
+
};
|
|
499
|
+
|
|
500
|
+
return <>{children(renderProps)}</>;
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
// ============ ITEM PRIMITIVE ============
|
|
504
|
+
|
|
505
|
+
/**
|
|
506
|
+
* Props for Upload.Item component.
|
|
507
|
+
*/
|
|
508
|
+
export interface UploadItemProps {
|
|
509
|
+
/** Item ID */
|
|
510
|
+
id: string;
|
|
511
|
+
/** Children (can be render function or regular children) */
|
|
512
|
+
children: ReactNode | ((props: UploadItemContextValue) => ReactNode);
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
/**
|
|
516
|
+
* Scoped context provider for a specific upload item.
|
|
517
|
+
*
|
|
518
|
+
* @example
|
|
519
|
+
* ```tsx
|
|
520
|
+
* <Upload.Item id={item.id}>
|
|
521
|
+
* {({ file, state, abort, remove }) => (
|
|
522
|
+
* <View>
|
|
523
|
+
* <Text>{file.data.name}</Text>
|
|
524
|
+
* <Text>{state.progress}%</Text>
|
|
525
|
+
* <Pressable onPress={abort}><Text>Cancel</Text></Pressable>
|
|
526
|
+
* <Pressable onPress={remove}><Text>Remove</Text></Pressable>
|
|
527
|
+
* </View>
|
|
528
|
+
* )}
|
|
529
|
+
* </Upload.Item>
|
|
530
|
+
* ```
|
|
531
|
+
*/
|
|
532
|
+
function UploadItem({ id, children }: UploadItemProps) {
|
|
533
|
+
const upload = useUploadContext();
|
|
534
|
+
|
|
535
|
+
const item = upload.state.items.find((i) => i.id === id);
|
|
536
|
+
|
|
537
|
+
if (!item) {
|
|
538
|
+
// Item not found
|
|
539
|
+
return null;
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
const contextValue: UploadItemContextValue = {
|
|
543
|
+
id,
|
|
544
|
+
file: item.file,
|
|
545
|
+
state: {
|
|
546
|
+
status: item.status,
|
|
547
|
+
progress: item.progress,
|
|
548
|
+
bytesUploaded: item.bytesUploaded,
|
|
549
|
+
totalBytes: item.totalBytes,
|
|
550
|
+
error: item.error,
|
|
551
|
+
result: item.result,
|
|
552
|
+
},
|
|
553
|
+
abort: () => upload.abortItem(id),
|
|
554
|
+
retry: () => upload.retryItem(id),
|
|
555
|
+
remove: () => upload.removeItem(id),
|
|
556
|
+
};
|
|
557
|
+
|
|
558
|
+
return (
|
|
559
|
+
<UploadItemContext.Provider value={contextValue}>
|
|
560
|
+
{typeof children === "function" ? children(contextValue) : children}
|
|
561
|
+
</UploadItemContext.Provider>
|
|
562
|
+
);
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
// ============ PROGRESS PRIMITIVE ============
|
|
566
|
+
|
|
567
|
+
/**
|
|
568
|
+
* Render props for Upload.Progress component.
|
|
569
|
+
*/
|
|
570
|
+
export interface UploadProgressRenderProps {
|
|
571
|
+
/** Progress percentage (0-100) */
|
|
572
|
+
progress: number;
|
|
573
|
+
/** Bytes uploaded so far */
|
|
574
|
+
bytesUploaded: number;
|
|
575
|
+
/** Total bytes to upload */
|
|
576
|
+
totalBytes: number;
|
|
577
|
+
/** Whether any uploads are active */
|
|
578
|
+
isUploading: boolean;
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
/**
|
|
582
|
+
* Props for Upload.Progress component.
|
|
583
|
+
*/
|
|
584
|
+
export interface UploadProgressProps {
|
|
585
|
+
/** Render function receiving progress state */
|
|
586
|
+
children: (props: UploadProgressRenderProps) => ReactNode;
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
/**
|
|
590
|
+
* Progress display component within an Upload.
|
|
591
|
+
*
|
|
592
|
+
* @example
|
|
593
|
+
* ```tsx
|
|
594
|
+
* <Upload.Progress>
|
|
595
|
+
* {({ progress, isUploading }) => (
|
|
596
|
+
* isUploading && <Text>{progress}%</Text>
|
|
597
|
+
* )}
|
|
598
|
+
* </Upload.Progress>
|
|
599
|
+
* ```
|
|
600
|
+
*/
|
|
601
|
+
function UploadProgress({ children }: UploadProgressProps) {
|
|
602
|
+
const upload = useUploadContext();
|
|
603
|
+
|
|
604
|
+
const renderProps: UploadProgressRenderProps = {
|
|
605
|
+
progress: upload.state.totalProgress,
|
|
606
|
+
bytesUploaded: upload.state.totalUploaded,
|
|
607
|
+
totalBytes: upload.state.totalBytes,
|
|
608
|
+
isUploading: upload.state.activeCount > 0,
|
|
609
|
+
};
|
|
610
|
+
|
|
611
|
+
return <>{children(renderProps)}</>;
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
// ============ STATUS PRIMITIVE ============
|
|
615
|
+
|
|
616
|
+
/**
|
|
617
|
+
* Render props for Upload.Status component.
|
|
618
|
+
*/
|
|
619
|
+
export interface UploadStatusRenderProps {
|
|
620
|
+
/** Overall status */
|
|
621
|
+
status: "idle" | "uploading" | "success" | "error";
|
|
622
|
+
/** Whether idle (no uploads active or completed) */
|
|
623
|
+
isIdle: boolean;
|
|
624
|
+
/** Whether uploading */
|
|
625
|
+
isUploading: boolean;
|
|
626
|
+
/** Whether all uploads succeeded */
|
|
627
|
+
isSuccess: boolean;
|
|
628
|
+
/** Whether any upload failed */
|
|
629
|
+
isError: boolean;
|
|
630
|
+
/** Number of total items */
|
|
631
|
+
total: number;
|
|
632
|
+
/** Number of successful uploads */
|
|
633
|
+
successful: number;
|
|
634
|
+
/** Number of failed uploads */
|
|
635
|
+
failed: number;
|
|
636
|
+
/** Number of currently uploading */
|
|
637
|
+
active: number;
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
/**
|
|
641
|
+
* Props for Upload.Status component.
|
|
642
|
+
*/
|
|
643
|
+
export interface UploadStatusProps {
|
|
644
|
+
/** Render function receiving status state */
|
|
645
|
+
children: (props: UploadStatusRenderProps) => ReactNode;
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
/**
|
|
649
|
+
* Status display component within an Upload.
|
|
650
|
+
*
|
|
651
|
+
* @example
|
|
652
|
+
* ```tsx
|
|
653
|
+
* <Upload.Status>
|
|
654
|
+
* {({ status, successful, failed, total }) => (
|
|
655
|
+
* <Text>
|
|
656
|
+
* {status}: {successful}/{total} uploaded, {failed} failed
|
|
657
|
+
* </Text>
|
|
658
|
+
* )}
|
|
659
|
+
* </Upload.Status>
|
|
660
|
+
* ```
|
|
661
|
+
*/
|
|
662
|
+
function UploadStatus({ children }: UploadStatusProps) {
|
|
663
|
+
const upload = useUploadContext();
|
|
664
|
+
const { state } = upload;
|
|
665
|
+
|
|
666
|
+
// Derive overall status
|
|
667
|
+
let status: "idle" | "uploading" | "success" | "error" = "idle";
|
|
668
|
+
if (state.activeCount > 0) {
|
|
669
|
+
status = "uploading";
|
|
670
|
+
} else if (state.items.length > 0) {
|
|
671
|
+
const allComplete = state.items.every(
|
|
672
|
+
(item) => item.status === "success" || item.status === "error" || item.status === "aborted"
|
|
673
|
+
);
|
|
674
|
+
if (allComplete) {
|
|
675
|
+
status = state.failedCount > 0 ? "error" : "success";
|
|
676
|
+
}
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
const renderProps: UploadStatusRenderProps = {
|
|
680
|
+
status,
|
|
681
|
+
isIdle: status === "idle",
|
|
682
|
+
isUploading: state.activeCount > 0,
|
|
683
|
+
isSuccess: state.completedCount > 0 && state.failedCount === 0 && state.activeCount === 0,
|
|
684
|
+
isError: state.failedCount > 0,
|
|
685
|
+
total: state.items.length,
|
|
686
|
+
successful: state.completedCount,
|
|
687
|
+
failed: state.failedCount,
|
|
688
|
+
active: state.activeCount,
|
|
689
|
+
};
|
|
690
|
+
|
|
691
|
+
return <>{children(renderProps)}</>;
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
// ============ ERROR PRIMITIVE ============
|
|
695
|
+
|
|
696
|
+
/**
|
|
697
|
+
* Render props for Upload.Error component.
|
|
698
|
+
*/
|
|
699
|
+
export interface UploadErrorRenderProps {
|
|
700
|
+
/** Whether there are any errors */
|
|
701
|
+
hasError: boolean;
|
|
702
|
+
/** Number of failed uploads */
|
|
703
|
+
failedCount: number;
|
|
704
|
+
/** Failed items */
|
|
705
|
+
failedItems: UploadItemState[];
|
|
706
|
+
/** Clear all items */
|
|
707
|
+
clear: () => void;
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
/**
|
|
711
|
+
* Props for Upload.Error component.
|
|
712
|
+
*/
|
|
713
|
+
export interface UploadErrorProps {
|
|
714
|
+
/** Render function receiving error state */
|
|
715
|
+
children: (props: UploadErrorRenderProps) => ReactNode;
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
/**
|
|
719
|
+
* Error display component within an Upload.
|
|
720
|
+
*
|
|
721
|
+
* @example
|
|
722
|
+
* ```tsx
|
|
723
|
+
* <Upload.Error>
|
|
724
|
+
* {({ hasError, failedItems, clear }) => (
|
|
725
|
+
* hasError && (
|
|
726
|
+
* <View>
|
|
727
|
+
* {failedItems.map(item => (
|
|
728
|
+
* <Text key={item.id}>{item.file.data.name}: {item.error?.message}</Text>
|
|
729
|
+
* ))}
|
|
730
|
+
* <Pressable onPress={clear}><Text>Clear</Text></Pressable>
|
|
731
|
+
* </View>
|
|
732
|
+
* )
|
|
733
|
+
* )}
|
|
734
|
+
* </Upload.Error>
|
|
735
|
+
* ```
|
|
736
|
+
*/
|
|
737
|
+
function UploadError({ children }: UploadErrorProps) {
|
|
738
|
+
const upload = useUploadContext();
|
|
739
|
+
|
|
740
|
+
const failedItems = upload.state.items.filter((item) =>
|
|
741
|
+
item.status === "error" || item.status === "aborted"
|
|
742
|
+
);
|
|
743
|
+
|
|
744
|
+
const renderProps: UploadErrorRenderProps = {
|
|
745
|
+
hasError: failedItems.length > 0,
|
|
746
|
+
failedCount: failedItems.length,
|
|
747
|
+
failedItems,
|
|
748
|
+
clear: upload.clear,
|
|
749
|
+
};
|
|
750
|
+
|
|
751
|
+
return <>{children(renderProps)}</>;
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
// ============ ACTION PRIMITIVES ============
|
|
755
|
+
|
|
756
|
+
/**
|
|
757
|
+
* Render props for Upload.Cancel component.
|
|
758
|
+
*/
|
|
759
|
+
export interface UploadCancelRenderProps {
|
|
760
|
+
/** Cancel all uploads */
|
|
761
|
+
cancel: () => void;
|
|
762
|
+
/** Whether cancel is disabled */
|
|
763
|
+
disabled: boolean;
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
/**
|
|
767
|
+
* Props for Upload.Cancel component.
|
|
768
|
+
*/
|
|
769
|
+
export interface UploadCancelProps {
|
|
770
|
+
/** Render function receiving cancel state */
|
|
771
|
+
children: (props: UploadCancelRenderProps) => ReactNode;
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
/**
|
|
775
|
+
* Cancel component that aborts all active uploads.
|
|
776
|
+
*/
|
|
777
|
+
function UploadCancel({ children }: UploadCancelProps) {
|
|
778
|
+
const upload = useUploadContext();
|
|
779
|
+
|
|
780
|
+
const cancel = useCallback(() => {
|
|
781
|
+
upload.state.items
|
|
782
|
+
.filter((item) => item.status === "uploading")
|
|
783
|
+
.forEach((item) => upload.abortItem(item.id));
|
|
784
|
+
}, [upload]);
|
|
785
|
+
|
|
786
|
+
const renderProps: UploadCancelRenderProps = {
|
|
787
|
+
cancel,
|
|
788
|
+
disabled: upload.state.activeCount === 0,
|
|
789
|
+
};
|
|
790
|
+
|
|
791
|
+
return <>{children(renderProps)}</>;
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
/**
|
|
795
|
+
* Render props for Upload.Retry component.
|
|
796
|
+
*/
|
|
797
|
+
export interface UploadRetryRenderProps {
|
|
798
|
+
/** Retry all failed uploads */
|
|
799
|
+
retry: () => Promise<void>;
|
|
800
|
+
/** Whether retry is disabled */
|
|
801
|
+
disabled: boolean;
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
/**
|
|
805
|
+
* Props for Upload.Retry component.
|
|
806
|
+
*/
|
|
807
|
+
export interface UploadRetryProps {
|
|
808
|
+
/** Render function receiving retry state */
|
|
809
|
+
children: (props: UploadRetryRenderProps) => ReactNode;
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
/**
|
|
813
|
+
* Retry component that retries all failed uploads.
|
|
814
|
+
*/
|
|
815
|
+
function UploadRetry({ children }: UploadRetryProps) {
|
|
816
|
+
const upload = useUploadContext();
|
|
817
|
+
|
|
818
|
+
const retry = useCallback(async () => {
|
|
819
|
+
const failedItems = upload.state.items.filter(
|
|
820
|
+
(item) => item.status === "error" || item.status === "aborted"
|
|
821
|
+
);
|
|
822
|
+
for (const item of failedItems) {
|
|
823
|
+
await upload.retryItem(item.id);
|
|
824
|
+
}
|
|
825
|
+
}, [upload]);
|
|
826
|
+
|
|
827
|
+
const renderProps: UploadRetryRenderProps = {
|
|
828
|
+
retry,
|
|
829
|
+
disabled: upload.state.failedCount === 0,
|
|
830
|
+
};
|
|
831
|
+
|
|
832
|
+
return <>{children(renderProps)}</>;
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
/**
|
|
836
|
+
* Render props for Upload.Reset component.
|
|
837
|
+
*/
|
|
838
|
+
export interface UploadResetRenderProps {
|
|
839
|
+
/** Reset all state */
|
|
840
|
+
reset: () => void;
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
/**
|
|
844
|
+
* Props for Upload.Reset component.
|
|
845
|
+
*/
|
|
846
|
+
export interface UploadResetProps {
|
|
847
|
+
/** Render function receiving reset state */
|
|
848
|
+
children: (props: UploadResetRenderProps) => ReactNode;
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
/**
|
|
852
|
+
* Reset component that clears all items and state.
|
|
853
|
+
*/
|
|
854
|
+
function UploadReset({ children }: UploadResetProps) {
|
|
855
|
+
const upload = useUploadContext();
|
|
856
|
+
|
|
857
|
+
const renderProps: UploadResetRenderProps = {
|
|
858
|
+
reset: upload.clear,
|
|
859
|
+
};
|
|
860
|
+
|
|
861
|
+
return <>{children(renderProps)}</>;
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
/**
|
|
865
|
+
* Render props for Upload.StartAll component.
|
|
866
|
+
*/
|
|
867
|
+
export interface UploadStartAllRenderProps {
|
|
868
|
+
/** Start all pending uploads */
|
|
869
|
+
start: () => Promise<void>;
|
|
870
|
+
/** Whether start is disabled */
|
|
871
|
+
disabled: boolean;
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
/**
|
|
875
|
+
* Props for Upload.StartAll component.
|
|
876
|
+
*/
|
|
877
|
+
export interface UploadStartAllProps {
|
|
878
|
+
/** Render function receiving start state */
|
|
879
|
+
children: (props: UploadStartAllRenderProps) => ReactNode;
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
/**
|
|
883
|
+
* Start all component that begins all queued uploads.
|
|
884
|
+
*/
|
|
885
|
+
function UploadStartAll({ children }: UploadStartAllProps) {
|
|
886
|
+
const upload = useUploadContext();
|
|
887
|
+
|
|
888
|
+
const idleCount = upload.state.items.filter(
|
|
889
|
+
(item) => item.status === "idle"
|
|
890
|
+
).length;
|
|
891
|
+
|
|
892
|
+
const start = useCallback(async () => {
|
|
893
|
+
await upload.startAll();
|
|
894
|
+
}, [upload]);
|
|
895
|
+
|
|
896
|
+
const renderProps: UploadStartAllRenderProps = {
|
|
897
|
+
start,
|
|
898
|
+
disabled: upload.state.activeCount > 0 || idleCount === 0,
|
|
899
|
+
};
|
|
900
|
+
|
|
901
|
+
return <>{children(renderProps)}</>;
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
// ============ COMPOUND COMPONENT EXPORT ============
|
|
905
|
+
|
|
906
|
+
/**
|
|
907
|
+
* Upload compound component for React Native.
|
|
908
|
+
*
|
|
909
|
+
* Provides a composable, headless API for building upload interfaces on mobile.
|
|
910
|
+
* Uses picker components instead of drag-and-drop (which isn't available on mobile).
|
|
911
|
+
* All sub-components use render props for complete UI control.
|
|
912
|
+
*
|
|
913
|
+
* @example Single file upload
|
|
914
|
+
* ```tsx
|
|
915
|
+
* <Upload onSuccess={handleSuccess}>
|
|
916
|
+
* <Upload.FilePicker>
|
|
917
|
+
* {({ pick }) => (
|
|
918
|
+
* <Pressable onPress={pick}>
|
|
919
|
+
* <Text>Select File</Text>
|
|
920
|
+
* </Pressable>
|
|
921
|
+
* )}
|
|
922
|
+
* </Upload.FilePicker>
|
|
923
|
+
* <Upload.Progress>
|
|
924
|
+
* {({ progress }) => <Text>{progress}%</Text>}
|
|
925
|
+
* </Upload.Progress>
|
|
926
|
+
* </Upload>
|
|
927
|
+
* ```
|
|
928
|
+
*
|
|
929
|
+
* @example Multi-file upload
|
|
930
|
+
* ```tsx
|
|
931
|
+
* <Upload multiple maxConcurrent={3} onComplete={handleComplete}>
|
|
932
|
+
* <Upload.GalleryPicker>
|
|
933
|
+
* {({ pick }) => (
|
|
934
|
+
* <Pressable onPress={pick}>
|
|
935
|
+
* <Text>Select Photos</Text>
|
|
936
|
+
* </Pressable>
|
|
937
|
+
* )}
|
|
938
|
+
* </Upload.GalleryPicker>
|
|
939
|
+
* <Upload.Items>
|
|
940
|
+
* {({ items }) => items.map(item => (
|
|
941
|
+
* <Upload.Item key={item.id} id={item.id}>
|
|
942
|
+
* {({ file, state }) => (
|
|
943
|
+
* <Text>{file.data.name}: {state.progress}%</Text>
|
|
944
|
+
* )}
|
|
945
|
+
* </Upload.Item>
|
|
946
|
+
* ))}
|
|
947
|
+
* </Upload.Items>
|
|
948
|
+
* <Upload.StartAll>
|
|
949
|
+
* {({ start, disabled }) => (
|
|
950
|
+
* <Pressable onPress={start} disabled={disabled}>
|
|
951
|
+
* <Text>Upload All</Text>
|
|
952
|
+
* </Pressable>
|
|
953
|
+
* )}
|
|
954
|
+
* </Upload.StartAll>
|
|
955
|
+
* </Upload>
|
|
956
|
+
* ```
|
|
957
|
+
*/
|
|
958
|
+
export const Upload = Object.assign(UploadRoot, {
|
|
959
|
+
FilePicker: UploadFilePicker,
|
|
960
|
+
GalleryPicker: UploadGalleryPicker,
|
|
961
|
+
CameraPicker: UploadCameraPicker,
|
|
962
|
+
Items: UploadItems,
|
|
963
|
+
Item: UploadItem,
|
|
964
|
+
Progress: UploadProgress,
|
|
965
|
+
Status: UploadStatus,
|
|
966
|
+
Error: UploadError,
|
|
967
|
+
Cancel: UploadCancel,
|
|
968
|
+
Retry: UploadRetry,
|
|
969
|
+
Reset: UploadReset,
|
|
970
|
+
StartAll: UploadStartAll,
|
|
971
|
+
});
|