iT邦幫忙

2026 iThome 鐵人賽

DAY 26
0
自我挑戰組

30 天打造 Accessible UI Kit:從 shadcn/ui 到自己的 Design System系列 第 26 篇

Day 26: Storybook + Accessibility:測試通過,就代表無障礙了嗎?

  • 分享至 

  • xImage
  •  

昨天 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。


1. 今天要完成什麼?

今天會從三個層次建立 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 已經全部完成。


2. 為什麼需要自動化 Accessibility Testing?

前面我們已經做過不少 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 時,可能就忘記重新測試。

所以我們需要把可以自動化的部分,交給工具持續檢查。


3. Storybook Accessibility Addon

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 檢查功能。


4. Axe 在檢查什麼?

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。


5. 修正 Icon-only 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,也開始能驗證前面建立的使用規範。


6. 不要只測 Default Story

這是我覺得很容易忽略的地方。

假設 Button 有:

Primary
Secondary
Outline
Ghost
Destructive
Disabled
Icon Only

如果只測:

Primary

然後通過:

No violations

不代表其他 Variants 都沒有問題。

因為不同 Variant 可能使用不同的:

Foreground Color
Background Color
Border
Focus Style

而不同 State 也可能改變 DOM 或互動行為。

因此:

每個具有不同語意、結構或視覺狀態的 Story,都應該納入適當的測試。


7. Color Contrast:還記得 Day 6 嗎?

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

兩者是互補的。

不過自動化工具不一定能正確判斷所有複雜背景、漸層、圖片或狀態。

因此仍然需要人工檢查。


8. 對比度不是所有元素都一樣

以 WCAG 2.2 AA 常見的對比度要求來說:

一般文字:

4.5:1

大型文字:

3:1

需要辨識的 UI Component 邊界與重要圖形資訊:

3:1

但不是所有畫面上的顏色都需要套用同一個數值。

例如純裝飾性圖形、停用狀態等,可能有不同的適用條件。

因此 CUI 不應該只做:

所有顏色一律 4.5:1

而是依照元素的用途與 WCAG 成功準則判斷。


9. 自動化測試能找到所有問題嗎?

不能。

例如:

<Button aria-label="送出">
  <TrashIcon aria-hidden="true" />
</Button>

Axe 可能認為:

Button 有 Accessible Name

所以不會因為缺少名稱而報錯。

但這顆按鈕實際上是:

刪除

Accessible Name 卻寫:

送出

這就是語意錯誤。

工具很難知道這個 Icon 真正代表什麼操作。

所以:

Technically Valid
≠
Semantically Correct

這也是為什麼自動化測試不能取代人工驗證。


10. 再來看表單

我們前面做過:

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 是否正確移動?

使用者是否知道如何修正?

這些都需要額外測試。


11. Storybook 的 Interaction Testing

Storybook 除了展示 UI,也支援 Interaction Testing。

我們可以使用 Story 的:

play

函式,模擬使用者操作。

例如:

Click
Type
Tab
Keyboard
Assert

這讓 Story 不再只是:

靜態畫面

而是可以測試:

使用者做了什麼
↓
元件應該發生什麼事

12. 先從簡單的 Button 開始

例如:

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 版本調整。


13. 為什麼偏好 getByRole?

例如:

canvas.getByRole("button", {
  name: "儲存",
})

這裡同時確認:

Role
→ button

Accessible Name
→ 儲存

如果某天有人把 Button 改成:

<div class="button">
  儲存
</div>

測試就可能找不到預期的 Button Role。

這對 Accessible UI Kit 很有價值。

因為我們不只在測試:

畫面上有沒有一個東西

而是在測試:

使用者能不能以正確語意找到它

14. Keyboard Testing

接著來測鍵盤。

例如一般 Button:

Tab
→ Focus Button

Enter
→ Activate

Space
→ Activate

Checkbox:

Tab
→ Focus Checkbox

Space
→ Toggle

Pagination:

Tab
→ Focus Link

Enter
→ Navigate

這些行為不應該靠我們自己發明。

而是優先保留原生 HTML 與 Primitive 的 Keyboard Behavior。


15. Focus Visible 也需要檢查

假設一顆 Button:

Mouse Click
→ 正常

但使用者按 Tab:

Focus 移到 Button

卻完全看不出來。

這就是問題。

所以 CUI 需要確認:

Keyboard Focus
→ 有清楚的 Focus Indicator

而且 Focus Indicator 的對比度與可見性也需要依適用的 WCAG 準則檢查。

但要注意:

Axe 沒有報錯

不代表 Focus Indicator 一定足夠清楚。

因此這部分仍然很適合人工測試。


16. Dialog 是今天最重要的測試案例

昨天 Day 24 我們做了 Dialog。

它正好可以驗證:

Automated Testing
+
Interaction Testing
+
Manual Testing

三者為什麼都需要。

因為 Dialog 的 Accessibility 不只存在於 DOM。

還包含整個操作流程。

例如:

Trigger
↓
Open
↓
Initial Focus
↓
Tab Navigation
↓
Escape
↓
Focus Return

這些事情不是掃描一次靜態 DOM 就能完整確認的。


17. Dialog 的 Interaction Test

我們可以先測一個簡單流程:

找到「編輯資料」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 流程建立起來。


18. 為什麼 Dialog 的 Focus Return 要另外測?

假設使用者:

Tab
↓
編輯資料
↓
Enter
↓
Dialog Open
↓
Escape
↓
Dialog Close

理想情況下:

Focus
→ 回到編輯資料

如果關閉後 Focus 跑到:

<body>

使用者可能需要重新找回原本的位置。

這件事可能不會被一般的 Axe DOM 掃描完整檢查。

所以我們需要:

Interaction Test

或:

Manual Keyboard Test

驗證實際流程。


19. Data List 也可以做 Accessibility 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

但工具不一定知道:

使用者是否真的理解目前發生什麼事?

這仍然需要人工判斷。


20. Screen Reader Test

今天不能只依賴 Storybook Addon。

還需要實際使用 Screen Reader。

例如:

Windows
→ NVDA

macOS
→ VoiceOver

我們可以從最常見的幾種元件開始。

Button:

儲存,按鈕

Checkbox:

同意條款,核取方塊,未勾選

Input:

電子郵件,編輯文字

Dialog:

編輯申請資料,對話框

實際朗讀方式會因瀏覽器、Screen Reader 與操作情境而不同。

重點不是要求每一套工具朗讀完全相同的句子。

而是確認使用者能理解:

這是什麼?

目前狀態是什麼?

可以做什麼?

操作後發生什麼事?

21. Keyboard-only Test

我覺得這個測試很值得養成習慣。

因為它不需要安裝任何工具。

只要把滑鼠放旁邊。

使用:

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

如果連鍵盤都操作不順,通常就值得重新檢查元件設計。


22. 不要把所有鍵盤操作都自己寫

這也是 CUI 一直強調的原則。

例如:

Radio Group

不應該由我們自己隨便設計:

ArrowRight
→ Next

而是依照適用的 ARIA Authoring Practices 與 Primitive 行為來實作。

同樣:

Dialog

也不應該自己在每顆 Button 上加一堆:

onKeyDown

來模擬 Focus Trap。

因為這些行為已經有成熟的實作模式。

CUI 的責任是:

選擇可靠的 Primitive
↓
確認符合使用情境
↓
建立一致的 Contract
↓
持續測試

23. Automated、Interaction、Manual 的差異

整理成一張表:

測試方式 擅長檢查 可能漏掉
Automated 常見 ARIA、Label、部分對比度與 DOM 問題 語意是否合理、完整操作流程
Interaction Click、Keyboard、Focus、狀態變化 真實輔助科技體驗
Manual 實際可理解性、鍵盤操作、Screen Reader 體驗 難以快速覆蓋所有組合

所以不是:

選一個最好的

而是:

三者互補

24. Storybook 的 Accessibility 文件

除了測試,我們也可以開始把 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。


25. Storybook 文件也要遵守 Day 22 的 Contract

還記得 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 的人類可閱讀版本。


26. Accessibility Test 應該放進 CI 嗎?

我覺得應該。

但今天先不急著把整套 CI Pipeline 全部建立起來。

因為目前我們才剛開始:

Storybook
+
Accessibility Addon
+
Interaction Tests

下一步才是讓這些測試能在:

Pull Request

時自動執行。

例如:

Git Push
↓
GitHub Actions
↓
Build
↓
Lint
↓
Contrast Check
↓
Storybook Tests
↓
Result

這樣未來修改 Button 或 Dialog 時,就不必完全依賴人工記憶。


27. 但 CI 也不等於無障礙認證

這點很重要。

即使未來:

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。


28. 今天建立一份 CUI Accessibility Checklist

到目前為止,我想先把常見檢查整理成:

CUI Accessibility Checklist

Semantics

  • [ ] 使用正確的 HTML 元素
  • [ ] Accessible Name 清楚
  • [ ] ARIA Role / State / Property 正確
  • [ ] Label 與 Description 關聯正確

Keyboard

  • [ ] 可以使用鍵盤操作
  • [ ] Focus Indicator 清楚
  • [ ] Focus Order 合理
  • [ ] 不會造成 Keyboard Trap

Visual

  • [ ] 文字對比度符合適用要求
  • [ ] UI Component 邊界與重要圖形可辨識
  • [ ] 不只依賴顏色傳達資訊
  • [ ] 放大與窄螢幕下仍可操作

Interaction

  • [ ] 開啟與關閉行為正確
  • [ ] 狀態變化可理解
  • [ ] Validation Error 能被發現
  • [ ] Focus Management 合理

Testing

  • [ ] Axe 檢查
  • [ ] Interaction Test
  • [ ] Keyboard-only Test
  • [ ] Screen Reader Test

29. 今天的實作範圍

今天不打算把所有 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

其他元件之後可以逐步補齊。


30. 最後執行檢查

先啟動 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"

Day 26 完成

今天我們讓 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 品質要求,而不是最後才補上的檢查項目。


Next:Day 27

到目前為止,我們已經有:

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。


上一篇
Day 25: Storybook:讓 UI Kit 不再只活在 App.tsx 裡
下一篇
# Day 27:CDN Build:讓 CUI 不只存在於 React
系列文
30 天打造 Accessible UI Kit:從 shadcn/ui 到自己的 Design System 共 30 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言