generated from coulomb/repo-seed
258 lines
6.0 KiB
Markdown
258 lines
6.0 KiB
Markdown
# Tutorial 4: Component Composition
|
||
|
||
Welcome to the **fourth tutorial** for TestDriveUi building on the first three and introduces **component composition**.
|
||
|
||
You’ll learn how to make multiple Lit components **interact** while keeping the development **test-driven** and agent-friendly.
|
||
|
||
---
|
||
|
||
# 🧩 Tutorial 4 — Composing Components
|
||
|
||
### “Hello Dashboard” — combining reusable components
|
||
|
||
---
|
||
|
||
## 🎯 Goal
|
||
|
||
We’ll create a small **dashboard** component called `<hello-dashboard>` that:
|
||
|
||
1. Renders multiple `<hello-world>` components.
|
||
2. Tracks how many greetings are currently visible.
|
||
3. Updates a counter when a new greeter is added.
|
||
4. Uses **TDD** for structure and behavior.
|
||
|
||
In short:
|
||
|
||
> “A dashboard that shows several personalized greetings and a live counter.”
|
||
|
||
---
|
||
|
||
## 🧪 1. Step 1 — Write the failing test first
|
||
|
||
Create `src/components/hello-dashboard/hello-dashboard.test.js`:
|
||
|
||
```javascript
|
||
import "../hello-world/hello-world.js";
|
||
import "./hello-dashboard.js";
|
||
|
||
describe("<hello-dashboard>", () => {
|
||
it("renders a header and a counter", () => {
|
||
const el = document.createElement("hello-dashboard");
|
||
document.body.appendChild(el);
|
||
|
||
const header = el.shadowRoot.querySelector("h2");
|
||
const counter = el.shadowRoot.querySelector(".counter");
|
||
|
||
expect(header.textContent).to.include("Hello Dashboard");
|
||
expect(counter.textContent).to.match(/Total greetings:/);
|
||
});
|
||
|
||
it("renders at least one <hello-world> component", () => {
|
||
const el = document.createElement("hello-dashboard");
|
||
document.body.appendChild(el);
|
||
|
||
const greeters = el.shadowRoot.querySelectorAll("hello-world");
|
||
expect(greeters.length).to.be.greaterThan(0);
|
||
});
|
||
|
||
it("adds a new greeter when the Add button is clicked", async () => {
|
||
const el = document.createElement("hello-dashboard");
|
||
document.body.appendChild(el);
|
||
|
||
const addButton = el.shadowRoot.querySelector("button");
|
||
addButton.click();
|
||
await el.updateComplete;
|
||
|
||
const greeters = el.shadowRoot.querySelectorAll("hello-world");
|
||
expect(greeters.length).to.equal(2);
|
||
|
||
const counter = el.shadowRoot.querySelector(".counter").textContent;
|
||
expect(counter).to.include("2");
|
||
});
|
||
});
|
||
```
|
||
|
||
Run it:
|
||
|
||
```bash
|
||
npm test
|
||
```
|
||
|
||
All tests fail (expected).
|
||
|
||
---
|
||
|
||
## ⚙️ 2. Step 2 — Implement `<hello-dashboard>`
|
||
|
||
Create a new folder:
|
||
|
||
```
|
||
src/components/hello-dashboard/
|
||
```
|
||
|
||
and add this file:
|
||
`src/components/hello-dashboard/hello-dashboard.js`
|
||
|
||
```javascript
|
||
import { LitElement, html, css } from "lit";
|
||
import "../hello-world/hello-world.js";
|
||
|
||
export class HelloDashboard extends LitElement {
|
||
static styles = css`
|
||
.container {
|
||
font-family: system-ui, sans-serif;
|
||
padding: 1rem;
|
||
text-align: center;
|
||
}
|
||
.counter {
|
||
margin: 0.5rem 0 1rem 0;
|
||
font-weight: bold;
|
||
color: #007acc;
|
||
}
|
||
button {
|
||
background: #007acc;
|
||
color: white;
|
||
border: none;
|
||
border-radius: 6px;
|
||
padding: 0.4rem 1rem;
|
||
cursor: pointer;
|
||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
|
||
}
|
||
button:hover {
|
||
background: #005fa3;
|
||
}
|
||
`;
|
||
|
||
constructor() {
|
||
super();
|
||
this.greeters = ["World"];
|
||
}
|
||
|
||
_addGreeter() {
|
||
const newName = `User${this.greeters.length + 1}`;
|
||
this.greeters = [...this.greeters, newName];
|
||
}
|
||
|
||
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>
|
||
`;
|
||
}
|
||
}
|
||
|
||
customElements.define("hello-dashboard", HelloDashboard);
|
||
```
|
||
|
||
Run tests again:
|
||
|
||
```bash
|
||
npm test
|
||
```
|
||
|
||
✅ All should now pass.
|
||
|
||
---
|
||
|
||
## 🌐 3. Step 3 — Try it live
|
||
|
||
Add this to `src/index.html`:
|
||
|
||
```html
|
||
<script type="module" src="./components/hello-dashboard/hello-dashboard.js"></script>
|
||
<hello-dashboard></hello-dashboard>
|
||
```
|
||
|
||
Then start the dev server:
|
||
|
||
```bash
|
||
npm run dev
|
||
```
|
||
|
||
Open [http://localhost:5173](http://localhost:5173)
|
||
You’ll see:
|
||
|
||
```
|
||
Hello Dashboard
|
||
Total greetings: 1
|
||
Hello, World!
|
||
[Add Greeting]
|
||
```
|
||
|
||
Click **Add Greeting** — new greeters appear, and the counter updates automatically.
|
||
|
||
---
|
||
|
||
## 🧠 4. Step 4 — Extend your test coverage (optional)
|
||
|
||
Add to `hello-dashboard.test.js`:
|
||
|
||
```javascript
|
||
it("propagates custom names correctly", () => {
|
||
const el = document.createElement("hello-dashboard");
|
||
el.greeters = ["Alpha", "Beta", "Gamma"];
|
||
document.body.appendChild(el);
|
||
|
||
const greeters = el.shadowRoot.querySelectorAll("hello-world");
|
||
const names = Array.from(greeters).map((g) => g.getAttribute("name"));
|
||
expect(names).to.deep.equal(["Alpha", "Beta", "Gamma"]);
|
||
});
|
||
```
|
||
|
||
---
|
||
|
||
## 📚 5. Step 5 — Optional Storybook story
|
||
|
||
`src/components/hello-dashboard/hello-dashboard.stories.js`:
|
||
|
||
```javascript
|
||
import "./hello-dashboard.js";
|
||
|
||
export default {
|
||
title: "UI/Hello Dashboard"
|
||
};
|
||
|
||
export const Default = () => `<hello-dashboard></hello-dashboard>`;
|
||
```
|
||
|
||
---
|
||
|
||
## 🧩 6. Lessons learned
|
||
|
||
| Concept | What you practiced |
|
||
| --------------------- | ------------------------------------------- |
|
||
| **Composition** | One Lit component rendering others |
|
||
| **Reactive arrays** | Using property updates to trigger re-render |
|
||
| **Dynamic templates** | Rendering lists with `.map()` |
|
||
| **Event testing** | Simulating clicks and rechecking the DOM |
|
||
| **Incremental TDD** | Adding small features one test at a time |
|
||
|
||
---
|
||
|
||
## ✅ 7. Summary
|
||
|
||
You’ve now implemented:
|
||
|
||
1. A simple reactive component (`hello-world`)
|
||
2. User interaction (input updates)
|
||
3. Property-based rendering
|
||
4. Component composition (`hello-dashboard`)
|
||
|
||
Your **TestDrive-UI** foundation now supports:
|
||
|
||
* Isolated unit tests (`Mocha + jsdom`)
|
||
* Composed component testing
|
||
* Interactive browser previews (`Vite`)
|
||
|
||
---
|
||
|
||
Continue to **Event communication** between components — e.g., the dashboard listening to events fired by each greeter when they change their name.
|
||
|
||
xxx
|