Skip to content

[Live] Dispatch browser events #794

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 1 commit into from
Apr 16, 2023
Merged
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
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') }}">
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Redundant quote at the end?

Copy link
Member Author

@weaverryan weaverryan Apr 17, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Absolutely - thanks for mentioning that :) - fixed in an upcoming PR

<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