Dropzone
Drop, browse or paste files onto an area - checked, previewed and either submitted with the form or uploaded with progress
Overview
E.dropzone() turns an element into a place to put files. Drag them onto it, click it (or press Enter / Space) to open the file picker, or paste copied files into it. Each file is checked against the rules you give, shown in a list with a thumbnail for images, and either kept for the form or uploaded on its own with a progress bar.
Underneath there is always a real <input type="file"> - one you wrote, or one the component adds - so the area works inside an ordinary <form>. The input's file list is kept in step with what the area shows, so the form submits exactly those files.
Key Features
- Three ways in - drag and drop, the file picker, and paste
- Rules -
accept,maxSize,minSize,maxFilesand a per-fileonBeforeAdd - Forms - the files submit with the form; no upload code needed
- Uploads - one request per file with progress, concurrency, retry and cancel
- Accessible - a real button, keyboard operable, adds and refusals announced
- Safe - file names are always text, previews are released when files go
Basic Usage
E.dropzone('#attachments', {
accept: 'image/*,.pdf',
maxSize: 5 * 1024 * 1024, // 5 MB
maxFiles: 4,
name: 'attachments'
});
Drop, browse or paste
Drop a few files here, or tab to the area and press Enter. Images get a thumbnail; the × on a row removes it. Copy an image and paste it while the area has focus.
Holding: 0 files
<div id="demo-basic" class="mb-3"></div>
<p class="mb-4">Holding: <span id="demo-basic-count">0 files</span></p>/** Drop, browse or paste - a dropzone with its defaults. */
export function mount(root) {
const count = $(root).find('#demo-basic-count');
const dz = E.dropzone($(root).find('#demo-basic'));
dz.on('change', (files) => count.text(files.length + (files.length === 1 ? ' file' : ' files')));
return () => dz.destroy();
}const dz = E.dropzone('#demo-basic');
dz.on('change', (files) => $('#demo-basic-count').text(files.length + ' files'));
Accept, size and count
This one takes up to three images or PDFs of at most 1 MB each. Anything else is refused with a reason - 'type', 'size' or 'count' - through onReject, and the refusal is read out to screen readers. The hint under the prompt is written from the rules unless you give labels.hint.
- Nothing refused yet
<div id="demo-rules" class="mb-4"></div>
<ul id="demo-rules-log" class="sx-dz-log mb-4" aria-live="off">
<li class="sx-dz-empty">Nothing refused yet</li>
</ul>/** Accept, size and count - files that break a rule are refused with a reason. */
export function mount(root) {
const log = $(root).find('#demo-rules-log');
const dz = E.dropzone($(root).find('#demo-rules'), {
accept: ['image/*', '.pdf'],
maxSize: 1024 * 1024,
maxFiles: 3,
// Return false to refuse one file for your own reasons
onBeforeAdd: (entry) => !entry.name.startsWith('~'),
onReject: (entry, reason) => {
log.find('.sx-dz-empty').remove();
log.prepend($('<li>').text(entry.name + ' - ' + reason));
}
});
return () => dz.destroy();
}E.dropzone('#demo-rules', {
accept: ['image/*', '.pdf'],
maxSize: 1024 * 1024,
maxFiles: 3,
onReject: (entry, reason) => console.log(entry.name, reason),
// Return false to refuse one file for your own reasons
onBeforeAdd: (entry) => !entry.name.startsWith('~')
});
accept reads like the input attribute: extensions (.pdf), wildcards (image/*) and exact types (application/json), as a comma list or an array. Without multiple the area holds one file, and a new one replaces it.
Inside a form
Without upload, the files travel with the form. Wrap (or sit next to) your own <input type="file"> and the component uses it - its name, accept and multiple become the defaults. Add files and press Show to see what new FormData(form) holds.
Nothing submitted yet
<form id="demo-form" class="mb-4">
<div class="form-group mb-3">
<label class="form-label" for="demo-form-title">Title</label>
<input id="demo-form-title" class="form-input" name="title" value="Holiday photos">
</div>
<div id="demo-form-zone" class="mb-3">
<input type="file" name="photos" accept="image/*" multiple>
</div>
<div class="flex gap-2">
<button class="btn btn-primary" type="submit">Show FormData</button>
<button class="btn btn-outline" type="reset">Reset</button>
</div>
</form>
<pre id="demo-form-output" class="sx-dz-output mb-4">Nothing submitted yet</pre>/** Inside a form - the dropzone uses the form's own file input, so the files submit with it. */
export function mount(root) {
const form = $(root).find('#demo-form');
const output = $(root).find('#demo-form-output');
const dz = E.dropzone($(root).find('#demo-form-zone'));
// Show what the form would send instead of sending it
const show = (e) => {
e.preventDefault();
const lines = [];
for (const [key, value] of new FormData(e.currentTarget).entries()) {
lines.push(key + ' = ' + (typeof value === 'string' ? value : value.name + ' (' + value.size + ' bytes)'));
}
output.text(lines.join('\n') || '(empty)');
};
form.on('submit', show);
return () => {
form.off('submit', show);
dz.destroy();
};
}<form action="/albums" method="post" enctype="multipart/form-data">
<div id="photos">
<input type="file" name="photos" accept="image/*" multiple>
</div>
<button>Save</button>
</form>
<script>
E.dropzone('#photos');
</script>
The input's FileList is rewritten with a DataTransfer after each add and remove (every current browser). Where that is missing, the form's formdata event adds the files instead, which covers a normal submit and new FormData(form); code that builds its own request some other way should read dz.getFiles(). A form reset empties the list.
Uploading with progress
Give upload and each file is sent on its own as multipart/form-data, two at a time by default, with a progress bar. A failed file can be retried; the × on a file that is still sending cancels it. This page has no server, so the demo passes a pretend request through upload.xhr - files whose name contains "fail" fail.
- Nothing uploaded yet
<div id="demo-upload" class="mb-3"></div>
<div class="flex gap-2 mb-4">
<button id="demo-upload-cancel" class="btn btn-outline btn-sm" type="button">Cancel all</button>
<button id="demo-upload-clear" class="btn btn-ghost btn-sm" type="button">Clear</button>
</div>
<ul id="demo-upload-log" class="sx-dz-log mb-4" aria-live="off">
<li class="sx-dz-empty">Nothing uploaded yet</li>
</ul>/** Uploading with progress - against a pretend server, as this page has none. */
// Stands in for XMLHttpRequest: reports progress over about two seconds, then answers.
// A file whose name contains "fail" gets a 500.
class PretendUpload {
constructor(entry) {
this.entry = entry;
this.listeners = {};
this.upload = {listeners: [], addEventListener: (type, fn) => this.upload.listeners.push(fn)};
this.status = 0;
this.statusText = '';
this.responseText = '';
}
addEventListener(type, fn) {
(this.listeners[type] ||= []).push(fn);
}
open() {}
setRequestHeader() {}
send() {
const total = this.entry.size || 1;
let loaded = 0;
this.timer = setInterval(() => {
loaded = Math.min(total, loaded + total / 10);
this.upload.listeners.forEach(fn => fn({lengthComputable: true, loaded, total}));
if (loaded < total) return;
clearInterval(this.timer);
const fails = /fail/i.test(this.entry.name);
this.status = fails ? 500 : 200;
this.statusText = fails ? 'Server error' : 'OK';
this.responseText = JSON.stringify({id: this.entry.id, name: this.entry.name});
(this.listeners.load || []).forEach(fn => fn());
}, 200);
}
abort() {
clearInterval(this.timer);
(this.listeners.abort || []).forEach(fn => fn());
}
}
export function mount(root) {
const list = $(root).find('#demo-upload-log');
const log = (message) => {
list.find('.sx-dz-empty').remove();
list.prepend($('<li>').text(D().format('HH:mm:ss') + ' - ' + message));
list.find('li').filter((i) => i >= 6).remove();
};
const uploader = E.dropzone($(root).find('#demo-upload'), {
upload: {url: '/api/files', xhr: (entry) => new PretendUpload(entry)},
onSuccess: (entry) => log('Uploaded ' + entry.name),
onError: (entry, error) => log('Failed ' + entry.name + ': ' + error.message),
onComplete: (entries) => log('Batch finished (' + entries.length + ' files)')
});
const cancel = $(root).find('#demo-upload-cancel');
const clear = $(root).find('#demo-upload-clear');
const cancelAll = () => uploader.cancel();
const clearAll = () => uploader.clear();
cancel.on('click', cancelAll);
clear.on('click', clearAll);
return () => {
cancel.off('click', cancelAll);
clear.off('click', clearAll);
uploader.destroy(); // aborts uploads still running
};
}E.dropzone('#demo-upload', {
upload: {
url: '/api/files',
field: 'file', // the FormData field name
headers: {'X-CSRF-Token': token}, // or (entry) => ({...})
data: (entry) => ({folder: 'inbox'}),
concurrency: 2,
withCredentials: false
},
autoUpload: true, // false: call dz.upload() yourself
retry: 1, // automatic retries per file
onProgress: (entry, percent) => {},
onSuccess: (entry, response) => {}, // JSON is parsed for you
onError: (entry, error) => {}, // {status, message, response}
onComplete: (entries) => {}
});
Uploads use XMLHttpRequest rather than H: H is built on fetch(), which reports no upload progress. With upload set, the input is kept empty so a surrounding form does not send the files a second time.
Your own content and a list elsewhere
Anything already in the element is kept in place of the default prompt. list sends the file list to another element; disable() / enable() switch it off and on.
<div id="demo-custom" class="sx-dz-compact mb-3">
<span data-icon="image" aria-hidden="true"></span>
<span>Add a profile picture</span>
</div>
<div id="demo-custom-list" class="mb-3"></div>
<button id="demo-custom-toggle" class="btn btn-outline btn-sm mb-4" type="button">Disable</button>/** Your own content and a list elsewhere - a compact, single-file picker. */
export function mount(root) {
const toggle = $(root).find('#demo-custom-toggle');
const avatar = E.dropzone($(root).find('#demo-custom'), {
accept: 'image/*',
multiple: false,
list: $(root).find('#demo-custom-list').get(0),
labels: {added: 'Picture chosen: {name}'}
});
const flip = () => {
if (avatar.isDisabled()) avatar.enable();
else avatar.disable();
toggle.text(avatar.isDisabled() ? 'Enable' : 'Disable');
};
toggle.on('click', flip);
return () => {
toggle.off('click', flip);
avatar.destroy();
};
}const avatar = E.dropzone('#demo-custom', {
accept: 'image/*',
multiple: false,
list: '#demo-custom-list',
labels: {added: 'Picture chosen: {name}'}
});
avatar.disable();
In an F.create blueprint
type: 'dropzone' gives a form field whose value is the array of files. formConfig takes the dropzone's options, and required means at least one file.
Submit to see the value
<div id="demo-blueprint" class="mb-3"></div>
<pre id="demo-blueprint-output" class="sx-dz-output mb-4">Submit to see the value</pre>/** In an F.create blueprint - a dropzone field whose value is the array of files. */
export function mount(root) {
const output = $(root).find('#demo-blueprint-output');
const form = F.create({
subject: {type: 'string', label: 'Subject', required: true},
evidence: {
type: 'dropzone',
label: 'Evidence',
required: true,
formConfig: {accept: 'image/*,.pdf', maxFiles: 3, maxSize: 2 * 1024 * 1024}
}
}, {}, {
submitText: 'Send report',
onSubmit: (data) => {
const files = (data.evidence || []).map(f => f.name).join(', ');
output.text('subject = ' + data.subject + '\nevidence = [' + files + ']');
}
});
const target = $(root).find('#demo-blueprint');
form.renderTo(target.get(0));
return () => {
form.destroy();
target.empty();
};
}const form = F.create({
subject: {type: 'string', label: 'Subject', required: true},
evidence: {
type: 'dropzone',
label: 'Evidence',
required: true,
formConfig: {accept: 'image/*,.pdf', maxFiles: 3, maxSize: 2 * 1024 * 1024}
}
}, {}, {onSubmit: (data) => console.log(data.evidence)}); // [File, ...]
form.renderTo('#demo-blueprint');
form.destroy(); // removes the dropzone with the form
API
Methods
getFiles()- the File objects shown;getEntries()- with status, progress and responseaddFiles(fileList),removeFile(id | entry | file),clear()browse()- open the file pickerupload(),retry(id?),cancel(id?)enable(),disable(),isDisabled(),destroy()
Events
Each is an onX option, an .on('x') listener and a bubbling dm:dropzone:x DOM event. They get the file's entry {id, file, name, size, type, status, progress, response, error}.
beforeadd(entry)- returnfalseto refuse itadd(entry),remove(entry),reject(entry, 'type' | 'size' | 'count')change(files)- after any add or removeuploadstart(entry),progress(entry, percent),success(entry, response),error(entry, error),complete(entries)
This one takes images only; it logs its own add, remove and change events, and a refusal through the DOM event.
- Nothing yet - add, remove or refuse a file (this one takes images only)
<div id="demo-events" class="mb-3"></div>
<ul id="demo-log" class="sx-dz-log" aria-live="off">
<li class="sx-dz-empty">Nothing yet - add, remove or refuse a file (this one takes images only)</li>
</ul>/** Events - .on() listeners on the instance and one DOM listener for refusals. */
export function mount(root) {
const list = $(root).find('#demo-log');
const log = (message) => {
list.find('.sx-dz-empty').remove();
list.prepend($('<li>').text(D().format('HH:mm:ss') + ' - ' + message));
list.find('li').filter((i) => i >= 8).remove();
};
const dz = E.dropzone($(root).find('#demo-events'), {accept: 'image/*'});
dz.on('add', (entry) => log('add: ' + entry.name));
dz.on('remove', (entry) => log('remove: ' + entry.name));
dz.on('change', (files) => log('change: ' + files.length + ' file(s)'));
// Every event also bubbles as dm:dropzone:<event>, with {instance, args} in detail
const onReject = (e) => {
const [entry, reason] = e.detail.args;
log('dm:dropzone:reject: ' + entry.name + ' (' + reason + ')');
};
$(root).on('dm:dropzone:reject', onReject);
return () => {
$(root).off('dm:dropzone:reject', onReject);
dz.destroy();
};
}const dz = E.dropzone('#demo-events', {accept: 'image/*'});
dz.on('add', (entry) => log('add: ' + entry.name));
dz.on('change', (files) => log('change: ' + files.length + ' file(s)'));
// detail = {instance, args}
$('#demo-events').on('dm:dropzone:reject', (e) => {
const [entry, reason] = e.detail.args;
log(entry.name + ' refused (' + reason + ')');
});