Skip to content

Commit 7a04b94

Browse files
feat: implement the headless component pattern
1 parent a6a6f76 commit 7a04b94

File tree

7 files changed

+518
-1
lines changed

7 files changed

+518
-1
lines changed

src/course/01-introduction/01-Welcome.mdx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@ Each lesson is broken down in an exercise file and a final file. The exercise fi
6363

6464
- [Higher order component](?path=/docs/lessons-🥇-gold-higher-order-components-pattern-01-lesson--docs)
6565
- [Suspense & lazy loading pattern](?path=/docs/lessons-🥇-gold-suspense-lazy-loading-01-lesson--docs)
66+
- [Headless components pattern](?path=/docs/lessons-🥇-gold-headless-components-01-lesson--docs)
6667

6768
## FAQs
6869

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import type { Meta, StoryObj } from '@storybook/react';
2+
import { Exercise } from './exercise';
3+
4+
const meta: Meta<typeof Exercise> = {
5+
title: 'Lessons/🥇 Gold/🎭 Headless Components/Exercise',
6+
component: Exercise,
7+
parameters: {
8+
layout: 'centered',
9+
},
10+
};
11+
12+
export default meta;
13+
type Story = StoryObj<typeof meta>;
14+
15+
export const Default: Story = {};
Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,162 @@
1+
import { useState } from 'react';
2+
3+
interface IPokemon {
4+
id: number;
5+
name: string;
6+
type: string;
7+
level: number;
8+
caught: boolean;
9+
}
10+
11+
/*
12+
* Observations
13+
* 💅 Pokemon inventory logic is tightly coupled with card UI
14+
* Filtering, sorting, and state management mixed with presentation
15+
* Hard to reuse logic for different UI designs
16+
17+
* Tasks
18+
* 1A 💻 - Extract inventory logic into usePokemonInventory hook
19+
* 1B 💻 - Return state and actions from the headless hook
20+
* 1C 💻 - Create CardView component that uses the headless logic
21+
* 1D 💻 - Create ListView component with same logic, different UI
22+
* 1E 💻 - Test both components work with shared functionality
23+
*/
24+
25+
const mockPokemon: IPokemon[] = [
26+
{
27+
id: 1,
28+
name: 'Pikachu',
29+
type: 'Electric',
30+
level: 25,
31+
caught: true
32+
},
33+
{
34+
id: 4,
35+
name: 'Charmander',
36+
type: 'Fire',
37+
level: 12,
38+
caught: false
39+
},
40+
{ id: 7, name: 'Squirtle', type: 'Water', level: 18, caught: true },
41+
{
42+
id: 25,
43+
name: 'Pichu',
44+
type: 'Electric',
45+
level: 8,
46+
caught: false
47+
},
48+
{
49+
id: 150,
50+
name: 'Mewtwo',
51+
type: 'Psychic',
52+
level: 70,
53+
caught: true
54+
}
55+
];
56+
57+
export const PokemonInventory = () => {
58+
const [pokemon, setPokemon] = useState<IPokemon[]>(mockPokemon);
59+
const [filter, setFilter] = useState<string>('all');
60+
const [sortBy, setSortBy] = useState<string>('name');
61+
62+
const toggleCaught = (id: number) => {
63+
setPokemon((prev) =>
64+
prev.map((p) => (p.id === id ? { ...p, caught: !p.caught } : p))
65+
);
66+
};
67+
68+
const filteredPokemon = pokemon.filter((p) => {
69+
if (filter === 'caught') return p.caught;
70+
if (filter === 'wild') return !p.caught;
71+
return true;
72+
});
73+
74+
const sortedPokemon = [...filteredPokemon].sort((a, b) => {
75+
if (sortBy === 'level') return b.level - a.level;
76+
if (sortBy === 'type') return a.type.localeCompare(b.type);
77+
return a.name.localeCompare(b.name);
78+
});
79+
80+
return (
81+
<div className="bg-blue-50 p-6 rounded-lg max-w-4xl">
82+
<h2 className="text-2xl font-bold mb-4">Pokemon Inventory</h2>
83+
84+
{/* Controls */}
85+
<div className="flex gap-4 mb-6">
86+
<div>
87+
<label className="block text-sm font-medium mb-1">
88+
Filter:
89+
</label>
90+
<select
91+
value={filter}
92+
onChange={(e) => setFilter(e.currentTarget.value)}
93+
className="p-2 border rounded"
94+
>
95+
<option value="all">All Pokemon</option>
96+
<option value="caught">Caught</option>
97+
<option value="wild">Wild</option>
98+
</select>
99+
</div>
100+
101+
<div>
102+
<label className="block text-sm font-medium mb-1">
103+
Sort by:
104+
</label>
105+
<select
106+
value={sortBy}
107+
onChange={(e) => setSortBy(e.currentTarget.value)}
108+
className="p-2 border rounded"
109+
>
110+
<option value="name">Name</option>
111+
<option value="level">Level</option>
112+
<option value="type">Type</option>
113+
</select>
114+
</div>
115+
</div>
116+
117+
{/* Pokemon Cards */}
118+
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
119+
{sortedPokemon.map((p) => (
120+
<div key={p.id} className="bg-white p-4 rounded-lg border">
121+
<div className="flex justify-between items-start mb-2">
122+
<h3 className="font-bold text-lg">{p.name}</h3>
123+
<span
124+
className={`px-2 py-1 rounded text-xs ${
125+
p.caught
126+
? 'bg-green-100 text-green-800'
127+
: 'bg-gray-100 text-gray-600'
128+
}`}
129+
>
130+
{p.caught ? 'Caught' : 'Wild'}
131+
</span>
132+
</div>
133+
134+
<div className="text-sm text-gray-600 mb-3">
135+
<p>Type: {p.type}</p>
136+
<p>Level: {p.level}</p>
137+
</div>
138+
139+
<button
140+
onClick={() => toggleCaught(p.id)}
141+
className={`w-full py-2 px-4 rounded text-sm font-medium ${
142+
p.caught
143+
? 'bg-red-500 text-white hover:bg-red-600'
144+
: 'bg-blue-500 text-white hover:bg-blue-600'
145+
}`}
146+
>
147+
{p.caught ? 'Release' : 'Catch'}
148+
</button>
149+
</div>
150+
))}
151+
</div>
152+
153+
<div className="mt-4 text-sm text-gray-600">
154+
Showing {sortedPokemon.length} of {pokemon.length} Pokemon
155+
</div>
156+
</div>
157+
);
158+
};
159+
160+
export const Exercise = () => {
161+
return <PokemonInventory />;
162+
};
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import type { Meta, StoryObj } from '@storybook/react';
2+
import { Final } from './final';
3+
4+
const meta: Meta<typeof Final> = {
5+
title: 'Lessons/🥇 Gold/🎭 Headless Components/Final',
6+
component: Final,
7+
parameters: {
8+
layout: 'centered',
9+
},
10+
};
11+
12+
export default meta;
13+
type Story = StoryObj<typeof meta>;
14+
15+
export const Default: Story = {};

0 commit comments

Comments
 (0)