Symfony UX Dropzone
Symfony UX Dropzone is a Symfony bundle providing light dropzones for file inputs in Symfony Forms. It is part of the Symfony UX initiative.
It allows visitors to drag and drop files into a container instead of having to browse their computer for a file.
Installation
Caution
Before you start, make sure you have StimulusBundle configured in your app.
Install the bundle using Composer and Symfony Flex:
1
$ composer require symfony/ux-dropzone
If you're using WebpackEncore, install your assets and restart Encore (not needed if you're using AssetMapper):
1 2
$ npm install --force
$ npm run watch
Note
For more complex installation scenarios, you can install the JavaScript assets through the @symfony/ux-dropzone npm package
Usage
The most common usage of Symfony UX Dropzone is to use it as a drop-in replacement of the native FileType class:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
// ...
use Symfony\UX\Dropzone\Form\DropzoneType;
class CommentFormType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder
// ...
->add('photo', DropzoneType::class)
// ...
;
}
// ...
}
Changing the placeholder
3.7
The placeholder option was added in Dropzone 3.7.
Before, the placeholder was passed in the attr option.
The text shown in the Dropzone is 'Drag and drop or browse' by default.
Change it with the placeholder option, which goes through the form's translation domain and also accepts a TranslatableInterface object.
Pass false to show no text.
When you pass your own attr option, the default text is not used: pass the placeholder option too:
1 2 3
->add('photo', DropzoneType::class, [
'placeholder' => 'Drop your photo here',
])
Uploading multiple files
Upload several files at once, pass the standard multiple
option (inherited from FileType):
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18
// ...
use Symfony\UX\Dropzone\Form\DropzoneType;
class CommentFormType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder
// ...
->add('photos', DropzoneType::class, [
'multiple' => true,
])
// ...
;
}
// ...
}
In multiple mode, the Dropzone accumulates files across successive
selections instead of replacing the previous one, displays a preview for
each selected file and lets the user remove files individually before
submitting.
Both modes dispatch the same events.
dropzone:change is dispatched when files are added: beware that it carries a single File in single-file mode, but a FileList in multiple mode.
In multiple mode, dropzone:remove is dispatched when a file is removed, with the removed File in event.detail.
dropzone:clear is dispatched whenever the Dropzone becomes empty: when the file is cleared in single-file mode, when the last file is removed in multiple mode, and when the form is reset.
When the controller connects, for example after the element was moved in the page, both modes keep the files already in the input.
3.7
Before Dropzone 3.7, dropzone:clear was not dispatched in multiple mode, and in single-file mode the controller emptied the input and dispatched dropzone:clear when it connected.
The label of the per-file remove button can be changed with the
remove_label option (default 'Remove'). It goes through the
form's translation domain, and the accessible name of each button is
that label followed by the file name:
1 2 3 4
->add('photos', DropzoneType::class, [
'multiple' => true,
'remove_label' => 'Delete',
])
Customizing the preview of each file
3.7
The dropzone_preview_item_template block was added in Dropzone 3.7.
The preview is built with fixed markup and CSS classes: one list item per file in multiple mode, and the preview elements of the form theme in single-file mode.
To use your own markup (utility classes, an icon in the remove button, extra elements), override the dropzone_preview_item_template block in your form theme and render a <template> in it:
1 2 3 4 5 6 7 8 9 10 11 12
{# templates/form/dropzone_theme.html.twig #}
{% block dropzone_preview_item_template %}
<template data-symfony--ux-dropzone--dropzone-target="previewItemTemplate">
<li class="relative size-20">
<div data-dropzone-slot="image" class="size-full bg-cover bg-center"></div>
<span data-dropzone-slot="filename" class="sr-only"></span>
<button type="button" data-dropzone-slot="remove" class="absolute top-1 right-1">
{{ ux_icon('lucide:x') }}
</button>
</li>
</template>
{% endblock %}
The template must contain exactly one root element, which is cloned for each file: otherwise, the controller does not connect and reports an error in the console.
The controller fills the elements marked with a data-dropzone-slot attribute, the root element included.
All slots are optional, and a slot can be on several elements:
image: shows the preview of an image file. On an<img>, it is set as thesrc, on any other element as the background image. The element has thehiddenattribute until the preview is loaded, and keeps it for files that are not images;filename: receives the name of the file, as text;remove: removes the file on click. Itsaria-labelis theremove_labeloption followed by the file name.
In single-file mode, the copy of the template replaces the content of the preview, and its remove slot clears the Dropzone.
Note
A CSS class that sets display on the image slot (such as block or flex) wins over its hidden attribute.
The default stylesheet of the bundle still keeps the slot hidden.
If you disabled the default stylesheet, add the .dropzone-container [hidden] rule shown in the next section, "Customizing the design".
Then use this form theme, for all forms or for a single one (see How to Work with Form Themes).
Restricting the files
3.7
The filtering of dropped files by accept, the max_files option and the dropzone:reject event were added in Dropzone 3.7.
The file picker only shows the files that match the accept attribute of the input, but drag and drop ignores it.
The Dropzone checks the dropped files against accept and leaves out the files that do not match.
An extension (.pdf) is checked against the file name, a MIME type (image/png, image/*) against the type of the file.
The type comes from the operating system, so it can differ from the one you expect (on Windows, a CSV file is often application/vnd.ms-excel): prefer extensions in accept.
Browsers report no type for some files (HEIC photos, for example): those files are not rejected because of a MIME type.
In single-file mode, a rejected drop keeps the file selected before.
In multiple mode, the max_files option limits the number of files, picked or dropped.
When a selection goes over the limit, the files that fit are added and the others are left out.
When the Validator component is installed, the option also adds a Count constraint, so the limit is checked on the server too:
1 2 3 4 5
->add('photos', DropzoneType::class, [
'multiple' => true,
'max_files' => 5,
'attr' => ['accept' => 'image/*'],
])
The files left out are not added, and the dropzone:reject event is dispatched once per reason, with the files in event.detail.files and the reason (accept or max_files) in event.detail.reason.
In single-file mode, a selection of several files keeps the first one and leaves out the others with the max_files reason.
Use it to show your own message:
1 2 3 4
this.element.addEventListener('dropzone:reject', (event) => {
const names = event.detail.files.map((file) => file.name).join(', ');
alert(event.detail.reason === 'accept' ? `Not an image: ${names}` : `Too many files: ${names}`);
});
The accept check only helps the user: validate the type of the files on the server too, for example with the File constraint:
1 2 3 4 5 6 7 8 9 10 11
use Symfony\Component\Validator\Constraints\All;
use Symfony\Component\Validator\Constraints\File;
->add('photos', DropzoneType::class, [
'multiple' => true,
'max_files' => 5,
'attr' => ['accept' => 'image/*'],
'constraints' => [
new All([new File(mimeTypes: ['image/*'])]),
],
])
Customizing the design
Symfony UX Dropzone provides a default stylesheet in order to ease usage. You can disable it to add your own design if you wish.
In assets/controllers.json, disable the default stylesheet by
switching the @symfony/ux-dropzone/dist/style.min.css autoimport to
false:
1 2 3 4 5 6 7 8 9 10 11 12 13 14
{
"controllers": {
"@symfony/ux-dropzone": {
"dropzone": {
"enabled": true,
"fetch": "eager",
"autoimport": {
"@symfony/ux-dropzone/dist/style.min.css": false
}
}
}
},
"entrypoints": []
}
Note
Note: you should put the value to false and not remove the line
so that Symfony Flex won't try to add the line again in the future.
Once done, the default stylesheet won't be used anymore and you can implement your own CSS on top of the Dropzone.
3.7
The data-state attribute and the use of the hidden attribute were added in Dropzone 3.7.
Before, the parts of the Dropzone were shown and hidden with inline styles.
The controller shows and hides the parts of the Dropzone (input, placeholder, preview, image preview) with the hidden attribute.
A rule of your own that sets display on these parts wins over the hidden attribute, so add this rule to your stylesheet:
1 2 3
.dropzone-container [hidden] {
display: none !important;
}
The container also has a data-state attribute, so you can style it for each state:
empty: no file is selected;filled: at least one file is selected;dragging: files are dragged over the Dropzone;processing: adropzone:pre-addlistener is transforming files.
When several states apply, the last one of this list wins:
1 2 3 4 5 6 7
.dropzone-container[data-state="dragging"] {
border-color: #3b82f6;
}
.dropzone-container[data-state="processing"] {
opacity: 0.5;
}
If you override the dropzone_widget block in your form theme, the clear button, the file name and the image preview of the single-file mode are optional: leave out the elements with the previewClearButton, previewFilename or previewImage target that your design does not need.
Without the clear button, the user cannot deselect the file: once a file is chosen, the input is hidden behind the preview.
To clear the Dropzone from another element inside it, use the clear action of the controller:
1
<button type="button" data-action="symfony--ux-dropzone--dropzone#clear">Remove</button>
3.7
These targets became optional in Dropzone 3.7.
Extend the default behavior
Symfony UX Dropzone allows you to extend its default behavior using a custom Stimulus controller:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30
// mydropzone_controller.js
import { Controller } from '@hotwired/stimulus';
export default class extends Controller {
connect() {
this.element.addEventListener('dropzone:connect', this._onConnect);
this.element.addEventListener('dropzone:change', this._onChange);
this.element.addEventListener('dropzone:clear', this._onClear);
}
disconnect() {
// You should always remove listeners when the controller is disconnected to avoid side-effects
this.element.removeEventListener('dropzone:connect', this._onConnect);
this.element.removeEventListener('dropzone:change', this._onChange);
this.element.removeEventListener('dropzone:clear', this._onClear);
}
_onConnect(event) {
// The dropzone was just created
}
_onChange(event) {
// The dropzone just changed
}
_onClear(event) {
// The dropzone has just been cleared
}
}
Then in your form, add your controller as an HTML attribute:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18
// ...
use Symfony\UX\Dropzone\Form\DropzoneType;
class CommentFormType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder
// ...
->add('photo', DropzoneType::class, [
'attr' => ['data-controller' => 'mydropzone'],
])
// ...
;
}
// ...
}
Transforming files before they are added
3.7
The dropzone:pre-add event was added in Dropzone 3.7.
Each time the user picks or drops files, the dropzone:pre-add event is dispatched before the files are added and previewed, in single-file and in multiple mode.
event.detail.files holds the new files, as an array of File.
A listener can:
- pass a promise to
event.detail.waitUntil(): theFileobjects the promise resolves to are added instead (fewer, more or other files). Call it once, synchronously, from the listener. It also accepts the array of files itself; - call
event.preventDefault(): no file is added.
While a promise is pending, the container has a data-state="processing" attribute.
Use it to show a spinner or to disable the submit button: until the promise resolves, the input holds the files added before.
If the promise rejects, or resolves to anything other than an array of File objects, the original files are added and the error is logged to the console.
Selections are added in the order they were made, and clearing the Dropzone drops the selections still processed.
In multiple mode, a file picked twice is added once: files are compared by name, size and lastModified.
When you create a new File, pass the lastModified of the original, or the same file picked twice is added twice.
For example, to resize the photos in the browser before they are uploaded:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37
// assets/controllers/image_resize_controller.js
import { Controller } from '@hotwired/stimulus';
const MAX_DIMENSION = 2000;
export default class extends Controller {
connect() {
this.element.addEventListener('dropzone:pre-add', this._onPreAdd);
}
disconnect() {
this.element.removeEventListener('dropzone:pre-add', this._onPreAdd);
}
_onPreAdd = (event) => {
// Keep the original of a file that cannot be resized, without giving up on the others
event.detail.waitUntil(Promise.all(event.detail.files.map((file) => this._resize(file).catch(() => file))));
};
async _resize(file) {
if (!file.type.startsWith('image/')) {
return file;
}
const bitmap = await createImageBitmap(file);
try {
const scale = Math.min(1, MAX_DIMENSION / Math.max(bitmap.width, bitmap.height));
const canvas = new OffscreenCanvas(Math.round(bitmap.width * scale), Math.round(bitmap.height * scale));
canvas.getContext('2d').drawImage(bitmap, 0, 0, canvas.width, canvas.height);
const blob = await canvas.convertToBlob({ type: 'image/jpeg', quality: 0.85 });
return new File([blob], file.name.replace(/\.\w+$/, '.jpg'), { type: 'image/jpeg', lastModified: file.lastModified });
} finally {
bitmap.close();
}
}
}
Then add the controller to the field:
1 2 3 4
->add('photos', DropzoneType::class, [
'multiple' => true,
'attr' => ['data-controller' => 'image-resize'],
])
Backward Compatibility promise
This bundle aims at following the same Backward Compatibility promise as the Symfony framework: https://symfony.com/doc/current/contributing/code/bc.html