252 lines
6.5 KiB
Markdown
252 lines
6.5 KiB
Markdown
|
|
# Tutorial 5: Component Communication
|
|||
|
|
|
|||
|
|
Welcome to the **fifth tutorial** takes you into one of the most important patterns in UI development: **communication between components.**
|
|||
|
|
Up to now, your `<hello-world>` components have been independent. In this tutorial, you’ll make them *talk* to their parent `<hello-dashboard>` component via **custom events**, keeping everything under **TDD**.
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
# 🧭 Tutorial 5 — Component Communication
|
|||
|
|
|
|||
|
|
### “Talking Components” — events between parent and child
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## 🎯 Goal
|
|||
|
|
|
|||
|
|
We’ll make each `<hello-world>` component **emit an event** when its name changes.
|
|||
|
|
The `<hello-dashboard>` component will **listen to these events** and maintain a live **activity log**.
|
|||
|
|
|
|||
|
|
The flow will look like this:
|
|||
|
|
|
|||
|
|
```
|
|||
|
|
[User types in <hello-world> input]
|
|||
|
|
↓
|
|||
|
|
<hello-world> fires event "name-changed"
|
|||
|
|
↓
|
|||
|
|
<hello-dashboard> listens, updates log
|
|||
|
|
↓
|
|||
|
|
Log list displays latest updates
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## 🧪 1. Step 1 — Write the failing test first
|
|||
|
|
|
|||
|
|
Create `src/components/hello-dashboard/hello-dashboard.events.test.js`:
|
|||
|
|
|
|||
|
|
```javascript
|
|||
|
|
import "../hello-world/hello-world.js";
|
|||
|
|
import "./hello-dashboard.js";
|
|||
|
|
|
|||
|
|
describe("<hello-dashboard> (events)", () => {
|
|||
|
|
it("shows a log section that updates when a child emits 'name-changed'", async () => {
|
|||
|
|
const el = document.createElement("hello-dashboard");
|
|||
|
|
document.body.appendChild(el);
|
|||
|
|
|
|||
|
|
// Initial render
|
|||
|
|
let log = el.shadowRoot.querySelector(".log");
|
|||
|
|
expect(log.textContent).to.include("No activity yet");
|
|||
|
|
|
|||
|
|
// Simulate child event
|
|||
|
|
const firstChild = el.shadowRoot.querySelector("hello-world");
|
|||
|
|
const event = new CustomEvent("name-changed", {
|
|||
|
|
detail: { oldName: "World", newName: "Agent" },
|
|||
|
|
bubbles: true,
|
|||
|
|
composed: true
|
|||
|
|
});
|
|||
|
|
firstChild.dispatchEvent(event);
|
|||
|
|
await el.updateComplete;
|
|||
|
|
|
|||
|
|
log = el.shadowRoot.querySelector(".log");
|
|||
|
|
expect(log.textContent).to.include("World → Agent");
|
|||
|
|
});
|
|||
|
|
});
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
Run:
|
|||
|
|
|
|||
|
|
```bash
|
|||
|
|
npm test
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
The test fails (expected).
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## 🧩 2. Step 2 — Update `<hello-world>` to fire events
|
|||
|
|
|
|||
|
|
Open `src/components/hello-world/hello-world.js`
|
|||
|
|
and update `_onInput()` to emit an event whenever the name changes:
|
|||
|
|
|
|||
|
|
```javascript
|
|||
|
|
_onInput(event) {
|
|||
|
|
const oldName = this.name;
|
|||
|
|
this.name = event.target.value;
|
|||
|
|
|
|||
|
|
this.dispatchEvent(
|
|||
|
|
new CustomEvent("name-changed", {
|
|||
|
|
detail: { oldName, newName: this.name },
|
|||
|
|
bubbles: true,
|
|||
|
|
composed: true
|
|||
|
|
})
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
That’s it — now `hello-world` tells the world when its name changes.
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## ⚙️ 3. Step 3 — Update `<hello-dashboard>` to handle events
|
|||
|
|
|
|||
|
|
Edit `src/components/hello-dashboard/hello-dashboard.js`:
|
|||
|
|
|
|||
|
|
Add a new property and event handler:
|
|||
|
|
|
|||
|
|
```javascript
|
|||
|
|
constructor() {
|
|||
|
|
super();
|
|||
|
|
this.greeters = ["World"];
|
|||
|
|
this.logs = [];
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
connectedCallback() {
|
|||
|
|
super.connectedCallback();
|
|||
|
|
this.addEventListener("name-changed", this._onNameChanged);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
_onNameChanged = (e) => {
|
|||
|
|
const { oldName, newName } = e.detail;
|
|||
|
|
this.logs = [`${oldName} → ${newName}`, ...this.logs];
|
|||
|
|
};
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
Now render the log area under the button:
|
|||
|
|
|
|||
|
|
```javascript
|
|||
|
|
render() {
|
|||
|
|
return html`
|
|||
|
|
<div class="container">
|
|||
|
|
<h2>Hello Dashboard</h2>
|
|||
|
|
<div class="counter">Total greetings: ${this.greeters.length}</div>
|
|||
|
|
${this.greeters.map(
|
|||
|
|
(name) => html`<hello-world name=${name}></hello-world>`
|
|||
|
|
)}
|
|||
|
|
<button @click=${this._addGreeter}>Add Greeting</button>
|
|||
|
|
|
|||
|
|
<div class="log">
|
|||
|
|
${this.logs.length === 0
|
|||
|
|
? html`<p>No activity yet</p>`
|
|||
|
|
: html`<ul>
|
|||
|
|
${this.logs.map((l) => html`<li>${l}</li>`)}
|
|||
|
|
</ul>`}
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
`;
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
Run tests again:
|
|||
|
|
|
|||
|
|
```bash
|
|||
|
|
npm test
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
✅ The event test should now pass.
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## 🌐 4. Step 4 — Try it live
|
|||
|
|
|
|||
|
|
In `src/index.html`:
|
|||
|
|
|
|||
|
|
```html
|
|||
|
|
<hello-dashboard></hello-dashboard>
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
Then:
|
|||
|
|
|
|||
|
|
```bash
|
|||
|
|
npm run dev
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
Open the page —
|
|||
|
|
Type into a greeter input, and you’ll see a live **activity log** appear below the button:
|
|||
|
|
|
|||
|
|
```
|
|||
|
|
Hello Dashboard
|
|||
|
|
Total greetings: 1
|
|||
|
|
Hello, World!
|
|||
|
|
[ Add Greeting ]
|
|||
|
|
Activity Log:
|
|||
|
|
World → Agent
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## 🧠 5. Step 5 — Add a test for multiple events (optional)
|
|||
|
|
|
|||
|
|
Extend `hello-dashboard.events.test.js`:
|
|||
|
|
|
|||
|
|
```javascript
|
|||
|
|
it("keeps a running list of multiple name changes", async () => {
|
|||
|
|
const el = document.createElement("hello-dashboard");
|
|||
|
|
document.body.appendChild(el);
|
|||
|
|
|
|||
|
|
const child = el.shadowRoot.querySelector("hello-world");
|
|||
|
|
|
|||
|
|
const events = [
|
|||
|
|
new CustomEvent("name-changed", { detail: { oldName: "World", newName: "A" }, bubbles: true, composed: true }),
|
|||
|
|
new CustomEvent("name-changed", { detail: { oldName: "A", newName: "B" }, bubbles: true, composed: true })
|
|||
|
|
];
|
|||
|
|
|
|||
|
|
child.dispatchEvent(events[0]);
|
|||
|
|
child.dispatchEvent(events[1]);
|
|||
|
|
await el.updateComplete;
|
|||
|
|
|
|||
|
|
const items = Array.from(el.shadowRoot.querySelectorAll(".log li")).map(li => li.textContent);
|
|||
|
|
expect(items).to.deep.equal(["A → B", "World → A"]);
|
|||
|
|
});
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
✅ It should pass too.
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## 🔩 6. Key Concepts
|
|||
|
|
|
|||
|
|
| Concept | Explanation |
|
|||
|
|
| -------------------------- | ----------------------------------------------------------------- |
|
|||
|
|
| **CustomEvent** | A way for child components to send structured messages to parents |
|
|||
|
|
| **Bubbling + composed** | Ensures events cross shadow DOM boundaries |
|
|||
|
|
| **Reactive logs** | Updating an array property triggers rerender |
|
|||
|
|
| **Hierarchical testing** | TDD across parent/child relationships |
|
|||
|
|
| **Single source of truth** | Dashboard owns state; children only emit events |
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## ✅ 7. Summary
|
|||
|
|
|
|||
|
|
You’ve now built:
|
|||
|
|
|
|||
|
|
1. Independent reactive components (`hello-world`)
|
|||
|
|
2. Composed layouts (`hello-dashboard`)
|
|||
|
|
3. Two-way UI interaction (input updates)
|
|||
|
|
4. **Inter-component event communication**
|
|||
|
|
|
|||
|
|
Your **TestDrive-UI** framework now supports:
|
|||
|
|
|
|||
|
|
* Pure front-end logic
|
|||
|
|
* Reactive re-rendering
|
|||
|
|
* Nested component tests
|
|||
|
|
* Event-driven state management
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
Next step (Tutorial 6) will cover:
|
|||
|
|
|
|||
|
|
> **State synchronization** — introducing a shared store or context (e.g. using Lit’s reactive controllers or a lightweight signal system) so that multiple components reflect shared data automatically.
|
|||
|
|
|
|||
|
|
Would you like to continue with that direction next?
|
|||
|
|
|
|||
|
|
xxx
|