On this page
Import
import {
Button,
Column,
DropZone,
List,
Progress,
Row,
Text,
Textarea,
ToastProvider,
useToast,
} from '@envsemble/envui';
Usage
Keep the uploads in state, each with its file's name and size, how much has been sent and whether it is uploading, done or failed.
- DropZone with
multiplehands the files it takes toonFilesAccepted: check their size, set the zone'serrorfor any turned away, add the rest and start each upload. - Each upload is a List.Item: Progress in its children while it runs, the size or the failure in
description, and Try again and Remove as text Buttons in a Row at itsend.
import { useEffect, useRef, useState } from 'react';
import {
Button,
Column,
DropZone,
List,
Progress,
Row,
Text,
ToastProvider,
useToast,
} from '@envsemble/envui';
interface Upload {
id: number;
name: string;
size: number;
sent: number;
state: 'uploading' | 'done' | 'failed';
}
const limit = 10 * 1024 * 1024;
const size = (bytes: number) =>
bytes < 1024 * 1024
? `${bytes === 0 ? 0 : Math.max(1, Math.round(bytes / 1024))} KB`
: `${(bytes / 1024 / 1024).toFixed(1)} MB`;
function Uploads() {
const toast = useToast();
const [uploads, setUploads] = useState<Upload[]>([]);
const [error, setError] = useState<string>();
const timers = useRef(new Map<number, number>());
const attempts = useRef(0);
useEffect(() => {
const running = timers.current;
return () => running.forEach((timer) => window.clearInterval(timer));
}, []);
const list = useRef<HTMLUListElement>(null);
// A removed row takes its buttons with it, so focus moves to the next row's first button, or to
// the list once it is empty.
const focusNext = useRef<(() => void) | null>(null);
useEffect(() => {
focusNext.current?.();
focusNext.current = null;
});
const change = (id: number, update: (upload: Upload) => Partial<Upload>) =>
setUploads((current) =>
current.map((upload) => (upload.id === id ? { ...upload, ...update(upload) } : upload)),
);
function start(id: number) {
change(id, () => ({ state: 'uploading', sent: 0 }));
// A timer stands in for the upload, reporting what has been sent, about 1 MB a second. Every
// other upload drops part way, as on an unsteady connection, to show a failure.
const drops = attempts.current++ % 2 === 1;
timers.current.set(
id,
window.setInterval(() => {
change(id, ({ sent, size }) => {
const next = Math.min(size, sent + 300_000);
if (drops && next > size * 0.6) {
window.clearInterval(timers.current.get(id));
return { state: 'failed' };
}
if (next < size) return { sent: next };
window.clearInterval(timers.current.get(id));
return { sent: size, state: 'done' };
});
}, 250),
);
}
function add(files: File[]) {
const fit = files.filter((file) => file.size <= limit);
const tooLarge = files.filter((file) => file.size > limit);
setError(
tooLarge.length > 0
? `${tooLarge.map((file) => file.name).join(', ')} ${tooLarge.length === 1 ? 'is' : 'are'} larger than 10 MB.`
: undefined,
);
const added = fit.map((file, index) => ({
id: Date.now() + index,
name: file.name,
size: file.size,
sent: 0,
state: 'uploading' as const,
}));
setUploads((current) => [...current, ...added]);
added.forEach((upload) => start(upload.id));
}
function remove(upload: Upload) {
const index = uploads.indexOf(upload);
window.clearInterval(timers.current.get(upload.id));
focusNext.current = () => {
const rows = list.current?.querySelectorAll('li') ?? [];
const row = rows[Math.min(index, rows.length - 1)];
(row?.querySelector('button') ?? list.current)?.focus();
};
// Removed at once, with no question first: Undo puts it back, and an upload starts again.
setUploads((current) => current.filter((item) => item !== upload));
const id = toast.show({
message: `${upload.name} removed.`,
action: {
label: 'Undo',
onClick: () => {
setUploads((current) => {
const restored = [...current];
restored.splice(index, 0, upload);
return restored;
});
if (upload.state === 'uploading') start(upload.id);
toast.dismiss(id);
},
},
});
}
const done = uploads.filter((upload) => upload.state === 'done').length;
const failed = uploads.filter((upload) => upload.state === 'failed').length;
return (
// As wide as a form column.
<Column gap={4} style={{ inlineSize: 'min(100%, 28rem)' }}>
<DropZone
multiple
label="Add files"
description="Any file up to 10 MB"
error={error}
onFilesAccepted={add}
/>
<List.Root ref={list} aria-label="Files" tabIndex={-1}>
{uploads.map((upload) => (
<List.Item
key={upload.id}
label={upload.name}
description={
upload.state === 'done'
? size(upload.size)
: upload.state === 'failed'
? "Couldn't upload. Check your connection and try again."
: undefined
}
end={
// Try again beside Remove: the pair moves below the text together.
<Row gap={2}>
{upload.state === 'failed' && (
<Button
variant="text"
aria-label={`Try again to upload ${upload.name}`}
onClick={() => start(upload.id)}
>
Try again
</Button>
)}
<Button
variant="text"
aria-label={`Remove ${upload.name}`}
onClick={() => remove(upload)}
>
Remove
</Button>
</Row>
}
>
{upload.state === 'uploading' && (
<Progress
label="Uploading"
value={upload.sent}
max={upload.size}
valueText={`${size(upload.sent)} of ${size(upload.size)}`}
aria-valuetext={`${upload.name}, ${size(upload.sent)} of ${size(upload.size)}`}
/>
)}
</List.Item>
))}
{uploads.length === 0 && <List.Item label="No files yet." />}
</List.Root>
<Text variant="caption" emphasis="muted" role="status">
{uploads.length > 0 &&
`${done} of ${uploads.length} uploaded${failed > 0 ? `, ${failed} failed` : ''}.`}
</Text>
</Column>
);
}
export default function UploadFiles() {
// An application has one ToastProvider at its root; the example brings its own.
return (
<ToastProvider>
<Uploads />
</ToastProvider>
);
}Progress for each file
fetch doesn't report what it has sent, so upload through XMLHttpRequest, whose upload.onprogress does, and give each upload an AbortController for Remove. Give each Progress value and max in bytes, valueText such as 1.2 of 2.4 MB, and aria-valuetext with the file's name, since every bar's label is Uploading.
function upload(file: File, onProgress: (sent: number) => void, signal: AbortSignal) {
return new Promise<void>((resolve, reject) => {
const request = new XMLHttpRequest();
request.upload.onprogress = (event) => onProgress(event.loaded);
request.onload = () => (request.status < 400 ? resolve() : reject(new Error('Upload failed')));
request.onerror = request.onabort = () => reject(new Error('Upload failed'));
signal.addEventListener('abort', () => request.abort());
const body = new FormData();
body.append('file', file);
request.open('POST', '/api/files');
request.send(body);
});
}
Rejected files
accept turns away other kinds of file, which go to onFilesRejected; check the size of those onFilesAccepted receives. Name the file and the reason in error. A drop of both kinds calls onFilesRejected, then onFilesAccepted, so keep the first message through the second, as the TurnedAway example does.
<DropZone
multiple
label="Add documents"
description="PDF or images, up to 10 MB each"
accept="application/pdf,.pdf,image/*"
error={error}
onFilesRejected={(files) => setError(`${files[0].name} isn't a PDF or an image.`)}
onFilesAccepted={addAndCheckSize}
/>
Failed uploads
Mark the upload failed and keep it in the list, with the reason in description and a Try again Button that starts it again from nothing. Try again leaves as it is pressed, so move focus to Remove in the same row through its ref, as the FailedUpload example does.
<Row gap={3}>
<Button variant="text" aria-label="Try again to upload team-photo.jpg" onClick={tryAgain}>
Try again
</Button>
<Button ref={remove} variant="text" aria-label="Remove team-photo.jpg" onClick={drop}>
Remove
</Button>
</Row>
Removing files
Abort the upload, take the entry out of the list and show a toast through useToast().show, with action: { label: 'Undo', onClick }, as the RemoveWithUndo example does.
- Undo puts the entry back at its index, starts it again if it wasn't done, and dismisses the toast.
- Move focus to the next row's first button, or to the list, with
tabIndex={-1}, once it is empty.
In a form
Upload the files as they are added, and keep what the server returns for each, such as an id, to send with the form. On submit, while any upload still runs, set pending on the submit Button and send once they are done, as the AttachInForm example does.
<Button type="submit" pending={sending}>
Send report
</Button>
Composition
A Column holds the DropZone, a List.Root named with aria-label, and a caption Text with role="status". Each List.Item has the file's name as label; its description is the size once done or the reason it failed, its children a Progress while it uploads, and its end a Row with Try again, when it failed, and Remove. In a form, the zone is size="sm" among the fields, with the list under it.
Accessibility
You provide
- The checks of kind and size, and the zone's
error. - An
aria-valuetexton each bar that names its file. - A
role="status"line that counts the files up and those that failed. - Focus after Remove and Try again, and one ToastProvider at the application's root.
envUI provides
- DropZone as one button that opens the file picker, named by its label and described by its description and error.
- Progress as a progress bar with its label and value, and List rows whose actions stay in reach on narrow screens.
- A toast with an action that stays until it is dismissed.
Changelog
- 1.0.0
The pattern.