Skip to content

Commit

Permalink
[Live] Dispatch browser events
Browse files Browse the repository at this point in the history
  • Loading branch information
weaverryan committed Apr 16, 2023
1 parent cc414dc commit e2ca326
Show file tree
Hide file tree
Showing 18 changed files with 270 additions and 13 deletions.
1 change: 0 additions & 1 deletion src/Autocomplete/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions src/LiveComponent/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
8 changes: 8 additions & 0 deletions src/LiveComponent/assets/dist/Component/ElementDriver.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -21,4 +25,8 @@ export declare class StandardElementDriver implements ElementDriver {
target: string | null;
componentName: string | null;
}>;
getBrowserEventsToDispatch(element: HTMLElement): Array<{
event: string;
payload: any;
}>;
}
12 changes: 12 additions & 0 deletions src/LiveComponent/assets/dist/live_controller.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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) {
Expand Down Expand Up @@ -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 {
Expand Down
11 changes: 11 additions & 0 deletions src/LiveComponent/assets/src/Component/ElementDriver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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);
}
}
8 changes: 8 additions & 0 deletions src/LiveComponent/assets/src/Component/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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);
}

Expand Down
43 changes: 43 additions & 0 deletions src/LiveComponent/assets/test/controller/dispatch-event.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <[email protected]>
*
* 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) => `
<div ${initComponent(data, {
name: 'simple-component',
})}>Simple Component!</div>
`);

let eventCalled = false;
test.element.addEventListener('fooEvent', (event: any) => {
eventCalled = true;
expect(event.detail).toEqual({ foo: 'bar' });
});

test.expectsAjaxCall()
.willReturn(() => `
<div ${initComponent({}, { browserDispatch: [
{ event: 'fooEvent', payload: { foo: 'bar' } }
]}
)}>Simple Component!</div>
`);

await test.component.render();
expect(eventCalled).toBe(true);
});
});
1 change: 1 addition & 0 deletions src/LiveComponent/assets/test/tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)}"` : ''}
`;
}

Expand Down
71 changes: 68 additions & 3 deletions src/LiveComponent/doc/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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

<div class="modal fade" {{ stimulus_controller('bootstrap-modal') }}">
<div class="modal-dialog">
... content ...
</div>
</div>

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
-----------------

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,13 @@
use Symfony\Contracts\Service\Attribute\Required;

/**
* Trait with shortcut methods useful for live components.
*
* @author Ryan Weaver <[email protected]>
*
* @experimental
*/
trait ComponentEmitsTrait
trait ComponentToolsTrait
{
private LiveResponder $liveResponder;

Expand All @@ -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);
}
}
23 changes: 20 additions & 3 deletions src/LiveComponent/src/LiveResponder.php
Original file line number Diff line number Diff line change
Expand Up @@ -19,12 +19,15 @@
final class LiveResponder
{
/**
* Key is the event name, value is an array with keys: event, data, target.
*
* @var array<string, array<string, mixed>>
* 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[] = [
Expand Down Expand Up @@ -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 = [];
}
}
5 changes: 5 additions & 0 deletions src/LiveComponent/src/Util/LiveAttributesCollection.php
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand Down
13 changes: 11 additions & 2 deletions src/LiveComponent/tests/Fixtures/Component/ComponentWithEmit.php
Original file line number Diff line number Diff line change
Expand Up @@ -14,15 +14,15 @@
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;

#[AsLiveComponent('component_with_emit', csrf: false)]
final class ComponentWithEmit
{
use DefaultActionTrait;
use ComponentEmitsTrait;
use ComponentToolsTrait;

public $events = [];

Expand All @@ -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'],
);
}
}
Loading

0 comments on commit e2ca326

Please sign in to comment.