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

SituationReach for
Attachment flows where users drag files from their desktop — tickets, evidence, importsDropzone
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, retriesApp-level: drive them off the change event; pair with progress

Examples

Multiple files, auto-init

Markup only — every .soma-dropzone with a file input binds on load. The prompt here is generated from the dropzone.browse catalog string (it follows the active locale). Drop files, or pick them with the input; both land in the same list, and dropping more adds to a multiple selection:

accept filter + reject message

This zone accepts .pdf,image/* and writes its own prompt (an authored .soma-dropzone-prompt is kept as-is). Drop anything else and the file fires soma-dropzone-reject and shows the localised message; the icon is the optional upload glyph:

Drop PDFs or images here

Single file

Without multiple the selection is one file: the next drop replaces it, and a multi-file drop keeps the first accepted file and rejects the surplus:

Drop the export file here

Events

Both contract events bubble from the zone; this counter listens for soma-dropzone-drop and soma-dropzone-reject (only .txt files are accepted here):

Drop .txt files here

No drops yet.

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.

KeyEffect
TabFocuses the file input inside the zone.
Enter / SpaceOpens 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

ClassEffect
.soma-dropzoneThe region: dashed border, inset surface, centered column. Needs a file input inside (auto-init binds it).
.soma-dropzone-overJS-applied while a file drag hovers the zone — primary border + tint.
.soma-dropzone-promptThe instruction line. Author it, or the component generates one from dropzone.browse.
.soma-dropzone-listComponent-rendered selection list (hidden while empty).
.soma-dropzone-fileOne selected file: name, size, remove button.
.soma-dropzone-file-name / -file-sizeEllipsised name; muted size in SI units.
.soma-dropzone-removePer-file remove button (localised aria-label).
.soma-dropzone-rejectComponent-rendered role="alert" message listing refused files.

JavaScript

Constructor

FormNotes
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-initEvery .soma-dropzone containing a file input is bound on DOMContentLoaded; markup alone is enough.

Instance methods

MethodEffect
.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

EventDetailWhen
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(); });