diff --git a/src/Autocomplete/CHANGELOG.md b/src/Autocomplete/CHANGELOG.md index 26a5f0e9625..db16b5a5037 100644 --- a/src/Autocomplete/CHANGELOG.md +++ b/src/Autocomplete/CHANGELOG.md @@ -10,7 +10,6 @@ - Added support for using [OptionGroups](https://tom-select.js.org/examples/optgroups/). - ## 2.7.0 - Add `assets/src` to `.gitattributes` to exclude them from the installation diff --git a/src/LiveComponent/CHANGELOG.md b/src/LiveComponent/CHANGELOG.md index 1d57a60222a..bace7035c92 100644 --- a/src/LiveComponent/CHANGELOG.md +++ b/src/LiveComponent/CHANGELOG.md @@ -31,6 +31,8 @@ public User $user; - You can now `emit()` events to communicate between components. +- You can now dispatch DOM/browser events from components. + - Boolean checkboxes are now supported. Of a checkbox does **not** have a `value` attribute, then the associated `LiveProp` will be set to a boolean when the input is checked/unchecked. diff --git a/src/LiveComponent/assets/dist/Component/ElementDriver.d.ts b/src/LiveComponent/assets/dist/Component/ElementDriver.d.ts index 6ccb7d9c3c5..972773de068 100644 --- a/src/LiveComponent/assets/dist/Component/ElementDriver.d.ts +++ b/src/LiveComponent/assets/dist/Component/ElementDriver.d.ts @@ -9,6 +9,10 @@ export interface ElementDriver { target: string | null; componentName: string | null; }>; + getBrowserEventsToDispatch(element: HTMLElement): Array<{ + event: string; + payload: any; + }>; } export declare class StandardElementDriver implements ElementDriver { getModelName(element: HTMLElement): string | null; @@ -21,4 +25,8 @@ export declare class StandardElementDriver implements ElementDriver { target: string | null; componentName: string | null; }>; + getBrowserEventsToDispatch(element: HTMLElement): Array<{ + event: string; + payload: any; + }>; } diff --git a/src/LiveComponent/assets/dist/live_controller.js b/src/LiveComponent/assets/dist/live_controller.js index 434ce04ebcd..251d1d81a19 100644 --- a/src/LiveComponent/assets/dist/live_controller.js +++ b/src/LiveComponent/assets/dist/live_controller.js @@ -1962,6 +1962,7 @@ class Component { const newProps = this.elementDriver.getComponentProps(newElement); this.valueStore.reinitializeAllProps(newProps); const eventsToEmit = this.elementDriver.getEventsToEmit(newElement); + const browserEventsToDispatch = this.elementDriver.getBrowserEventsToDispatch(newElement); this.externalMutationTracker.handlePendingChanges(); this.externalMutationTracker.stop(); executeMorphdom(this.element, newElement, this.unsyncedInputsTracker.getUnsyncedInputs(), (element) => getValueFromElement(element, this.valueStore), Array.from(this.getChildren().values()), this.elementDriver.findChildComponentElement, this.elementDriver.getKeyFromElement, this.externalMutationTracker); @@ -1980,6 +1981,12 @@ class Component { } this.emit(event, data, componentName); }); + browserEventsToDispatch.forEach(({ event, payload }) => { + this.element.dispatchEvent(new CustomEvent(event, { + detail: payload, + bubbles: true, + })); + }); this.hooks.triggerHook('render:finished', this); } calculateDebounce(debounce) { @@ -2215,6 +2222,11 @@ class StandardElementDriver { const eventsJson = (_a = element.dataset.liveEmit) !== null && _a !== void 0 ? _a : '[]'; return JSON.parse(eventsJson); } + getBrowserEventsToDispatch(element) { + var _a; + const eventsJson = (_a = element.dataset.liveBrowserDispatch) !== null && _a !== void 0 ? _a : '[]'; + return JSON.parse(eventsJson); + } } class LoadingPlugin { diff --git a/src/LiveComponent/assets/src/Component/ElementDriver.ts b/src/LiveComponent/assets/src/Component/ElementDriver.ts index 8aaf72b8375..0be5bdf7cf4 100644 --- a/src/LiveComponent/assets/src/Component/ElementDriver.ts +++ b/src/LiveComponent/assets/src/Component/ElementDriver.ts @@ -19,6 +19,11 @@ export interface ElementDriver { * Given an element from a response, find all the events that should be emitted. */ getEventsToEmit(element: HTMLElement): Array<{event: string, data: any, target: string|null, componentName: string|null }>; + + /** + * Given an element from a response, find all the events that should be dispatched. + */ + getBrowserEventsToDispatch(element: HTMLElement): Array<{event: string, payload: any }>; } export class StandardElementDriver implements ElementDriver { @@ -51,4 +56,10 @@ export class StandardElementDriver implements ElementDriver { return JSON.parse(eventsJson); } + + getBrowserEventsToDispatch(element: HTMLElement): Array<{event: string, payload: any }> { + const eventsJson = element.dataset.liveBrowserDispatch ?? '[]'; + + return JSON.parse(eventsJson); + } } diff --git a/src/LiveComponent/assets/src/Component/index.ts b/src/LiveComponent/assets/src/Component/index.ts index 7ae56ba03e3..738025bd070 100644 --- a/src/LiveComponent/assets/src/Component/index.ts +++ b/src/LiveComponent/assets/src/Component/index.ts @@ -452,6 +452,7 @@ export default class Component { this.valueStore.reinitializeAllProps(newProps); const eventsToEmit = this.elementDriver.getEventsToEmit(newElement); + const browserEventsToDispatch = this.elementDriver.getBrowserEventsToDispatch(newElement); // make sure we've processed all external changes before morphing this.externalMutationTracker.handlePendingChanges(); @@ -489,6 +490,13 @@ export default class Component { this.emit(event, data, componentName); }); + browserEventsToDispatch.forEach(({ event, payload }) => { + this.element.dispatchEvent(new CustomEvent(event, { + detail: payload, + bubbles: true, + })); + }); + this.hooks.triggerHook('render:finished', this); } diff --git a/src/LiveComponent/assets/test/controller/dispatch-event.test.ts b/src/LiveComponent/assets/test/controller/dispatch-event.test.ts new file mode 100644 index 00000000000..c91b5e40b58 --- /dev/null +++ b/src/LiveComponent/assets/test/controller/dispatch-event.test.ts @@ -0,0 +1,43 @@ +/* + * This file is part of the Symfony package. + * + * (c) Fabien Potencier + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +'use strict'; + +import {createTest, initComponent, shutdownTests} from '../tools'; + +describe('LiveController Event Dispatching Tests', () => { + afterEach(() => { + shutdownTests() + }); + + it('dispatches events sent from an AJAX request', async () => { + const test = await createTest({ }, (data: any) => ` +
Simple Component!
+ `); + + let eventCalled = false; + test.element.addEventListener('fooEvent', (event: any) => { + eventCalled = true; + expect(event.detail).toEqual({ foo: 'bar' }); + }); + + test.expectsAjaxCall() + .willReturn(() => ` +
Simple Component!
+ `); + + await test.component.render(); + expect(eventCalled).toBe(true); + }); +}); diff --git a/src/LiveComponent/assets/test/tools.ts b/src/LiveComponent/assets/test/tools.ts index df983524162..3a89e213a31 100644 --- a/src/LiveComponent/assets/test/tools.ts +++ b/src/LiveComponent/assets/test/tools.ts @@ -427,6 +427,7 @@ export function initComponent(props: any = {}, controllerValues: any = {}) { ${controllerValues.id ? `data-live-id="${controllerValues.id}"` : ''} ${controllerValues.fingerprint ? `data-live-fingerprint-value="${controllerValues.fingerprint}"` : ''} ${controllerValues.listeners ? `data-live-listeners-value="${dataToJsonAttribute(controllerValues.listeners)}"` : ''} + ${controllerValues.browserDispatch ? `data-live-browser-dispatch="${dataToJsonAttribute(controllerValues.browserDispatch)}"` : ''} `; } diff --git a/src/LiveComponent/doc/index.rst b/src/LiveComponent/doc/index.rst index 4c57691fa22..2d1be8d1309 100644 --- a/src/LiveComponent/doc/index.rst +++ b/src/LiveComponent/doc/index.rst @@ -2114,13 +2114,13 @@ There are three ways to emit an event: data-event="productAdded" > -2. From your PHP component via ``ComponentEmitsTrait``:: +2. From your PHP component via ``ComponentToolsTrait``:: - use Symfony\UX\LiveComponent\ComponentEmitsTrait; + use Symfony\UX\LiveComponent\ComponentToolsTrait; class MyComponent { - use ComponentEmitsTrait; + use ComponentToolsTrait; #[LiveAction] public function saveProduct() @@ -2248,6 +2248,71 @@ Or, in PHP:: $this->emitSelf('productAdded'); +Dispatching Browser/JavaScript Events +------------------------------------- + +Sometimes you may want to dispatch a JavaScript event from your component. You +could use this to signal, for example, that a modal should close:: + + use Symfony\UX\LiveComponent\ComponentToolsTrait; + // ... + + class MyComponent + { + use ComponentToolsTrait; + + #[LiveAction] + public function saveProduct() + { + // ... + + $this->dispatchBrowserEvent('modal:close'); + } + } + +This will dispatch a ``modal:close`` event on the top-level element of +your component. It's often handy to listen to this event in a custom +Stimulus controller - like this for Bootstrap's modal: + +.. code-block:: javascript + + // assets/controllers/bootstrap-modal-controller.js + import { Controller } from '@hotwired/stimulus'; + import { Modal } from 'bootstrap'; + + export default class extends Controller { + modal = null; + + initialize() { + this.modal = Modal.getOrCreateInstance(this.element); + window.addEventListener('modal:close', () => this.modal.hide()); + } + } + +Just make sure this controller is attached to the modal element: + +.. code-block:: html+twig + + + +You can also pass data to the event:: + + $this->dispatchBrowserEvent('product:created', [ + 'product' => $product->getId(), + ]); + +This becomes the ``detail`` property of the event: + +.. code-block:: javascript + + window.addEventListener('product:created', (event) => { + console.log(event.detail.product); + }); + Nested Components ----------------- diff --git a/src/LiveComponent/src/ComponentEmitsTrait.php b/src/LiveComponent/src/ComponentToolsTrait.php similarity index 81% rename from src/LiveComponent/src/ComponentEmitsTrait.php rename to src/LiveComponent/src/ComponentToolsTrait.php index d564d54cace..30ff8ce45cd 100644 --- a/src/LiveComponent/src/ComponentEmitsTrait.php +++ b/src/LiveComponent/src/ComponentToolsTrait.php @@ -14,11 +14,13 @@ use Symfony\Contracts\Service\Attribute\Required; /** + * Trait with shortcut methods useful for live components. + * * @author Ryan Weaver * * @experimental */ -trait ComponentEmitsTrait +trait ComponentToolsTrait { private LiveResponder $liveResponder; @@ -45,4 +47,9 @@ public function emitSelf(string $eventName, array $data = []): void { $this->liveResponder->emitSelf($eventName, $data); } + + public function dispatchBrowserEvent(string $eventName, array $payload = []): void + { + $this->liveResponder->dispatchBrowserEvent($eventName, $payload); + } } diff --git a/src/LiveComponent/src/LiveResponder.php b/src/LiveComponent/src/LiveResponder.php index ccdcbc3a194..4a7caca57f7 100644 --- a/src/LiveComponent/src/LiveResponder.php +++ b/src/LiveComponent/src/LiveResponder.php @@ -19,12 +19,15 @@ final class LiveResponder { /** - * Key is the event name, value is an array with keys: event, data, target. - * - * @var array> + * Each item is an array with keys: event, data, target, componentName. */ private array $eventsToEmit = []; + /** + * Each item is an array with keys: event, payload. + */ + private array $browserEventsToDispatch = []; + public function emit(string $eventName, array $data = [], string $componentName = null): void { $this->eventsToEmit[] = [ @@ -55,13 +58,27 @@ public function emitSelf(string $eventName, array $data = []): void ]; } + public function dispatchBrowserEvent(string $event, array $payload = []): void + { + $this->browserEventsToDispatch[] = [ + 'event' => $event, + 'payload' => $payload, + ]; + } + public function getEventsToEmit(): array { return $this->eventsToEmit; } + public function getBrowserEventsToDispatch(): array + { + return $this->browserEventsToDispatch; + } + public function reset(): void { $this->eventsToEmit = []; + $this->browserEventsToDispatch = []; } } diff --git a/src/LiveComponent/src/Util/LiveAttributesCollection.php b/src/LiveComponent/src/Util/LiveAttributesCollection.php index e56763751dc..7c99a19f8e7 100644 --- a/src/LiveComponent/src/Util/LiveAttributesCollection.php +++ b/src/LiveComponent/src/Util/LiveAttributesCollection.php @@ -92,6 +92,11 @@ public function setEventsToEmit(array $events): void $this->attributes['data-live-emit'] = $events; } + public function setBrowserEventsToDispatch(array $browserEventsToDispatch): void + { + $this->attributes['data-live-browser-dispatch'] = $browserEventsToDispatch; + } + private function escapeAttribute(string $value): string { return twig_escape_filter($this->twig, $value, 'html_attr'); diff --git a/src/LiveComponent/src/Util/LiveControllerAttributesCreator.php b/src/LiveComponent/src/Util/LiveControllerAttributesCreator.php index ed740274393..b7945fd6a97 100644 --- a/src/LiveComponent/src/Util/LiveControllerAttributesCreator.php +++ b/src/LiveComponent/src/Util/LiveControllerAttributesCreator.php @@ -68,10 +68,15 @@ public function attributesForRendering(MountedComponent $mounted, ComponentMetad } $eventsToEmit = $this->liveResponder->getEventsToEmit(); + $browserEventsToDispatch = $this->liveResponder->getBrowserEventsToDispatch(); + $this->liveResponder->reset(); if ($eventsToEmit) { $attributesCollection->setEventsToEmit($eventsToEmit); } + if ($browserEventsToDispatch) { + $attributesCollection->setBrowserEventsToDispatch($browserEventsToDispatch); + } $mountedAttributes = $mounted->getAttributes(); diff --git a/src/LiveComponent/tests/Fixtures/Component/ComponentWithEmit.php b/src/LiveComponent/tests/Fixtures/Component/ComponentWithEmit.php index 2b8327a3d37..0c765ce2cf5 100644 --- a/src/LiveComponent/tests/Fixtures/Component/ComponentWithEmit.php +++ b/src/LiveComponent/tests/Fixtures/Component/ComponentWithEmit.php @@ -14,7 +14,7 @@ use Symfony\UX\LiveComponent\Attribute\AsLiveComponent; use Symfony\UX\LiveComponent\Attribute\LiveAction; use Symfony\UX\LiveComponent\Attribute\LiveProp; -use Symfony\UX\LiveComponent\ComponentEmitsTrait; +use Symfony\UX\LiveComponent\ComponentToolsTrait; use Symfony\UX\LiveComponent\DefaultActionTrait; use Symfony\UX\LiveComponent\Tests\Fixtures\Entity\Entity1; @@ -22,7 +22,7 @@ final class ComponentWithEmit { use DefaultActionTrait; - use ComponentEmitsTrait; + use ComponentToolsTrait; public $events = []; @@ -32,4 +32,13 @@ public function actionThatEmits(): void $this->emit('event1', ['foo' => 'bar']); $this->events = $this->liveResponder->getEventsToEmit(); } + + #[LiveAction] + public function actionThatDispatchesABrowserEvent(): void + { + $this->liveResponder->dispatchBrowserEvent( + 'browser-event', + ['fooKey' => 'barVal'], + ); + } } diff --git a/src/LiveComponent/tests/Functional/LiveResponderTest.php b/src/LiveComponent/tests/Functional/LiveResponderTest.php index 7d8dded3985..2412dc66fda 100644 --- a/src/LiveComponent/tests/Functional/LiveResponderTest.php +++ b/src/LiveComponent/tests/Functional/LiveResponderTest.php @@ -37,4 +37,27 @@ public function testComponentCanEmitEvents(): void ->assertSee('Event: event1') ->assertSee('Data: {"foo":"bar"}'); } + + public function testComponentCanDispatchBrowserEvents(): void + { + $component = $this->mountComponent('component_with_emit'); + $dehydrated = $this->dehydrateComponent($component); + + $crawler = $this->browser() + ->throwExceptions() + ->post('/_components/component_with_emit/actionThatDispatchesABrowserEvent', [ + 'body' => json_encode(['props' => $dehydrated->getProps()]), + ]) + ->assertSuccessful() + ->crawler() + ; + + $div = $crawler->filter('div'); + $browserDispatch = $div->attr('data-live-browser-dispatch'); + $this->assertNotNull($browserDispatch); + $browserDispatchData = json_decode($browserDispatch, true); + $this->assertSame([ + ['event' => 'browser-event', 'payload' => ['fooKey' => 'barVal']], + ], $browserDispatchData); + } } diff --git a/src/LiveComponent/tests/Unit/LiveResponderTest.php b/src/LiveComponent/tests/Unit/LiveResponderTest.php index dbaeff1a522..d22a1e24206 100644 --- a/src/LiveComponent/tests/Unit/LiveResponderTest.php +++ b/src/LiveComponent/tests/Unit/LiveResponderTest.php @@ -72,4 +72,23 @@ public function testEmitSelf(): void ], ], $responder->getEventsToEmit()); } + + public function testDispatchBrowserEvent(): void + { + $responder = new LiveResponder(); + $responder->dispatchBrowserEvent('event_name1', ['data_key' => 'data_value']); + $responder->dispatchBrowserEvent('event_name2', ['data_key' => 'data_value']); + $this->assertSame([ + [ + 'event' => 'event_name1', + 'payload' => ['data_key' => 'data_value'], + ], + [ + 'event' => 'event_name2', + 'payload' => ['data_key' => 'data_value'], + ], + ], $responder->getBrowserEventsToDispatch()); + $responder->reset(); + $this->assertSame([], $responder->getBrowserEventsToDispatch()); + } } diff --git a/src/TwigComponent/src/ComponentAttributes.php b/src/TwigComponent/src/ComponentAttributes.php index 2fa65fbdce5..3187f488eb1 100644 --- a/src/TwigComponent/src/ComponentAttributes.php +++ b/src/TwigComponent/src/ComponentAttributes.php @@ -108,10 +108,10 @@ public function add(AbstractStimulusDto $stimulusDto): self $controllersAttributes = $stimulusDto->toArray(); $attributes = $this->attributes; - $attributes['data-controller'] = implode(' ', array_merge( - explode(' ', $attributes['data-controller']), + $attributes['data-controller'] = trim(implode(' ', array_merge( + explode(' ', $attributes['data-controller'] ?? ''), explode(' ', $controllersAttributes['data-controller'] ?? []) - )); + ))); unset($controllersAttributes['data-controller']); $clone = new self($attributes); diff --git a/src/TwigComponent/tests/Unit/ComponentAttributesTest.php b/src/TwigComponent/tests/Unit/ComponentAttributesTest.php index 8714fc1c56e..960ed1d6d1b 100644 --- a/src/TwigComponent/tests/Unit/ComponentAttributesTest.php +++ b/src/TwigComponent/tests/Unit/ComponentAttributesTest.php @@ -88,6 +88,29 @@ public function testCanAddStimulusController(): void ], $attributes->all()); } + public function testCanAddStimulusControllerIfNoneAlreadyPresent(): void + { + $attributes = new ComponentAttributes([ + 'class' => 'foo', + ]); + + $controllerDto = $this->createMock(AbstractStimulusDto::class); + $controllerDto->expects(self::once()) + ->method('toArray') + ->willReturn([ + 'data-controller' => 'foo bar', + 'data-foo-name-value' => 'ryan', + ]); + + $attributes = $attributes->add($controllerDto); + + $this->assertEquals([ + 'class' => 'foo', + 'data-controller' => 'foo bar', + 'data-foo-name-value' => 'ryan', + ], $attributes->all()); + } + public function testBooleanBehaviour(): void { $attributes = new ComponentAttributes(['disabled' => true]);