RZREDZ.UI

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

        KeyForIn English
        file.typeA file the input's accept does not take; {name} is its name{name} is not a kind of file this takes
        file.sizeA file over data-max-size; {size} the limit in the page's units{name} is larger than {size}
        removeA file's remove buttonRemove {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.