Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions apps/e2e/src/Controller/DropzoneController.php
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,10 @@
namespace App\Controller;

use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\UX\Dropzone\Form\DropzoneType;

#[Route('/ux-dropzone', name: 'app_ux_dropzone_')]
final class DropzoneController extends AbstractController
Expand All @@ -25,4 +27,30 @@ public function index(): Response
'controller_name' => 'DropzoneController',
]);
}

#[Route('/multiple', name: 'multiple')]
public function multiple(Request $request): Response
{
$form = $this->createFormBuilder()
->add('photos', DropzoneType::class, [
'multiple' => true,
'required' => false,
])
->getForm()
;

$form->handleRequest($request);

$uploadedFiles = [];
if ($form->isSubmitted() && $form->isValid()) {
foreach ($form->get('photos')->getData() ?? [] as $file) {
$uploadedFiles[] = $file->getClientOriginalName();
}
}

return $this->render('ux_dropzone/multiple.html.twig', [
'form' => $form,
'uploadedFiles' => $uploadedFiles,
]);
}
}
1 change: 1 addition & 0 deletions apps/e2e/src/Repository/ExampleRepository.php
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ public function __construct()
new Example(UxPackage::ChartJs, 'Pie chart with options', 'A pie chart with custom options to control the appearance and behavior.', 'app_ux_chartjs_pie_with_options'),
new Example(UxPackage::Cropperjs, 'Image cropper', 'Crop an image with Cropper.js using default options.', 'app_ux_cropperjs_crop'),
new Example(UxPackage::Cropperjs, 'Image cropper with aspect ratio', 'Crop an image with a fixed 16:9 aspect ratio constraint.', 'app_ux_cropperjs_crop_with_aspect_ratio'),
new Example(UxPackage::Dropzone, 'Multiple file upload', 'Upload several files at once: accumulate across picks, preview each, and remove individually.', 'app_ux_dropzone_multiple'),
new Example(UxPackage::LiveComponent, 'Examples filtering', 'On this page, you can filter all examples by query terms, and observe how the UI and URLs update during and after processing.', 'app_home'),
new Example(UxPackage::LiveComponent, 'Counter', 'A basic counter that you can increment or decrement.', 'app_ux_live_component_counter'),
new Example(UxPackage::Turbo, 'Turbo Drive navigation', 'Navigate between pages without full page reload using Turbo Drive.', 'app_ux_turbo_drive'),
Expand Down
2 changes: 2 additions & 0 deletions apps/e2e/src/UxPackage.php
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ enum UxPackage: string
case CalendarLink = 'UX CalendarLink';
case ChartJs = 'UX Chart';
case Cropperjs = 'UX Cropperjs';
case Dropzone = 'UX Dropzone';
case Icons = 'UX Icons';
case LiveComponent = 'UX LiveComponent';
// case LazyImage = 'UX LazyImage'; // deprecated/removed
Expand All @@ -40,6 +41,7 @@ public function getDocumentationUrl(): string
self::CalendarLink => 'https://ux.symfony.com/calendar-link',
self::ChartJs => 'https://ux.symfony.com/chartjs',
self::Cropperjs => 'https://ux.symfony.com/cropperjs',
self::Dropzone => 'https://ux.symfony.com/dropzone',
self::Icons => 'https://ux.symfony.com/icons',
self::LiveComponent => 'https://ux.symfony.com/live-component',
self::Map => 'https://ux.symfony.com/map',
Expand Down
25 changes: 25 additions & 0 deletions apps/e2e/templates/ux_dropzone/multiple.html.twig
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
{% extends 'example.html.twig' %}

{% block example %}
<div class="row">
<div class="col-md-8 mx-auto">
{% if uploadedFiles %}
<div class="alert alert-success mb-4" id="upload-success">
<h5>Uploaded {{ uploadedFiles|length }} file(s):</h5>
<ul>
{% for name in uploadedFiles %}
<li>{{ name }}</li>
{% endfor %}
</ul>
</div>
{% endif %}

{{ form_start(form, {attr: {'data-turbo': 'false'}}) }}
{{ form_row(form.photos) }}
<div class="mt-3">
<button type="submit" class="btn btn-primary" id="upload-submit">Upload</button>
</div>
{{ form_end(form) }}
</div>
</div>
{% endblock %}
4 changes: 4 additions & 0 deletions src/Dropzone/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
# CHANGELOG

## 3.x

- Add support for multiple file uploads: accumulate files across selections, preview each file, and remove them individually

## 3.0.0

- Minimum required Symfony version is now 7.4
Expand Down
16 changes: 15 additions & 1 deletion src/Dropzone/assets/dist/controller.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,15 +6,29 @@ declare class export_default extends Controller {
readonly previewClearButtonTarget: HTMLButtonElement;
readonly previewFilenameTarget: HTMLDivElement;
readonly previewImageTarget: HTMLDivElement;
readonly previewListTarget: HTMLUListElement;
readonly hasPreviewListTarget: boolean;
readonly multipleValue: boolean;
static targets: string[];
static values: {
multiple: BooleanConstructor;
};
private dataTransfer;
initialize(): void;
connect(): void;
disconnect(): void;
clear(): void;
onInputChange(event: any): void;
_populateImagePreview(file: Blob): void;
_populateImagePreview(target: HTMLElement, file: Blob): void;
onDragEnter(): void;
onDragLeave(event: any): void;
private connectMultiple;
onMultipleChange(): void;
private syncMultiple;
private removeFileAt;
private containsFile;
private renderList;
private buildListItem;
private dispatchEvent;
}
export { export_default as default };
67 changes: 62 additions & 5 deletions src/Dropzone/assets/dist/controller.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,14 @@ var _Class = class extends Controller {
this.onInputChange = this.onInputChange.bind(this);
this.onDragEnter = this.onDragEnter.bind(this);
this.onDragLeave = this.onDragLeave.bind(this);
this.onMultipleChange = this.onMultipleChange.bind(this);
}
connect() {
if (this.multipleValue) {
this.connectMultiple();
this.dispatchEvent("connect");
return;
}
this.clear();
this.previewClearButtonTarget.addEventListener("click", this.clear);
this.inputTarget.addEventListener("change", this.onInputChange);
Expand All @@ -15,6 +21,10 @@ var _Class = class extends Controller {
this.dispatchEvent("connect");
}
disconnect() {
if (this.multipleValue) {
this.inputTarget.removeEventListener("change", this.onMultipleChange);
return;
}
this.previewClearButtonTarget.removeEventListener("click", this.clear);
this.inputTarget.removeEventListener("change", this.onInputChange);
this.element.removeEventListener("dragenter", this.onDragEnter);
Expand All @@ -38,15 +48,15 @@ var _Class = class extends Controller {
this.previewFilenameTarget.textContent = file.name;
this.previewTarget.style.display = "flex";
this.previewImageTarget.style.display = "none";
if (file.type && file.type.indexOf("image") !== -1) this._populateImagePreview(file);
if (file.type && file.type.indexOf("image") !== -1) this._populateImagePreview(this.previewImageTarget, file);
this.dispatchEvent("change", file);
}
_populateImagePreview(file) {
_populateImagePreview(target, file) {
if (typeof FileReader === "undefined") return;
const reader = new FileReader();
reader.addEventListener("load", (event) => {
this.previewImageTarget.style.display = "block";
this.previewImageTarget.style.backgroundImage = `url("${event.target.result}")`;
target.style.display = "block";
target.style.backgroundImage = `url("${event.target.result}")`;
});
reader.readAsDataURL(file);
}
Expand All @@ -63,6 +73,51 @@ var _Class = class extends Controller {
this.previewTarget.style.display = "block";
}
}
connectMultiple() {
this.dataTransfer = new DataTransfer();
for (const file of Array.from(this.inputTarget.files ?? [])) this.dataTransfer.items.add(file);
this.inputTarget.addEventListener("change", this.onMultipleChange);
this.renderList();
}
onMultipleChange() {
for (const file of Array.from(this.inputTarget.files ?? [])) if (!this.containsFile(file)) this.dataTransfer.items.add(file);
this.syncMultiple();
}
syncMultiple() {
this.inputTarget.files = this.dataTransfer.files;
this.renderList();
this.dispatchEvent("change", this.inputTarget.files);
}
removeFileAt(index, file) {
this.dataTransfer.items.remove(index);
this.syncMultiple();
this.dispatchEvent("remove", file);
}
containsFile(file) {
return Array.from(this.dataTransfer.files).some((existing) => existing.name === file.name && existing.size === file.size && existing.lastModified === file.lastModified);
}
renderList() {
if (!this.hasPreviewListTarget) return;
const items = Array.from(this.dataTransfer.files).map((file, index) => this.buildListItem(file, index));
this.previewListTarget.replaceChildren(...items);
}
buildListItem(file, index) {
const item = document.createElement("li");
item.className = "dropzone-preview-list-item";
const image = document.createElement("div");
image.className = "dropzone-preview-image";
image.style.display = "none";
if (file.type && file.type.indexOf("image") !== -1) this._populateImagePreview(image, file);
const filename = document.createElement("span");
filename.className = "dropzone-preview-filename";
filename.textContent = file.name;
const remove = document.createElement("button");
remove.type = "button";
remove.className = "dropzone-preview-list-remove";
remove.addEventListener("click", () => this.removeFileAt(index, file));
item.append(image, filename, remove);
return item;
}
dispatchEvent(name, payload = {}) {
this.dispatch(name, {
detail: payload,
Expand All @@ -76,6 +131,8 @@ _Class.targets = [
"preview",
"previewClearButton",
"previewFilename",
"previewImage"
"previewImage",
"previewList"
];
_Class.values = { multiple: Boolean };
export { _Class as default };
2 changes: 1 addition & 1 deletion src/Dropzone/assets/dist/style.min.css

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading
Loading