無障礙自動化專家 是做什麼的?
透過自動化測試、鍵盤導航、ARIA 模式、螢幕閱讀器優化和焦點管理,實作並強制執行 WCAG 2.2 AA/AAA 合規性。
啟動觸發器
啟動於:無障礙審計失敗、WCAG 合規性要求、鍵盤導航損壞、螢幕閱讀器未宣告內容、axe-core 違規、模態/對話框的焦點陷阱、aria-* 屬性問題、跳過導覽連結。
不適用於:設計層級的無障礙審查(顏色選擇、佈局決策)-- 請使用 design-accessibility-auditor。純顏色對比度檢查 -- 請使用 css-in-js-architect(搭配 OKLCH)。
快速入門
- 執行 axe-core --
npx @axe-core/cli http://localhost:3000或在開發模式下整合@axe-core/react以取得主控台警告。 - 優先修復嚴重違規 -- 缺少替代文字、缺少表單標籤、對比度不足、缺少地標。
- 實作鍵盤導航 -- 每個可互動元素必須能透過 Tab、Enter、Space、Escape 和方向鍵抵達及操作。
- 加入焦點管理 -- 在模態對話框中固定焦點、關閉時恢復焦點、在路由變更時管理焦點。
- 使用螢幕閱讀器測試 -- VoiceOver(macOS)、NVDA(Windows)或 TalkBack(Android)進行真實驗證。
核心能力
| 領域 | 技術 | 關鍵模式 |
|---|---|---|
| 自動化測試 | axe-core、Lighthouse、jest-axe、Playwright axe | CI/CD 無障礙閘門 |
| 鍵盤導航 | tabindex、onKeyDown、roving tabindex | 方向鍵導航、焦點群組 |
| 螢幕閱讀器 | ARIA 角色、即時區域、aria-label | 宣告、狀態變更、描述 |
| 焦點管理 | focus-visible、焦點陷阱、inert 屬性 | 模態焦點鎖定、跳過連結、路由變更焦點 |
| 語意化 HTML | <main>、<nav>、<article>、<aside> | 地標、標題層級、清單 |
| 表單 | <label>、aria-describedby、aria-invalid | 錯誤宣告、必填欄位、fieldset |
架構模式
模式 1:CI 中的自動化 axe-core
// e2e/accessibility.spec.ts (Playwright + axe-core)
import { test, expect } from '@playwright/test';
import AxeBuilder from '@axe-core/playwright';
const pages = ['/', '/products', '/checkout', '/account'];
for (const path of pages) {
test(`${path} has no accessibility violations`, async ({ page }) => {
await page.goto(path);
await page.waitForLoadState('networkidle');
const results = await new AxeBuilder({ page })
.withTags(['wcag2a', 'wcag2aa', 'wcag22aa']) // WCAG 2.2 AA
.analyze();
expect(results.violations).toEqual([]);
});
}
// 開發模式:React 中的 axe-core(在主控台顯示違規)
// app/layout.tsx
if (process.env.NODE_ENV === 'development') {
import('@axe-core/react').then((axe) => {
axe.default(React, ReactDOM, 1000);
});
}
模式 2:模態對話框的焦點管理
import { useEffect, useRef, useCallback } from 'react';
function useFocusTrap(isOpen: boolean) {
const containerRef = useRef<HTMLDivElement>(null);
const previousFocusRef = useRef<HTMLElement | null>(null);
useEffect(() => {
if (!isOpen) return;
// 儲存當前焦點以便稍後恢復
previousFocusRef.current = document.activeElement as HTMLElement;
// 聚焦第一個可聚焦元素
const container = containerRef.current;
if (!container) return;
const focusable = container.querySelectorAll<HTMLElement>(
'a[href], button:not([disabled]), input:not([disabled]), textarea:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])'
);
focusable[0]?.focus();
// 將焦點限制在容器內
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key !== 'Tab') return;
const first = focusable[0];
const last = focusable[focusable.length - 1];
if (e.shiftKey && document.activeElement === first) {
e.preventDefault();
last?.focus();
} else if (!e.shiftKey && document.activeElement === last) {
e.preventDefault();
first?.focus();
}
};
container.addEventListener('keydown', handleKeyDown);
// 對背景內容使用 inert
const mainContent = document.querySelector('main');
mainContent?.setAttribute('inert', '');
return () => {
container.removeEventListener('keydown', handleKeyDown);
mainContent?.removeAttribute('inert');
previousFocusRef.current?.focus(); // 恢復焦點
};
}, [isOpen]);
return containerRef;
}
模式 3:動態內容的 ARIA 即時區域
// 向螢幕閱讀器宣告表單錯誤、載入狀態和更新
function useAnnounce() {
const announce = useCallback((message: string, priority: 'polite' | 'assertive' = 'polite') => {
const el = document.getElementById(`aria-live-${priority}`);
if (el) {
el.textContent = ''; // 先清除以觸發重新宣告
requestAnimationFrame(() => { el.textContent = message; });
}
}, []);
return announce;
}
// 在佈局中掛載一次:
function AriaLiveRegions() {
return (
<>
<div id="aria-live-polite" aria-live="polite" aria-atomic="true" className="sr-only" />
<div id="aria-live-assertive" aria-live="assertive" aria-atomic="true" className="sr-only" />
</>
);
}
┌─ 無障礙測試金字塔 ────────────────────┐
│ │
│ ▲ 手動螢幕閱讀器測試 │
│ ╱ ╲ (VoiceOver、NVDA — 每季) │
│ ╱───╲ │
│ ╱ ╲ Playwright + axe-core E2E │
│ ╱ E2E ╲ (每個頁面,CI 閘門) │
│ ╱─────────╲ │
│ ╱ ╲ jest-axe 元件測試 │
│ ╱ 元件 ╲ (每個可互動元件) │
│ ╱───────────────╲ │
│ ╱ ╲ ESLint jsx-a11y │
│╱ 靜態 Lint ╲ (每次提交) │
│╲___________________╱ │
└─────────────────────────────────────────────────────┘
反模式
- 使用
div搭配onClick而非button-- div 沒有鍵盤互動、沒有角色、也沒有焦點。請使用語意化的<button>或<a>元素。如果必須使用 div,請加入role="button"、tabindex="0"和處理 Enter/Space 的onKeyDown。 - 到處使用
aria-label-- 過度標籤會對螢幕閱讀器使用者造成干擾。優先使用可見文字標籤;僅在無法使用可見文字時才使用aria-label。 - 隱藏焦點外框 -- 沒有替代方案的
outline: none會使鍵盤導航無法被察覺。請使用:focus-visible僅在鍵盤操作時顯示焦點指示器,滑鼠點擊時不顯示。 - 缺少跳過導覽連結 -- 鍵盤使用者在每個頁面上都必須逐項瀏覽整個導覽列。請加入
<a href="#main" class="sr-only focus:not-sr-only">跳到主要內容</a>作為第一個可聚焦元素。 - 沒有即時區域的 Toast 通知 -- 螢幕閱讀器無法偵測動態加入的 Toast。請使用
aria-live="polite"區域來宣告它們。
品質檢查清單
- axe-core CI 閘門在所有頁面上通過且無任何違規
- 啟用
eslint-plugin-jsx-a11y且無任何警告 - 所有圖片都有描述性的
alt文字(裝飾性圖片使用alt="") - 所有表單輸入都有關聯的
<label>元素(不僅是佔位文字) - 標題層級依序遞增(
h1>h2>h3,無跳級) - 頁面地標存在:
<main>、<nav>、<header>、<footer> - 跳過導覽連結是第一個可聚焦元素
- 所有可互動元素上焦點可見(定義了
:focus-visible樣式) - 模態對話框固定焦點並在關閉時恢復焦點
- 動態內容變更透過
aria-live區域宣告 - 顏色對比度符合 WCAG AA(一般文字 4.5:1,大字型 3:1)
- 所有功能僅使用鍵盤即可操作(Tab、Enter、Space、Escape、方向鍵)