gbs-add-block 1.2.12 → 1.2.13
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 +3 -2
- package/index.cjs +8 -2
- package/package.json +1 -1
- package/source/beta-components/fileuploader/README.md +99 -0
- package/source/beta-components/fileuploader/__tests__/core.test.ts +395 -0
- package/source/beta-components/fileuploader/core/format.ts +79 -0
- package/source/beta-components/fileuploader/core/index.ts +26 -0
- package/source/beta-components/fileuploader/core/store.ts +432 -0
- package/source/beta-components/fileuploader/core/transport.ts +145 -0
- package/source/beta-components/fileuploader/core/types.ts +126 -0
- package/source/beta-components/fileuploader/core/validate.ts +83 -0
- package/source/beta-components/fileuploader/index.ts +7 -0
- package/source/beta-components/fileuploader/react/FileRow.tsx +268 -0
- package/source/beta-components/fileuploader/react/FileUploader.tsx +515 -0
- package/source/beta-components/fileuploader/react/icons.tsx +80 -0
- package/source/beta-components/fileuploader/react/locale.ts +33 -0
- package/source/beta-components/fileuploader/react/props.ts +31 -0
- package/source/beta-components/fileuploader/react/useFileUploader.ts +32 -0
- package/source/beta-components/fileuploader/styles.css +395 -0
|
@@ -0,0 +1,515 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
useEffect,
|
|
5
|
+
useId,
|
|
6
|
+
useImperativeHandle,
|
|
7
|
+
useMemo,
|
|
8
|
+
useRef,
|
|
9
|
+
useState,
|
|
10
|
+
type ClipboardEvent,
|
|
11
|
+
type CSSProperties,
|
|
12
|
+
type DragEvent,
|
|
13
|
+
type ReactNode,
|
|
14
|
+
type Ref,
|
|
15
|
+
} from "react";
|
|
16
|
+
import { formatBytes, readFileId } from "../core/format";
|
|
17
|
+
import { summarize } from "../core/store";
|
|
18
|
+
import { createHttpTransport, type HttpTransportOptions } from "../core/transport";
|
|
19
|
+
import type {
|
|
20
|
+
ExistingFile,
|
|
21
|
+
FileRejection,
|
|
22
|
+
Transport,
|
|
23
|
+
UploaderLocaleText,
|
|
24
|
+
UploaderSize,
|
|
25
|
+
UploadItem,
|
|
26
|
+
} from "../core/types";
|
|
27
|
+
import { ExistingFileRow, FileRow } from "./FileRow";
|
|
28
|
+
import { AlertIcon, UploadIcon, XIcon } from "./icons";
|
|
29
|
+
import { defaultUploaderText } from "./locale";
|
|
30
|
+
import { cx, type FileUploaderHandle, type UploaderSlot } from "./props";
|
|
31
|
+
import { useFileUploader } from "./useFileUploader";
|
|
32
|
+
|
|
33
|
+
export interface FileUploaderProps {
|
|
34
|
+
/* ----------------------------------------------------------- selection */
|
|
35
|
+
/** Several files. When false, a new file replaces the current one. Default false. */
|
|
36
|
+
multiple?: boolean;
|
|
37
|
+
/** Same syntax as `<input accept>`: `.pdf`, `image/*`, `application/json`. */
|
|
38
|
+
accept?: string;
|
|
39
|
+
/** Total files, counting ones already selected. */
|
|
40
|
+
maxFiles?: number;
|
|
41
|
+
/** Bytes per file. */
|
|
42
|
+
maxSize?: number;
|
|
43
|
+
/** Bytes per file. */
|
|
44
|
+
minSize?: number;
|
|
45
|
+
/** Return a message to reject a file. */
|
|
46
|
+
validate?(file: File): string | null | undefined;
|
|
47
|
+
allowDuplicates?: boolean;
|
|
48
|
+
|
|
49
|
+
/* ------------------------------------------------------------- upload */
|
|
50
|
+
/** Where chunks are POSTed. Without `endpoint` or `transport`, files are only selected. */
|
|
51
|
+
endpoint?: HttpTransportOptions["endpoint"];
|
|
52
|
+
method?: HttpTransportOptions["method"];
|
|
53
|
+
headers?: HttpTransportOptions["headers"];
|
|
54
|
+
withCredentials?: boolean;
|
|
55
|
+
/** Extra data sent as JSON with every chunk. */
|
|
56
|
+
params?: HttpTransportOptions["params"];
|
|
57
|
+
fieldNames?: HttpTransportOptions["fieldNames"];
|
|
58
|
+
parseResponse?: HttpTransportOptions["parseResponse"];
|
|
59
|
+
/** Replaces the built-in HTTP transport, e.g. for presigned URLs or an SDK. */
|
|
60
|
+
transport?: Transport;
|
|
61
|
+
/** Bytes per request. Default 5 MiB. */
|
|
62
|
+
chunkSize?: number;
|
|
63
|
+
/** Files uploading at once. Default 3. */
|
|
64
|
+
concurrency?: number;
|
|
65
|
+
/** Extra attempts per chunk. Default 3. */
|
|
66
|
+
retries?: number;
|
|
67
|
+
/** First retry delay in ms, doubled each time. Default 1000. */
|
|
68
|
+
retryDelay?: number;
|
|
69
|
+
/** Upload as soon as files are added. Default false. */
|
|
70
|
+
autoUpload?: boolean;
|
|
71
|
+
/** The value posted for an uploaded file. Default: the id found in the server's response. */
|
|
72
|
+
getFileId?(item: UploadItem): string | undefined;
|
|
73
|
+
|
|
74
|
+
/* ------------------------------------------------------ display & form */
|
|
75
|
+
/** Thumbnails for images. Default true. */
|
|
76
|
+
preview?: boolean;
|
|
77
|
+
/** Files already stored, shown above new ones. */
|
|
78
|
+
existingFiles?: readonly ExistingFile[];
|
|
79
|
+
onRemoveExisting?(file: ExistingFile): void;
|
|
80
|
+
/** Show remove buttons. Default true. */
|
|
81
|
+
removable?: boolean;
|
|
82
|
+
label?: ReactNode;
|
|
83
|
+
description?: ReactNode;
|
|
84
|
+
/** Message below the drop zone; also marks it invalid. */
|
|
85
|
+
error?: ReactNode;
|
|
86
|
+
required?: boolean;
|
|
87
|
+
disabled?: boolean;
|
|
88
|
+
size?: UploaderSize;
|
|
89
|
+
/**
|
|
90
|
+
* Form field name. With an endpoint, the stored file ids are posted; without
|
|
91
|
+
* one, the files themselves are, so a plain form or Server Action gets them.
|
|
92
|
+
*/
|
|
93
|
+
name?: string;
|
|
94
|
+
id?: string;
|
|
95
|
+
/** For sizes and percentages. */
|
|
96
|
+
locale?: string;
|
|
97
|
+
className?: string;
|
|
98
|
+
classNames?: Partial<Record<UploaderSlot, string>>;
|
|
99
|
+
style?: CSSProperties;
|
|
100
|
+
localeText?: Partial<UploaderLocaleText>;
|
|
101
|
+
|
|
102
|
+
/* ------------------------------------------------------------- events */
|
|
103
|
+
/** Selected files changed. */
|
|
104
|
+
onChange?(files: File[]): void;
|
|
105
|
+
onRejected?(rejections: FileRejection[]): void;
|
|
106
|
+
onFileSuccess?(item: UploadItem): void;
|
|
107
|
+
onFileError?(item: UploadItem, error: unknown): void;
|
|
108
|
+
/** Every file in an upload run has stopped (uploaded, failed, paused or canceled). */
|
|
109
|
+
onUploadComplete?(items: UploadItem[]): void;
|
|
110
|
+
|
|
111
|
+
ref?: Ref<FileUploaderHandle>;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** `.pdf, image/*` → `PDF, image/*` for the hint line. */
|
|
115
|
+
const describeAccept = (accept: string) =>
|
|
116
|
+
accept
|
|
117
|
+
.split(",")
|
|
118
|
+
.map((token) => token.trim())
|
|
119
|
+
.filter(Boolean)
|
|
120
|
+
.map((token) => (token.startsWith(".") ? token.slice(1).toUpperCase() : token))
|
|
121
|
+
.join(", ");
|
|
122
|
+
|
|
123
|
+
const carriesFiles = (event: DragEvent) => Array.from(event.dataTransfer.types).includes("Files");
|
|
124
|
+
|
|
125
|
+
/** Drop, browse or paste files; upload them in resumable chunks with progress. */
|
|
126
|
+
export function FileUploader(props: FileUploaderProps) {
|
|
127
|
+
const {
|
|
128
|
+
ref,
|
|
129
|
+
multiple = false,
|
|
130
|
+
accept,
|
|
131
|
+
maxFiles,
|
|
132
|
+
maxSize,
|
|
133
|
+
minSize,
|
|
134
|
+
validate,
|
|
135
|
+
allowDuplicates,
|
|
136
|
+
endpoint,
|
|
137
|
+
method,
|
|
138
|
+
headers,
|
|
139
|
+
withCredentials,
|
|
140
|
+
params,
|
|
141
|
+
fieldNames,
|
|
142
|
+
parseResponse,
|
|
143
|
+
transport: customTransport,
|
|
144
|
+
chunkSize,
|
|
145
|
+
concurrency,
|
|
146
|
+
retries,
|
|
147
|
+
retryDelay,
|
|
148
|
+
autoUpload = false,
|
|
149
|
+
getFileId,
|
|
150
|
+
preview = true,
|
|
151
|
+
existingFiles,
|
|
152
|
+
onRemoveExisting,
|
|
153
|
+
removable = true,
|
|
154
|
+
label,
|
|
155
|
+
description,
|
|
156
|
+
error,
|
|
157
|
+
required = false,
|
|
158
|
+
disabled = false,
|
|
159
|
+
size = "md",
|
|
160
|
+
name,
|
|
161
|
+
id: idProp,
|
|
162
|
+
locale,
|
|
163
|
+
className,
|
|
164
|
+
classNames,
|
|
165
|
+
style,
|
|
166
|
+
localeText,
|
|
167
|
+
onChange,
|
|
168
|
+
onRejected,
|
|
169
|
+
onFileSuccess,
|
|
170
|
+
onFileError,
|
|
171
|
+
onUploadComplete,
|
|
172
|
+
} = props;
|
|
173
|
+
|
|
174
|
+
const reactId = useId();
|
|
175
|
+
const id = idProp ?? reactId;
|
|
176
|
+
const text = useMemo(() => ({ ...defaultUploaderText, ...localeText }), [localeText]);
|
|
177
|
+
|
|
178
|
+
const pickerRef = useRef<HTMLInputElement>(null);
|
|
179
|
+
const formFilesRef = useRef<HTMLInputElement>(null);
|
|
180
|
+
const dragDepth = useRef(0);
|
|
181
|
+
const [dragging, setDragging] = useState(false);
|
|
182
|
+
const [announcement, setAnnouncement] = useState("");
|
|
183
|
+
|
|
184
|
+
const transport = useMemo<Transport | undefined>(() => {
|
|
185
|
+
if (customTransport) return customTransport;
|
|
186
|
+
if (!endpoint) return undefined;
|
|
187
|
+
return createHttpTransport({
|
|
188
|
+
endpoint,
|
|
189
|
+
method,
|
|
190
|
+
headers,
|
|
191
|
+
withCredentials,
|
|
192
|
+
params,
|
|
193
|
+
fieldNames,
|
|
194
|
+
parseResponse,
|
|
195
|
+
});
|
|
196
|
+
}, [customTransport, endpoint, method, headers, withCredentials, params, fieldNames, parseResponse]);
|
|
197
|
+
|
|
198
|
+
const { store, items, rejections } = useFileUploader({
|
|
199
|
+
multiple,
|
|
200
|
+
accept,
|
|
201
|
+
maxFiles,
|
|
202
|
+
maxSize,
|
|
203
|
+
minSize,
|
|
204
|
+
validate,
|
|
205
|
+
allowDuplicates,
|
|
206
|
+
transport,
|
|
207
|
+
chunkSize,
|
|
208
|
+
concurrency,
|
|
209
|
+
retries,
|
|
210
|
+
retryDelay,
|
|
211
|
+
autoUpload,
|
|
212
|
+
onChange,
|
|
213
|
+
onRejected,
|
|
214
|
+
onFileSuccess(item) {
|
|
215
|
+
setAnnouncement(text.announceDone(item.file.name));
|
|
216
|
+
onFileSuccess?.(item);
|
|
217
|
+
},
|
|
218
|
+
onFileError(item, reason) {
|
|
219
|
+
setAnnouncement(text.announceFailed(item.file.name));
|
|
220
|
+
onFileError?.(item, reason);
|
|
221
|
+
},
|
|
222
|
+
onComplete: onUploadComplete,
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
const canUpload = transport !== undefined;
|
|
226
|
+
const postsFiles = Boolean(name) && !canUpload;
|
|
227
|
+
|
|
228
|
+
// Without an endpoint the files ride along with the form, in a hidden file input.
|
|
229
|
+
useEffect(() => {
|
|
230
|
+
const input = formFilesRef.current;
|
|
231
|
+
if (!input) return;
|
|
232
|
+
const transfer = new DataTransfer();
|
|
233
|
+
for (const item of items) transfer.items.add(item.file);
|
|
234
|
+
input.files = transfer.files;
|
|
235
|
+
}, [items, postsFiles]);
|
|
236
|
+
|
|
237
|
+
const open = () => {
|
|
238
|
+
if (!disabled) pickerRef.current?.click();
|
|
239
|
+
};
|
|
240
|
+
|
|
241
|
+
useImperativeHandle(
|
|
242
|
+
ref,
|
|
243
|
+
() => ({
|
|
244
|
+
open: () => {
|
|
245
|
+
if (!disabled) pickerRef.current?.click();
|
|
246
|
+
},
|
|
247
|
+
addFiles: (files) => store.addFiles(files),
|
|
248
|
+
upload: () => store.upload(),
|
|
249
|
+
pause: (fileId) => store.pause(fileId),
|
|
250
|
+
cancel: (fileId) => store.cancel(fileId),
|
|
251
|
+
clear: () => store.clear(),
|
|
252
|
+
getFiles: () => store.getSnapshot().items.map((item) => item.file),
|
|
253
|
+
getItems: () => store.getSnapshot().items,
|
|
254
|
+
}),
|
|
255
|
+
[store, disabled],
|
|
256
|
+
);
|
|
257
|
+
|
|
258
|
+
const add = (files: FileList | null | undefined) => {
|
|
259
|
+
if (disabled || !files || files.length === 0) return;
|
|
260
|
+
store.addFiles(files);
|
|
261
|
+
};
|
|
262
|
+
|
|
263
|
+
const onDragEnter = (event: DragEvent<HTMLDivElement>) => {
|
|
264
|
+
if (disabled || !carriesFiles(event)) return;
|
|
265
|
+
event.preventDefault();
|
|
266
|
+
dragDepth.current += 1;
|
|
267
|
+
setDragging(true);
|
|
268
|
+
};
|
|
269
|
+
|
|
270
|
+
const onDragOver = (event: DragEvent<HTMLDivElement>) => {
|
|
271
|
+
if (disabled || !carriesFiles(event)) return;
|
|
272
|
+
event.preventDefault();
|
|
273
|
+
event.dataTransfer.dropEffect = "copy";
|
|
274
|
+
};
|
|
275
|
+
|
|
276
|
+
// dragenter/leave fire for every child the pointer crosses, so count the depth.
|
|
277
|
+
const onDragLeave = () => {
|
|
278
|
+
if (!dragging) return;
|
|
279
|
+
dragDepth.current -= 1;
|
|
280
|
+
if (dragDepth.current <= 0) {
|
|
281
|
+
dragDepth.current = 0;
|
|
282
|
+
setDragging(false);
|
|
283
|
+
}
|
|
284
|
+
};
|
|
285
|
+
|
|
286
|
+
const onDrop = (event: DragEvent<HTMLDivElement>) => {
|
|
287
|
+
if (!carriesFiles(event)) return;
|
|
288
|
+
event.preventDefault();
|
|
289
|
+
dragDepth.current = 0;
|
|
290
|
+
setDragging(false);
|
|
291
|
+
add(event.dataTransfer.files);
|
|
292
|
+
};
|
|
293
|
+
|
|
294
|
+
const onPaste = (event: ClipboardEvent<HTMLDivElement>) => {
|
|
295
|
+
if (event.clipboardData.files.length === 0) return;
|
|
296
|
+
event.preventDefault();
|
|
297
|
+
add(event.clipboardData.files);
|
|
298
|
+
};
|
|
299
|
+
|
|
300
|
+
const rejectionText = (rejection: FileRejection): string => {
|
|
301
|
+
const fileName = rejection.file.name;
|
|
302
|
+
switch (rejection.code) {
|
|
303
|
+
case "file-type":
|
|
304
|
+
return text.rejectFileType(fileName);
|
|
305
|
+
case "file-too-large":
|
|
306
|
+
return text.rejectTooLarge(fileName, formatBytes(maxSize ?? 0, locale));
|
|
307
|
+
case "file-too-small":
|
|
308
|
+
return text.rejectTooSmall(fileName, formatBytes(minSize ?? 0, locale));
|
|
309
|
+
case "too-many-files":
|
|
310
|
+
return text.rejectTooMany(String(multiple ? (maxFiles ?? 1) : 1));
|
|
311
|
+
case "duplicate":
|
|
312
|
+
return text.rejectDuplicate(fileName);
|
|
313
|
+
case "custom":
|
|
314
|
+
return rejection.message ?? fileName;
|
|
315
|
+
}
|
|
316
|
+
};
|
|
317
|
+
|
|
318
|
+
const hints = [
|
|
319
|
+
accept ? text.hintTypes(describeAccept(accept)) : null,
|
|
320
|
+
maxSize !== undefined ? text.hintMaxSize(formatBytes(maxSize, locale)) : null,
|
|
321
|
+
multiple && maxFiles !== undefined && Number.isFinite(maxFiles)
|
|
322
|
+
? text.hintMaxFiles(String(maxFiles))
|
|
323
|
+
: null,
|
|
324
|
+
]
|
|
325
|
+
.filter(Boolean)
|
|
326
|
+
.join(" · ");
|
|
327
|
+
|
|
328
|
+
const summary = summarize(items);
|
|
329
|
+
const uploadedIds = name && canUpload
|
|
330
|
+
? items
|
|
331
|
+
.filter((item) => item.status === "success")
|
|
332
|
+
.map((item) => getFileId?.(item) ?? readFileId(item.response) ?? item.uploadId)
|
|
333
|
+
: [];
|
|
334
|
+
const hasRows = items.length > 0 || (existingFiles?.length ?? 0) > 0;
|
|
335
|
+
const showFooter = items.length > 0 && (canUpload || multiple);
|
|
336
|
+
const describedBy =
|
|
337
|
+
cx(
|
|
338
|
+
hints ? `${id}-hint` : "",
|
|
339
|
+
description ? `${id}-description` : "",
|
|
340
|
+
error ? `${id}-error` : "",
|
|
341
|
+
) || undefined;
|
|
342
|
+
|
|
343
|
+
const rowProps = { preview, removable, disabled, locale, text, classNames };
|
|
344
|
+
|
|
345
|
+
return (
|
|
346
|
+
<div
|
|
347
|
+
className={cx("fu-root", classNames?.root, className)}
|
|
348
|
+
style={style}
|
|
349
|
+
data-size={size}
|
|
350
|
+
data-disabled={disabled || undefined}
|
|
351
|
+
data-invalid={error ? "" : undefined}
|
|
352
|
+
onPaste={onPaste}
|
|
353
|
+
>
|
|
354
|
+
{label && (
|
|
355
|
+
<span
|
|
356
|
+
id={`${id}-label`}
|
|
357
|
+
className={cx("fu-label", classNames?.label)}
|
|
358
|
+
data-required={required || undefined}
|
|
359
|
+
>
|
|
360
|
+
{label}
|
|
361
|
+
</span>
|
|
362
|
+
)}
|
|
363
|
+
|
|
364
|
+
<div
|
|
365
|
+
className={cx("fu-dropzone", classNames?.dropzone)}
|
|
366
|
+
data-dragging={dragging || undefined}
|
|
367
|
+
onDragEnter={onDragEnter}
|
|
368
|
+
onDragOver={onDragOver}
|
|
369
|
+
onDragLeave={onDragLeave}
|
|
370
|
+
onDrop={onDrop}
|
|
371
|
+
>
|
|
372
|
+
<button
|
|
373
|
+
type="button"
|
|
374
|
+
id={id}
|
|
375
|
+
className="fu-trigger"
|
|
376
|
+
disabled={disabled}
|
|
377
|
+
aria-labelledby={label ? `${id}-label ${id}-cta` : undefined}
|
|
378
|
+
aria-describedby={describedBy}
|
|
379
|
+
onClick={open}
|
|
380
|
+
>
|
|
381
|
+
<UploadIcon className="fu-dropzone-icon" />
|
|
382
|
+
<span id={`${id}-cta`} className="fu-dropzone-text">
|
|
383
|
+
{dragging ? (
|
|
384
|
+
text.dropHere
|
|
385
|
+
) : (
|
|
386
|
+
<>
|
|
387
|
+
{text.dropzone} <span className="fu-browse">{text.browse}</span>
|
|
388
|
+
</>
|
|
389
|
+
)}
|
|
390
|
+
</span>
|
|
391
|
+
{hints && (
|
|
392
|
+
<span id={`${id}-hint`} className="fu-hint">
|
|
393
|
+
{hints}
|
|
394
|
+
</span>
|
|
395
|
+
)}
|
|
396
|
+
</button>
|
|
397
|
+
<input
|
|
398
|
+
ref={pickerRef}
|
|
399
|
+
type="file"
|
|
400
|
+
className="fu-sr-only"
|
|
401
|
+
tabIndex={-1}
|
|
402
|
+
aria-hidden="true"
|
|
403
|
+
multiple={multiple}
|
|
404
|
+
accept={accept}
|
|
405
|
+
disabled={disabled}
|
|
406
|
+
onChange={(event) => {
|
|
407
|
+
add(event.currentTarget.files);
|
|
408
|
+
// Let the same file be picked again after it was removed.
|
|
409
|
+
event.currentTarget.value = "";
|
|
410
|
+
}}
|
|
411
|
+
/>
|
|
412
|
+
</div>
|
|
413
|
+
|
|
414
|
+
{description && (
|
|
415
|
+
<span id={`${id}-description`} className="fu-description">
|
|
416
|
+
{description}
|
|
417
|
+
</span>
|
|
418
|
+
)}
|
|
419
|
+
{error && (
|
|
420
|
+
<span id={`${id}-error`} className="fu-error" role="alert">
|
|
421
|
+
{error}
|
|
422
|
+
</span>
|
|
423
|
+
)}
|
|
424
|
+
|
|
425
|
+
{postsFiles && <input ref={formFilesRef} type="file" name={name} multiple hidden tabIndex={-1} />}
|
|
426
|
+
{uploadedIds.map((value) => (
|
|
427
|
+
<input key={value} type="hidden" name={name} value={value} />
|
|
428
|
+
))}
|
|
429
|
+
{name &&
|
|
430
|
+
existingFiles?.map((file) => (
|
|
431
|
+
<input key={file.id} type="hidden" name={`${name}-existing`} value={file.id} />
|
|
432
|
+
))}
|
|
433
|
+
|
|
434
|
+
{rejections.length > 0 && (
|
|
435
|
+
<ul className={cx("fu-rejections", classNames?.rejections)} role="alert">
|
|
436
|
+
{rejections.map((rejection) => (
|
|
437
|
+
<li key={rejection.id} className="fu-rejection">
|
|
438
|
+
<AlertIcon />
|
|
439
|
+
<span>{rejectionText(rejection)}</span>
|
|
440
|
+
<button
|
|
441
|
+
type="button"
|
|
442
|
+
className="fu-icon-button"
|
|
443
|
+
aria-label={text.dismiss}
|
|
444
|
+
title={text.dismiss}
|
|
445
|
+
onClick={() => store.dismissRejection(rejection.id)}
|
|
446
|
+
>
|
|
447
|
+
<XIcon />
|
|
448
|
+
</button>
|
|
449
|
+
</li>
|
|
450
|
+
))}
|
|
451
|
+
</ul>
|
|
452
|
+
)}
|
|
453
|
+
|
|
454
|
+
{hasRows && (
|
|
455
|
+
<ul
|
|
456
|
+
className={cx("fu-list", classNames?.list)}
|
|
457
|
+
aria-labelledby={label ? `${id}-label` : undefined}
|
|
458
|
+
>
|
|
459
|
+
{existingFiles?.map((file) => (
|
|
460
|
+
<ExistingFileRow key={`existing-${file.id}`} file={file} onRemove={onRemoveExisting} {...rowProps} />
|
|
461
|
+
))}
|
|
462
|
+
{items.map((item) => (
|
|
463
|
+
<FileRow key={item.id} item={item} store={store} canUpload={canUpload} {...rowProps} />
|
|
464
|
+
))}
|
|
465
|
+
</ul>
|
|
466
|
+
)}
|
|
467
|
+
|
|
468
|
+
{showFooter && (
|
|
469
|
+
<div className={cx("fu-footer", classNames?.footer)}>
|
|
470
|
+
{summary.busy ? (
|
|
471
|
+
<>
|
|
472
|
+
<div
|
|
473
|
+
className="fu-progress"
|
|
474
|
+
role="progressbar"
|
|
475
|
+
aria-valuemin={0}
|
|
476
|
+
aria-valuemax={100}
|
|
477
|
+
aria-valuenow={Math.round(summary.progress * 100)}
|
|
478
|
+
aria-label={typeof label === "string" ? label : undefined}
|
|
479
|
+
>
|
|
480
|
+
<span className="fu-progress-bar" style={{ transform: `scaleX(${summary.progress})` }} />
|
|
481
|
+
</div>
|
|
482
|
+
<span className="fu-footer-text">
|
|
483
|
+
{formatBytes(summary.uploadedBytes, locale)} / {formatBytes(summary.totalBytes, locale)}
|
|
484
|
+
</span>
|
|
485
|
+
<button type="button" className="fu-button" disabled={disabled} onClick={() => store.cancel()}>
|
|
486
|
+
{text.cancelAll}
|
|
487
|
+
</button>
|
|
488
|
+
</>
|
|
489
|
+
) : (
|
|
490
|
+
<>
|
|
491
|
+
<button type="button" className="fu-button" disabled={disabled} onClick={() => store.clear()}>
|
|
492
|
+
{text.clear}
|
|
493
|
+
</button>
|
|
494
|
+
{canUpload && summary.startable > 0 && (
|
|
495
|
+
<button
|
|
496
|
+
type="button"
|
|
497
|
+
className="fu-button"
|
|
498
|
+
data-variant="primary"
|
|
499
|
+
disabled={disabled}
|
|
500
|
+
onClick={() => void store.upload()}
|
|
501
|
+
>
|
|
502
|
+
{text.upload(String(summary.startable))}
|
|
503
|
+
</button>
|
|
504
|
+
)}
|
|
505
|
+
</>
|
|
506
|
+
)}
|
|
507
|
+
</div>
|
|
508
|
+
)}
|
|
509
|
+
|
|
510
|
+
<span className="fu-sr-only" role="status">
|
|
511
|
+
{announcement}
|
|
512
|
+
</span>
|
|
513
|
+
</div>
|
|
514
|
+
);
|
|
515
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import type { SVGProps } from "react";
|
|
2
|
+
import type { FileKind } from "../core/types";
|
|
3
|
+
|
|
4
|
+
type IconProps = SVGProps<SVGSVGElement>;
|
|
5
|
+
|
|
6
|
+
function Svg(props: IconProps) {
|
|
7
|
+
return (
|
|
8
|
+
<svg
|
|
9
|
+
width="16"
|
|
10
|
+
height="16"
|
|
11
|
+
viewBox="0 0 24 24"
|
|
12
|
+
fill="none"
|
|
13
|
+
stroke="currentColor"
|
|
14
|
+
strokeWidth="2"
|
|
15
|
+
strokeLinecap="round"
|
|
16
|
+
strokeLinejoin="round"
|
|
17
|
+
aria-hidden="true"
|
|
18
|
+
focusable="false"
|
|
19
|
+
{...props}
|
|
20
|
+
/>
|
|
21
|
+
);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export const UploadIcon = (p: IconProps) => (
|
|
25
|
+
<Svg width={28} height={28} strokeWidth={1.75} {...p}>
|
|
26
|
+
<path d="M12 15V4M7.5 8.5 12 4l4.5 4.5" />
|
|
27
|
+
<path d="M20 15v3a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2v-3" />
|
|
28
|
+
</Svg>
|
|
29
|
+
);
|
|
30
|
+
export const XIcon = (p: IconProps) => <Svg width={14} height={14} {...p}><path d="M18 6 6 18M6 6l12 12" /></Svg>;
|
|
31
|
+
export const PauseIcon = (p: IconProps) => <Svg width={14} height={14} {...p}><path d="M8 5v14M16 5v14" /></Svg>;
|
|
32
|
+
export const PlayIcon = (p: IconProps) => <Svg width={14} height={14} {...p}><path d="M7 4.5v15L19 12Z" /></Svg>;
|
|
33
|
+
export const RetryIcon = (p: IconProps) => (
|
|
34
|
+
<Svg width={14} height={14} {...p}>
|
|
35
|
+
<path d="M3 12a9 9 0 0 1 15.5-6.2L21 8" />
|
|
36
|
+
<path d="M21 3v5h-5M21 12a9 9 0 0 1-15.5 6.2L3 16" />
|
|
37
|
+
<path d="M3 21v-5h5" />
|
|
38
|
+
</Svg>
|
|
39
|
+
);
|
|
40
|
+
export const DownloadIcon = (p: IconProps) => (
|
|
41
|
+
<Svg width={14} height={14} {...p}>
|
|
42
|
+
<path d="M12 4v11M7.5 10.5 12 15l4.5-4.5M5 20h14" />
|
|
43
|
+
</Svg>
|
|
44
|
+
);
|
|
45
|
+
export const AlertIcon = (p: IconProps) => (
|
|
46
|
+
<Svg width={14} height={14} {...p}>
|
|
47
|
+
<circle cx="12" cy="12" r="9" />
|
|
48
|
+
<path d="M12 7.5v5.5M12 16.5h.01" />
|
|
49
|
+
</Svg>
|
|
50
|
+
);
|
|
51
|
+
|
|
52
|
+
const KIND_PATHS: Record<FileKind, string> = {
|
|
53
|
+
image: "M3 16l5-5 4 4 3-3 6 6M8.5 8.5h.01",
|
|
54
|
+
video: "m10 9 5 3-5 3Z",
|
|
55
|
+
audio: "M9 18V6l10-2v12M9 18a2 2 0 1 1-4 0 2 2 0 0 1 4 0Zm10-2a2 2 0 1 1-4 0 2 2 0 0 1 4 0Z",
|
|
56
|
+
pdf: "M8 13h8M8 17h5",
|
|
57
|
+
spreadsheet: "M8 12h8M8 16h8M12 12v6",
|
|
58
|
+
document: "M8 12h8M8 16h8",
|
|
59
|
+
archive: "M11 4h2M11 7h2M11 10h2M10 13h4v4h-4Z",
|
|
60
|
+
other: "",
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
/** A page outline with a mark for the kind of file. */
|
|
64
|
+
export function FileKindIcon({ kind, ...p }: IconProps & { kind: FileKind }) {
|
|
65
|
+
if (kind === "image" || kind === "video" || kind === "audio") {
|
|
66
|
+
return (
|
|
67
|
+
<Svg width={20} height={20} {...p}>
|
|
68
|
+
{kind === "audio" ? null : <rect x="3" y="4" width="18" height="16" rx="2" />}
|
|
69
|
+
<path d={KIND_PATHS[kind]} />
|
|
70
|
+
</Svg>
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
return (
|
|
74
|
+
<Svg width={20} height={20} {...p}>
|
|
75
|
+
<path d="M14 3H7a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V8Z" />
|
|
76
|
+
<path d="M14 3v5h5" />
|
|
77
|
+
{KIND_PATHS[kind] && <path d={KIND_PATHS[kind]} />}
|
|
78
|
+
</Svg>
|
|
79
|
+
);
|
|
80
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type { UploaderLocaleText } from "../core/types";
|
|
2
|
+
|
|
3
|
+
export const defaultUploaderText: UploaderLocaleText = {
|
|
4
|
+
dropzone: "Drag files here or",
|
|
5
|
+
browse: "browse",
|
|
6
|
+
dropHere: "Drop to add files",
|
|
7
|
+
hintTypes: (types) => types,
|
|
8
|
+
hintMaxSize: (size) => `Up to ${size} each`,
|
|
9
|
+
hintMaxFiles: (count) => `${count} files max`,
|
|
10
|
+
upload: (count) => (count === "1" ? "Upload 1 file" : `Upload ${count} files`),
|
|
11
|
+
cancelAll: "Cancel all",
|
|
12
|
+
clear: "Clear",
|
|
13
|
+
dismiss: "Dismiss",
|
|
14
|
+
remove: (name) => `Remove ${name}`,
|
|
15
|
+
cancel: (name) => `Cancel ${name}`,
|
|
16
|
+
pause: (name) => `Pause ${name}`,
|
|
17
|
+
resume: (name) => `Resume ${name}`,
|
|
18
|
+
retry: (name) => `Retry ${name}`,
|
|
19
|
+
download: (name) => `Download ${name}`,
|
|
20
|
+
queued: "Waiting",
|
|
21
|
+
paused: "Paused",
|
|
22
|
+
uploaded: "Uploaded",
|
|
23
|
+
canceled: "Canceled",
|
|
24
|
+
failed: "Failed",
|
|
25
|
+
progress: (sent, total, percent) => `${sent} of ${total} · ${percent}`,
|
|
26
|
+
rejectFileType: (name) => `${name} isn't an allowed file type`,
|
|
27
|
+
rejectTooLarge: (name, max) => `${name} is larger than ${max}`,
|
|
28
|
+
rejectTooSmall: (name, min) => `${name} is smaller than ${min}`,
|
|
29
|
+
rejectTooMany: (max) => (max === "1" ? "Only one file can be added" : `You can add up to ${max} files`),
|
|
30
|
+
rejectDuplicate: (name) => `${name} is already added`,
|
|
31
|
+
announceDone: (name) => `${name} uploaded`,
|
|
32
|
+
announceFailed: (name) => `${name} failed to upload`,
|
|
33
|
+
};
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import type { AddResult } from "../core/store";
|
|
2
|
+
import type { UploadItem } from "../core/types";
|
|
3
|
+
|
|
4
|
+
export type UploaderSlot =
|
|
5
|
+
| "root"
|
|
6
|
+
| "label"
|
|
7
|
+
| "dropzone"
|
|
8
|
+
| "rejections"
|
|
9
|
+
| "list"
|
|
10
|
+
| "item"
|
|
11
|
+
| "thumb"
|
|
12
|
+
| "progress"
|
|
13
|
+
| "actions"
|
|
14
|
+
| "footer";
|
|
15
|
+
|
|
16
|
+
export interface FileUploaderHandle {
|
|
17
|
+
/** Opens the system file picker. */
|
|
18
|
+
open(): void;
|
|
19
|
+
/** Adds files from code, with the same validation as a drop. */
|
|
20
|
+
addFiles(files: Iterable<File> | ArrayLike<File>): AddResult;
|
|
21
|
+
/** Uploads every file not uploaded yet. Resolves when they have all stopped. */
|
|
22
|
+
upload(): Promise<UploadItem[]>;
|
|
23
|
+
pause(id?: string): void;
|
|
24
|
+
cancel(id?: string): void;
|
|
25
|
+
clear(): void;
|
|
26
|
+
getFiles(): File[];
|
|
27
|
+
getItems(): readonly UploadItem[];
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export const cx = (...names: (string | false | null | undefined)[]) =>
|
|
31
|
+
names.filter(Boolean).join(" ");
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useEffect, useState, useSyncExternalStore } from "react";
|
|
4
|
+
import { createUploaderStore, type UploaderConfig, type UploaderStore } from "../core/store";
|
|
5
|
+
import type { UploaderSnapshot } from "../core/types";
|
|
6
|
+
|
|
7
|
+
export interface UseFileUploaderResult extends UploaderSnapshot {
|
|
8
|
+
store: UploaderStore;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* An upload store that lives as long as the component, always using the latest
|
|
13
|
+
* config and callbacks. Exported for building a different UI on the same engine.
|
|
14
|
+
*/
|
|
15
|
+
export function useFileUploader(config: UploaderConfig = {}): UseFileUploaderResult {
|
|
16
|
+
const [store] = useState(() => createUploaderStore(config));
|
|
17
|
+
|
|
18
|
+
// No dependency list on purpose: callbacks and limits stay current on every render.
|
|
19
|
+
useEffect(() => {
|
|
20
|
+
store.configure(config);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
// Nothing should keep uploading for a component that is gone.
|
|
24
|
+
useEffect(() => () => store.cancel(), [store]);
|
|
25
|
+
|
|
26
|
+
const snapshot = useSyncExternalStore(
|
|
27
|
+
store.subscribe,
|
|
28
|
+
store.getSnapshot,
|
|
29
|
+
store.getServerSnapshot,
|
|
30
|
+
);
|
|
31
|
+
return { store, ...snapshot };
|
|
32
|
+
}
|