File upload
Photos, documents, a price list: chosen, dropped or pasted, each file checked at once and sent on its own, with its own progress and its own error.
Examples
upload holds the file input, an upload-zone button that opens it and takes files dropped on it, a <template> of one file's row and an upload-list. A paste while the focus is in the upload takes the clipboard's files. Each file is checked against the input's accept and the upload's data-max-size in bytes; a refusal shows in the file's row, the page's text file.type or file.size. A file dropped beside the zone does not open in the window.
<div class="upload" data-max-size="2097152" data-docs-upload>
<input class="visually-hidden" id="upload-photos-input" type="file" name="photos[]" accept="image/*,.pdf" multiple tabindex="-1" aria-label="Photos">
<button class="upload-zone" type="button" aria-describedby="upload-photos-hint">
<i class="bi bi-cloud-arrow-up fs-24" aria-hidden="true"></i>
<span>Drop photos here, or choose them</span>
<span class="fs-12" id="upload-photos-hint">JPEG, PNG or PDF, up to 2 MB each</span>
</button>
<ul class="upload-list" aria-label="Files"></ul>
<template>
<li>
<i class="bi bi-file-earmark-image" aria-hidden="true"></i>
<span class="flex-fill" data-file-name></span>
<progress class="progress" max="1" value="0" aria-label="Sent"></progress>
<span class="invalid-feedback" data-file-error></span>
<button class="btn btn-sm btn-icon btn-text" type="button" data-file-remove><i class="bi bi-x-lg" aria-hidden="true"></i></button>
</li>
</template>
</div>One file
An input without multiple holds one file: a new one chosen, dropped or pasted takes the place of the one before - its row, its sending, its place in the input - and of several dropped at once only the first is taken.
<div class="upload" data-max-size="2097152">
<input class="visually-hidden" id="upload-one-input" type="file" name="photo" accept="image/*" tabindex="-1" aria-label="Reference photo">
<button class="upload-zone" type="button" aria-describedby="upload-one-hint">
<i class="bi bi-image fs-24" aria-hidden="true"></i>
<span>Drop a photo here, or choose one</span>
<span class="fs-12" id="upload-one-hint">One image, up to 2 MB</span>
</button>
<ul class="upload-list" aria-label="File"></ul>
<template>
<li>
<i class="bi bi-file-earmark-image" aria-hidden="true"></i>
<span class="flex-fill" data-file-name></span>
<span class="invalid-feedback" data-file-error></span>
<button class="btn btn-sm btn-icon btn-text" type="button" data-file-remove><i class="bi bi-x-lg" aria-hidden="true"></i></button>
</li>
</template>
</div>Thumbnails
upload-thumbs on the upload draws its files as 5rem squares: an img data-file-preview in the row's template shows the image itself, cut to fill, its remove button on the corner; a refused file keeps a whole row for its words. data-max-files takes that many and refuses the rest with the page's text file.count. A page that prepares each image first - resized, turned upright - answers redz:files with a promise of the new file, which takes the old one's place in the input.
<div class="upload upload-thumbs" data-max-files="3" data-max-size="5242880">
<input class="visually-hidden" id="upload-shots-input" type="file" name="shots[]" accept="image/*" multiple tabindex="-1" aria-label="Screenshots">
<button class="upload-zone" type="button" aria-describedby="upload-shots-hint">
<i class="bi bi-images fs-24" aria-hidden="true"></i>
<span>Drop or paste screenshots here, or choose them</span>
<span class="fs-12" id="upload-shots-hint">Up to 3 images</span>
</button>
<ul class="upload-list" aria-label="Screenshots"></ul>
<template>
<li>
<img data-file-preview alt="">
<span data-file-name></span>
<span class="invalid-feedback" data-file-error></span>
<button class="btn btn-xs btn-icon" type="button" data-file-remove><i class="bi bi-x-lg" aria-hidden="true"></i></button>
</li>
</template>
</div>upload.addEventListener('redz:files', (event) => {
event.preventDefault();
event.detail.respondWith(createImageBitmap(event.detail.file, {imageOrientation: 'from-image'}).then(resize));
});
Sent by the page
Each file fires redz:files on the upload with the file, its row, progress(fraction) for the row's bar and a signal its remove button aborts. A page that sends cancels it and answers with a promise: resolved, the row is done; resolved with the id the server gave the file - a string or a number - the row is a stored one from then on, as the server would write it, data-file-stored its id, and its Remove asks the page in redz:remove; rejected, the page's words show in the row's data-file-error. One file's error leaves the others. Left alone, the file joins the input's files and goes with the form. The example above answers in a second and a half.
document.addEventListener('redz:files', (event) => {
event.preventDefault();
const {file, progress, signal} = event.detail;
event.detail.respondWith(new Promise((sent, failed) => {
const request = new XMLHttpRequest();
request.open('POST', '/files');
request.upload.onprogress = (step) => progress(step.loaded / step.total);
request.onload = () => (request.status === 200 ? sent(request.responseText) : failed(request.responseText));
request.onerror = () => failed('The file did not arrive');
signal.addEventListener('abort', () => request.abort());
request.send(file);
}));
});
Files the server holds
The server writes a row for each file it holds, as the template's, with data-file-stored - its id - and the name of its remove button. Remove asks the page in redz:remove with detail.id and detail.row: a page that cancels it and answers with a promise keeps the row busy until it settles - gone when it resolves, its words said in its data-file-error when it rejects. Left alone, the row goes, and a hidden field of its own with it, so the form sends the ids still kept. After each change the upload fires redz:change with detail.count, the files it holds, stored and new; data-max-files counts the stored ones too. A file the page sends becomes such a row when its redz:files promise resolves with the id the server gave it. Below, the contract is in use by an invoice, and a paper added is sent and kept under the next id.
- contract.pdf
- site-plan.png
<div class="upload" data-max-files="4">
<input class="visually-hidden" id="upload-stored-input" type="file" name="papers[]" accept=".pdf,image/*" multiple tabindex="-1" aria-label="Papers">
<ul class="upload-list" aria-label="Papers">
<li data-file-stored="41"><input type="hidden" name="kept[]" value="41"><i class="bi bi-file-earmark-text" aria-hidden="true"></i><span data-file-name>contract.pdf</span><span class="invalid-feedback" data-file-error></span><button class="btn btn-xs btn-icon" type="button" data-file-remove aria-label="Remove contract.pdf"><i class="bi bi-x-lg" aria-hidden="true"></i></button></li>
<li data-file-stored="42"><input type="hidden" name="kept[]" value="42"><i class="bi bi-file-earmark-image" aria-hidden="true"></i><span data-file-name>site-plan.png</span><span class="invalid-feedback" data-file-error></span><button class="btn btn-xs btn-icon" type="button" data-file-remove aria-label="Remove site-plan.png"><i class="bi bi-x-lg" aria-hidden="true"></i></button></li>
</ul>
<button class="upload-zone btn btn-sm" type="button"><i class="bi bi-plus-lg" aria-hidden="true"></i>Add papers</button>
<template>
<li>
<i class="bi bi-file-earmark" aria-hidden="true"></i>
<span data-file-name></span>
<progress class="progress progress-xs"></progress>
<span class="invalid-feedback" data-file-error></span>
<button class="btn btn-xs btn-icon" type="button" data-file-remove><i class="bi bi-x-lg" aria-hidden="true"></i></button>
</li>
</template>
</div>An image field
upload-image on an upload of one image shows its image whole on white, --redz-upload-image-height tall - 12rem by default - with its remove button in the corner; the zone, a plain button here, replaces it. The capture of the input goes to the browser as it is.
lagoon.svg
<div class="upload upload-image max-w-360">
<input class="visually-hidden" id="upload-image-input" type="file" name="cover" accept="image/*" tabindex="-1" aria-label="Cover">
<ul class="upload-list" aria-label="Cover">
<li data-file-stored="7"><input type="hidden" name="cover_kept" value="7"><img data-file-preview src="img/lagoon.svg" alt=""><span data-file-name>lagoon.svg</span><span class="invalid-feedback" data-file-error></span><button class="btn btn-xs btn-icon" type="button" data-file-remove aria-label="Remove lagoon.svg"><i class="bi bi-x-lg" aria-hidden="true"></i></button></li>
</ul>
<button class="upload-zone btn btn-sm" type="button"><i class="bi bi-image" aria-hidden="true"></i>Choose another image</button>
<template>
<li>
<img data-file-preview alt="">
<span data-file-name></span>
<progress class="progress progress-xs"></progress>
<span class="invalid-feedback" data-file-error></span>
<button class="btn btn-xs btn-icon" type="button" data-file-remove><i class="bi bi-x-lg" aria-hidden="true"></i></button>
</li>
</template>
</div>Keyboard and accessibility
- The zone is one real button: Enter or Space opens the browser's picker; the input itself stays out of the Tab order, and its words - which follow the browser's language, not the page's - never show.
- A refused or failed file says why in its row, at once; a removed file's focus goes to the next file's remove button, else to the zone.
- Each remove button is named after its file, from the page's text
remove.
Texts
| Key | For | In English |
|---|---|---|
file.type | A file the input's accept does not take; {name} is its name | {name} is not a kind of file this takes |
file.size | A file over data-max-size; {size} the limit in the page's units | {name} is larger than {size} |
remove | A file's remove button | Remove {label} |
Tokens
From css/components/upload.css, which declares no custom property of its own.
It reads the theme's --redz-space-8, --redz-space-4, --redz-space-24, --redz-space-16, --redz-border-strong, --redz-radius-12, --redz-bg, --redz-text-muted, --redz-primary, --redz-bg-hover, --redz-border, --redz-upload-image-height, --redz-radius-8, --redz-bg-page, --redz-space-2.