做到 Day 24,CUI 已經累積不少元件。
從一開始的 Button,到後來的 Form、Table、Pagination,甚至昨天的 Dialog,我們已經做了不少 Accessibility 相關的實作。
目前的元件大致有:
Button
Input
Field
Textarea
Checkbox
Radio
Switch
Select
Badge
Alert
Empty State
Table
Pagination
Skeleton
Tooltip
Separator
Visually Hidden
Dialog
但一直以來,我們都把元件展示寫在:
src/App.tsx
例如:
<section>
<h2>Button</h2>
<Button variant="primary">
Primary
</Button>
<Button variant="secondary">
Secondary
</Button>
</section>
接著又加入:
Input Demo
Field Demo
Table Demo
Pagination Demo
Tooltip Demo
Dialog Demo
一開始還滿方便的。
畢竟只要:
npm run dev
就能直接看到畫面。
但元件越來越多,App.tsx 也開始越來越像一個大型展示倉庫。
而且每次想測試不同狀態,都要回去修改 JSX。
例如想看看:
<Button disabled>
儲存
</Button>
就得先把原本的程式碼改掉。
想測試:
<Button variant="destructive">
刪除
</Button>
又要再改一次。
這樣其實不太適合長期維護 UI Kit。
所以今天我們要正式引入:
Storybook。
讓 CUI 的元件不再只是存在於程式碼裡,而是開始擁有自己的展示與文件環境。
Storybook 是一個用來獨立開發、展示與測試 UI Component 的工具。
一般開發流程可能是:
Application
│
├── Page
│ ├── Form
│ ├── Table
│ └── Button
│
└── API / Business Logic
元件通常需要放進某個頁面,才能看到實際效果。
但 Storybook 可以把 Component 獨立出來:
Storybook
│
├── Button
│ ├── Primary
│ ├── Secondary
│ └── Disabled
│
├── Input
│ ├── Default
│ └── Invalid
│
└── Dialog
├── Default
└── Edit Form
我們不需要先建立完整頁面,就能直接觀察元件。
對 UI Kit 來說,這非常適合。
還記得 Day 1 的目標嗎?
CUI 並不是只提供 React Component。
我們希望它最後能服務不同使用者與環境:
CUI
│
Design Tokens
│
Component Contract
│
┌───────────┴───────────┐
↓ ↓
React CDN
│ │
↓ ↓
New Systems Legacy Web
│
Documentation
┌────┴────┐
↓ ↓
Storybook AI Skill
│ │
↓ ↓
Humans AI
其中 Storybook 的定位是:
讓人類開發者理解、探索與使用 CUI 的文件入口。
它不只是展示元件長什麼樣。
未來還可以包含:
Props
Variants
States
Usage Examples
Accessibility Notes
Interaction Tests
所以今天建立 Storybook,也是讓 CUI 從元件集合走向 Design System 的重要一步。
目前專案使用:
React
TypeScript
Vite
Tailwind CSS v4
shadcn/ui
Base UI
Storybook 可以搭配 Vite 使用。
先從最新的 main 建立分支:
git switch main
git pull
git switch -c feat/storybook
接著執行:
npm create storybook@latest
依照 CLI 提示選擇與現有 React + Vite 專案相符的設定。
初始化完成後,Storybook 通常會新增:
.storybook/
├── main.ts
└── preview.ts
src/
└── stories/
└── ...
package.json
實際檔案結構會依 Storybook 版本與初始化選項而有所不同。
同時,package.json 通常也會加入:
{
"scripts": {
"storybook": "storybook dev -p 6006",
"build-storybook": "storybook build"
}
}
完成後執行:
npm run storybook
正常就能開啟 Storybook 的開發介面。
這裡先釐清一件事。
我們原本已經有:
npm run dev
為什麼還要:
npm run storybook
因為它們的用途不同。
Vite App:
展示應用程式
驗證元件組合
測試實際頁面
Storybook:
獨立展示 Component
管理不同 States
建立元件文件
測試 Component Behavior
所以:
App.tsx
≠
Storybook
Storybook 不是取代 React 開發環境。
而是提供一個專門給 UI Component 使用的工作環境。
初始化完成後,第一件事不是急著寫 Stories。
而是確認:
Storybook 有沒有正確載入我們的 Design Tokens 和 Tailwind CSS?
因為 CUI 的樣式都建立在:
src/index.css
以及:
src/styles/tokens.css
src/styles/theme.css
之上。
如果 Storybook 沒有載入這些 CSS,就可能出現:
Button 有出現
但完全沒有樣式
所以先檢查:
.storybook/preview.ts
並匯入全域 CSS:
import "../src/index.css"
這樣 Storybook 裡的元件才能使用相同的:
Design Tokens
Tailwind Utilities
Component Styles
也就是:
React App
│
├── CUI CSS
│
Storybook
兩邊使用同一份樣式來源。
目前 CUI 使用 Tailwind CSS v4。
所以 Storybook 不只是要匯入 CSS,還要確認 Vite 的 Tailwind 整合有正常運作。
如果原本專案的 Vite 已經使用:
import tailwindcss from "@tailwindcss/vite"
Storybook 也需要能正確處理這些樣式。
另外,Tailwind 的 Utility Class 需要在建置時被偵測到。
如果某些 Story 裡的 Class 沒有被包含在 Tailwind 掃描來源中,也可能導致樣式缺失。
因此今天至少要確認:
Primary Button
Secondary Button
Disabled Button
Focus Style
在 Storybook 和原本 App.tsx 裡的呈現一致。
Storybook 裡最重要的概念就是:
Story。
一個 Story 可以理解成:
某個 Component 在特定情境下的展示案例。
例如 Button:
Button
├── Primary
├── Secondary
├── Destructive
├── Disabled
└── Icon Only
每一個都是不同的 Story。
而不是把所有 Button 狀態全部寫在同一個巨大 JSX 裡。
今天先從最熟悉的 Button 開始。
新增:
src/components/ui/button.stories.tsx
內容:
import type { Meta, StoryObj } from "@storybook/react-vite"
import { Button } from "./button"
const meta = {
title: "Components/Button",
component: Button,
tags: ["autodocs"],
args: {
children: "儲存",
variant: "primary",
size: "md",
},
} satisfies Meta<typeof Button>
export default meta
type Story = StoryObj<typeof meta>
export const Primary: Story = {}
export const Secondary: Story = {
args: {
variant: "secondary",
},
}
export const Destructive: Story = {
args: {
variant: "destructive",
children: "刪除",
},
}
export const Disabled: Story = {
args: {
disabled: true,
},
}
這裡有幾個新的概念:
Meta
StoryObj
args
tags
我們逐一看。
首先:
const meta = {
title: "Components/Button",
component: Button,
}
這是在告訴 Storybook:
這個 Story 屬於哪個 Component?
要放在側邊欄哪個位置?
例如:
Components
└── Button
而:
component: Button
讓 Storybook 知道我們正在展示哪個 React Component。
接著:
type Story = StoryObj<typeof meta>
我們可以用這個型別建立不同 Story:
export const Primary: Story = {}
因為預設的 args 已經設定:
variant: "primary"
所以 Primary 不需要重複寫一次。
Secondary:
export const Secondary: Story = {
args: {
variant: "secondary",
},
}
Disabled:
export const Disabled: Story = {
args: {
disabled: true,
},
}
這樣每個案例都能獨立展示。
Storybook 很方便的一點是:
args
它可以對應到 Component Props。
例如:
<Button
variant="primary"
size="md"
disabled={false}
>
儲存
</Button>
在 Storybook 裡,可以透過 Controls 調整:
variant
size
disabled
children
不需要回去改 JSX。
這正好解決我們之前在 App.tsx 遇到的問題。
雖然 Storybook 可以從 TypeScript Props 推導部分資訊,但我們也可以明確定義:
argTypes: {
variant: {
control: "select",
options: [
"primary",
"secondary",
"outline",
"ghost",
"destructive",
"link",
],
},
size: {
control: "select",
options: [
"sm",
"md",
"lg",
"icon-sm",
"icon-md",
"icon-lg",
],
},
disabled: {
control: "boolean",
},
}
這樣使用者可以直接在 Storybook 裡:
variant → secondary
size → lg
disabled → true
然後立即看到畫面改變。
不過 Icon-only 的尺寸通常需要搭配 Icon 內容才有意義。
因此正式文件裡,Icon Button 最好仍然提供獨立 Story。
這裡就跟 Day 22 接起來了。
當時我們整理:
variant
size
disabled
data-cui-*
現在 Storybook 可以把這些規則直接呈現出來。
例如 Button 的 Controls:
Variant
Primary
Secondary
Outline
Ghost
Destructive
Link
Size
sm
md
lg
Disabled
true / false
如果未來有新開發者想使用 CUI,就不需要先翻完整的 button.tsx。
他可以直接在 Storybook 裡操作。
這也是 Component Contract 文件化的第一步。
例如 Icon-only Button。
我們不能只做:
<Button size="icon-md">
<SearchIcon />
</Button>
因為這顆 Button 缺少 Accessible Name。
所以應該建立:
export const IconOnly: Story = {
args: {
size: "icon-md",
"aria-label": "搜尋",
},
render: (args) => (
<Button {...args}>
<SearchIcon aria-hidden="true" />
</Button>
),
}
這樣 Storybook 展示的不是只有:
漂亮的 Icon Button
而是:
正確使用的 Icon Button
這點對 Accessible UI Kit 特別重要。
以前我們在 App.tsx 展示:
Default
Disabled
Invalid
主要是看外觀。
但 Storybook 的價值還可以更進一步。
例如:
Button / Disabled
不只是展示灰色 Button。
也要讓開發者理解:
Disabled Button
→ 無法觸發操作
→ 原生 disabled
→ 通常不進入 Tab 順序
同樣:
Input / Invalid
不只是紅色邊框。
還應該包含:
aria-invalid
aria-describedby
FieldError
也就是:
Story 應該展示正確的使用方式,而不是只有視覺狀態。
Button 完成後,第二顆可以選 Input。
但我不想只展示:
<Input />
因為我們在 Day 12 已經整理過:
Input 不應該脫離 Label 與 Field 的語意關係。
所以更適合展示:
Input
├── Default
├── With Description
├── Required
├── Invalid
└── Disabled
例如:
<Field>
<FieldLabel htmlFor="storybook-email">
電子郵件
</FieldLabel>
<Input
id="storybook-email"
type="email"
placeholder="name@example.com"
/>
<FieldDescription>
請輸入常用的電子郵件地址。
</FieldDescription>
</Field>
這樣我們展示的是一個完整且合理的 Form Field。
而不是只有一個孤零零的 Input。
例如:
<Field data-invalid="true">
<FieldLabel htmlFor="invalid-email">
電子郵件
</FieldLabel>
<Input
id="invalid-email"
type="email"
aria-invalid="true"
aria-describedby="invalid-email-error"
defaultValue="abc"
/>
<FieldError id="invalid-email-error">
請輸入有效的電子郵件地址。
</FieldError>
</Field>
這個 Story 可以同時展示:
Visual Error State
+
ARIA Invalid State
+
Error Association
未來有人問:
CUI 的表單錯誤應該怎麼寫?
就可以直接參考這個 Story。
目前我們除了單一 Component,也已經有:
Form Pattern
Data List Pattern
所以 Storybook 的側邊欄不一定全部放在:
Components
可以開始分層:
Components
├── Button
├── Input
├── Badge
├── Alert
├── Table
├── Pagination
├── Tooltip
└── Dialog
Patterns
├── Form
└── Data List
這樣可以讓開發者理解:
Component
→ 單一可重用元件
Pattern
→ 多個元件的組合方式
這跟我們 Day 20、Day 21 討論的架構完全一致。
例如:
Patterns / Data List
可以展示:
Default
Loading
Empty
No Results
Error
這樣就不需要每次在 App.tsx 裡:
setDataState("loading")
再切:
setDataState("error")
而是直接選:
Data List / Loading
就能看到該狀態。
這也讓 Day 21 的 Data State 真正成為可維護的文件案例。
昨天剛完成 Dialog。
它跟 Button 不太一樣。
因為 Dialog 是 Interactive Component。
所以 Story 不能只是把:
<DialogContent>
單獨丟在畫面上。
它需要完整的:
Dialog Root
Trigger
Content
例如:
Dialog / Edit Form
Story 裡可以提供:
[編輯資料]
讓使用者真的按下去。
接著測試:
Open
Focus
Tab
Escape
Close
這樣 Storybook 就不只是展示外觀,也能作為互動測試的入口。
Tooltip 的 Story 也應該包含:
Trigger
Content
例如:
Tooltip / Icon Button
展示:
Hover
Focus
Escape
而且 Trigger 必須有 Accessible Name。
否則我們可能做出一個:
Tooltip 看起來正常
但:
Icon Button 本身沒有名稱
的錯誤示範。
所以 Storybook 裡的每個 Story,也要遵守 CUI 的 Accessibility Rules。
前面 Button Story 裡有:
tags: ["autodocs"]
這可以讓 Storybook 在支援的設定下,自動產生文件頁面。
例如:
Button
│
├── Description
├── Props / Controls
├── Primary
├── Secondary
├── Disabled
└── Examples
這讓我們不用從零開始寫每一個元件的展示頁。
但自動產生的文件不一定知道:
什麼時候該用 Primary?
什麼時候不該用 Disabled?
Icon-only Button 為什麼需要 Accessible Name?
這些仍然需要我們自己補充。
例如 Button 的 API 文件可能告訴你:
variant: primary | secondary | outline ...
但它不會自動解釋:
同一個區域不應該放太多 Primary Action
或:
Disabled Button 可能讓使用者不知道為什麼不能操作
所以未來 Storybook 文件至少可以分成:
API
→ 怎麼使用?
Usage
→ 什麼時候使用?
Accessibility
→ 使用時要注意什麼?
今天先把 Story 和 Controls 建起來。
明天 Day 26 再深入 Accessibility 文件與測試。
這裡還有一件很重要的事。
我們不是在 Storybook 裡重新寫一套 Button。
而是直接:
import { Button } from "./button"
也就是:
src/components/ui/button.tsx
│
├── React App
│
└── Storybook
兩邊使用同一個 Component。
所以如果未來修改:
Button Variant
Focus Style
Design Token
Storybook 會反映相同的元件實作。
這才符合我們的 Single Source 原則。
我覺得還不用急。
目前 App.tsx 可能還有很多:
Component Demo
Form Demo
Data List Demo
我們可以逐步把展示內容移到 Storybook。
例如今天先完成:
Button
Input
Badge
Dialog
之後再補:
Table
Pagination
Data List
等 Storybook 已經能完整承擔元件展示責任,再整理 App.tsx。
這樣比今天直接把 App.tsx 全刪掉安全很多。
目前可以先採用 Colocation:
src/
└── components/
└── ui/
├── button.tsx
├── button.stories.tsx
│
├── input.tsx
├── input.stories.tsx
│
├── badge.tsx
├── badge.stories.tsx
│
├── dialog.tsx
└── dialog.stories.tsx
也就是:
Component
+
Story
放在一起。
好處是修改元件時,很容易找到對應展示案例。
而比較大型的 Pattern,則可以另外整理:
src/
└── stories/
└── patterns/
├── form.stories.tsx
└── data-list.stories.tsx
目前不用為了 Storybook 重新搬動所有元件。
先維持簡單就好。
CUI 已經有十幾顆元件。
如果今天一次建立:
Button Stories
Input Stories
Textarea Stories
Checkbox Stories
Radio Stories
Switch Stories
Select Stories
Badge Stories
Alert Stories
Table Stories
Pagination Stories
Tooltip Stories
Dialog Stories
文章很容易變成:
今天寫了很多 Story 檔案。
但其實沒有真正理解 Storybook。
所以今天我想先完成幾個代表性案例:
| Story | 目的 |
|---|---|
| Button | Args / Controls / Variants |
| Input + Field | Label / Description / Invalid |
| Badge | Semantic Status Variants |
| Dialog | Interactive Component |
| Data List | Composition Pattern |
這五組已經足夠讓我們理解 Storybook 的基本架構。
其他元件可以沿用相同方式逐步補齊。
完成後,我們可以確認:
Storybook 環境
Stories
架構
首先確認 Storybook 開發環境:
npm run storybook
再測試 Storybook 的正式建置:
npm run build-storybook
接著執行原本專案的檢查:
npm run build
npm run lint
npm run check:contrast
如果專案的 Lint 規則開始掃描 Storybook 自動產生的檔案,也需要確認相關設定是否合理。
例如:
storybook-static/
這類建置輸出不應該被當成需要維護的原始碼。
最後:
git status
git diff
確認新增的 Storybook 設定與 Stories。
Commit 可以:
git add .
git diff --staged
git commit -m "feat: integrate storybook for component documentation"
今天我們終於讓 CUI 有了正式的元件展示環境。
以前:
App.tsx
│
├── Button Demo
├── Input Demo
├── Table Demo
└── Dialog Demo
現在開始變成:
Storybook
│
├── Components
│ ├── Button
│ ├── Input
│ ├── Badge
│ └── Dialog
│
└── Patterns
└── Data List
這不只是把展示畫面搬到另一個工具。
更重要的是,我們開始把元件的:
Props
Variants
States
Usage
整理成開發者可以直接探索的文件。
而且 Storybook 使用的是同一份 CUI Source。
所以:
Component
→ Implementation
Story
→ Example
Controls
→ Exploration
Docs
→ Documentation
各自有清楚的責任。
今天最值得記住的是:
Storybook 不是另一套 UI,而是讓既有 UI Components 變得更容易理解、測試與維護。
今天我們已經有:
Storybook
+
Stories
+
Controls
但 CUI 的主題一直都是:
Accessibility。
所以明天不能只停留在:
元件長得對不對?
還要進一步確認:
Accessible Name 正確嗎?
ARIA Relationship 正確嗎?
Color Contrast 有問題嗎?
Keyboard 可以操作嗎?
Dialog 的 Focus Management 正常嗎?
不同 State 有沒有被完整測試?
Day 26 我們會正式把 Accessibility Testing 接進 Storybook。
除了自動化檢查,也會整理哪些問題仍然需要人工測試。
因為:
Accessibility Test Pass,不代表整個 Component 就一定 Accessible。
Day 26:
Storybook + Accessibility:元件不只要好看,還要能被正確使用。