Files
testdrive-jsui/tutorials/Tutorial 5 Component Communication.md
2025-11-03 21:36:45 +01:00

6.5 KiB
Raw Blame History

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

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

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:

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:

_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
    })
  );
}

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

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:

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:

npm test

The event test should now pass.


🌐 4. Step 4 — Try it live

In src/index.html:

<hello-dashboard></hello-dashboard>

Then:

npm run dev

Open the page — Type into a greeter input, and youll 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:

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

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