Skip to content

Commit 2e2b82d

Browse files
committed
feature #794 [Live] Dispatch browser events (weaverryan)
This PR was squashed before being merged into the 2.x branch. Discussion ---------- [Live] Dispatch browser events | Q | A | ------------- | --- | Bug fix? | no | New feature? | yes | Tickets | None | License | MIT Hi! I figured we'd need this eventually :). While building an component that opens/closes a modal, I needed a way to close the modal from a `LiveAction` in PHP. The way to do that is to dispatch a DOM event from the action, then listen to that with a tiny bit of JavaScript that closes the modal. Modeled after Livewire, like with emit(). Super simple. Also fixed a bug with `attributes.add()` on non-live components. Cheers! Commits ------- e2ca326 [Live] Dispatch browser events
2 parents a61e48b + e2ca326 commit 2e2b82d

File tree

17 files changed

+270
-12
lines changed

17 files changed

+270
-12
lines changed

src/LiveComponent/CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,8 @@ public User $user;
3131

3232
- You can now `emit()` events to communicate between components.
3333

34+
- You can now dispatch DOM/browser events from components.
35+
3436
- Boolean checkboxes are now supported. Of a checkbox does **not** have a
3537
`value` attribute, then the associated `LiveProp` will be set to a boolean
3638
when the input is checked/unchecked.

src/LiveComponent/assets/dist/Component/ElementDriver.d.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,10 @@ export interface ElementDriver {
99
target: string | null;
1010
componentName: string | null;
1111
}>;
12+
getBrowserEventsToDispatch(element: HTMLElement): Array<{
13+
event: string;
14+
payload: any;
15+
}>;
1216
}
1317
export declare class StandardElementDriver implements ElementDriver {
1418
getModelName(element: HTMLElement): string | null;
@@ -21,4 +25,8 @@ export declare class StandardElementDriver implements ElementDriver {
2125
target: string | null;
2226
componentName: string | null;
2327
}>;
28+
getBrowserEventsToDispatch(element: HTMLElement): Array<{
29+
event: string;
30+
payload: any;
31+
}>;
2432
}

src/LiveComponent/assets/dist/live_controller.js

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1962,6 +1962,7 @@ class Component {
19621962
const newProps = this.elementDriver.getComponentProps(newElement);
19631963
this.valueStore.reinitializeAllProps(newProps);
19641964
const eventsToEmit = this.elementDriver.getEventsToEmit(newElement);
1965+
const browserEventsToDispatch = this.elementDriver.getBrowserEventsToDispatch(newElement);
19651966
this.externalMutationTracker.handlePendingChanges();
19661967
this.externalMutationTracker.stop();
19671968
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 {
19801981
}
19811982
this.emit(event, data, componentName);
19821983
});
1984+
browserEventsToDispatch.forEach(({ event, payload }) => {
1985+
this.element.dispatchEvent(new CustomEvent(event, {
1986+
detail: payload,
1987+
bubbles: true,
1988+
}));
1989+
});
19831990
this.hooks.triggerHook('render:finished', this);
19841991
}
19851992
calculateDebounce(debounce) {
@@ -2215,6 +2222,11 @@ class StandardElementDriver {
22152222
const eventsJson = (_a = element.dataset.liveEmit) !== null && _a !== void 0 ? _a : '[]';
22162223
return JSON.parse(eventsJson);
22172224
}
2225+
getBrowserEventsToDispatch(element) {
2226+
var _a;
2227+
const eventsJson = (_a = element.dataset.liveBrowserDispatch) !== null && _a !== void 0 ? _a : '[]';
2228+
return JSON.parse(eventsJson);
2229+
}
22182230
}
22192231

22202232
class LoadingPlugin {

src/LiveComponent/assets/src/Component/ElementDriver.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,11 @@ export interface ElementDriver {
1919
* Given an element from a response, find all the events that should be emitted.
2020
*/
2121
getEventsToEmit(element: HTMLElement): Array<{event: string, data: any, target: string|null, componentName: string|null }>;
22+
23+
/**
24+
* Given an element from a response, find all the events that should be dispatched.
25+
*/
26+
getBrowserEventsToDispatch(element: HTMLElement): Array<{event: string, payload: any }>;
2227
}
2328

2429
export class StandardElementDriver implements ElementDriver {
@@ -51,4 +56,10 @@ export class StandardElementDriver implements ElementDriver {
5156

5257
return JSON.parse(eventsJson);
5358
}
59+
60+
getBrowserEventsToDispatch(element: HTMLElement): Array<{event: string, payload: any }> {
61+
const eventsJson = element.dataset.liveBrowserDispatch ?? '[]';
62+
63+
return JSON.parse(eventsJson);
64+
}
5465
}

src/LiveComponent/assets/src/Component/index.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -452,6 +452,7 @@ export default class Component {
452452
this.valueStore.reinitializeAllProps(newProps);
453453

454454
const eventsToEmit = this.elementDriver.getEventsToEmit(newElement);
455+
const browserEventsToDispatch = this.elementDriver.getBrowserEventsToDispatch(newElement);
455456

456457
// make sure we've processed all external changes before morphing
457458
this.externalMutationTracker.handlePendingChanges();
@@ -489,6 +490,13 @@ export default class Component {
489490
this.emit(event, data, componentName);
490491
});
491492

493+
browserEventsToDispatch.forEach(({ event, payload }) => {
494+
this.element.dispatchEvent(new CustomEvent(event, {
495+
detail: payload,
496+
bubbles: true,
497+
}));
498+
});
499+
492500
this.hooks.triggerHook('render:finished', this);
493501
}
494502

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
/*
2+
* This file is part of the Symfony package.
3+
*
4+
* (c) Fabien Potencier <[email protected]>
5+
*
6+
* For the full copyright and license information, please view the LICENSE
7+
* file that was distributed with this source code.
8+
*/
9+
10+
'use strict';
11+
12+
import {createTest, initComponent, shutdownTests} from '../tools';
13+
14+
describe('LiveController Event Dispatching Tests', () => {
15+
afterEach(() => {
16+
shutdownTests()
17+
});
18+
19+
it('dispatches events sent from an AJAX request', async () => {
20+
const test = await createTest({ }, (data: any) => `
21+
<div ${initComponent(data, {
22+
name: 'simple-component',
23+
})}>Simple Component!</div>
24+
`);
25+
26+
let eventCalled = false;
27+
test.element.addEventListener('fooEvent', (event: any) => {
28+
eventCalled = true;
29+
expect(event.detail).toEqual({ foo: 'bar' });
30+
});
31+
32+
test.expectsAjaxCall()
33+
.willReturn(() => `
34+
<div ${initComponent({}, { browserDispatch: [
35+
{ event: 'fooEvent', payload: { foo: 'bar' } }
36+
]}
37+
)}>Simple Component!</div>
38+
`);
39+
40+
await test.component.render();
41+
expect(eventCalled).toBe(true);
42+
});
43+
});

src/LiveComponent/assets/test/tools.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -427,6 +427,7 @@ export function initComponent(props: any = {}, controllerValues: any = {}) {
427427
${controllerValues.id ? `data-live-id="${controllerValues.id}"` : ''}
428428
${controllerValues.fingerprint ? `data-live-fingerprint-value="${controllerValues.fingerprint}"` : ''}
429429
${controllerValues.listeners ? `data-live-listeners-value="${dataToJsonAttribute(controllerValues.listeners)}"` : ''}
430+
${controllerValues.browserDispatch ? `data-live-browser-dispatch="${dataToJsonAttribute(controllerValues.browserDispatch)}"` : ''}
430431
`;
431432
}
432433

src/LiveComponent/doc/index.rst

Lines changed: 68 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2114,13 +2114,13 @@ There are three ways to emit an event:
21142114
data-event="productAdded"
21152115
>
21162116

2117-
2. From your PHP component via ``ComponentEmitsTrait``::
2117+
2. From your PHP component via ``ComponentToolsTrait``::
21182118

2119-
use Symfony\UX\LiveComponent\ComponentEmitsTrait;
2119+
use Symfony\UX\LiveComponent\ComponentToolsTrait;
21202120

21212121
class MyComponent
21222122
{
2123-
use ComponentEmitsTrait;
2123+
use ComponentToolsTrait;
21242124

21252125
#[LiveAction]
21262126
public function saveProduct()
@@ -2248,6 +2248,71 @@ Or, in PHP::
22482248

22492249
$this->emitSelf('productAdded');
22502250

2251+
Dispatching Browser/JavaScript Events
2252+
-------------------------------------
2253+
2254+
Sometimes you may want to dispatch a JavaScript event from your component. You
2255+
could use this to signal, for example, that a modal should close::
2256+
2257+
use Symfony\UX\LiveComponent\ComponentToolsTrait;
2258+
// ...
2259+
2260+
class MyComponent
2261+
{
2262+
use ComponentToolsTrait;
2263+
2264+
#[LiveAction]
2265+
public function saveProduct()
2266+
{
2267+
// ...
2268+
2269+
$this->dispatchBrowserEvent('modal:close');
2270+
}
2271+
}
2272+
2273+
This will dispatch a ``modal:close`` event on the top-level element of
2274+
your component. It's often handy to listen to this event in a custom
2275+
Stimulus controller - like this for Bootstrap's modal:
2276+
2277+
.. code-block:: javascript
2278+
2279+
// assets/controllers/bootstrap-modal-controller.js
2280+
import { Controller } from '@hotwired/stimulus';
2281+
import { Modal } from 'bootstrap';
2282+
2283+
export default class extends Controller {
2284+
modal = null;
2285+
2286+
initialize() {
2287+
this.modal = Modal.getOrCreateInstance(this.element);
2288+
window.addEventListener('modal:close', () => this.modal.hide());
2289+
}
2290+
}
2291+
2292+
Just make sure this controller is attached to the modal element:
2293+
2294+
.. code-block:: html+twig
2295+
2296+
<div class="modal fade" {{ stimulus_controller('bootstrap-modal') }}">
2297+
<div class="modal-dialog">
2298+
... content ...
2299+
</div>
2300+
</div>
2301+
2302+
You can also pass data to the event::
2303+
2304+
$this->dispatchBrowserEvent('product:created', [
2305+
'product' => $product->getId(),
2306+
]);
2307+
2308+
This becomes the ``detail`` property of the event:
2309+
2310+
.. code-block:: javascript
2311+
2312+
window.addEventListener('product:created', (event) => {
2313+
console.log(event.detail.product);
2314+
});
2315+
22512316
Nested Components
22522317
-----------------
22532318

src/LiveComponent/src/ComponentEmitsTrait.php renamed to src/LiveComponent/src/ComponentToolsTrait.php

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,11 +14,13 @@
1414
use Symfony\Contracts\Service\Attribute\Required;
1515

1616
/**
17+
* Trait with shortcut methods useful for live components.
18+
*
1719
* @author Ryan Weaver <[email protected]>
1820
*
1921
* @experimental
2022
*/
21-
trait ComponentEmitsTrait
23+
trait ComponentToolsTrait
2224
{
2325
private LiveResponder $liveResponder;
2426

@@ -45,4 +47,9 @@ public function emitSelf(string $eventName, array $data = []): void
4547
{
4648
$this->liveResponder->emitSelf($eventName, $data);
4749
}
50+
51+
public function dispatchBrowserEvent(string $eventName, array $payload = []): void
52+
{
53+
$this->liveResponder->dispatchBrowserEvent($eventName, $payload);
54+
}
4855
}

src/LiveComponent/src/LiveResponder.php

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,12 +19,15 @@
1919
final class LiveResponder
2020
{
2121
/**
22-
* Key is the event name, value is an array with keys: event, data, target.
23-
*
24-
* @var array<string, array<string, mixed>>
22+
* Each item is an array with keys: event, data, target, componentName.
2523
*/
2624
private array $eventsToEmit = [];
2725

26+
/**
27+
* Each item is an array with keys: event, payload.
28+
*/
29+
private array $browserEventsToDispatch = [];
30+
2831
public function emit(string $eventName, array $data = [], string $componentName = null): void
2932
{
3033
$this->eventsToEmit[] = [
@@ -55,13 +58,27 @@ public function emitSelf(string $eventName, array $data = []): void
5558
];
5659
}
5760

61+
public function dispatchBrowserEvent(string $event, array $payload = []): void
62+
{
63+
$this->browserEventsToDispatch[] = [
64+
'event' => $event,
65+
'payload' => $payload,
66+
];
67+
}
68+
5869
public function getEventsToEmit(): array
5970
{
6071
return $this->eventsToEmit;
6172
}
6273

74+
public function getBrowserEventsToDispatch(): array
75+
{
76+
return $this->browserEventsToDispatch;
77+
}
78+
6379
public function reset(): void
6480
{
6581
$this->eventsToEmit = [];
82+
$this->browserEventsToDispatch = [];
6683
}
6784
}

src/LiveComponent/src/Util/LiveAttributesCollection.php

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,11 @@ public function setEventsToEmit(array $events): void
9292
$this->attributes['data-live-emit'] = $events;
9393
}
9494

95+
public function setBrowserEventsToDispatch(array $browserEventsToDispatch): void
96+
{
97+
$this->attributes['data-live-browser-dispatch'] = $browserEventsToDispatch;
98+
}
99+
95100
private function escapeAttribute(string $value): string
96101
{
97102
return twig_escape_filter($this->twig, $value, 'html_attr');

src/LiveComponent/src/Util/LiveControllerAttributesCreator.php

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,10 +68,15 @@ public function attributesForRendering(MountedComponent $mounted, ComponentMetad
6868
}
6969

7070
$eventsToEmit = $this->liveResponder->getEventsToEmit();
71+
$browserEventsToDispatch = $this->liveResponder->getBrowserEventsToDispatch();
72+
7173
$this->liveResponder->reset();
7274
if ($eventsToEmit) {
7375
$attributesCollection->setEventsToEmit($eventsToEmit);
7476
}
77+
if ($browserEventsToDispatch) {
78+
$attributesCollection->setBrowserEventsToDispatch($browserEventsToDispatch);
79+
}
7580

7681
$mountedAttributes = $mounted->getAttributes();
7782

src/LiveComponent/tests/Fixtures/Component/ComponentWithEmit.php

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,15 +14,15 @@
1414
use Symfony\UX\LiveComponent\Attribute\AsLiveComponent;
1515
use Symfony\UX\LiveComponent\Attribute\LiveAction;
1616
use Symfony\UX\LiveComponent\Attribute\LiveProp;
17-
use Symfony\UX\LiveComponent\ComponentEmitsTrait;
17+
use Symfony\UX\LiveComponent\ComponentToolsTrait;
1818
use Symfony\UX\LiveComponent\DefaultActionTrait;
1919
use Symfony\UX\LiveComponent\Tests\Fixtures\Entity\Entity1;
2020

2121
#[AsLiveComponent('component_with_emit', csrf: false)]
2222
final class ComponentWithEmit
2323
{
2424
use DefaultActionTrait;
25-
use ComponentEmitsTrait;
25+
use ComponentToolsTrait;
2626

2727
public $events = [];
2828

@@ -32,4 +32,13 @@ public function actionThatEmits(): void
3232
$this->emit('event1', ['foo' => 'bar']);
3333
$this->events = $this->liveResponder->getEventsToEmit();
3434
}
35+
36+
#[LiveAction]
37+
public function actionThatDispatchesABrowserEvent(): void
38+
{
39+
$this->liveResponder->dispatchBrowserEvent(
40+
'browser-event',
41+
['fooKey' => 'barVal'],
42+
);
43+
}
3544
}

0 commit comments

Comments
 (0)