Skip to content

[Dropzone] Make the clear button, file name and image preview targets optional in single-file mode - #4146

Merged
Kocal merged 1 commit into
symfony:3.xfrom
ker0x:feat/dropzone-optional-targets
Nov 2, 2026
Merged

Kocal merged 1 commit into
symfony:3.xfrom
ker0x:feat/dropzone-optional-targets

Conversation

@ker0x

@ker0x ker0x commented Oct 26, 2026 •

Copy link
Copy Markdown
Contributor
Q A
Bug fix? no
New feature? yes
Deprecations? no
Documentation? yes
Issues #4142
License MIT

Fourth step of the RFC in #4142.

In single-file mode, connect(), clear() and the change handler use the previewClearButton, previewFilename and previewImage targets without checking that they exist. A form theme that leaves one of them out, for example an avatar drop zone that shows no file name, makes the controller throw on connect: Stimulus reports Missing target element, and the drop zone does nothing.

The controller now checks the file name and image preview targets with has*Target before using them, as it already does for previewList in multiple mode: without one of them, that part is skipped and the rest of the preview still works. The clear button is bound with the previewClearButtonTargetConnected() and previewClearButtonTargetDisconnected() callbacks instead of in connect(), so a theme can leave it out, and a button rendered later (by a LiveComponent re-render or a Turbo morph, for example) also clears the drop zone. As before, it is not bound in multiple mode. The docs explain how to clear the drop zone from another element with the clear action, as the user cannot deselect the file otherwise.

Theme without... Before After
previewClearButton throws on connect works, nothing to click to clear
previewFilename throws on connect works, no file name shown
previewImage throws on connect works, no image preview

Tests: one unit test per target, which connects, picks a file and checks the preview and dropzone:change; a check that the file name still shows without the image preview, and the image preview without the file name; a disconnect without the clear button; a clear button added after connect; and a clear button that must not clear the files in multiple mode. The tests that connect without a target and the one with the clear button added later fail on the base branch.

@carsonbot carsonbot added Documentation Improvements or additions to documentation Dropzone Feature New Feature Status: Needs Review Needs to be reviewed labels Oct 26, 2026
@github-actions

github-actions Bot commented Oct 26, 2026 •

Copy link
Copy Markdown
Contributor

📊 Packages dist files size difference

Thanks for the PR! Here is the difference in size of the packages dist files between the base branch and the PR.
Please review the changes and make sure they are expected.

FileBefore (Size / Gzip)After (Size / Gzip)
Dropzone
controller.d.ts 1.57 kB / 592 B 1.8 kB+14% 📈 / 622 B+5% 📈
controller.js 9.03 kB / 2.56 kB 9.29 kB+3% 📈 / 2.61 kB+2% 📈

ker0x added a commit to ker0x/symfony-ux that referenced this pull request Oct 26, 2026
…hey are added

| Q              | A
| -------------- | ---
| Bug fix?       | no
| New feature?   | yes
| Deprecations?  | no
| Documentation? | yes
| Issues         | symfony#4142
| License        | MIT

First step of the RFC in symfony#4142.

An app cannot change the files a user selects before Dropzone stores and previews them, for example to resize phone photos so they fit `post_max_size`, or to crop an avatar. In `multiple` mode, the controller copies `input.files` into its own list and writes that list back before `dropzone:change`. A listener that replaces `input.files` is overwritten by the next selection, and the previews still show the original files. In single mode, the file is shown as soon as it is picked. The only workaround is to catch the input's `change` event on an ancestor in the capture phase and dispatch a new one, which depends on how the controller listens.

This adds a cancelable `dropzone:pre-add` event, dispatched for every new selection (pick or drop, both modes), before the files are added:

```js
this.element.addEventListener('dropzone:pre-add', (event) => {
    // event.detail.files: File[] about to be added
    event.detail.waitUntil(Promise.all(event.detail.files.map(resize)));
});
```

- `waitUntil()` takes a promise of the files to add (fewer, more or other files), or the files themselves. It can be called once, from the listener.
- `event.preventDefault()` adds nothing.
- While a promise is pending, the container has `data-state="processing"`, so the app can disable its submit button. In `multiple` mode, the input holds the files added before during that time, not only the new selection.
- A rejected promise, or one that does not resolve to an array of `File` objects, adds the original files and logs the error.
- Selections are added in the order they were made, even when an earlier promise resolves later, and a prevented selection waits for the pending ones too. In single-file mode, the input always holds the file that is shown.
- Clearing the drop zone, or disconnecting the controller, drops the selections still processed. A `data-state="processing"` left in a page snapshot is removed on connect.
- With no `waitUntil()` call, the files are added synchronously, as before.

| Selection                        | Before                          | After
| -------------------------------- | ------------------------------- | ---
| No `pre-add` listener            | added, `dropzone:change`        | same, still synchronous
| Listener resolves other files    | not possible                    | the resolved files are previewed and submitted
| Listener calls `preventDefault()`| not possible                    | nothing added (single mode: zone cleared)

Tests: unit tests for both modes (replace, prevent, pending state, rejection, results that are not an array of files, ordering of resolved and prevented selections, clearing while processing, a drop while processing, `waitUntil()` misuse), and Playwright tests that submit the transformed files through the e2e app in single and `multiple` mode, on Chrome lowest, Chrome latest and Firefox latest. The new tests fail on the base branch.

The unit tests now stop the Stimulus applications after each test, with the same code as symfony#4146: the controllers of earlier tests otherwise connect to the next test's markup and hide failures.
ker0x added a commit to ker0x/symfony-ux that referenced this pull request Oct 26, 2026
…le` mode

| Q              | A
| -------------- | ---
| Bug fix?       | no
| New feature?   | yes
| Deprecations?  | no
| Documentation? | yes
| Issues         | symfony#4142
| License        | MIT

Second step of the RFC in symfony#4142.

In `multiple` mode, the controller builds the preview of each file in JS, with fixed markup and CSS classes (`dropzone-preview-list-item`, `dropzone-preview-image`, ...). An app can only restyle those classes in CSS. It cannot use utility classes, put an icon component in the remove button, or add markup such as the file size.

This adds an optional `previewItemTemplate` target. When it is present, each preview is a clone of the template's root element, and the controller fills the elements marked with `data-dropzone-slot`, the root element included:

- `image`: the image preview, as the `src` of an `<img>` or as the background image of any other element. It has the `hidden` attribute until the preview is loaded, and keeps it for files that are not images.
- `filename`: the file name, as text.
- `remove`: removes the file on click, with the same `aria-label` as the default button.

All slots are optional, and a slot can be on several elements. The template must contain exactly one root element: otherwise the controller does not connect and Stimulus logs the error, before any file is added. To render the template without copying the whole widget, the form theme renders a new, empty `dropzone_preview_item_template` block inside the container in `multiple` mode. An app overrides only that block:

```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 %}
```

Without the block override, nothing changes: no `<template>` is rendered and the JS-built markup stays the default.

Tests: unit tests for the template (slots, slots on the root element, a slot on several elements, escaping of the file name, remove, `<img>` and background previews, no slots, a template with no or several root elements, and a remove button without type that must not submit the form), and PHPUnit tests that the block is rendered inside the container in `multiple` mode only. The new unit tests and the `multiple` mode PHPUnit test fail on the base branch.

The unit tests now stop the Stimulus applications after each test, with the same code as symfony#4146, and share the `selectFiles()` helper of symfony#4143.
@ker0x
ker0x force-pushed the feat/dropzone-optional-targets branch from 127e74e to 7d0d9bd Compare October 26, 2026 10:39
ker0x added a commit to ker0x/symfony-ux that referenced this pull request Oct 26, 2026
… the files over `max_files`, and dispatch `dropzone:reject`

| Q              | A
| -------------- | ---
| Bug fix?       | no
| New feature?   | yes
| Deprecations?  | no
| Documentation? | yes
| Issues         | symfony#4142
| License        | MIT

Seventh step of the RFC in symfony#4142.

The file picker only shows the files that match the `accept` attribute of the input, but drag and drop ignores it, so a drop zone for images happily takes a dropped PDF. There is also no way to limit the number of files in `multiple` mode, and no way for the app to tell the user why a file was not taken.

The controller now checks the dropped files against `accept`, in both modes: an extension (`.pdf`) is checked against the file name, a MIME type (`image/png`, `image/*`) against the type of the file. Picked files are not checked again: the picker already filters them with the file types of the operating system, which the browser reports differently (on Windows, a CSV file is often `application/vnd.ms-excel`). `*` and `*/*` accept every file, and a file with no type (HEIC photos, for example) is not rejected because of a MIME type.

A new `max_files` option on `DropzoneType` (`null` or a positive integer, `multiple` mode only) limits the number of files, picked or dropped. When a selection goes over the limit, the files that fit are added, in selection order, and the others are left out. Duplicates are skipped as before and do not count. When the Validator component is installed, the option also adds a `Count` constraint, after the field's own constraints, so the limit is checked on the server too.

The files left out are announced with a new `dropzone:reject` event, once per reason and after `dropzone:change`: `event.detail.files` holds the files and `event.detail.reason` is `accept` or `max_files`. In single-file mode, a rejected drop puts the file selected before back into the input, so it is kept, and the input only ever holds the file that is shown.

| Selection                                          | Before          | After
| -------------------------------------------------- | --------------- | ---
| `accept="image/*"`, a PDF dropped                  | added           | left out, `dropzone:reject` (`accept`), previous file kept
| `accept="image/*"`, a file picked with "All files" | added           | added
| `accept="image/*"`, a file with no type dropped    | added           | added
| `max_files: 2` with 1 file, 3 files picked         | not possible    | 1 added, 2 left out, `dropzone:reject` (`max_files`)
| `max_files: 2`, 3 files submitted                  | not possible    | `Count` constraint violation (with the Validator component)
| no `accept`, no `max_files`                        | added           | added

The first row is a behavior change for apps whose `accept` is narrower than the files they want to receive by drop. The `accept` check only helps the user: the docs show the `File` constraint to validate the type on the server.

Tests: unit tests for dropped files (extensions, MIME types, wildcards, `*` and `*/*`, a file with no type, the previous file kept in single mode, a drop of several files in single mode), picked files that are not checked, a drop without `change` that does not affect the next pick, the `max_files` limit (removing a file makes room again), duplicates, both reasons in one selection and their order after `dropzone:change`; PHPUnit tests for the `max_files` value, a `max_files` without `multiple` or of 0, and the `Count` constraint with and without the Validator component, including a submission over the limit; Playwright tests that replay a drop (Playwright cannot drop files: a drop event, then the files and `change` in the same task) and submit only the kept files, a picked file that is not checked, and the previous file kept in single mode, on Chrome lowest, Chrome latest and Firefox latest. The new tests fail on the base branch, except the ones checking that matching files, no `accept`, duplicates and the absence of the Validator component behave as before.

The unit tests now stop the Stimulus applications after each test, with the same code as symfony#4146: the controllers of earlier tests otherwise connect to the next test's markup, and the `max_files` test failed only in the full run.

`symfony/validator` is added to `require-dev` for the `Count` constraint tests.
ker0x added a commit to ker0x/symfony-ux that referenced this pull request Oct 26, 2026
| Q              | A
| -------------- | ---
| Bug fix?       | no
| New feature?   | yes
| Deprecations?  | no
| Documentation? | yes
| Issues         | Fix symfony#4142
| License        | MIT

Sixth and last step of the RFC in symfony#4142. Based on symfony#4143, symfony#4145, symfony#4144, symfony#4146, symfony#4148 and symfony#4149, replayed in that order: only the last commit belongs to this PR.

The two modes of the controller had grown into two code paths. Single-file mode showed one file with the preview of the form theme, cleared the input when the controller connected, and dispatched `dropzone:clear`; `multiple` mode kept a list, re-added the files already in the input on connect, and never dispatched `dropzone:clear`. Each feature of the RFC had to be written twice, and the modes differed in ways nobody chose: a prevented or rejected selection emptied the drop zone in single-file mode but kept the files in `multiple` mode, and the preview template only worked in `multiple` mode.

Single-file mode is now the stored list of files capped at one, and both modes go through the same steps for each selection: the input is put back to the stored files right away (also while `dropzone:pre-add` listeners are processing), the dropped files are checked against `accept`, the `dropzone:pre-add` listeners run, then the files are added (single-file mode: the new file replaces the previous one, and the files over the first one are rejected with `max_files`), the input is updated, the preview is rendered and `dropzone:change` is dispatched.

| Case                                              | Before                                         | After
| ------------------------------------------------- | ---------------------------------------------- | ---
| Single-file mode, controller connects with a file | input emptied, `dropzone:clear`                | file kept and shown
| `multiple` mode, last file removed                | no `dropzone:clear`                            | `dropzone:clear`
| Form reset                                        | drop zone keeps showing the files              | cleared, `dropzone:clear`
| Single-file mode, selection prevented by a `dropzone:pre-add` listener | drop zone emptied     | previous file kept, as in `multiple` mode
| Single-file mode, preview template                | not used                                       | rendered in the preview, its remove slot clears
| `multiple` mode, selection of files already in the list | `dropzone:change`          | no event
| Drag of text or a link over the drop zone         | `dragging`                                     | ignored
| Drag over a child of the drop zone in Safari      | `dragging` ends (`relatedTarget` is `null`)    | `dragging` kept
| Image previews                                    | read again on every render, an outdated read can show the image of a replaced file | read once per file, never shown for another file

`dropzone:change` keeps its payload (a `File` in single-file mode, a `FileList` in `multiple` mode), to stay compatible: changing it is left for 4.0. The form theme now renders the `dropzone_preview_item_template` block and the `remove_label` value in both modes. The drag state keeps the elements a drag has entered, from `dragenter` and `dragleave`, instead of reading `relatedTarget`, which WebKit does not set, and forgets the elements removed during the drag. `onMultipleChange()` is merged into `onInputChange()`. `src/Dropzone/UPGRADE.md` explains the changes for code listening to `dropzone:clear` on connect or to `dropzone:change`, and for controllers that extend this one.

Tests: unit tests for a file kept on connect without `dropzone:clear`, a prevented selection and a pending one in single-file mode, a selection of several files in single-file mode, an outdated image read, a form reset, a prevented form reset, a disconnect during a form reset, the clear button after a failed connect, the preview template in single-file mode, `dropzone:clear` after the last removal in `multiple` mode, a selection that adds no file in `multiple` mode, one read per image, an image read again after a failed read, a drag without files, a drag over a child without `relatedTarget`, and a drag over an element removed during the drag; PHPUnit tests for the template block and the `remove_label` value in single-file mode; Playwright tests for a form reset and the last removal in `multiple` mode. The new tests fail on the base of this PR. Two tests of the base changed: the single-file mode `file chosen` test now picks the file with a writable `files` property like the other tests, as `@testing-library/user-event` makes it read-only while the controller now writes the input on every selection; and the test of a prevented selection after a pending one now expects the previous file to be kept.
Kocal pushed a commit to ker0x/symfony-ux that referenced this pull request Nov 2, 2026
…hey are added

| Q              | A
| -------------- | ---
| Bug fix?       | no
| New feature?   | yes
| Deprecations?  | no
| Documentation? | yes
| Issues         | symfony#4142
| License        | MIT

First step of the RFC in symfony#4142.

An app cannot change the files a user selects before Dropzone stores and previews them, for example to resize phone photos so they fit `post_max_size`, or to crop an avatar. In `multiple` mode, the controller copies `input.files` into its own list and writes that list back before `dropzone:change`. A listener that replaces `input.files` is overwritten by the next selection, and the previews still show the original files. In single mode, the file is shown as soon as it is picked. The only workaround is to catch the input's `change` event on an ancestor in the capture phase and dispatch a new one, which depends on how the controller listens.

This adds a cancelable `dropzone:pre-add` event, dispatched for every new selection (pick or drop, both modes), before the files are added:

```js
this.element.addEventListener('dropzone:pre-add', (event) => {
    // event.detail.files: File[] about to be added
    event.detail.waitUntil(Promise.all(event.detail.files.map(resize)));
});
```

- `waitUntil()` takes a promise of the files to add (fewer, more or other files), or the files themselves. It can be called once, from the listener.
- `event.preventDefault()` adds nothing.
- While a promise is pending, the container has `data-state="processing"`, so the app can disable its submit button. In `multiple` mode, the input holds the files added before during that time, not only the new selection.
- A rejected promise, or one that does not resolve to an array of `File` objects, adds the original files and logs the error.
- Selections are added in the order they were made, even when an earlier promise resolves later, and a prevented selection waits for the pending ones too. In single-file mode, the input always holds the file that is shown.
- Clearing the drop zone, or disconnecting the controller, drops the selections still processed. A `data-state="processing"` left in a page snapshot is removed on connect.
- With no `waitUntil()` call, the files are added synchronously, as before.

| Selection                        | Before                          | After
| -------------------------------- | ------------------------------- | ---
| No `pre-add` listener            | added, `dropzone:change`        | same, still synchronous
| Listener resolves other files    | not possible                    | the resolved files are previewed and submitted
| Listener calls `preventDefault()`| not possible                    | nothing added (single mode: zone cleared)

Tests: unit tests for both modes (replace, prevent, pending state, rejection, results that are not an array of files, ordering of resolved and prevented selections, clearing while processing, a drop while processing, `waitUntil()` misuse), and Playwright tests that submit the transformed files through the e2e app in single and `multiple` mode, on Chrome lowest, Chrome latest and Firefox latest. The new tests fail on the base branch.

The unit tests now stop the Stimulus applications after each test, with the same code as symfony#4146: the controllers of earlier tests otherwise connect to the next test's markup and hide failures.
Kocal added a commit that referenced this pull request Nov 2, 2026
…files before they are added (ker0x)

This PR was merged into the 3.x branch.

Discussion
----------

[Dropzone] Add a `dropzone:pre-add` event to transform files before they are added

| Q              | A
| -------------- | ---
| Bug fix?       | no
| New feature?   | yes
| Deprecations?  | no
| Documentation? | yes
| Issues         | #4142
| License        | MIT

First step of the RFC in #4142.

An app cannot change the files a user selects before Dropzone stores and previews them, for example to resize phone photos so they fit `post_max_size`, or to crop an avatar. In `multiple` mode, the controller copies `input.files` into its own list and writes that list back before `dropzone:change`. A listener that replaces `input.files` is overwritten by the next selection, and the previews still show the original files. In single mode, the file is shown as soon as it is picked. The only workaround is to catch the input's `change` event on an ancestor in the capture phase and dispatch a new one, which depends on how the controller listens.

This adds a cancelable `dropzone:pre-add` event, dispatched for every new selection (pick or drop, both modes), before the files are added:

```js
this.element.addEventListener('dropzone:pre-add', (event) => {
    // event.detail.files: File[] about to be added
    event.detail.waitUntil(Promise.all(event.detail.files.map(resize)));
});
```

- `waitUntil()` takes a promise of the files to add (fewer, more or other files), or the files themselves. It can be called once, from the listener.
- `event.preventDefault()` adds nothing.
- While a promise is pending, the container has `data-state="processing"`, so the app can disable its submit button. In `multiple` mode, the input holds the files added before during that time, not only the new selection.
- A rejected promise, or one that does not resolve to an array of `File` objects, adds the original files and logs the error.
- Selections are added in the order they were made, even when an earlier promise resolves later, and a prevented selection waits for the pending ones too. In single-file mode, the input always holds the file that is shown.
- Clearing the drop zone, or disconnecting the controller, drops the selections still processed. A `data-state="processing"` left in a page snapshot is removed on connect.
- With no `waitUntil()` call, the files are added synchronously, as before.

| Selection                        | Before                          | After
| -------------------------------- | ------------------------------- | ---
| No `pre-add` listener            | added, `dropzone:change`        | same, still synchronous
| Listener resolves other files    | not possible                    | the resolved files are previewed and submitted
| Listener calls `preventDefault()`| not possible                    | nothing added (single mode: zone cleared)

Tests: unit tests for both modes (replace, prevent, pending state, rejection, results that are not an array of files, ordering of resolved and prevented selections, clearing while processing, a drop while processing, `waitUntil()` misuse), and Playwright tests that submit the transformed files through the e2e app in single and `multiple` mode, on Chrome lowest, Chrome latest and Firefox latest. The new tests fail on the base branch.

The unit tests now stop the Stimulus applications after each test, with the same code as #4146: the controllers of earlier tests otherwise connect to the next test's markup and hide failures.

Commits
-------

2071642 [Dropzone] Add a `dropzone:pre-add` event to transform files before they are added
Kocal pushed a commit to ker0x/symfony-ux that referenced this pull request Nov 2, 2026
…le` mode

| Q              | A
| -------------- | ---
| Bug fix?       | no
| New feature?   | yes
| Deprecations?  | no
| Documentation? | yes
| Issues         | symfony#4142
| License        | MIT

Second step of the RFC in symfony#4142.

In `multiple` mode, the controller builds the preview of each file in JS, with fixed markup and CSS classes (`dropzone-preview-list-item`, `dropzone-preview-image`, ...). An app can only restyle those classes in CSS. It cannot use utility classes, put an icon component in the remove button, or add markup such as the file size.

This adds an optional `previewItemTemplate` target. When it is present, each preview is a clone of the template's root element, and the controller fills the elements marked with `data-dropzone-slot`, the root element included:

- `image`: the image preview, as the `src` of an `<img>` or as the background image of any other element. It has the `hidden` attribute until the preview is loaded, and keeps it for files that are not images.
- `filename`: the file name, as text.
- `remove`: removes the file on click, with the same `aria-label` as the default button.

All slots are optional, and a slot can be on several elements. The template must contain exactly one root element: otherwise the controller does not connect and Stimulus logs the error, before any file is added. To render the template without copying the whole widget, the form theme renders a new, empty `dropzone_preview_item_template` block inside the container in `multiple` mode. An app overrides only that block:

```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 %}
```

Without the block override, nothing changes: no `<template>` is rendered and the JS-built markup stays the default.

Tests: unit tests for the template (slots, slots on the root element, a slot on several elements, escaping of the file name, remove, `<img>` and background previews, no slots, a template with no or several root elements, and a remove button without type that must not submit the form), and PHPUnit tests that the block is rendered inside the container in `multiple` mode only. The new unit tests and the `multiple` mode PHPUnit test fail on the base branch.

The unit tests now stop the Stimulus applications after each test, with the same code as symfony#4146, and share the `selectFiles()` helper of symfony#4143.
… optional in single-file mode

| Q              | A
| -------------- | ---
| Bug fix?       | no
| New feature?   | yes
| Deprecations?  | no
| Documentation? | yes
| Issues         | symfony#4142
| License        | MIT

Fourth step of the RFC in symfony#4142.

In single-file mode, `connect()`, `clear()` and the change handler use the `previewClearButton`, `previewFilename` and `previewImage` targets without checking that they exist. A form theme that leaves one of them out, for example an avatar drop zone that shows no file name, makes the controller throw on connect: Stimulus reports `Missing target element`, and the drop zone does nothing.

The controller now checks the file name and image preview targets with `has*Target` before using them, as it already does for `previewList` in `multiple` mode: without one of them, that part is skipped and the rest of the preview still works. The clear button is bound with the `previewClearButtonTargetConnected()` and `previewClearButtonTargetDisconnected()` callbacks instead of in `connect()`, so a theme can leave it out, and a button rendered later (by a LiveComponent re-render or a Turbo morph, for example) also clears the drop zone. As before, it is not bound in `multiple` mode. The docs explain how to clear the drop zone from another element with the `clear` action, as the user cannot deselect the file otherwise.

| Theme without...    | Before            | After
| ------------------- | ----------------- | ---
| `previewClearButton` | throws on connect | works, nothing to click to clear
| `previewFilename`   | throws on connect | works, no file name shown
| `previewImage`      | throws on connect | works, no image preview

Tests: one unit test per target, which connects, picks a file and checks the preview and `dropzone:change`; a check that the file name still shows without the image preview, and the image preview without the file name; a disconnect without the clear button; a clear button added after connect; and a clear button that must not clear the files in `multiple` mode. The tests that connect without a target and the one with the clear button added later fail on the base branch.

The unit tests now stop the Stimulus applications after each test. Before, every application stayed started, and the controllers of earlier tests also connected to the next test's markup: with them, the test without the clear button passed on the base branch.
@Kocal
Kocal force-pushed the feat/dropzone-optional-targets branch from 7d0d9bd to 6d255fa Compare November 2, 2026 20:10
@carsonbot carsonbot added Status: Reviewed Has been reviewed by a maintainer and removed Status: Needs Review Needs to be reviewed labels Nov 2, 2026
@Kocal

Kocal commented Nov 2, 2026

Copy link
Copy Markdown
Member

Thank you @ker0x.

@Kocal
Kocal merged commit 9214a93 into symfony:3.x Nov 2, 2026
38 checks passed
Kocal pushed a commit to ker0x/symfony-ux that referenced this pull request Nov 2, 2026
… the files over `max_files`, and dispatch `dropzone:reject`

| Q              | A
| -------------- | ---
| Bug fix?       | no
| New feature?   | yes
| Deprecations?  | no
| Documentation? | yes
| Issues         | symfony#4142
| License        | MIT

Seventh step of the RFC in symfony#4142.

The file picker only shows the files that match the `accept` attribute of the input, but drag and drop ignores it, so a drop zone for images happily takes a dropped PDF. There is also no way to limit the number of files in `multiple` mode, and no way for the app to tell the user why a file was not taken.

The controller now checks the dropped files against `accept`, in both modes: an extension (`.pdf`) is checked against the file name, a MIME type (`image/png`, `image/*`) against the type of the file. Picked files are not checked again: the picker already filters them with the file types of the operating system, which the browser reports differently (on Windows, a CSV file is often `application/vnd.ms-excel`). `*` and `*/*` accept every file, and a file with no type (HEIC photos, for example) is not rejected because of a MIME type.

A new `max_files` option on `DropzoneType` (`null` or a positive integer, `multiple` mode only) limits the number of files, picked or dropped. When a selection goes over the limit, the files that fit are added, in selection order, and the others are left out. Duplicates are skipped as before and do not count. When the Validator component is installed, the option also adds a `Count` constraint, after the field's own constraints, so the limit is checked on the server too.

The files left out are announced with a new `dropzone:reject` event, once per reason and after `dropzone:change`: `event.detail.files` holds the files and `event.detail.reason` is `accept` or `max_files`. In single-file mode, a rejected drop puts the file selected before back into the input, so it is kept, and the input only ever holds the file that is shown.

| Selection                                          | Before          | After
| -------------------------------------------------- | --------------- | ---
| `accept="image/*"`, a PDF dropped                  | added           | left out, `dropzone:reject` (`accept`), previous file kept
| `accept="image/*"`, a file picked with "All files" | added           | added
| `accept="image/*"`, a file with no type dropped    | added           | added
| `max_files: 2` with 1 file, 3 files picked         | not possible    | 1 added, 2 left out, `dropzone:reject` (`max_files`)
| `max_files: 2`, 3 files submitted                  | not possible    | `Count` constraint violation (with the Validator component)
| no `accept`, no `max_files`                        | added           | added

The first row is a behavior change for apps whose `accept` is narrower than the files they want to receive by drop. The `accept` check only helps the user: the docs show the `File` constraint to validate the type on the server.

Tests: unit tests for dropped files (extensions, MIME types, wildcards, `*` and `*/*`, a file with no type, the previous file kept in single mode, a drop of several files in single mode), picked files that are not checked, a drop without `change` that does not affect the next pick, the `max_files` limit (removing a file makes room again), duplicates, both reasons in one selection and their order after `dropzone:change`; PHPUnit tests for the `max_files` value, a `max_files` without `multiple` or of 0, and the `Count` constraint with and without the Validator component, including a submission over the limit; Playwright tests that replay a drop (Playwright cannot drop files: a drop event, then the files and `change` in the same task) and submit only the kept files, a picked file that is not checked, and the previous file kept in single mode, on Chrome lowest, Chrome latest and Firefox latest. The new tests fail on the base branch, except the ones checking that matching files, no `accept`, duplicates and the absence of the Validator component behave as before.

The unit tests now stop the Stimulus applications after each test, with the same code as symfony#4146: the controllers of earlier tests otherwise connect to the next test's markup, and the `max_files` test failed only in the full run.

`symfony/validator` is added to `require-dev` for the `Count` constraint tests.
Kocal pushed a commit to ker0x/symfony-ux that referenced this pull request Nov 2, 2026
| Q              | A
| -------------- | ---
| Bug fix?       | no
| New feature?   | yes
| Deprecations?  | no
| Documentation? | yes
| Issues         | Fix symfony#4142
| License        | MIT

Sixth and last step of the RFC in symfony#4142. Based on symfony#4143, symfony#4145, symfony#4144, symfony#4146, symfony#4148 and symfony#4149, replayed in that order: only the last commit belongs to this PR.

The two modes of the controller had grown into two code paths. Single-file mode showed one file with the preview of the form theme, cleared the input when the controller connected, and dispatched `dropzone:clear`; `multiple` mode kept a list, re-added the files already in the input on connect, and never dispatched `dropzone:clear`. Each feature of the RFC had to be written twice, and the modes differed in ways nobody chose: a prevented or rejected selection emptied the drop zone in single-file mode but kept the files in `multiple` mode, and the preview template only worked in `multiple` mode.

Single-file mode is now the stored list of files capped at one, and both modes go through the same steps for each selection: the input is put back to the stored files right away (also while `dropzone:pre-add` listeners are processing), the dropped files are checked against `accept`, the `dropzone:pre-add` listeners run, then the files are added (single-file mode: the new file replaces the previous one, and the files over the first one are rejected with `max_files`), the input is updated, the preview is rendered and `dropzone:change` is dispatched.

| Case                                              | Before                                         | After
| ------------------------------------------------- | ---------------------------------------------- | ---
| Single-file mode, controller connects with a file | input emptied, `dropzone:clear`                | file kept and shown
| `multiple` mode, last file removed                | no `dropzone:clear`                            | `dropzone:clear`
| Form reset                                        | drop zone keeps showing the files              | cleared, `dropzone:clear`
| Single-file mode, selection prevented by a `dropzone:pre-add` listener | drop zone emptied     | previous file kept, as in `multiple` mode
| Single-file mode, preview template                | not used                                       | rendered in the preview, its remove slot clears
| `multiple` mode, selection of files already in the list | `dropzone:change`          | no event
| Drag of text or a link over the drop zone         | `dragging`                                     | ignored
| Drag over a child of the drop zone in Safari      | `dragging` ends (`relatedTarget` is `null`)    | `dragging` kept
| Image previews                                    | read again on every render, an outdated read can show the image of a replaced file | read once per file, never shown for another file

`dropzone:change` keeps its payload (a `File` in single-file mode, a `FileList` in `multiple` mode), to stay compatible: changing it is left for 4.0. The form theme now renders the `dropzone_preview_item_template` block and the `remove_label` value in both modes. The drag state keeps the elements a drag has entered, from `dragenter` and `dragleave`, instead of reading `relatedTarget`, which WebKit does not set, and forgets the elements removed during the drag. `onMultipleChange()` is merged into `onInputChange()`. `src/Dropzone/UPGRADE.md` explains the changes for code listening to `dropzone:clear` on connect or to `dropzone:change`, and for controllers that extend this one.

Tests: unit tests for a file kept on connect without `dropzone:clear`, a prevented selection and a pending one in single-file mode, a selection of several files in single-file mode, an outdated image read, a form reset, a prevented form reset, a disconnect during a form reset, the clear button after a failed connect, the preview template in single-file mode, `dropzone:clear` after the last removal in `multiple` mode, a selection that adds no file in `multiple` mode, one read per image, an image read again after a failed read, a drag without files, a drag over a child without `relatedTarget`, and a drag over an element removed during the drag; PHPUnit tests for the template block and the `remove_label` value in single-file mode; Playwright tests for a form reset and the last removal in `multiple` mode. The new tests fail on the base of this PR. Two tests of the base changed: the single-file mode `file chosen` test now picks the file with a writable `files` property like the other tests, as `@testing-library/user-event` makes it read-only while the controller now writes the input on every selection; and the test of a prevented selection after a pending one now expects the previous file to be kept.
@ker0x
ker0x deleted the feat/dropzone-optional-targets branch November 3, 2026 10:57
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Documentation Improvements or additions to documentation Dropzone Feature New Feature Status: Reviewed Has been reviewed by a maintainer

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants