Skip to content

Commit eb298ee

Browse files
feature #765 [Live] Emit system for component-to-component communication! (weaverryan)
This PR was squashed before being merged into the 2.x branch. Discussion ---------- [Live] Emit system for component-to-component communication! | Q | A | ------------- | --- | Bug fix? | no | New feature? | yes | Tickets | Fix #163 | License | MIT Hi! This PR - which is modeled after Livewire's excellent emitting system - adds a way for a component to communicate to another components by emitting an event. You could use this to, for example, emit an event in ComponentA to trigger ComponentB to re-render itself... which is exactly what's described in #163. * [ ] Possibly add a way to dispatch a DOM event from PHP Cheers! Commits ------- 04171eae [Live] Emit system for component-to-component communication!
2 parents 231c398 + dc63b16 commit eb298ee

37 files changed

+1138
-68
lines changed

src/LiveComponent/CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@ public User $user;
1919
the DOM inside a live component, those changes will now be _kept_ when
2020
the component is re-rendered. This has limitations - see the documentation.
2121

22+
- You can now `emit()` events to communicate between components.
23+
2224
- Boolean checkboxes are now supported. Of a checkbox does **not** have a
2325
`value` attribute, then the associated `LiveProp` will be set to a boolean
2426
when the input is checked/unchecked.

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

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,22 @@ export interface ElementDriver {
33
getComponentProps(rootElement: HTMLElement): any;
44
findChildComponentElement(id: string, element: HTMLElement): HTMLElement | null;
55
getKeyFromElement(element: HTMLElement): string | null;
6+
getEventsToEmit(element: HTMLElement): Array<{
7+
event: string;
8+
data: any;
9+
target: string | null;
10+
componentName: string | null;
11+
}>;
612
}
713
export declare class StandardElementDriver implements ElementDriver {
814
getModelName(element: HTMLElement): string | null;
915
getComponentProps(rootElement: HTMLElement): any;
1016
findChildComponentElement(id: string, element: HTMLElement): HTMLElement | null;
1117
getKeyFromElement(element: HTMLElement): string | null;
18+
getEventsToEmit(element: HTMLElement): Array<{
19+
event: string;
20+
data: any;
21+
target: string | null;
22+
componentName: string | null;
23+
}>;
1224
}

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

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,12 @@ import { ElementDriver } from './ElementDriver';
44
import { PluginInterface } from './plugins/PluginInterface';
55
import BackendResponse from '../Backend/BackendResponse';
66
import { ModelBinding } from '../Directive/get_model_binding';
7+
export type ComponentFinder = (currentComponent: Component, onlyParents: boolean, onlyMatchName: string | null) => Component[];
78
export default class Component {
89
readonly element: HTMLElement;
10+
readonly name: string;
11+
readonly listeners: Map<string, string[]>;
12+
private readonly componentFinder;
913
private backend;
1014
private readonly elementDriver;
1115
id: string | null;
@@ -23,7 +27,10 @@ export default class Component {
2327
private children;
2428
private parent;
2529
private externalMutationTracker;
26-
constructor(element: HTMLElement, props: any, fingerprint: string | null, id: string | null, backend: BackendInterface, elementDriver: ElementDriver);
30+
constructor(element: HTMLElement, name: string, props: any, listeners: Array<{
31+
event: string;
32+
action: string;
33+
}>, componentFinder: ComponentFinder, fingerprint: string | null, id: string | null, backend: BackendInterface, elementDriver: ElementDriver);
2734
_swapBackend(backend: BackendInterface): void;
2835
addPlugin(plugin: PluginInterface): void;
2936
connect(): void;
@@ -39,6 +46,11 @@ export default class Component {
3946
removeChild(child: Component): void;
4047
getParent(): Component | null;
4148
getChildren(): Map<string, Component>;
49+
emit(name: string, data: any, onlyMatchingComponentsNamed?: string | null): void;
50+
emitUp(name: string, data: any, onlyMatchingComponentsNamed?: string | null): void;
51+
emitSelf(name: string, data: any): void;
52+
private performEmit;
53+
private doEmit;
4254
updateFromNewElement(toEl: HTMLElement): boolean;
4355
onChildComponentModelUpdate(modelName: string, value: any, childComponent: Component): void;
4456
private tryStartingRequest;
Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11
import Component from './Component';
2-
declare class ComponentRegistry {
3-
private components;
4-
registerComponent(element: HTMLElement, definition: Component): void;
5-
unregisterComponent(element: HTMLElement): void;
2+
export default class {
3+
private componentMapByElement;
4+
private componentMapByComponent;
5+
registerComponent(element: HTMLElement, component: Component): void;
6+
unregisterComponent(component: Component): void;
67
getComponent(element: HTMLElement): Promise<Component>;
8+
findComponents(currentComponent: Component, onlyParents: boolean, onlyMatchName: string | null): Component[];
79
}
8-
declare const _default: ComponentRegistry;
9-
export default _default;

src/LiveComponent/assets/dist/live_controller.d.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { Controller } from '@hotwired/stimulus';
22
import Component from './Component';
3+
import ComponentRegistry from './ComponentRegistry';
34
export { Component };
45
export declare const getComponent: (element: HTMLElement) => Promise<Component>;
56
export interface LiveEvent extends CustomEvent {
@@ -14,32 +15,47 @@ export interface LiveController {
1415
}
1516
export default class LiveControllerDefault extends Controller<HTMLElement> implements LiveController {
1617
static values: {
18+
name: StringConstructor;
1719
url: StringConstructor;
1820
props: ObjectConstructor;
1921
csrf: StringConstructor;
22+
listeners: {
23+
type: ArrayConstructor;
24+
default: never[];
25+
};
2026
debounce: {
2127
type: NumberConstructor;
2228
default: number;
2329
};
2430
id: StringConstructor;
2531
fingerprint: StringConstructor;
2632
};
33+
readonly nameValue: string;
2734
readonly urlValue: string;
2835
readonly propsValue: any;
2936
readonly csrfValue: string;
37+
readonly listenersValue: Array<{
38+
event: string;
39+
action: string;
40+
}>;
3041
readonly hasDebounceValue: boolean;
3142
readonly debounceValue: number;
3243
readonly fingerprintValue: string;
3344
private proxiedComponent;
3445
component: Component;
3546
pendingActionTriggerModelElement: HTMLElement | null;
3647
private elementEventListeners;
48+
static componentRegistry: ComponentRegistry;
3749
initialize(): void;
3850
connect(): void;
3951
disconnect(): void;
4052
update(event: any): void;
4153
action(event: any): void;
4254
$render(): Promise<import("./Backend/BackendResponse").default>;
55+
emit(event: Event): void;
56+
emitUp(event: Event): void;
57+
emitSelf(event: Event): void;
58+
private getEmitDirectives;
4359
$updateModel(model: string, value: any, shouldRender?: boolean, debounce?: number | boolean): Promise<import("./Backend/BackendResponse").default>;
4460
private handleInputEvent;
4561
private handleChangeEvent;

src/LiveComponent/assets/dist/live_controller.js

Lines changed: 124 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1728,7 +1728,7 @@ class ChildComponentWrapper {
17281728
}
17291729
}
17301730
class Component {
1731-
constructor(element, props, fingerprint, id, backend, elementDriver) {
1731+
constructor(element, name, props, listeners, componentFinder, fingerprint, id, backend, elementDriver) {
17321732
this.defaultDebounce = 150;
17331733
this.backendRequest = null;
17341734
this.pendingActions = [];
@@ -1737,10 +1737,20 @@ class Component {
17371737
this.children = new Map();
17381738
this.parent = null;
17391739
this.element = element;
1740+
this.name = name;
1741+
this.componentFinder = componentFinder;
17401742
this.backend = backend;
17411743
this.elementDriver = elementDriver;
17421744
this.id = id;
17431745
this.fingerprint = fingerprint;
1746+
this.listeners = new Map();
1747+
listeners.forEach((listener) => {
1748+
var _a;
1749+
if (!this.listeners.has(listener.event)) {
1750+
this.listeners.set(listener.event, []);
1751+
}
1752+
(_a = this.listeners.get(listener.event)) === null || _a === void 0 ? void 0 : _a.push(listener.action);
1753+
});
17441754
this.valueStore = new ValueStore(props);
17451755
this.unsyncedInputsTracker = new UnsyncedInputsTracker(this, elementDriver);
17461756
this.hooks = new HookManager();
@@ -1833,6 +1843,30 @@ class Component {
18331843
});
18341844
return children;
18351845
}
1846+
emit(name, data, onlyMatchingComponentsNamed = null) {
1847+
return this.performEmit(name, data, false, onlyMatchingComponentsNamed);
1848+
}
1849+
emitUp(name, data, onlyMatchingComponentsNamed = null) {
1850+
return this.performEmit(name, data, true, onlyMatchingComponentsNamed);
1851+
}
1852+
emitSelf(name, data) {
1853+
return this.doEmit(name, data);
1854+
}
1855+
performEmit(name, data, emitUp, matchingName) {
1856+
const components = this.componentFinder(this, emitUp, matchingName);
1857+
components.forEach((component) => {
1858+
component.doEmit(name, data);
1859+
});
1860+
}
1861+
doEmit(name, data) {
1862+
if (!this.listeners.has(name)) {
1863+
return;
1864+
}
1865+
const actions = this.listeners.get(name) || [];
1866+
actions.forEach((action) => {
1867+
this.action(action, data, 1);
1868+
});
1869+
}
18361870
updateFromNewElement(toEl) {
18371871
const props = this.elementDriver.getComponentProps(toEl);
18381872
if (props === null) {
@@ -1937,13 +1971,25 @@ class Component {
19371971
}
19381972
const newProps = this.elementDriver.getComponentProps(newElement);
19391973
this.valueStore.reinitializeAllProps(newProps);
1974+
const eventsToEmit = this.elementDriver.getEventsToEmit(newElement);
19401975
this.externalMutationTracker.handlePendingChanges();
19411976
this.externalMutationTracker.stop();
19421977
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);
19431978
this.externalMutationTracker.start();
19441979
Object.keys(modifiedModelValues).forEach((modelName) => {
19451980
this.valueStore.set(modelName, modifiedModelValues[modelName]);
19461981
});
1982+
eventsToEmit.forEach(({ event, data, target, componentName }) => {
1983+
if (target === 'up') {
1984+
this.emitUp(event, data, componentName);
1985+
return;
1986+
}
1987+
if (target === 'self') {
1988+
this.emitSelf(event, data);
1989+
return;
1990+
}
1991+
this.emit(event, data, componentName);
1992+
});
19471993
this.hooks.triggerHook('render:finished', this);
19481994
}
19491995
calculateDebounce(debounce) {
@@ -2165,6 +2211,11 @@ class StandardElementDriver {
21652211
getKeyFromElement(element) {
21662212
return element.dataset.liveId || null;
21672213
}
2214+
getEventsToEmit(element) {
2215+
var _a;
2216+
const eventsJson = (_a = element.dataset.liveEmit) !== null && _a !== void 0 ? _a : '[]';
2217+
return JSON.parse(eventsJson);
2218+
}
21682219
}
21692220

21702221
class LoadingPlugin {
@@ -2540,20 +2591,23 @@ function getModelBinding (modelDirective) {
25402591

25412592
class ComponentRegistry {
25422593
constructor() {
2543-
this.components = new WeakMap();
2594+
this.componentMapByElement = new WeakMap();
2595+
this.componentMapByComponent = new Map();
25442596
}
2545-
registerComponent(element, definition) {
2546-
this.components.set(element, definition);
2597+
registerComponent(element, component) {
2598+
this.componentMapByElement.set(element, component);
2599+
this.componentMapByComponent.set(component, component.name);
25472600
}
2548-
unregisterComponent(element) {
2549-
this.components.delete(element);
2601+
unregisterComponent(component) {
2602+
this.componentMapByElement.delete(component.element);
2603+
this.componentMapByComponent.delete(component);
25502604
}
25512605
getComponent(element) {
25522606
return new Promise((resolve, reject) => {
25532607
let count = 0;
25542608
const maxCount = 10;
25552609
const interval = setInterval(() => {
2556-
const component = this.components.get(element);
2610+
const component = this.componentMapByElement.get(element);
25572611
if (component) {
25582612
resolve(component);
25592613
}
@@ -2565,10 +2619,23 @@ class ComponentRegistry {
25652619
}, 5);
25662620
});
25672621
}
2622+
findComponents(currentComponent, onlyParents, onlyMatchName) {
2623+
const components = [];
2624+
this.componentMapByComponent.forEach((componentName, component) => {
2625+
if (onlyParents &&
2626+
(currentComponent === component || !component.element.contains(currentComponent.element))) {
2627+
return;
2628+
}
2629+
if (onlyMatchName && componentName !== onlyMatchName) {
2630+
return;
2631+
}
2632+
components.push(component);
2633+
});
2634+
return components;
2635+
}
25682636
}
2569-
var ComponentRegistry$1 = new ComponentRegistry();
25702637

2571-
const getComponent = (element) => ComponentRegistry$1.getComponent(element);
2638+
const getComponent = (element) => LiveControllerDefault.componentRegistry.getComponent(element);
25722639
class LiveControllerDefault extends Controller {
25732640
constructor() {
25742641
super(...arguments);
@@ -2582,7 +2649,7 @@ class LiveControllerDefault extends Controller {
25822649
initialize() {
25832650
this.handleDisconnectedChildControllerEvent = this.handleDisconnectedChildControllerEvent.bind(this);
25842651
const id = this.element.dataset.liveId || null;
2585-
this.component = new Component(this.element, this.propsValue, this.fingerprintValue, id, new Backend(this.urlValue, this.csrfValue), new StandardElementDriver());
2652+
this.component = new Component(this.element, this.nameValue, this.propsValue, this.listenersValue, (currentComponent, onlyParents, onlyMatchName) => LiveControllerDefault.componentRegistry.findComponents(currentComponent, onlyParents, onlyMatchName), this.fingerprintValue, id, new Backend(this.urlValue, this.csrfValue), new StandardElementDriver());
25862653
this.proxiedComponent = proxifyComponent(this.component);
25872654
this.element.__component = this.proxiedComponent;
25882655
if (this.hasDebounceValue) {
@@ -2600,19 +2667,19 @@ class LiveControllerDefault extends Controller {
26002667
});
26012668
}
26022669
connect() {
2670+
LiveControllerDefault.componentRegistry.registerComponent(this.element, this.component);
26032671
this.component.connect();
26042672
this.elementEventListeners.forEach(({ event, callback }) => {
26052673
this.component.element.addEventListener(event, callback);
26062674
});
2607-
ComponentRegistry$1.registerComponent(this.element, this.component);
26082675
this.dispatchEvent('connect');
26092676
}
26102677
disconnect() {
2678+
LiveControllerDefault.componentRegistry.unregisterComponent(this.component);
26112679
this.component.disconnect();
26122680
this.elementEventListeners.forEach(({ event, callback }) => {
26132681
this.component.element.removeEventListener(event, callback);
26142682
});
2615-
ComponentRegistry$1.unregisterComponent(this.element);
26162683
this.dispatchEvent('disconnect');
26172684
}
26182685
update(event) {
@@ -2659,6 +2726,48 @@ class LiveControllerDefault extends Controller {
26592726
$render() {
26602727
return this.component.render();
26612728
}
2729+
emit(event) {
2730+
this.getEmitDirectives(event).forEach(({ name, data, nameMatch }) => {
2731+
this.component.emit(name, data, nameMatch);
2732+
});
2733+
}
2734+
emitUp(event) {
2735+
this.getEmitDirectives(event).forEach(({ name, data, nameMatch }) => {
2736+
this.component.emitUp(name, data, nameMatch);
2737+
});
2738+
}
2739+
emitSelf(event) {
2740+
this.getEmitDirectives(event).forEach(({ name, data }) => {
2741+
this.component.emitSelf(name, data);
2742+
});
2743+
}
2744+
getEmitDirectives(event) {
2745+
const element = event.currentTarget;
2746+
if (!element.dataset.event) {
2747+
throw new Error(`No data-event attribute found on element: ${getElementAsTagText(element)}`);
2748+
}
2749+
const eventInfo = element.dataset.event;
2750+
const directives = parseDirectives(eventInfo);
2751+
const emits = [];
2752+
directives.forEach((directive) => {
2753+
let nameMatch = null;
2754+
directive.modifiers.forEach((modifier) => {
2755+
switch (modifier.name) {
2756+
case 'name':
2757+
nameMatch = modifier.value;
2758+
break;
2759+
default:
2760+
throw new Error(`Unknown modifier ${modifier.name} in event "${eventInfo}".`);
2761+
}
2762+
});
2763+
emits.push({
2764+
name: directive.action,
2765+
data: directive.named,
2766+
nameMatch,
2767+
});
2768+
});
2769+
return emits;
2770+
}
26622771
$updateModel(model, value, shouldRender = true, debounce = true) {
26632772
return this.component.set(model, value, shouldRender, debounce);
26642773
}
@@ -2739,12 +2848,15 @@ class LiveControllerDefault extends Controller {
27392848
}
27402849
}
27412850
LiveControllerDefault.values = {
2851+
name: String,
27422852
url: String,
27432853
props: Object,
27442854
csrf: String,
2855+
listeners: { type: Array, default: [] },
27452856
debounce: { type: Number, default: 150 },
27462857
id: String,
27472858
fingerprint: String,
27482859
};
2860+
LiveControllerDefault.componentRegistry = new ComponentRegistry();
27492861

27502862
export { Component, LiveControllerDefault as default, getComponent };

0 commit comments

Comments
 (0)