昨天 Day 25,我們終於把 Storybook 加進 CUI。
原本放在 App.tsx 裡的元件展示,開始逐步搬進 Storybook:
Storybook
│
├── Components
│ ├── Button
│ ├── Input
│ ├── Badge
│ ├── Tooltip
│ └── Dialog
│
└── Patterns
├── Form
└── Data List
現在我們可以直接選擇不同 Story,觀察元件的 Variants、States,甚至透過 Controls 調整 Props。
例如:
Button
├── Primary
├── Secondary
├── Destructive
├── Disabled
└── Icon Only
但做到這裡,我突然想到一個問題:
我們一直說 CUI 是 Accessible UI Kit,那要怎麼證明它真的 Accessible?
例如一顆 Button:
<Button variant="primary">
儲存
</Button>
畫面正常、顏色正常、點擊也正常。
這樣就代表它符合 Accessibility 嗎?
那如果是:
<Button size="icon-md">
<SearchIcon />
</Button>
視覺上看起來也很正常。
但 Screen Reader 可能不知道這顆 Button 是做什麼的。
又或者:
<Dialog>
...
</Dialog>
滑鼠可以正常開啟與關閉。
但鍵盤使用者按下 Tab,卻跑到背景去了。
這些問題不一定能靠肉眼發現。
所以今天要做的事情是:
讓 Storybook 不只是展示元件,也開始成為 CUI 的 Accessibility Testing Environment。
今天會從三個層次建立 CUI 的 Accessibility Testing:
Accessibility Testing
│
├── Automated Testing
│ ├── Accessibility Addon
│ ├── Axe Rules
│ └── Color Contrast
│
├── Interaction Testing
│ ├── Keyboard
│ ├── Focus
│ └── Component Behavior
│
└── Manual Testing
├── Screen Reader
├── Keyboard Only
└── Visual Inspection
其中最重要的一個觀念是:
Automated Test Pass,不代表 Accessibility 已經全部完成。
前面我們已經做過不少 Accessibility 檢查。
例如 Day 6:
Primary Contrast
Surface Contrast
Error Contrast
Success Contrast
Warning Contrast
我們建立了:
npm run check:contrast
確認 Design Tokens 的對比度。
Day 16 做表單時,我們檢查:
Label
Required
aria-invalid
aria-describedby
Error Message
Day 19 做 Pagination 時,我們檢查:
nav
aria-label
aria-current="page"
Day 24 做 Dialog 時,又多了:
Initial Focus
Focus Containment
Focus Return
Escape
但這些檢查目前有一個問題。
每次都要靠我們自己記得。
今天測了 Button,明天修改 Button 時,可能就忘記重新測試。
所以我們需要把可以自動化的部分,交給工具持續檢查。
Storybook 有專門用來協助檢查 Accessibility 的 Addon。
它使用 axe-core 的規則,分析 Story 渲染後的 DOM,找出可自動偵測的 Accessibility 問題。
安裝:
npm install -D @storybook/addon-a11y
接著確認:
.storybook/main.ts
已經註冊 Addon。
例如:
const config = {
addons: [
"@storybook/addon-a11y",
],
}
實際修改時應保留原本其他 Addons 與設定,不要直接覆蓋整個檔案。
重新啟動:
npm run storybook
就可以在 Storybook 裡使用 Accessibility 檢查功能。
Axe 是一套 Accessibility Testing Engine。
它可以分析 DOM,找出許多常見問題。
例如:
Image 缺少替代文字
Button 缺少 Accessible Name
Form Control 缺少 Label
ARIA Attribute 使用錯誤
部分 Color Contrast 問題
不合法的 ARIA Role
例如:
<Button size="icon-md">
<SearchIcon aria-hidden="true" />
</Button>
這顆 Button 可能被檢查出缺少 Accessible Name。
因為:
Icon
→ aria-hidden="true"
Button
→ 沒有文字
→ 沒有 aria-label
所以對 Assistive Technology 而言,它是一顆沒有名稱的 Button。
正確的做法之一:
<Button
size="icon-md"
aria-label="搜尋"
>
<SearchIcon aria-hidden="true" />
</Button>
這樣:
Accessible Name
→ 搜尋
另一種方式是:
<Button size="icon-md">
<SearchIcon aria-hidden="true" />
<span className="sr-only">
搜尋
</span>
</Button>
這兩種方式都可以提供 Accessible Name。
我們在 Day 23 的 Visually Hidden 已經討論過。
所以 Storybook 的 Accessibility Test,也開始能驗證前面建立的使用規範。
這是我覺得很容易忽略的地方。
假設 Button 有:
Primary
Secondary
Outline
Ghost
Destructive
Disabled
Icon Only
如果只測:
Primary
然後通過:
No violations
不代表其他 Variants 都沒有問題。
因為不同 Variant 可能使用不同的:
Foreground Color
Background Color
Border
Focus Style
而不同 State 也可能改變 DOM 或互動行為。
因此:
每個具有不同語意、結構或視覺狀態的 Story,都應該納入適當的測試。
Day 6 我們已經建立 Design Tokens 的對比度檢查。
例如:
Primary
Primary Container
Surface
Surface Variant
Error
Success
Warning
當時我們主要檢查:
Token Pair
例如:
on-primary
vs
primary
但實際 Component 還可能出現:
Hover
Disabled
Focus
Overlay
Opacity
所以今天 Storybook 的 Accessibility Addon,可以作為另一層檢查。
Design Token Contrast
+
Rendered Component Contrast
兩者是互補的。
不過自動化工具不一定能正確判斷所有複雜背景、漸層、圖片或狀態。
因此仍然需要人工檢查。
以 WCAG 2.2 AA 常見的對比度要求來說:
一般文字:
4.5:1
大型文字:
3:1
需要辨識的 UI Component 邊界與重要圖形資訊:
3:1
但不是所有畫面上的顏色都需要套用同一個數值。
例如純裝飾性圖形、停用狀態等,可能有不同的適用條件。
因此 CUI 不應該只做:
所有顏色一律 4.5:1
而是依照元素的用途與 WCAG 成功準則判斷。
不能。
例如:
<Button aria-label="送出">
<TrashIcon aria-hidden="true" />
</Button>
Axe 可能認為:
Button 有 Accessible Name
所以不會因為缺少名稱而報錯。
但這顆按鈕實際上是:
刪除
Accessible Name 卻寫:
送出
這就是語意錯誤。
工具很難知道這個 Icon 真正代表什麼操作。
所以:
Technically Valid
≠
Semantically Correct
這也是為什麼自動化測試不能取代人工驗證。
我們前面做過:
Field
├── FieldLabel
├── Input
├── FieldDescription
└── FieldError
例如:
<Field data-invalid="true">
<FieldLabel htmlFor="email">
電子郵件
</FieldLabel>
<Input
id="email"
aria-invalid="true"
aria-describedby="email-error"
/>
<FieldError id="email-error">
請輸入有效的電子郵件地址。
</FieldError>
</Field>
這裡 Accessibility Addon 可以協助檢查部分結構與 ARIA 問題。
但它不一定知道:
Error Message 是否真的有幫助?
錯誤是否在適當時機出現?
提交失敗後 Focus 是否正確移動?
使用者是否知道如何修正?
這些都需要額外測試。
Storybook 除了展示 UI,也支援 Interaction Testing。
我們可以使用 Story 的:
play
函式,模擬使用者操作。
例如:
Click
Type
Tab
Keyboard
Assert
這讓 Story 不再只是:
靜態畫面
而是可以測試:
使用者做了什麼
↓
元件應該發生什麼事
例如:
import { expect, fn, userEvent, within } from "storybook/test"
建立一個 Button Story:
export const Clickable: Story = {
args: {
children: "儲存",
onClick: fn(),
},
play: async ({ canvasElement, args }) => {
const canvas = within(canvasElement)
const button = canvas.getByRole("button", {
name: "儲存",
})
await userEvent.click(button)
await expect(args.onClick).toHaveBeenCalled()
},
}
這個 Story 會檢查:
找到 Accessible Name 為「儲存」的 Button
↓
模擬 Click
↓
確認 Handler 被呼叫
這裡使用:
getByRole("button", { name: "儲存" })
而不是:
querySelector(".button")
因為前者更接近使用者與 Assistive Technology 理解元件的方式。
實際匯入方式需依安裝的 Storybook 版本調整。
getByRole?例如:
canvas.getByRole("button", {
name: "儲存",
})
這裡同時確認:
Role
→ button
Accessible Name
→ 儲存
如果某天有人把 Button 改成:
<div class="button">
儲存
</div>
測試就可能找不到預期的 Button Role。
這對 Accessible UI Kit 很有價值。
因為我們不只在測試:
畫面上有沒有一個東西
而是在測試:
使用者能不能以正確語意找到它
接著來測鍵盤。
例如一般 Button:
Tab
→ Focus Button
Enter
→ Activate
Space
→ Activate
Checkbox:
Tab
→ Focus Checkbox
Space
→ Toggle
Pagination:
Tab
→ Focus Link
Enter
→ Navigate
這些行為不應該靠我們自己發明。
而是優先保留原生 HTML 與 Primitive 的 Keyboard Behavior。
假設一顆 Button:
Mouse Click
→ 正常
但使用者按 Tab:
Focus 移到 Button
卻完全看不出來。
這就是問題。
所以 CUI 需要確認:
Keyboard Focus
→ 有清楚的 Focus Indicator
而且 Focus Indicator 的對比度與可見性也需要依適用的 WCAG 準則檢查。
但要注意:
Axe 沒有報錯
不代表 Focus Indicator 一定足夠清楚。
因此這部分仍然很適合人工測試。
昨天 Day 24 我們做了 Dialog。
它正好可以驗證:
Automated Testing
+
Interaction Testing
+
Manual Testing
三者為什麼都需要。
因為 Dialog 的 Accessibility 不只存在於 DOM。
還包含整個操作流程。
例如:
Trigger
↓
Open
↓
Initial Focus
↓
Tab Navigation
↓
Escape
↓
Focus Return
這些事情不是掃描一次靜態 DOM 就能完整確認的。
我們可以先測一個簡單流程:
找到「編輯資料」Button
↓
Click
↓
Dialog 出現
↓
確認 Dialog 有 Accessible Name
↓
按 Escape
↓
Dialog 關閉
例如:
export const OpenAndClose: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement)
const trigger = canvas.getByRole("button", {
name: "編輯資料",
})
await userEvent.click(trigger)
const dialog = await within(document.body).findByRole(
"dialog",
{
name: "編輯申請資料",
}
)
await expect(dialog).toBeVisible()
await userEvent.keyboard("{Escape}")
await expect(dialog).not.toBeInTheDocument()
},
}
這裡使用 document.body 查找 Dialog,是因為 Dialog 通常透過 Portal 渲染到 Story Canvas 外部。
實際測試時,也可以使用 Storybook 提供的 canvas、userEvent 與頁面查詢能力。
而且這個範例還可以繼續擴充:
Initial Focus
Focus Containment
Focus Return
不過今天先把最基本的 Open / Close 流程建立起來。
假設使用者:
Tab
↓
編輯資料
↓
Enter
↓
Dialog Open
↓
Escape
↓
Dialog Close
理想情況下:
Focus
→ 回到編輯資料
如果關閉後 Focus 跑到:
<body>
使用者可能需要重新找回原本的位置。
這件事可能不會被一般的 Axe DOM 掃描完整檢查。
所以我們需要:
Interaction Test
或:
Manual Keyboard Test
驗證實際流程。
Day 20、Day 21 我們建立過 Data List Pattern。
其中有:
Default
Loading
Empty
No Results
Error
這些狀態非常適合放進 Storybook。
例如:
Data List / Loading
我們可以檢查:
aria-busy
Loading Status
Skeleton
而:
Data List / Error
可以檢查:
Error Message
Retry Button
Focus Behavior
但工具不一定知道:
使用者是否真的理解目前發生什麼事?
這仍然需要人工判斷。
今天不能只依賴 Storybook Addon。
還需要實際使用 Screen Reader。
例如:
Windows
→ NVDA
macOS
→ VoiceOver
我們可以從最常見的幾種元件開始。
Button:
儲存,按鈕
Checkbox:
同意條款,核取方塊,未勾選
Input:
電子郵件,編輯文字
Dialog:
編輯申請資料,對話框
實際朗讀方式會因瀏覽器、Screen Reader 與操作情境而不同。
重點不是要求每一套工具朗讀完全相同的句子。
而是確認使用者能理解:
這是什麼?
目前狀態是什麼?
可以做什麼?
操作後發生什麼事?
我覺得這個測試很值得養成習慣。
因為它不需要安裝任何工具。
只要把滑鼠放旁邊。
使用:
Tab
Shift + Tab
Enter
Space
Escape
Arrow Keys
逐一操作元件。
例如:
Button
→ Tab / Enter / Space
Checkbox
→ Tab / Space
Radio Group
→ Tab / Arrow Keys
Select
→ 依 Primitive 的鍵盤操作規則
Tooltip
→ Focus / Escape
Dialog
→ Tab / Shift + Tab / Escape
如果連鍵盤都操作不順,通常就值得重新檢查元件設計。
這也是 CUI 一直強調的原則。
例如:
Radio Group
不應該由我們自己隨便設計:
ArrowRight
→ Next
而是依照適用的 ARIA Authoring Practices 與 Primitive 行為來實作。
同樣:
Dialog
也不應該自己在每顆 Button 上加一堆:
onKeyDown
來模擬 Focus Trap。
因為這些行為已經有成熟的實作模式。
CUI 的責任是:
選擇可靠的 Primitive
↓
確認符合使用情境
↓
建立一致的 Contract
↓
持續測試
整理成一張表:
| 測試方式 | 擅長檢查 | 可能漏掉 |
|---|---|---|
| Automated | 常見 ARIA、Label、部分對比度與 DOM 問題 | 語意是否合理、完整操作流程 |
| Interaction | Click、Keyboard、Focus、狀態變化 | 真實輔助科技體驗 |
| Manual | 實際可理解性、鍵盤操作、Screen Reader 體驗 | 難以快速覆蓋所有組合 |
所以不是:
選一個最好的
而是:
三者互補
除了測試,我們也可以開始把 Accessibility Rules 寫進 Storybook。
例如 Button:
Accessibility
- Use native button semantics.
- Icon-only buttons must have an accessible name.
- Preserve visible keyboard focus.
- Do not rely on color alone to communicate meaning.
Input:
Accessibility
- Every input needs an accessible label.
- Error messages should be associated with the input.
- Use aria-invalid when the value is invalid.
- Required information should be communicated clearly.
Dialog:
Accessibility
- Provide an accessible title.
- Move focus into the dialog when opened.
- Contain focus within a modal dialog.
- Restore focus appropriately when closed.
- Ensure background content is not interactive.
這些內容可以放進 Storybook Docs 的 Description。
還記得 Day 22 嗎?
我們整理過:
Semantic HTML
Variant
Size
State
data-cui-*
Design Tokens
現在這些規則都可以反映在 Storybook。
例如:
Button
│
├── Props
│ ├── variant
│ ├── size
│ └── disabled
│
├── Examples
│ ├── Primary
│ ├── Secondary
│ └── Icon Only
│
└── Accessibility
├── Accessible Name
├── Keyboard
└── Focus
Storybook 會開始變成:
CUI Component Contract 的人類可閱讀版本。
我覺得應該。
但今天先不急著把整套 CI Pipeline 全部建立起來。
因為目前我們才剛開始:
Storybook
+
Accessibility Addon
+
Interaction Tests
下一步才是讓這些測試能在:
Pull Request
時自動執行。
例如:
Git Push
↓
GitHub Actions
↓
Build
↓
Lint
↓
Contrast Check
↓
Storybook Tests
↓
Result
這樣未來修改 Button 或 Dialog 時,就不必完全依賴人工記憶。
這點很重要。
即使未來:
Build ✓
Lint ✓
Contrast ✓
Axe ✓
Interaction ✓
也不代表:
WCAG AA Certified
或:
整個網站符合所有無障礙要求
因為 CUI 只是一套元件與使用規範。
實際網站仍然需要處理:
Page Structure
Content
Navigation
Forms
Media
Responsive Layout
Focus Order
Error Recovery
所以我們可以說:
CUI 提供經過 Accessibility Testing 的元件與使用模式。
但不能只因為元件測試通過,就保證所有使用 CUI 的網站都符合 WCAG AA。
到目前為止,我想先把常見檢查整理成:
CUI Accessibility Checklist
Semantics
Keyboard
Visual
Interaction
Testing
今天不打算把所有 Component 的測試一次補完。
先選幾個有代表性的:
Button
→ Accessible Name
→ Disabled
→ Keyboard
Input / Field
→ Label
→ Invalid
→ Error Association
Dialog
→ Open / Close
→ Focus
Data List
→ Loading
→ Empty
→ Error
這幾組就能涵蓋:
Semantic
State
Interaction
Focus
其他元件之後可以逐步補齊。
先啟動 Storybook:
npm run storybook
確認 Accessibility Addon 可以正常分析各個 Story。
再執行:
npm run build-storybook
確認 Storybook 能正常建置。
接著執行原本的檢查:
npm run build
npm run lint
npm run check:contrast
如果今天也設定了 Storybook Test Runner 或 Vitest 整合,再執行對應的測試指令。
要注意:
build-storybook 成功不代表 Accessibility Tests 已經執行。
它只是確認 Storybook 可以正常產生靜態建置。
自動化測試仍然需要額外設定測試執行環境。
最後:
git status
git diff
Commit 可以:
git add .
git diff --staged
git commit -m "test: add storybook accessibility checks"
今天我們讓 Storybook 從:
Component Showcase
開始往:
Component Documentation
+
Accessibility Testing
前進。
目前 CUI 的驗證方式也逐漸完整:
CUI Component
│
┌───────────┼───────────┐
↓ ↓ ↓
Automated Interaction Manual
│ │ │
↓ ↓ ↓
Axe Keyboard Screen Reader
Contrast Focus Visual Check
│ │ │
└───────────┼───────────┘
↓
Accessibility
Confidence
但這裡我不想把最後一格寫成:
Accessibility Guaranteed
因為沒有任何單一測試工具,可以保證一個元件在所有使用情境下都完全無障礙。
我們真正要做的是:
透過不同層次的測試,持續降低 Accessibility 問題發生的機率。
而且這些測試不應該只在元件完成的那一天執行。
未來每次修改:
Component
Design Token
Interaction
ARIA
都應該重新驗證。
這才是把 Accessibility 當成 Design System 品質要求,而不是最後才補上的檢查項目。
到目前為止,我們已經有:
React Components
Storybook
Accessibility Testing
Component Contract
Design Tokens
但還記得 Day 1 的目標嗎?
CUI 不只要給 React 使用。
還要讓既有的 Legacy 系統也能使用。
例如:
mod_plsql
這些系統可能沒有:
React
Vite
TypeScript
但仍然需要:
Button
Input
Alert
Table
所以明天我們要開始處理一個從 Day 1 就留下來的問題:
如何從同一套 CUI Source,產生可以讓 Legacy 系統使用的 CSS / JavaScript?
例如:
CUI Source
│
Build
│
┌─────────┴─────────┐
↓ ↓
React CDN
│
cui.min.css
cui.js
│
↓
Legacy Web
而且這次不能只是另外寫一份:
legacy.css
因為那會違反我們一直希望維持的:
Single Source, Multiple Outputs。
Day 27:
CDN Build:讓 CUI 不只存在於 React。