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, maxFiles and a per-file onBeforeAdd
  • 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.

Drop, browse or paste

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.

Accept, size and count
  • 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.

Inside a form
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.

Uploading with progress
  • 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.

Your own content and a list elsewhere
Add a profile picture
<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.

In an F.create blueprint
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 response
  • addFiles(fileList), removeFile(id | entry | file), clear()
  • browse() - open the file picker
  • upload(), 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) - return false to refuse it
  • add(entry), remove(entry), reject(entry, 'type' | 'size' | 'count')
  • change(files) - after any add or remove
  • uploadstart(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.

Events
  • 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 + ')');
});