diff --git a/src/Dropzone/CHANGELOG.md b/src/Dropzone/CHANGELOG.md index 69917fc4921..0d2149859a0 100644 --- a/src/Dropzone/CHANGELOG.md +++ b/src/Dropzone/CHANGELOG.md @@ -1,5 +1,9 @@ # CHANGELOG +## 2.31 + +- Support for multiple files drag and drop and peviews + ## 2.30 - Ensure compatibility with PHP 8.5 diff --git a/src/Dropzone/assets/dist/controller.d.ts b/src/Dropzone/assets/dist/controller.d.ts index 075cf96c110..5f5952fb0bf 100644 --- a/src/Dropzone/assets/dist/controller.d.ts +++ b/src/Dropzone/assets/dist/controller.d.ts @@ -3,19 +3,30 @@ import { Controller } from '@hotwired/stimulus'; declare class export_default extends Controller { readonly inputTarget: HTMLInputElement; readonly placeholderTarget: HTMLDivElement; - readonly previewTarget: HTMLDivElement; - readonly previewClearButtonTarget: HTMLButtonElement; - readonly previewFilenameTarget: HTMLDivElement; - readonly previewImageTarget: HTMLDivElement; + readonly previewTargets: HTMLDivElement[]; + readonly previewContainerTarget: HTMLDivElement; static targets: string[]; + files: Map; initialize(): void; connect(): void; disconnect(): void; - clear(): void; - onInputChange(event: any): void; - _populateImagePreview(file: Blob): void; + clear(event?: { + target?: HTMLElement; + params?: { + filename?: string; + }; + }): void; + onInputChange(): void; + private renderPreview; + private clearPreviewContainer; + private buildPreview; + _populateImagePreview(element: HTMLElement, file: File): void; onDragEnter(): void; onDragLeave(event: any): void; + private updateFileInput; + private addFiles; + private isImage; + private get isMultiple(); private dispatchEvent; } diff --git a/src/Dropzone/assets/dist/controller.js b/src/Dropzone/assets/dist/controller.js index e08d8371e7e..9b3b728124a 100644 --- a/src/Dropzone/assets/dist/controller.js +++ b/src/Dropzone/assets/dist/controller.js @@ -1,6 +1,10 @@ // src/controller.ts import { Controller } from "@hotwired/stimulus"; var controller_default = class extends Controller { + constructor() { + super(...arguments); + this.files = /* @__PURE__ */ new Map(); + } initialize() { this.clear = this.clear.bind(this); this.onInputChange = this.onInputChange.bind(this); @@ -9,72 +13,133 @@ var controller_default = class extends Controller { } connect() { this.clear(); - this.previewClearButtonTarget.addEventListener("click", this.clear); this.inputTarget.addEventListener("change", this.onInputChange); this.element.addEventListener("dragenter", this.onDragEnter); this.element.addEventListener("dragleave", this.onDragLeave); this.dispatchEvent("connect"); } disconnect() { - this.previewClearButtonTarget.removeEventListener("click", this.clear); this.inputTarget.removeEventListener("change", this.onInputChange); this.element.removeEventListener("dragenter", this.onDragEnter); this.element.removeEventListener("dragleave", this.onDragLeave); } - clear() { - this.inputTarget.value = ""; - this.inputTarget.style.display = "block"; - this.placeholderTarget.style.display = "block"; - this.previewTarget.style.display = "none"; - this.previewImageTarget.style.display = "none"; - this.previewImageTarget.style.backgroundImage = "none"; - this.previewFilenameTarget.textContent = ""; + clear(event) { + if (event?.params) { + const filename = event.params.filename; + if (filename && this.files.has(filename)) { + this.files.delete(filename); + this.updateFileInput(); + this.renderPreview(); + } + } + if (!this.inputTarget || !this.inputTarget.files || this.inputTarget?.files?.length === 0) { + this.placeholderTarget.style.display = "block"; + if (!this.isMultiple) { + this.inputTarget.style.display = "block"; + } + } this.dispatchEvent("clear"); } - onInputChange(event) { - const file = event.target.files[0]; - if (typeof file === "undefined") { + onInputChange() { + const files = this.inputTarget.files; + if (!files || files.length <= 0) { return; } - this.inputTarget.style.display = "none"; - this.placeholderTarget.style.display = "none"; - 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 (!this.isMultiple && this.files.size > 0) { + this.inputTarget.style.display = "none"; } - this.dispatchEvent("change", file); + const selectedFiles = this.isMultiple ? Array.from(files) : Array.from(files).slice(0, 1); + this.addFiles(selectedFiles); + this.updateFileInput(); + this.renderPreview(); + this.dispatchEvent("change", files); } - _populateImagePreview(file) { - if (typeof FileReader === "undefined") { - return; + renderPreview() { + this.clearPreviewContainer(); + for (const file of this.files.values()) { + const preview = this.buildPreview(file); + if (preview) { + this.previewContainerTarget.appendChild(preview); + } + } + if (this.previewTargets.length > 1) { + this.placeholderTarget.style.display = "none"; + if (!this.isMultiple) { + this.inputTarget.style.display = "none"; + } else { + this.inputTarget.style.display = "block"; + } + } + } + clearPreviewContainer() { + const previews = this.previewTargets; + previews.slice(1).forEach((el) => el.remove()); + } + buildPreview(file, element) { + if (!element) { + element = this.previewContainerTarget.firstElementChild?.cloneNode(true); + } + element.style.display = "flex"; + const fileName = element.querySelector(".dropzone-preview-filename"); + if (fileName) { + fileName.textContent = file.name; + } + const button = element.querySelector(".dropzone-preview-button"); + if (button) { + button.setAttribute("data-symfony--ux-dropzone--dropzone-filename-param", file.name); + } + this._populateImagePreview(element, file); + return element; + } + _populateImagePreview(element, file) { + const image = element.querySelector(".dropzone-preview-image"); + if (image && this.isImage(file) && typeof FileReader !== "undefined") { + const reader = new FileReader(); + reader.addEventListener("load", (event) => { + image.querySelector(".dropzone-preview-image")?.remove(); + image.style.backgroundImage = `url('${event.target.result}')`; + image.style.display = "block"; + }); + reader.readAsDataURL(file); } - const reader = new FileReader(); - reader.addEventListener("load", (event) => { - this.previewImageTarget.style.display = "block"; - this.previewImageTarget.style.backgroundImage = `url("${event.target.result}")`; - }); - reader.readAsDataURL(file); } onDragEnter() { this.inputTarget.style.display = "block"; - this.placeholderTarget.style.display = "block"; - this.previewTarget.style.display = "none"; } onDragLeave(event) { event.preventDefault(); - if (!this.element.contains(event.relatedTarget)) { - this.inputTarget.style.display = "none"; - this.placeholderTarget.style.display = "none"; - this.previewTarget.style.display = "block"; + } + updateFileInput() { + const dataTransfer = new DataTransfer(); + for (const file of this.files.values()) { + dataTransfer.items.add(file); + } + this.inputTarget.files = dataTransfer.files; + } + addFiles(files) { + for (const file of files) { + this.files.set(file.name, file); } } + isImage(file) { + return typeof file.type !== "undefined" && file.type.indexOf("image") !== -1; + } + get isMultiple() { + return this.inputTarget.multiple; + } dispatchEvent(name, payload = {}) { this.dispatch(name, { detail: payload, prefix: "dropzone" }); } }; -controller_default.targets = ["input", "placeholder", "preview", "previewClearButton", "previewFilename", "previewImage"]; +controller_default.targets = [ + "input", + "placeholder", + "preview", + "previewClearButton", + "previewFilename", + "previewImage", + "previewContainer" +]; export { controller_default as default }; diff --git a/src/Dropzone/assets/dist/style.min.css b/src/Dropzone/assets/dist/style.min.css index 4c1e49daedb..9ac03adcf14 100644 --- a/src/Dropzone/assets/dist/style.min.css +++ b/src/Dropzone/assets/dist/style.min.css @@ -1 +1 @@ -.dropzone-container{border:2px dashed #bbb;align-items:center;min-height:100px;padding:20px 10px;display:flex;position:relative}.dropzone-input{opacity:0;cursor:pointer;z-index:1;width:100%;height:100%;display:block;position:absolute;top:0;left:0}.dropzone-preview{align-items:center;max-width:100%;display:flex}.dropzone-preview-image{background-position:50%;background-repeat:no-repeat;background-size:contain;flex-basis:0;min-width:50px;max-width:50px;height:50px;margin-right:10px}.dropzone-preview-filename{word-wrap:anywhere}.dropzone-preview-button{z-index:1;width:auto;color:inherit;font:inherit;-webkit-font-smoothing:inherit;-moz-osx-font-smoothing:inherit;-webkit-appearance:none;background:0 0;border:none;margin:0;padding:0;line-height:normal;position:absolute;top:0;right:0;overflow:visible}.dropzone-preview-button:before{content:"×";cursor:pointer;padding:3px 7px}.dropzone-placeholder{text-align:center;color:#999;flex-grow:1} \ No newline at end of file +.dropzone-container{border:2px dashed #bbb;align-items:center;min-height:100px;padding:20px 10px;display:flex;position:relative}.dropzone-input{opacity:0;cursor:pointer;z-index:1;width:100%;height:100%;display:block;position:absolute;top:0;left:0}.dropzone-preview{align-items:center;max-width:100%;padding:10px 0;display:flex}.dropzone-preview-image{background-position:50%;background-repeat:no-repeat;background-size:contain;flex-basis:0;min-width:50px;max-width:50px;height:50px;margin-right:10px}.dropzone-preview-filename{word-wrap:anywhere}.dropzone-preview-button{z-index:1;width:auto;color:inherit;font:inherit;-webkit-font-smoothing:inherit;-moz-osx-font-smoothing:inherit;-webkit-appearance:none;background:0 0;border:none;margin:0;padding:0;line-height:normal;position:relative;top:0;right:0;overflow:visible}.dropzone-preview-button:before{content:"×";cursor:pointer;padding:3px 7px}.dropzone-placeholder{text-align:center;color:#999;flex-grow:1} \ No newline at end of file diff --git a/src/Dropzone/assets/package.json b/src/Dropzone/assets/package.json index a59e0740666..bf08c07d716 100644 --- a/src/Dropzone/assets/package.json +++ b/src/Dropzone/assets/package.json @@ -2,7 +2,7 @@ "name": "@symfony/ux-dropzone", "description": "File input dropzones for Symfony Forms", "license": "MIT", - "version": "2.31.0", + "version": "2.30.0", "keywords": [ "symfony-ux" ], diff --git a/src/Dropzone/assets/src/controller.ts b/src/Dropzone/assets/src/controller.ts index b2533329388..9f5c7667039 100644 --- a/src/Dropzone/assets/src/controller.ts +++ b/src/Dropzone/assets/src/controller.ts @@ -12,12 +12,20 @@ import { Controller } from '@hotwired/stimulus'; export default class extends Controller { declare readonly inputTarget: HTMLInputElement; declare readonly placeholderTarget: HTMLDivElement; - declare readonly previewTarget: HTMLDivElement; - declare readonly previewClearButtonTarget: HTMLButtonElement; - declare readonly previewFilenameTarget: HTMLDivElement; - declare readonly previewImageTarget: HTMLDivElement; + declare readonly previewTargets: HTMLDivElement[]; + declare readonly previewContainerTarget: HTMLDivElement; - static targets = ['input', 'placeholder', 'preview', 'previewClearButton', 'previewFilename', 'previewImage']; + static targets = [ + 'input', + 'placeholder', + 'preview', + 'previewClearButton', + 'previewFilename', + 'previewImage', + 'previewContainer', + ]; + + files: Map = new Map(); initialize() { this.clear = this.clear.bind(this); @@ -30,9 +38,6 @@ export default class extends Controller { // Reset when connecting to work with Turbolinks this.clear(); - // Clear on click on clear button - this.previewClearButtonTarget.addEventListener('click', this.clear); - // Listen on input change and display preview this.inputTarget.addEventListener('change', this.onInputChange); @@ -46,80 +51,138 @@ export default class extends Controller { } disconnect() { - this.previewClearButtonTarget.removeEventListener('click', this.clear); this.inputTarget.removeEventListener('change', this.onInputChange); this.element.removeEventListener('dragenter', this.onDragEnter); this.element.removeEventListener('dragleave', this.onDragLeave); } - clear() { - this.inputTarget.value = ''; - this.inputTarget.style.display = 'block'; - this.placeholderTarget.style.display = 'block'; - this.previewTarget.style.display = 'none'; - this.previewImageTarget.style.display = 'none'; - this.previewImageTarget.style.backgroundImage = 'none'; - this.previewFilenameTarget.textContent = ''; + clear(event?: { target?: HTMLElement; params?: { filename?: string } }) { + if (event?.params) { + const filename = event.params.filename; + if (filename && this.files.has(filename)) { + this.files.delete(filename); + this.updateFileInput(); + this.renderPreview(); + } + } + if (!this.inputTarget || !this.inputTarget.files || this.inputTarget?.files?.length === 0) { + this.placeholderTarget.style.display = 'block'; + if (!this.isMultiple) { + this.inputTarget.style.display = 'block'; + } + } this.dispatchEvent('clear'); } - onInputChange(event: any) { - const file = event.target.files[0]; - if (typeof file === 'undefined') { + onInputChange() { + const files = this.inputTarget.files; + + if (!files || files.length <= 0) { return; } - // Hide the input and placeholder - this.inputTarget.style.display = 'none'; - this.placeholderTarget.style.display = 'none'; + if (!this.isMultiple && this.files.size > 0) { + this.inputTarget.style.display = 'none'; + } + + const selectedFiles = this.isMultiple ? Array.from(files) : Array.from(files).slice(0, 1); + this.addFiles(selectedFiles); + this.updateFileInput(); + this.renderPreview(); + this.dispatchEvent('change', files); + } - // Show the filename in preview - this.previewFilenameTarget.textContent = file.name; - this.previewTarget.style.display = 'flex'; + private renderPreview() { + this.clearPreviewContainer(); + for (const file of this.files.values()) { + const preview = this.buildPreview(file); + if (preview) { + this.previewContainerTarget.appendChild(preview); + } + } - // If the file is an image, load it and display it as preview - this.previewImageTarget.style.display = 'none'; - if (file.type && file.type.indexOf('image') !== -1) { - this._populateImagePreview(file); + if (this.previewTargets.length > 1) { + this.placeholderTarget.style.display = 'none'; + if (!this.isMultiple) { + this.inputTarget.style.display = 'none'; + } else { + this.inputTarget.style.display = 'block'; + } } + } - this.dispatchEvent('change', file); + private clearPreviewContainer() { + const previews = this.previewTargets; + previews.slice(1).forEach((el) => el.remove()); } - _populateImagePreview(file: Blob) { - if (typeof FileReader === 'undefined') { - // FileReader API not available, skip - return; + private buildPreview(file: File, element?: HTMLElement): HTMLElement { + if (!element) { + element = this.previewContainerTarget.firstElementChild?.cloneNode(true) as HTMLElement; + } + element.style.display = 'flex'; + + const fileName = element.querySelector('.dropzone-preview-filename'); + if (fileName) { + fileName.textContent = file.name; } - const reader = new FileReader(); + const button = element.querySelector('.dropzone-preview-button'); + if (button) { + button.setAttribute('data-symfony--ux-dropzone--dropzone-filename-param', file.name); + } + + this._populateImagePreview(element, file); + return element; + } + + _populateImagePreview(element: HTMLElement, file: File) { + const image = element.querySelector('.dropzone-preview-image'); - reader.addEventListener('load', (event: any) => { - this.previewImageTarget.style.display = 'block'; - this.previewImageTarget.style.backgroundImage = `url("${event.target.result}")`; - }); + if (image && this.isImage(file) && typeof FileReader !== 'undefined') { + const reader = new FileReader(); - reader.readAsDataURL(file); + reader.addEventListener('load', (event: any) => { + image.querySelector('.dropzone-preview-image')?.remove(); + image.style.backgroundImage = `url('${event.target.result}')`; + image.style.display = 'block'; + }); + + reader.readAsDataURL(file as Blob); + } } onDragEnter() { this.inputTarget.style.display = 'block'; - this.placeholderTarget.style.display = 'block'; - this.previewTarget.style.display = 'none'; } onDragLeave(event: any) { event.preventDefault(); + } - // Check if we really leave the main drag area - if (!this.element.contains(event.relatedTarget as Node)) { - this.inputTarget.style.display = 'none'; - this.placeholderTarget.style.display = 'none'; - this.previewTarget.style.display = 'block'; + private updateFileInput() { + const dataTransfer = new DataTransfer(); + for (const file of this.files.values()) { + dataTransfer.items.add(file); + } + this.inputTarget.files = dataTransfer.files; + } + + private addFiles(files: File[]) { + for (const file of files) { + this.files.set(file.name, file); } } + private isImage(file: File): boolean { + return typeof file.type !== 'undefined' && file.type.indexOf('image') !== -1; + } + + private get isMultiple(): boolean { + return this.inputTarget.multiple; + } + private dispatchEvent(name: string, payload: any = {}) { this.dispatch(name, { detail: payload, prefix: 'dropzone' }); } diff --git a/src/Dropzone/assets/src/style.css b/src/Dropzone/assets/src/style.css index d942b59cb60..1df9e519303 100644 --- a/src/Dropzone/assets/src/style.css +++ b/src/Dropzone/assets/src/style.css @@ -23,6 +23,7 @@ display: flex; align-items: center; max-width: 100%; + padding: 10px 0; } .dropzone-preview-image { @@ -41,7 +42,7 @@ } .dropzone-preview-button { - position: absolute; + position: relative; top: 0; right: 0; z-index: 1; diff --git a/src/Dropzone/assets/test/unit/controller.test.ts b/src/Dropzone/assets/test/unit/controller.test.ts index 522a22414cc..c2373eb3964 100644 --- a/src/Dropzone/assets/test/unit/controller.test.ts +++ b/src/Dropzone/assets/test/unit/controller.test.ts @@ -9,7 +9,6 @@ import { Application, Controller } from '@hotwired/stimulus'; import { getByTestId, waitFor } from '@testing-library/dom'; -import user from '@testing-library/user-event'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { clearDOM, mountDOM } from '../../../../../test/stimulus-helpers'; import DropzoneController from '../../src/controller'; @@ -45,25 +44,29 @@ describe('DropzoneController', () => { data-testid="placeholder"> Placeholder - - `); @@ -96,11 +99,14 @@ describe('DropzoneController', () => { getByTestId(container, 'preview').style.display = 'block'; // Click the clear button - getByTestId(container, 'button').click(); + const button = getByTestId(container, 'button'); + await waitFor(() => expect(button).toBeInTheDocument()); + + button.click(); await waitFor(() => expect(getByTestId(container, 'input')).toHaveStyle({ display: 'block' })); await waitFor(() => expect(getByTestId(container, 'placeholder')).toHaveStyle({ display: 'block' })); - await waitFor(() => expect(getByTestId(container, 'preview')).toHaveStyle({ display: 'none' })); + await waitFor(() => expect(getByTestId(container, 'preview')).toHaveStyle({ display: 'block' })); // The event should have been dispatched expect(dispatched).toBe(true); @@ -120,7 +126,13 @@ describe('DropzoneController', () => { const input = getByTestId(container, 'input'); const file = new File(['hello'], 'hello.png', { type: 'image/png' }); - await user.upload(input, file); + Object.defineProperty(input, 'files', { + configurable: true, + writable: true, + value: [file], + }); + input.dispatchEvent(new Event('change', { bubbles: true })); + expect(input.files[0]).toStrictEqual(file); // The dropzone should be in preview mode @@ -129,7 +141,7 @@ describe('DropzoneController', () => { // The event should have been dispatched expect(dispatched).not.toBeNull(); - expect(dispatched.detail).toStrictEqual(file); + expect(dispatched.detail).toStrictEqual(input.files); }); it('on drag', async () => { @@ -150,8 +162,8 @@ describe('DropzoneController', () => { getByTestId(container, 'container').dispatchEvent(dragLeaveEvent); // Check that the input and placeholder are hidden, and preview shown - await waitFor(() => expect(getByTestId(container, 'input')).toHaveStyle({ display: 'none' })); - await waitFor(() => expect(getByTestId(container, 'placeholder')).toHaveStyle({ display: 'none' })); - await waitFor(() => expect(getByTestId(container, 'preview')).toHaveStyle({ display: 'block' })); + await waitFor(() => expect(getByTestId(container, 'input')).toHaveStyle({ display: 'block' })); + await waitFor(() => expect(getByTestId(container, 'placeholder')).toHaveStyle({ display: 'block' })); + await waitFor(() => expect(getByTestId(container, 'preview')).toHaveStyle({ display: 'none' })); }); }); diff --git a/src/Dropzone/assets/test/vitest.setup.js b/src/Dropzone/assets/test/vitest.setup.js new file mode 100644 index 00000000000..8fb1483269d --- /dev/null +++ b/src/Dropzone/assets/test/vitest.setup.js @@ -0,0 +1,34 @@ +class MockDataTransferItemList { + constructor(files) { + this._files = files; + } + + add(file) { + this._files.push(file); + } + + remove(index) { + this._files.splice(index, 1); + } + + clear() { + this._files.length = 0; + } +} + +class MockDataTransfer { + constructor() { + this.files = []; + this.items = new MockDataTransferItemList(this.files); + } + + setData() {} + getData() { + return ''; + } + clearData() { + this.files.length = 0; + } +} + +globalThis.DataTransfer = MockDataTransfer; diff --git a/src/Dropzone/assets/vitest.config.mjs b/src/Dropzone/assets/vitest.config.mjs index 3331ac1d8a2..d55633a367d 100644 --- a/src/Dropzone/assets/vitest.config.mjs +++ b/src/Dropzone/assets/vitest.config.mjs @@ -1,4 +1,12 @@ -import { mergeConfig } from 'vitest/config'; +import { defineConfig, mergeConfig } from 'vitest/config'; import configShared from '../../../vitest.config.base.mjs'; -export default mergeConfig(configShared, {}); +export default mergeConfig( + configShared, + defineConfig({ + test: { + environment: 'jsdom', + setupFiles: ['./test/vitest.setup.js'], + }, + }) +); diff --git a/src/Dropzone/templates/form_theme.html.twig b/src/Dropzone/templates/form_theme.html.twig index 1dbdda0b8f0..14bad358c64 100644 --- a/src/Dropzone/templates/form_theme.html.twig +++ b/src/Dropzone/templates/form_theme.html.twig @@ -11,14 +11,17 @@ {%- endif -%} - {%- endblock %} diff --git a/src/Dropzone/tests/Form/DropzoneTypeTest.php b/src/Dropzone/tests/Form/DropzoneTypeTest.php index cbd447776ab..93e23b4de0d 100644 --- a/src/Dropzone/tests/Form/DropzoneTypeTest.php +++ b/src/Dropzone/tests/Form/DropzoneTypeTest.php @@ -43,14 +43,17 @@ public function testRenderForm()
- ',