Dropzone
Summary
Drag-and-drop file selection as progressive
enhancement over a native
<input type="file">. The input stays visible
and focusable (it is the keyboard and screen-reader
path) and the zone adds the drop affordance on top: a
drag-over state, a rendered selection list with per-file
remove buttons, and a visible localised message for files the
accept filter refuses. Dropped files are assigned
to input.files and a native bubbling
change fires, so form submission and existing
listeners work unchanged. The component manages the
selection; uploading it is your application's job.
When to use
| Situation | Reach for |
|---|---|
| Attachment flows where users drag files from their desktop — tickets, evidence, imports | Dropzone |
| A single quick file in a dense form (an avatar, a CSV) | The plain .soma-input-file, the same input this component enhances |
| Upload progress, chunking, retries | App-level: drive them off the change event; pair with progress |
Examples
Selection model
input.files is the single source of truth: the
component never keeps a shadow copy, so a form submits exactly
what the list shows. multiple and
accept on the input are respected: extension
tokens (.pdf) match the file name
case-insensitively, image/* matches the MIME
family, exact types match verbatim, and no
accept attribute accepts everything. In
multiple mode dropped files are added to
the current selection (exact duplicates — same name, size and
timestamp — are skipped); in single mode a drop replaces it.
Files the filter refuses, and the surplus of a multi-file
drop on a single-file input, fire
soma-dropzone-reject and show the visible
message. Picks made through the native control are the
browser's own behaviour and bypass the filter UI (browsers
already scope the picker to accept).
Keyboard & accessibility
There is no custom keyboard model: the native input is the interactive element, which is why it must never be visually hidden or removed from the tab order.
| Key | Effect |
|---|---|
| Tab | Focuses the file input inside the zone. |
| Enter / Space | Opens the platform file picker, the keyboard equivalent of the drop. |
The reject message renders with role="alert" so
it is announced when it appears; each remove button carries a
localised per-file aria-label
(dropzone.remove). Give the input an accessible
name (aria-label or a <label>);
the component does not invent one.
HTML
The full anatomy: the prompt is optional (generated from the catalog when absent), the list and reject message are always component-rendered:
<div class="soma-dropzone">
<!-- optional; generated from dropzone.browse when absent -->
<p class="soma-dropzone-prompt">Drop PDFs or images here</p>
<!-- the real control — visible, focusable, submits with the form -->
<input class="soma-input-file" type="file" multiple
accept=".pdf,image/*" aria-label="Attachments" />
<!-- component-rendered: .soma-dropzone-reject (on refusal) and -->
<!-- <ul class="soma-dropzone-list"> with one row per file -->
</div>
CSS classes
| Class | Effect |
|---|---|
.soma-dropzone | The region: dashed border, inset surface, centered column. Needs a file input inside (auto-init binds it). |
.soma-dropzone-over | JS-applied while a file drag hovers the zone — primary border + tint. |
.soma-dropzone-prompt | The instruction line. Author it, or the component generates one from dropzone.browse. |
.soma-dropzone-list | Component-rendered selection list (hidden while empty). |
.soma-dropzone-file | One selected file: name, size, remove button. |
.soma-dropzone-file-name / -file-size | Ellipsised name; muted size in SI units. |
.soma-dropzone-remove | Per-file remove button (localised aria-label). |
.soma-dropzone-reject | Component-rendered role="alert" message listing refused files. |
JavaScript
Constructor
| Form | Notes |
|---|---|
Soma.dropzone(elOrSelector) | Get or create the singleton for a .soma-dropzone. Throws when nothing matches, the class is missing, or there is no <input type="file"> inside. No options — multiple and accept are read from the input. |
| auto-init | Every .soma-dropzone containing a file input is bound on DOMContentLoaded; markup alone is enough. |
Instance methods
| Method | Effect |
|---|---|
.files() | The current selection as a File[] (a snapshot of input.files). |
.clear() | Empty the selection; fires a native bubbling change. Chainable. |
.on(event, fn) / .off(event, fn) | Sugar for the bubbling events: 'drop', 'reject'. Chainable. |
.destroy() | Unbind and remove the generated list, message and (if generated) prompt. The input keeps its current selection. |
Events
| Event | Detail | When |
|---|---|---|
soma-dropzone-drop | { files } | Files a drop added to the selection (the accepted ones only). Bubbles from the zone. |
soma-dropzone-reject | { files } | Files a drop refused (accept mismatch, or the surplus on a single-file input). |
change (native, on the input) | — | After every selection change: drops, removes, clear(), and native picks alike. The one event upload code needs. |
Driving an upload needs no component API at all — listen to the input like it was never enhanced:
const zone = document.querySelector('#attachments');
zone.querySelector('input[type="file"]').addEventListener('change', (e) => {
const body = new FormData();
for (const file of e.target.files) body.append('files', file);
fetch('/api/attachments', { method: 'POST', body });
});
The live examples above, exactly as this page wires them:
// The zones themselves auto-init from markup. The events demo only
// adds listeners — via the instance's .on() sugar:
const dz = Soma.dropzone('#dz-events');
const log = document.getElementById('dz-events-log');
let dropped = 0;
let rejected = 0;
const update = () => {
log.textContent = `${dropped} file(s) accepted, ${rejected} rejected; `
+ `selection: ${dz.files().map((f) => f.name).join(', ') || 'empty'}`;
};
dz.on('drop', (e) => { dropped += e.detail.files.length; update(); })
.on('reject', (e) => { rejected += e.detail.files.length; update(); });