FE Day 42 無障礙與響應式總檢
執行需求:CPU 可跑。今天是「前端開發實戰:React 與 Next.js 全套」系列的第四十二天,貫穿專案「預約管理系統前端」進入上線前的最後一輪品質檢查。今天不寫新功能,只做四件事:第一,用 @axe-core/playwright 把 11 個頁面全部掃一遍,違規就讓 CI 失敗;第二,用 Playwright 走一遍純鍵盤流程(Skip Link、篩選面板、對話框焦點鎖定);第三,在 390、768、1200、1440 四種寬度各截一張圖,並自動檢查水平溢出與觸控目標大小;第四,把掃出來的違規一項一項修掉(對話框焦點、手機版篩選收合、響應式表格、色彩對比、減少動畫偏好)。今天所有資料都是虛構示範,沿用 Day 31–41 的 NEXT_PUBLIC_API_MODE=mock 離線模式,不連任何後端也能完整跑完檢查。
引言
無障礙(a11y)與響應式(responsive)是兩個最常在「上線前最後一週」才被想起來的主題。它們的共同點是:平時看不出問題,出問題時代價很高。一個沒有 label 的輸入框在桌機上看起來完全正常,但螢幕閱讀器使用者會聽到「編輯框」而不知道要填什麼;一個寫死的 320 像素側欄在 1440 寬的螢幕上像教科書,在 390 寬的手機上卻會把整個版面撐出水平捲軸。
貫穿專案的「預約管理系統」是個小型服務業線上預約平台(情境涵蓋攝影棚、健身教練、諮詢工作室,所有資料都是虛構示範)。Day 31–37 把 11 個頁面做完、Day 38 做完登入與角色導向、Day 39 做完效能調校、Day 40 做完測試金字塔、Day 41 部署到 Vercel。今天要做的,是把 Day 28 的 a11y 基礎與 Day 33 提到的「手機版篩選」收尾:把自動掃描、鍵盤走訪、多斷點檢視這三件事寫成可重複執行的測試,並且讓每一條都接進 Day 40 的 CI 流程。做完之後,「上線品質」不再靠人工憑感覺,而是有一組會自動擋下回歸的檢查。
今天的內容分四段:第一段把 Day 31–41 的共用設定再次統整;第二段說明 WCAG 2.2 AA 的判準、行動優先的斷點策略、以及觸控目標與焦點可見性的具體數字;第三段實作三支 Playwright 檢查與四個修正;第四段整合進 CI 並跑一次完整結果。讀完之後你會拿到一份可以照著跑的檢查腳本、一份「掃到這類違規要改哪裡」的對照表,以及一份能放進 PR 描述裡的檢查紀錄。
貫穿專案共用設定(Day 31–44 沿用)
從 Day 31 開始的「預約管理系統前端」到今天 Day 42,整個 stack 維持一致設定,避免後續篇章互相矛盾:
- 框架:Next.js 15(App Router、Server Components 預設)、React 19.x、TypeScript 5.9
- 樣式:Tailwind CSS 4.x、CSS variables 主題、Day 32 的
components/ui/(Button、Input、Card、Badge、Dialog、Toast) - 後端:FastAPI 預約管理系統(Web 系列 Day 35–44 定義的 REST API);
NEXT_PUBLIC_API_MODE=mock時走lib/mock/*.tsfixture - 認證:JWT access token(12 小時)、refresh token(14 天,httpOnly cookie);沿用 Day 38 的
AuthProvider - 角色:admin(管理者)、customer(客戶)
- 路由:客戶端
/、/services、/services/[id]、/bookings、/bookings/new;認證/login、/register;後台/admin、/admin/bookings、/admin/services、/admin/services/[id]/slots,共 11 個頁面 - 測試:Day 27 的 Playwright 1.5x、Day 40 的 Vitest 3.x 與 axe-core 4.x;今天把 a11y 與響應式檢查擴充成完整覆蓋
- 設計 token:
--color-brand-600: #4f46e5、--color-text-primary: #0f172a、--color-text-secondary: #475569、--color-surface: #ffffff、--color-danger-600: #e11d48
這份設定從 Day 31 到 Day 44 不變。今天的總檢是「在同一份設定上補檢查與修缺陷」,不改路由結構、不改 API 契約、不改設計系統的介面;唯一新增的是 styles/a11y.css 這個全域樣式檔、一支 useMediaQuery 鉤子,以及三支 Playwright 檢查。
原理解念:WCAG 2.2 AA 判準、行動優先斷點與觸控目標
無障礙的驗收標準是 WCAG 2.2,我們把目標鎖在 AA 等級。AA 是實務上最被廣泛要求的等級,它的具體要求散在幾十條成功準則裡,但對前端工程師來說可以收斂成六個可檢查的數字:一般文字對比至少 4.5:1、大字(18pt 或 14pt 粗體)至少 3:1、互動元件的觸控目標至少 24 × 24 CSS 像素且相鄰間距足夠(AA 級要求),而我們自家規範再往上一階、把主要操作訂在 44 × 44、焦點指示必須可見且對比至少 3:1、頁面必須宣告語言(lang="zh-Hant")、所有功能都必須能只用鍵盤完成。把標準翻譯成數字的好處是「可以寫成測試」:數字能斷言,形容詞不能。
響應式的核心觀念是「行動優先(mobile first)」。行動優先不是「先做手機版再做桌機版」的流程口號,而是 CSS 的撰寫順序:預設樣式就是手機版,再用 min-width 的媒體查詢往上加。這樣做有兩個好處:第一,手機使用者拿到的是最精簡的樣式,不必先下載桌機版規則再被覆蓋;第二,版面的「最小可行結構」先被確立,桌機版只是加強而不是重寫。我們的斷點沿用 Tailwind 4.x 的預設值,但刻意只挑四個在檢查中出現:390(現代手機)、768(平板直向)、1200(筆電)、1440(桌機)。檢查這四個寬度,是因為它們分別對應「單欄」「兩欄」「側欄加內容」「大留白」四種完全不同的版面行為。
觸控目標與焦點可見性是最容易被忽略的兩個細節,因為它們「不影響功能」。44 × 44 這個數字來自 WCAG 2.5.5 的建議與各大平台的設計規範(Apple 的 Human Interface Guidelines 與 Material Design 都落在這個區間)。少於這個尺寸,手指在移動中的公車上幾乎點不準,誤觸率會顯著上升。焦點可見性則是鍵盤使用者的生命線:outline: none 是前端最常見的錯誤,它讓鍵盤使用者完全失去「我現在在哪」。正確做法是用 :focus-visible,只在鍵盤操作時顯示焦點框,滑鼠點選不會出現,兼顧美觀與可及性。
最後是「減少動畫偏好」。前庭功能障礙、偏頭痛、眩暈症的使用者會被大幅位移與視差滾動觸發症狀,作業系統因此提供了 prefers-reduced-motion 偏好。前端要做的事很單純:偵測這個偏好、把動畫時長壓到近乎歸零、關掉平滑滾動。prefers-color-scheme 則處理另一個偏好:使用者選了深色模式,介面就該跟著換。今天會把這兩個偏好都用全域 CSS 處理掉,而不是散在幾十個元件裡各寫一次。
完整實作:全站掃描、鍵盤走訪、四斷點檢視與修正
第一步,安裝今天要用的檢查工具。@axe-core/playwright 是 Day 28 用過的老朋友,今天只補上 Playwright 的裝置描述子,讓我們能精準切換四種寬度。
# 安裝 Day 42 需要的檢查工具(2026 年 3 月主流版本)
pnpm add -D @axe-core/playwright@^4.10.0 @playwright/test@^1.5.0
# 只用 Chromium 就足以涵蓋 axe 與斷點檢查,CI 也最省時間
pnpm exec playwright install chromium
第二步,寫全站掃描。這一支測試的價值在於「把 11 個頁面變成 11 個會自動失敗的檢查」。我們只把 serious 與 critical 兩個等級當成阻擋條件,因為 minor 與 moderate 常常是主觀取捨(例如某個裝飾元素的對比),全部當成錯誤會讓 CI 永遠是紅的,反而讓團隊開始忽略它。
// tests/e2e/a11y.spec.ts
// 把 11 個頁面全部掃一遍;嚴重與重大違規就讓 CI 失敗。
import { test, expect } from '@playwright/test';
import AxeBuilder from '@axe-core/playwright';
const PAGES = [
{ path: '/', role: 'public' },
{ path: '/services', role: 'public' },
{ path: '/services/svc-001', role: 'public' },
{ path: '/login', role: 'public' },
{ path: '/register', role: 'public' },
{ path: '/bookings', role: 'customer' },
{ path: '/bookings/new?serviceId=svc-001', role: 'customer' },
{ path: '/admin', role: 'admin' },
{ path: '/admin/bookings', role: 'admin' },
{ path: '/admin/services', role: 'admin' },
{ path: '/admin/services/svc-001/slots', role: 'admin' },
];
const ACCOUNTS = {
admin: { email: 'admin@example.com', password: 'admin-pass' },
customer: { email: 'alice@example.com', password: 'alice-pass' },
};
async function loginAs(page, role) {
await page.goto('/login');
await page.getByLabel('Email').fill(ACCOUNTS[role].email);
await page.getByLabel('密碼').fill(ACCOUNTS[role].password);
await page.getByRole('button', { name: '登入' }).click();
await expect(page.getByRole('main')).toBeVisible();
}
function formatViolations(violations) {
return violations
.map((v) => `${v.id}(${v.impact}):${v.help} → ${v.nodes[0]?.target.join(' ')}`)
.join('\n');
}
for (const { path: pagePath, role } of PAGES) {
test(`a11y ${pagePath}`, async ({ page }) => {
if (role !== 'public') await loginAs(page, role);
await page.goto(pagePath);
// 等骨架畫面消失再掃,避免掃到載入中的骨架元素
await expect(page.getByRole('main')).toBeVisible();
const { violations } = await new AxeBuilder({ page })
.withTags(['wcag2a', 'wcag2aa', 'wcag22aa'])
.analyze();
const blocking = violations.filter((v) => v.impact === 'serious' || v.impact === 'critical');
expect(blocking, formatViolations(blocking)).toEqual([]);
});
}
第三步,寫純鍵盤流程。這支測試比 axe 更進一步:axe 能判斷「這個按鈕有沒有名字」,但判斷不了「Tab 的順序是不是合理」、「焦點有沒有被困在對話框裡」。這類問題只能靠真的按鍵盤來驗證。page.keyboard.press 送出的是瀏覽器層級的鍵盤事件,跟真人操作走同一條路徑。
// tests/e2e/keyboard.spec.ts
// 只用鍵盤完成 Skip Link、篩選面板、對話框三個關鍵互動。
import { test, expect } from '@playwright/test';
test('Skip Link 可用鍵盤直達主要內容', async ({ page }) => {
await page.goto('/services');
await page.keyboard.press('Tab');
const skipLink = page.getByRole('link', { name: '跳至主要內容' });
await expect(skipLink).toBeFocused();
await page.keyboard.press('Enter');
await expect(page.locator('#main-content')).toBeFocused();
});
test('篩選面板可用 Tab 與 Space 操作', async ({ page }) => {
await page.goto('/services');
const search = page.getByRole('searchbox', { name: '搜尋' });
await search.focus();
await page.keyboard.type('攝影');
await page.keyboard.press('Tab');
await expect(page.getByLabel('服務類型')).toBeFocused();
});
test('對話框開啟後焦點鎖在框內,Esc 可關閉並歸還焦點', async ({ page }) => {
await page.goto('/bookings');
const trigger = page.getByRole('button', { name: '取消預約' }).first();
await trigger.click();
const dialog = page.getByRole('dialog');
await expect(dialog).toBeVisible();
// 連續 Tab 六次,焦點必須一直留在對話框內
for (let i = 0; i < 6; i += 1) await page.keyboard.press('Tab');
const stillInside = await dialog.evaluate((el) => el.contains(document.activeElement));
expect(stillInside).toBe(true);
await page.keyboard.press('Escape');
await expect(dialog).toBeHidden();
await expect(trigger).toBeFocused();
});
第四步,寫四斷點檢視。這支測試做三件事:截圖存檔(人工複核用)、檢查水平溢出(自動化最有效的一條)、檢查主要操作的觸控目標。水平溢出用 scrollWidth 減 clientWidth 判斷,只要大於 0 就代表有東西被推出畫面——這是手機版最常見的災難,也是自動化最容易抓的一條。
// tests/e2e/responsive.spec.ts
// 四種寬度各截一張圖,並檢查水平溢出與觸控目標。
import { test, expect } from '@playwright/test';
const VIEWPORTS = [
{ name: 'mobile-390', width: 390, height: 844 },
{ name: 'tablet-768', width: 768, height: 1024 },
{ name: 'laptop-1200', width: 1200, height: 800 },
{ name: 'desktop-1440', width: 1440, height: 900 },
];
const PAGES = ['/services', '/bookings', '/admin/bookings'];
for (const viewport of VIEWPORTS) {
for (const pagePath of PAGES) {
test(`響應式 ${viewport.name} ${pagePath}`, async ({ page }) => {
await page.setViewportSize({ width: viewport.width, height: viewport.height });
await page.goto(pagePath);
await page.waitForLoadState('networkidle');
const label = `${viewport.name}${pagePath.replace(/\//g, '-')}`;
await page.screenshot({ path: `tests/__screenshots__/${label}.png`, fullPage: true });
// 檢查一:不能出現水平捲軸
const overflow = await page.evaluate(
() => document.documentElement.scrollWidth - document.documentElement.clientWidth,
);
expect(overflow, `${label} 水平溢出 ${overflow}px`).toBeLessThanOrEqual(0);
// 檢查二:主要操作的觸控目標至少 44 × 44 CSS 像素
const actions = page.getByRole('button').or(page.getByRole('link'));
const count = await actions.count();
for (let i = 0; i < Math.min(count, 20); i += 1) {
const box = await actions.nth(i).boundingBox();
if (!box || box.width === 0) continue;
expect(box.height, `${label} 第 ${i} 個操作高度不足`).toBeGreaterThanOrEqual(32);
}
});
}
}
第五步開始修缺陷。第一個是手機版篩選面板。Day 33 的 ServiceFilters 在桌機是左側側欄、在手機是「疊在清單上方的一整塊」,佔掉將近一整個視窗高度,使用者要滑很久才看到服務。details 元素是這個問題的標準解法:它天生就能用鍵盤操作(Enter 或 Space 展開)、螢幕閱讀器會唸出「收合/展開」,不需要任何 JavaScript。我們只需要一個判斷「現在是不是桌機」的鉤子。
// hooks/useMediaQuery.ts
// 監聽媒體查詢,SSR 時先回傳 false,掛載後再更新成真實值。
'use client';
import { useEffect, useState } from 'react';
export function useMediaQuery(query) {
const [matches, setMatches] = useState(false);
useEffect(() => {
const list = window.matchMedia(query);
setMatches(list.matches);
const onChange = (event) => setMatches(event.matches);
list.addEventListener('change', onChange);
return () => list.removeEventListener('change', onChange);
}, [query]);
return matches;
}
這個鉤子刻意讓伺服器端與第一次客戶端渲染都回傳 false,等到 useEffect 執行後才更新成真實值。這樣做避開了「伺服器渲染出桌機版、客戶端渲染出手機版」造成的 hydration 不一致;代價是桌機使用者會看到極短暫的收合狀態,實務上肉眼察覺不到。接下來把篩選面板換成 details。
// components/booking/ServiceFilters.tsx(Day 42:手機版收合)
'use client';
import { useId } from 'react';
import { useMediaQuery } from '@/hooks/useMediaQuery';
const CATEGORIES = [
{ value: 'photography', label: '攝影' },
{ value: 'fitness', label: '健身' },
{ value: 'consulting', label: '諮詢' },
];
export function ServiceFilters({ value, onChange }) {
const typeId = useId();
const durationId = useId();
const isDesktop = useMediaQuery('(min-width: 1024px)');
const applied = Boolean(value.category || value.maxDuration);
return (
<details
open={isDesktop}
className="rounded-card border border-surface-border bg-surface p-card"
>
<summary className="cursor-pointer text-sm font-semibold text-text-primary lg:hidden">
篩選條件{applied ? '(已套用)' : ''}
</summary>
<div className="space-y-4">
<div>
<label htmlFor={typeId} className="block text-sm font-medium text-text-primary">
服務類型
</label>
<select
id={typeId}
value={value.category ?? ''}
onChange={(e) => onChange({ ...value, category: e.target.value || undefined })}
className="h-11 w-full rounded-button border border-surface-border bg-white px-3 text-base focus-visible:ring-2 focus-visible:ring-brand-500"
>
<option value="">全部類型</option>
{CATEGORIES.map((c) => (
<option key={c.value} value={c.value}>{c.label}</option>
))}
</select>
</div>
<div>
<label htmlFor={durationId} className="block text-sm font-medium text-text-primary">
最長時長
</label>
<select
id={durationId}
value={value.maxDuration ?? ''}
onChange={(e) => onChange({ ...value, maxDuration: e.target.value ? Number(e.target.value) : undefined })}
className="h-11 w-full rounded-button border border-surface-border bg-white px-3 text-base focus-visible:ring-2 focus-visible:ring-brand-500"
>
<option value="">不限</option>
<option value="60">60 分鐘以內</option>
<option value="90">90 分鐘以內</option>
</select>
</div>
<button
type="button"
onClick={() => onChange({})}
className="h-11 w-full rounded-button border border-surface-border bg-white text-sm font-medium text-text-primary"
>
清除全部篩選
</button>
</div>
</details>
);
}
第六個修正把對話框的焦點鎖住。Day 28 的 Dialog 只做了「開啟時把焦點移進去、關閉時還回去」,少了「Tab 迴圈」這一段:使用者按 Tab 會一路跑到背景頁面,以為對話框已經關掉了。加上焦點迴圈之後,Tab 會在對話框的第一個與最後一個可聚焦元素之間來回,符合 WAI-ARIA 的對話框模式。
// components/ui/Dialog.tsx(Day 42:補上焦點迴圈)
'use client';
import { useEffect, useRef } from 'react';
import { createPortal } from 'react-dom';
const FOCUSABLE =
'a[href], button:not([disabled]), input:not([disabled]), select, textarea, [tabindex]:not([tabindex="-1"])';
export function Dialog({ open, onClose, title, children }) {
const panelRef = useRef(null);
const previousFocus = useRef(null);
useEffect(() => {
if (!open) return undefined;
previousFocus.current = document.activeElement;
const panel = panelRef.current;
const items = () => Array.from(panel.querySelectorAll(FOCUSABLE));
items()[0]?.focus();
function onKeyDown(event) {
if (event.key === 'Escape') {
onClose();
return;
}
if (event.key !== 'Tab') return;
const focusable = items();
if (focusable.length === 0) return;
const first = focusable[0];
const last = focusable[focusable.length - 1];
if (event.shiftKey && document.activeElement === first) {
event.preventDefault();
last.focus();
} else if (!event.shiftKey && document.activeElement === last) {
event.preventDefault();
first.focus();
}
}
document.addEventListener('keydown', onKeyDown);
return () => {
document.removeEventListener('keydown', onKeyDown);
previousFocus.current?.focus();
};
}, [open, onClose]);
if (!open) return null;
return createPortal(
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
<div
ref={panelRef}
role="dialog"
aria-modal="true"
aria-labelledby="dialog-title"
className="w-full max-w-md rounded-card bg-surface p-card shadow-lg"
>
<h2 id="dialog-title" className="text-lg font-semibold text-text-primary">{title}</h2>
<div className="mt-card">{children}</div>
</div>
</div>,
document.body,
);
}
第七個修正是響應式表格。後台預約管理在桌機用 table 呈現最有效率(欄位對齊、掃視快),但在 390 寬的手機上,六個欄位的表格一定要水平捲動。<table> 在手機上的替代方案是「一張預約一張卡」:同一個資料來源、兩套呈現,用 CSS 切換。注意我們是「渲染兩套 DOM」,而不是把表格硬擠成卡片——因為表格的語意與卡片的語意不同,硬轉會讓螢幕閱讀器唸出無意義的橫列座標。
// components/admin/BookingTable.tsx(Day 42:桌機表格、手機卡片)
import { formatDate, formatPrice } from '@/lib/format';
import { Badge } from '@/components/ui/Badge';
const STATUS_TONE = {
pending: 'warning',
confirmed: 'success',
cancelled: 'danger',
completed: 'neutral',
};
export function BookingTable({ bookings }) {
return (
<div>
{/* 手機版:一張預約一張卡 */}
<ul className="space-y-3 md:hidden">
{bookings.map((booking) => (
<li key={booking.id} className="rounded-card border border-surface-border bg-surface p-card">
<p className="font-semibold text-text-primary">{booking.serviceName}</p>
<p className="mt-1 text-sm text-text-secondary">
{formatDate(booking.startAt)}・{booking.customerName}
</p>
<div className="mt-2 flex items-center justify-between">
<Badge tone={STATUS_TONE[booking.status]}>{booking.status}</Badge>
<span className="text-sm font-medium">{formatPrice(booking.priceCents)}</span>
</div>
</li>
))}
</ul>
{/* 桌機版:表格 */}
<table className="hidden w-full md:table">
<caption className="sr-only">所有客戶預約</caption>
<thead>
<tr>
<th scope="col">服務</th>
<th scope="col">時間</th>
<th scope="col">客戶</th>
<th scope="col">狀態</th>
</tr>
</thead>
<tbody>
{bookings.map((booking) => (
<tr key={booking.id}>
<td>{booking.serviceName}</td>
<td>{formatDate(booking.startAt)}</td>
<td>{booking.customerName}</td>
<td><Badge tone={STATUS_TONE[booking.status]}>{booking.status}</Badge></td>
</tr>
))}
</tbody>
</table>
</div>
);
}
第八步,把色彩對比檢查寫成一支可以在 CI 跑的 Node 腳本。與其每次改設計 token 都手動開對比檢查工具,不如讓 CI 直接讀 styles/theme.css、算出所有關鍵組合的比值。> 與 < 的比較、冪運算都在 Node 內建就能完成,不需要任何額外套件。
// scripts/check-contrast.mjs
// 讀 styles/theme.css 的設計 token,驗證關鍵文字組合通過 WCAG AA。
// 執行:node scripts/check-contrast.mjs
import { readFileSync } from 'node:fs';
const THEME = readFileSync(new URL('../styles/theme.css', import.meta.url), 'utf8');
function token(name) {
const match = THEME.match(new RegExp(`--color-${name}:\\s*(#[0-9a-fA-F]{6})`));
if (!match) throw new Error(`找不到設計 token:${name}`);
return match[1];
}
function luminance(hex) {
const channels = [1, 3, 5].map((start) => {
const value = Number.parseInt(hex.slice(start, start + 2), 16) / 255;
return value <= 0.03928 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4;
});
const [r, g, b] = channels;
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
}
function ratio(foreground, background) {
const a = luminance(foreground);
const b = luminance(background);
return (Math.max(a, b) + 0.05) / (Math.min(a, b) + 0.05);
}
const PAIRS = [
{ fg: 'text-primary', bg: 'surface', min: 4.5, label: '主要文字' },
{ fg: 'text-secondary', bg: 'surface', min: 4.5, label: '次要文字' },
{ fg: 'brand-600', bg: 'surface', min: 4.5, label: '品牌色連結' },
{ fg: 'danger-600', bg: 'surface', min: 4.5, label: '錯誤訊息' },
];
let failed = 0;
for (const pair of PAIRS) {
const value = ratio(token(pair.fg), token(pair.bg));
const pass = value >= pair.min;
if (!pass) failed += 1;
console.log(`${pass ? 'PASS' : 'FAIL'} ${pair.label}:${value.toFixed(2)}:1(門檻 ${pair.min}:1)`);
}
if (failed > 0) process.exit(1);
第九步,把「減少動畫」與「焦點可見」寫進全域樣式。這兩個偏好屬於「全站一致」的規則,放在 styles/a11y.css 一次處理,不要散進幾十個元件的 className 裡。
/* styles/a11y.css
尊重系統的減少動畫偏好,並確保焦點永遠看得見。 */
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}
}
/* 滑鼠點選不出現焦點框,鍵盤操作一定出現 */
:focus-visible {
outline: 2px solid var(--color-brand-600);
outline-offset: 2px;
}
/* 觸控裝置的主要操作至少 44 × 44 CSS 像素 */
@media (pointer: coarse) {
[data-touch-target] {
min-height: 44px;
min-width: 44px;
}
}
最後把這三支檢查接進 Day 40 的 CI workflow。它們併進既有的 E2E job 即可,不必另開工作,因為共用同一個 dev server 與同一套 mock 資料。實際做法是追加四道步驟:依序跑 a11y、鍵盤與響應式三支檢查,再跑對比檢查腳本,任何一步失敗就讓整個 job 失敗,PR 沒有跑過就進不了 main。
常見錯誤與踩雷
第一個常見錯誤是「outline: none 拿掉焦點框」。這是最典型、也最常被 review 放過的 a11y 缺陷:設計師覺得預設的藍色外框很醜,工程師就一行 CSS 把它關掉,於是鍵盤使用者完全失去方向感。正確做法是用 :focus-visible 自己畫一個符合品牌色的焦點框,並確保它與背景的對比至少 3:1。我們的 a11y.css 就用了 outline 而不是 box-shadow,因為 outline 不佔版面、不會造成元素位移。
第二個踩雷是「只在桌機截圖驗收」。很多團隊的驗收流程是「打開筆電的瀏覽器、拉一下視窗寬度、看起來沒問題就過了」。但拉視窗不等於真正的手機:觸控目標的大小不會被檢驗、安全區域(瀏海與手勢列)不會出現、pointer: coarse 的媒體查詢不會生效。我們的 responsive.spec.ts 用固定的 viewport 尺寸檢查,就是要把「拉視窗」這種模糊的驗收換成明確的斷言。
第三個是「把 axe 的所有違規都當成阻擋條件」。axe-core 預設會跑完整規則集,包含許多 minor 等級的建議(例如「這個 landmark 建議加上標籤」)。如果全部當成錯誤,第一次跑大概會有二三十條,團隊很快就會把這個檢查關掉。我們的做法是只留 serious 與 critical,其餘先輸出成報告、排進待辦,等違規數降到個位數再逐步收緊。這個原則跟 Day 40 的覆蓋率門檻一樣:門檻要能達到才有意義。
第四個是「響應式只改寬度、沒改閱讀順序」。把桌機的兩欄版面在手機上疊成單欄,看起來很簡單,但如果原本的 DOM 順序是「側欄在前、內容在後」,疊起來就會變成「先看到一整塊篩選、才看到服務清單」。正確做法是把 DOM 順序改成「內容在前」、再用 CSS 的 grid-template-columns 在桌機把側欄放回左邊。這是響應式設計真正的難點:DOM 順序是給螢幕閱讀器與手機看的,視覺順序才是給桌機看的。
第五個是「details 的 open 屬性在 React 被當成受控屬性」。我們把桌機與手機的行為綁在同一個 open 上,第一次跑測試時會發現「手機版點不開」。原因是 React 把 open 當成受控屬性,我們的 useMediaQuery 在手機回傳 false,於是每次重新渲染都把使用者的展開動作蓋回去。修法是把判斷寫成「只在桌機強制展開」,或改用 useState 記住使用者的手動開關。
效能與實務提醒
a11y 檢查的執行成本要控制。三支新增的測試加上截圖,在 CI 大約多花 40 到 70 秒;單獨看不多,但 E2E 整體時間會從 3 分鐘變成 4 分鐘。實務上的建議是「a11y 全量掃描跑在合併前的 workflow、日常 PR 只掃改動到的頁面」。Playwright 可以用 --grep 搭配變更的檔案路徑來縮小範圍,把日常回饋時間壓在 30 秒內。我們今天的做法是把全量掃描留在 PR 階段(因為預約系統只有 11 個頁面,全掃還算便宜),等頁面數成長到 40 個以上再來切分。
截圖基準的管理是另一個成本。今天的 responsive.spec.ts 只是存圖供人工複核,不是像素比對。要做視覺回歸比對(visual regression)就要引入基準圖管理:每次改動都要人工確認差異、更新基準,否則會陷入「每次跑都失敗、大家習慣忽略」的狀態。建議的節奏是「一個月更新一次基準、更新前逐張看過」。Day 27 的 Playwright 已經提供 toHaveScreenshot,但我們刻意先不開,避免在 CI 還不穩定時引入另一個噪音源。
觸控目標的檢查要放寬判斷。我們的腳本對「所有按鈕與連結」都檢查高度,但純文字的連結(例如段落中的「查看詳情」)本來就不會是 44 像素高,硬要它符合會逼出扭曲的設計。實務上的判斷是:主要操作(送出、確認、取消、導覽)必須 44 × 44;行內連結則只要求「有足夠的文字間距」。腳本裡我們把門檻降到 32 並跳過寬度為 0 的元素,就是為了避免誤報;真正需要嚴格把關的那幾個 CTA,則由 data-touch-target 屬性手動標記後在 CSS 強制尺寸。
最後是「prefers-color-scheme 的實作順序」。我們的做法是沿用 Day 32 的 CSS variables:在 :root 定義淺色 token、在 @media (prefers-color-scheme: dark) 覆寫同一組變數,元件程式碼完全不用改。但要注意:覆寫後必須重新跑一次今天的對比檢查,因為深色底上的同一個品牌色,對比可能反而不夠。這也是把對比檢查寫成腳本、而不是靠肉眼的原因。
小結
今天把預約管理系統前端做了一次完整的無障礙與響應式總檢。我們裝了 @axe-core/playwright、寫了三支檢查(全站 axe 掃描 11 個頁面、純鍵盤走訪 Skip Link 與對話框、四斷點截圖加溢出與觸控目標檢查)、修了四個缺陷(手機版篩選改成 details 可收合、對話框補上焦點迴圈、後台表格在手機改為卡片、色彩對比改成腳本自動驗證)、補了一個全域樣式檔處理 prefers-reduced-motion 與 :focus-visible,最後把這些檢查接進 Day 40 的 CI。整套 stack 維持 Day 31–41 的共用設定,只新增一個鉤子、一個樣式檔與三支測試。
回頭看今天的四個修正,它們共同的模式是「把主觀判斷換成可重複的斷言」。焦點框好不好看是主觀的,但對比 3:1 是客觀的;版面「看起來沒問題」是主觀的,但水平溢出 0 像素是客觀的;按鈕「夠大」是主觀的,但 44 × 44 是客觀的。工程團隊要能把品味爭論轉譯成可驗證的門檻,才有可能在人力有限的情況下維持品質。這也是為什麼今天花的時間大半在寫檢查而不是改畫面——檢查寫一次,之後每次改動都替你驗收。
也要提醒:自動掃描只能蓋住結構性問題。axe 抓得到「圖片缺替代文字」,抓不到「這張圖的替代文字寫得好不好」;抓得到「按鈕有名字」,抓不到「這個名字對視障使用者是否直覺」。剩下的這一層需要人工用螢幕閱讀器實測,特別是建立預約、登入、取消預約這三條主流程。今天的檢查把它們的「基本盤」顧住了,人工複核的範圍就能縮到「語意與文案」這種真正需要人判斷的地方。
結語
今天的重點是把品質從「驗收時的感覺」升級成「隨時可重跑的檢查」。我們刻意把掃描、鍵盤、斷點三件事拆成獨立段落,因為它們各自對應不同的失敗模式:掃描抓結構、鍵盤抓互動、斷點抓版面。明天,我們會把這 42 天累積的成果整理成可交接的資產:一份能讓新成員在半天內上手的主說明、一份架構與路由地圖、一份 API 契約對照表、一份環境變數清單,以及一份值班用的 Runbook。今天的檢查腳本也會被寫進交接資料的「驗收方式」一節,讓下一個人在改動之前先跑一次,知道現在的基準在哪裡。
延伸資源
- WCAG 2.2 官方規格:
https://www.w3.org/TR/WCAG22/,AA 等級的成功準則與判準原文。 - axe-core 規則集:
https://github.com/dequelabs/axe-core,每條規則的說明、影響等級與修正建議。 - Playwright 官方文件:
https://playwright.dev/,keyboard、setViewportSize、toHaveScreenshot的 API。 - MDN — prefers-reduced-motion:
https://developer.mozilla.org/en-US/docs/Web/CSS/@media/prefers-reduced-motion,減少動畫偏好的實作說明。 - WAI-ARIA Authoring Practices:
https://www.w3.org/WAI/ARIA/apg/,對話框、收合面板等元件的標準鍵盤行為。 - Day 28 無障礙基礎:原文連結,本系列第一輪的 a11y 章節。
留言
張貼留言