Skip to main content
Search
Search the documentation

File input

Pick files by click or drop, validate them, show what was picked. An input, not an uploader.

pnpm dlx shadcn-svelte@latest add https://coral.imlargo.dev/r/kit-file-input.json

Almost every app grows one, and it is almost always the same hand-rolled shape: a <div> with an onclick over a hidden <input type="file">, a MIME check that turns away valid files, and a list that grows a second row when the same file is dropped twice. This is that input with those fixed.

What Coral adds

  • The keyboard can open it. Putting onclick on a <div> and hiding the input with hidden removes it from the accessibility tree, and the result cannot be operated without a mouse. Here the input is real and the zone is its <label>.
  • Drag and drop that actually works, and stays highlighted while the pointer moves over the contents of the zone.
  • accept that does not reject valid files. The bug below.
  • One selection, one source of truth: value is the state, not a mirror of some private copy.
  • Sizes go through Intl, so the decimal separator follows the locale: 1,5 MB where that is how the number is written, rather than a hardcoded 1.5 MB.
  • The same file twice is once. A hand-rolled input appends blindly, so dropping the same file again puts two identical rows on screen and posts it twice.
  • Images show what they are. An image file is previewed from an object URL, and that URL is released when the file leaves the selection - otherwise the bytes behind it stay in memory for as long as the page is open.

The rows

Each held file is an attachment, in the idle state: picked, not uploaded. Images get a thumbnail and everything else a file icon, with the name, the size and a remove button that is named after the file rather than just "Remove".

idle is the honest state for a picker, and the remaining ones - uploading, processing, error, done - are what an uploader drives through the file snippet below.

An input, not an uploader

Coral stops at File[]. There is no progress, no retry, no remote URL, no queue. All of that needs to know where the bytes are going, and Coral does not.

The boundary is easy to cross by accident. A file picker that wants to look like an uploader ends up rendering a progress bar driven by this:

// Don't. This measures nothing.
$effect(() => {
	if (files.length > 0 && uploadProgress < 100) {
		const timer = setTimeout(() => (uploadProgress = Math.min(uploadProgress + 10, 100)), 80);
		return () => clearTimeout(timer);
	}
});

A bar that fills at a fixed rate the moment a file is chosen, with no request behind it: the appearance of an uploader, which is all a component without an API can offer.

The real seam is the file snippet: Coral holds the selection and renders the zone, the project renders each row with whatever its own uploader knows - including the attachment states the default row deliberately leaves alone.

The accept bug

A pure MIME check looks obviously right and is wrong:

// Rejects clip.mov, which the user can plainly see is a video.
accept.split(',').some((type) => type === file.type || file.type.startsWith(category));

Browsers report an empty file.type for plenty of ordinary files: .mov, .avi, .m4v, .mkv, notably on Windows, in installed PWAs, and on iOS. The usual patch is a hardcoded table of video and image extensions, which is wrong again the first time a format is missing from it.

Coral needs no table. A file the browser refuses to type can only be judged by its extension:

acceptfilefile.typeResult
video/*clip.mov''accepted - nothing to check the MIME entry with
.pdf,image/*clip.mov''rejected - extensions were listed, none matched
.mov,video/mp4clip.mov''accepted - the extension matches
image/*photo.pngimage/pngaccepted
.pdfreport.pdfanythingaccepted - extension entries are read at all, which a MIME check alone skips

The server is the real gate either way; the point is not to turn away a file the person is looking at.

Constraints and rejections

accept, maxSize and maxFiles filter what gets in. What gets turned away goes to onreject, as structured data:

type FileRejection = {
	file: File;
	reason: 'type' | 'size' | 'count';
};

Coral renders no error text. The message is copy, copy belongs to the project, and so does where it goes: inline under the field, or a toast. Coral says what happened and stays out of it.

The consequence to respect: set a constraint, wire onreject, or files are dropped in silence.

Every rejection is reported, not just the first. Keeping only errors[0] and discarding the rest means dropping five oversized files explains one of them.

multiple and maxFiles

One number, not two. multiple opens the door, maxFiles says how many fit, and without multiple the limit is 1 whatever maxFiles says. A single-file input replaces what it holds rather than refusing the new file.

The shape to avoid is carrying both a variant: 'single' | 'multiple' and a maxFiles, defaulted to 'single' and 5: two props that contradict each other before anyone touches them.

Accessibility

The zone is a <label> wrapping a real <input type="file"> that is sr-only rather than hidden. That single change is the difference between a picker the keyboard can open and one it cannot:

  • The click is native. No onclick, no .click() call, no a11y_click_events_have_key_events suppression, which a clickable <div> needs just to compile.
  • Tab reaches the input; the zone shows the focus ring through has-[input:focus-visible].
  • Space and Enter open the picker, because that is what a focused file input does.

Every prop a native file input takes is forwarded to it: id, required, capture, aria-*, so a surrounding Field labels it the usual way.

The held files are a real list, one <li> per file, so their number is announced before they are read. An image preview carries an empty alt: the file's name is already the row's title, and repeating it makes a screen reader say it twice.

Import

<script lang="ts">
	import FileInput from '#lib/components/coral/kit/file-input/file-input.svelte';
	import type { FileRejection } from '#lib/components/coral/kit/file-input/types.js';
</script>

Props

Everything a native file input accepts is forwarded to it. On top of that:

PropTypeDefaultDescription
valueFile[][]Bindable. The selection.
onchange(files: File[]) => void-Pick, drop or removal. Never on mount.
onreject(rejections: FileRejection[]) => void-What was turned away, and why.
acceptstring''image/*,.pdf. Empty takes anything.
multiplebooleanfalseAllows more than one file.
maxFilesnumberunboundedOnly read when multiple.
maxSizenumberunboundedBytes, per file.
disabledbooleanfalseBlocks the zone and the remove buttons.
labelstringDrop files here, or click to browseThe line inside the zone.
hintstringfrom accept + maxSizeThe line under it. '' renders none.
removeLabel(file: File) => stringRemove <name>Accessible label for a remove button.
classstring-Merged onto the zone - height, padding.
listClassstring-Merged onto the file list.
zoneSnippet<[{ dragging, disabled, hint }]>-Replaces the contents of the zone.
fileSnippet<[{ file, index, remove }]>-Replaces each row. The uploader seam.

hint defaults to a summary built from the constraints: PDF, IMAGE · 1 MB. It is deliberately wordless: extensions and categories reduce to bare tokens, so nothing in it needs translating.

Forms

The field underneath holds what has been picked, so it takes part in a form like any other: name submits the files, required makes the browser hold the form up until there is one, and a reset empties it.

<form method="POST" enctype="multipart/form-data">
	<FileInput name="attachments" multiple required bind:value={files} />
</form>

value and the field are kept in step in both directions. The field's own files is written from value - so a removal, a rejected pick and a drop all leave it holding exactly the selection - and writing it fires no event, so it never reaches onchange. It is also why a file can be picked again after it was removed: the field is emptied with the selection, and a dialog that returns exactly what the field already holds fires no change.

Resetting the form is the reader emptying it, so value is emptied and onchange is called with [].

Code that uploads through fetch can still build its own request from value:

const body = new FormData();
for (const file of files) body.append('attachments', file);