系列文章: 前端工程師的 Modern Web 實踐之道 - Day 21
預計閱讀時間: 12 分鐘
難度等級: ⭐⭐⭐⭐☆
在前幾篇文章中,我們深入探討了效能最佳化、記憶體管理和除錯技巧。今天我們將把視角轉向一個經常被忽視但至關重要的主題——Web 無障礙設計 (Web Accessibility)。
根據世界衛生組織 (WHO) 的統計,全球約有 13 億人(約佔總人口的 16%)有不同程度的身心障礙。當我們忽視無障礙設計時,實際上是在將這龐大的使用者群體排除在我們的產品之外。
Web 無障礙設計(Web Accessibility,通常簡稱 a11y)是指讓所有人,包括身心障礙者,都能感知、理解、導航和互動 Web 內容的實踐。這包括但不限於:
Web Content Accessibility Guidelines (WCAG) 是由 W3C 制定的國際無障礙標準,目前最新版本是 WCAG 2.2。它基於四大核心原則,稱為 POUR 原則:
使用者必須能夠感知所呈現的資訊和使用者介面元件。
使用者介面元件和導航必須是可操作的。
資訊和使用者介面的操作必須是可理解的。
內容必須足夠強健,能被各種使用者代理(包括輔助技術)可靠地解讀。
WCAG 定義了三個符合等級:
語義化 HTML 是無障礙設計的基礎。螢幕閱讀器和其他輔助技術依賴正確的 HTML 結構來理解頁面內容。
<!-- 問題:缺乏語義,螢幕閱讀器無法理解結構 -->
<div class="header">
<div class="nav">
<div class="nav-item" onclick="navigate()">首頁</div>
<div class="nav-item" onclick="navigate()">關於我們</div>
<div class="nav-item" onclick="navigate()">聯絡我們</div>
</div>
</div>
<div class="main">
<div class="article">
<div class="title">文章標題</div>
<div class="content">文章內容...</div>
</div>
</div>
<div class="button" onclick="submit()">提交</div>
<!--
優勢:
1. 使用語義化標籤 (header, nav, main, article, button)
2. 提供 ARIA 標籤和角色
3. 鍵盤可訪問
4. 螢幕閱讀器友善
-->
<header role="banner">
<nav role="navigation" aria-label="主導航選單">
<ul>
<li><a href="/" aria-current="page">首頁</a></li>
<li><a href="/about">關於我們</a></li>
<li><a href="/contact">聯絡我們</a></li>
</ul>
</nav>
</header>
<main role="main" aria-label="主要內容">
<article>
<h1>文章標題</h1>
<p>文章內容...</p>
</article>
</main>
<button type="submit" aria-label="提交表單">
提交
</button>
關鍵改進點:
<header>, <nav>, <main>, <article> 等語義化標籤aria-label 提供清晰的描述<button> 而不是 <div>,確保鍵盤可訪問性許多使用者無法使用滑鼠,完全依賴鍵盤導航。確保所有互動元素都可以通過鍵盤訪問是基本要求。
/**
* 可訪問的下拉選單組件
* 符合 WCAG 2.2 AA 標準
* 支援鍵盤導航和螢幕閱讀器
*/
import { useState, useRef, useEffect, KeyboardEvent } from 'react';
interface DropdownProps {
label: string;
options: Array<{ value: string; label: string }>;
onChange: (value: string) => void;
value?: string;
}
export function AccessibleDropdown({
label,
options,
onChange,
value
}: DropdownProps) {
const [isOpen, setIsOpen] = useState(false);
const [focusedIndex, setFocusedIndex] = useState(-1);
const buttonRef = useRef<HTMLButtonElement>(null);
const listRef = useRef<HTMLUListElement>(null);
// 處理鍵盤導航
const handleKeyDown = (event: KeyboardEvent) => {
switch (event.key) {
case 'ArrowDown':
event.preventDefault();
if (!isOpen) {
setIsOpen(true);
setFocusedIndex(0);
} else {
setFocusedIndex((prev) =>
prev < options.length - 1 ? prev + 1 : prev
);
}
break;
case 'ArrowUp':
event.preventDefault();
if (isOpen) {
setFocusedIndex((prev) => (prev > 0 ? prev - 1 : prev));
}
break;
case 'Enter':
case ' ':
event.preventDefault();
if (isOpen && focusedIndex >= 0) {
onChange(options[focusedIndex].value);
setIsOpen(false);
buttonRef.current?.focus();
} else {
setIsOpen(!isOpen);
}
break;
case 'Escape':
event.preventDefault();
setIsOpen(false);
buttonRef.current?.focus();
break;
case 'Home':
event.preventDefault();
if (isOpen) {
setFocusedIndex(0);
}
break;
case 'End':
event.preventDefault();
if (isOpen) {
setFocusedIndex(options.length - 1);
}
break;
default:
break;
}
};
// 焦點管理:當選單打開時,管理焦點位置
useEffect(() => {
if (isOpen && focusedIndex >= 0) {
const focusedElement = listRef.current?.children[focusedIndex] as HTMLElement;
focusedElement?.focus();
}
}, [isOpen, focusedIndex]);
// 點擊外部關閉選單
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (
buttonRef.current &&
!buttonRef.current.contains(event.target as Node) &&
listRef.current &&
!listRef.current.contains(event.target as Node)
) {
setIsOpen(false);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => {
document.removeEventListener('mousedown', handleClickOutside);
};
}, []);
const selectedOption = options.find(opt => opt.value === value);
return (
<div className="dropdown">
{/* 標籤與按鈕關聯 */}
<label id={`${label}-label`} className="dropdown-label">
{label}
</label>
{/* 可訪問的觸發按鈕 */}
<button
ref={buttonRef}
type="button"
aria-haspopup="listbox"
aria-expanded={isOpen}
aria-labelledby={`${label}-label`}
onKeyDown={handleKeyDown}
onClick={() => setIsOpen(!isOpen)}
className="dropdown-button"
>
{selectedOption?.label || '請選擇'}
<span aria-hidden="true">▼</span>
</button>
{/* 選項列表 */}
{isOpen && (
<ul
ref={listRef}
role="listbox"
aria-labelledby={`${label}-label`}
className="dropdown-list"
>
{options.map((option, index) => (
<li
key={option.value}
role="option"
aria-selected={option.value === value}
tabIndex={-1}
className={`dropdown-option ${
index === focusedIndex ? 'focused' : ''
} ${option.value === value ? 'selected' : ''}`}
onClick={() => {
onChange(option.value);
setIsOpen(false);
buttonRef.current?.focus();
}}
onKeyDown={handleKeyDown}
>
{option.label}
</li>
))}
</ul>
)}
</div>
);
}
無障礙特性說明:
完整的鍵盤支援:
Arrow Up/Down: 在選項間導航Enter/Space: 選擇選項或開關選單Escape: 關閉選單並返回焦點Home/End: 跳到第一個/最後一個選項ARIA 屬性:
aria-haspopup="listbox": 告知這是一個下拉選單aria-expanded: 表示選單是否展開aria-labelledby: 關聯標籤與控制項role="listbox" 和 role="option": 定義選單結構焦點管理:
色彩對比不足是最常見的無障礙問題之一。WCAG 要求:
/**
* 色彩對比度計算工具
* 基於 WCAG 2.2 標準
*/
// 將十六進位顏色轉換為 RGB
function hexToRgb(hex: string): { r: number; g: number; b: number } | null {
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
return result
? {
r: parseInt(result[1], 16),
g: parseInt(result[2], 16),
b: parseInt(result[3], 16),
}
: null;
}
// 計算相對亮度
function getLuminance(r: number, g: number, b: number): number {
const [rs, gs, bs] = [r, g, b].map((c) => {
const sRGB = c / 255;
return sRGB <= 0.03928
? sRGB / 12.92
: Math.pow((sRGB + 0.055) / 1.055, 2.4);
});
return 0.2126 * rs + 0.7152 * gs + 0.0722 * bs;
}
// 計算對比度
export function getContrastRatio(color1: string, color2: string): number {
const rgb1 = hexToRgb(color1);
const rgb2 = hexToRgb(color2);
if (!rgb1 || !rgb2) {
throw new Error('Invalid color format');
}
const lum1 = getLuminance(rgb1.r, rgb1.g, rgb1.b);
const lum2 = getLuminance(rgb2.r, rgb2.g, rgb2.b);
const lighter = Math.max(lum1, lum2);
const darker = Math.min(lum1, lum2);
return (lighter + 0.05) / (darker + 0.05);
}
// 檢查是否符合 WCAG 標準
export function checkWCAGCompliance(
foreground: string,
background: string,
fontSize: number = 16,
isBold: boolean = false
): {
ratio: number;
AA: { normal: boolean; large: boolean };
AAA: { normal: boolean; large: boolean };
recommendations: string[];
} {
const ratio = getContrastRatio(foreground, background);
const isLargeText = fontSize >= 18 || (fontSize >= 14 && isBold);
const AA = {
normal: ratio >= 4.5,
large: ratio >= 3,
};
const AAA = {
normal: ratio >= 7,
large: ratio >= 4.5,
};
const passes = isLargeText ? AA.large : AA.normal;
const recommendations: string[] = [];
if (!passes) {
if (ratio < 3) {
recommendations.push('對比度嚴重不足,建議選擇更深或更淺的顏色');
} else if (ratio < 4.5) {
recommendations.push('對比度偏低,建議增強對比或增大字體');
}
// 提供具體改善建議
const rgb = hexToRgb(foreground);
if (rgb) {
const avgBrightness = (rgb.r + rgb.g + rgb.b) / 3;
if (avgBrightness > 127) {
recommendations.push('建議使用更深的前景色');
} else {
recommendations.push('建議使用更淺的前景色或深色背景');
}
}
}
return { ratio, AA, AAA, recommendations };
}
// 實際使用範例
export function ColorContrastChecker() {
const [foreground, setForeground] = useState('#767676');
const [background, setBackground] = useState('#ffffff');
const [fontSize, setFontSize] = useState(16);
const result = checkWCAGCompliance(foreground, background, fontSize);
return (
<div className="contrast-checker">
<h2>色彩對比度檢查器</h2>
<div className="color-inputs">
<label>
前景色:
<input
type="color"
value={foreground}
onChange={(e) => setForeground(e.target.value)}
/>
<input
type="text"
value={foreground}
onChange={(e) => setForeground(e.target.value)}
/>
</label>
<label>
背景色:
<input
type="color"
value={background}
onChange={(e) => setBackground(e.target.value)}
/>
<input
type="text"
value={background}
onChange={(e) => setBackground(e.target.value)}
/>
</label>
<label>
字體大小 (px):
<input
type="number"
value={fontSize}
onChange={(e) => setFontSize(Number(e.target.value))}
/>
</label>
</div>
{/* 視覺預覽 */}
<div
className="preview"
style={{
backgroundColor: background,
color: foreground,
fontSize: `${fontSize}px`,
padding: '20px',
}}
>
這是預覽文字範例
</div>
{/* 檢查結果 */}
<div className="results">
<h3>對比度: {result.ratio.toFixed(2)}:1</h3>
<div className="compliance">
<div className={result.AA.normal ? 'pass' : 'fail'}>
AA 級 (正常文字): {result.AA.normal ? '✓ 通過' : '✗ 未通過'}
</div>
<div className={result.AA.large ? 'pass' : 'fail'}>
AA 級 (大文字): {result.AA.large ? '✓ 通過' : '✗ 未通過'}
</div>
<div className={result.AAA.normal ? 'pass' : 'fail'}>
AAA 級 (正常文字): {result.AAA.normal ? '✓ 通過' : '✗ 未通過'}
</div>
<div className={result.AAA.large ? 'pass' : 'fail'}>
AAA 級 (大文字): {result.AAA.large ? '✓ 通過' : '✗ 未通過'}
</div>
</div>
{result.recommendations.length > 0 && (
<div className="recommendations">
<h4>改善建議:</h4>
<ul>
{result.recommendations.map((rec, index) => (
<li key={index}>{rec}</li>
))}
</ul>
</div>
)}
</div>
</div>
);
}
表單是 Web 應用中最重要的互動元素之一,也是無障礙問題的高發區域。
/**
* 可訪問的表單組件
* 包含完整的錯誤處理和螢幕閱讀器支援
*/
import { useState, FormEvent, ChangeEvent } from 'react';
interface FormErrors {
[key: string]: string;
}
export function AccessibleForm() {
const [formData, setFormData] = useState({
name: '',
email: '',
message: '',
subscribe: false,
});
const [errors, setErrors] = useState<FormErrors>({});
const [touched, setTouched] = useState<Set<string>>(new Set());
const [submitStatus, setSubmitStatus] = useState<'idle' | 'success' | 'error'>('idle');
// 驗證規則
const validate = (field: string, value: any): string => {
switch (field) {
case 'name':
if (!value.trim()) return '姓名為必填欄位';
if (value.length < 2) return '姓名至少需要 2 個字元';
return '';
case 'email':
if (!value.trim()) return '電子郵件為必填欄位';
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(value)) return '請輸入有效的電子郵件地址';
return '';
case 'message':
if (!value.trim()) return '訊息內容為必填欄位';
if (value.length < 10) return '訊息內容至少需要 10 個字元';
return '';
default:
return '';
}
};
// 處理輸入變更
const handleChange = (
e: ChangeEvent<HTMLInputElement | HTMLTextAreaElement>
) => {
const { name, value, type } = e.target;
const checked = (e.target as HTMLInputElement).checked;
const newValue = type === 'checkbox' ? checked : value;
setFormData((prev) => ({ ...prev, [name]: newValue }));
// 即時驗證(僅在欄位已被觸碰後)
if (touched.has(name)) {
const error = validate(name, newValue);
setErrors((prev) => ({ ...prev, [name]: error }));
}
};
// 處理欄位失焦
const handleBlur = (field: string) => {
setTouched((prev) => new Set(prev).add(field));
const error = validate(field, formData[field as keyof typeof formData]);
setErrors((prev) => ({ ...prev, [field]: error }));
};
// 處理表單提交
const handleSubmit = async (e: FormEvent) => {
e.preventDefault();
// 驗證所有欄位
const newErrors: FormErrors = {};
Object.keys(formData).forEach((field) => {
if (field !== 'subscribe') {
const error = validate(field, formData[field as keyof typeof formData]);
if (error) newErrors[field] = error;
}
});
setErrors(newErrors);
// 如果有錯誤,聚焦到第一個錯誤欄位
if (Object.keys(newErrors).length > 0) {
const firstErrorField = Object.keys(newErrors)[0];
document.getElementById(firstErrorField)?.focus();
setSubmitStatus('error');
return;
}
// 提交表單
try {
// 模擬 API 呼叫
await new Promise((resolve) => setTimeout(resolve, 1000));
setSubmitStatus('success');
// 宣告成功訊息給螢幕閱讀器
const announcement = document.getElementById('form-status');
if (announcement) {
announcement.textContent = '表單提交成功!';
}
} catch (error) {
setSubmitStatus('error');
}
};
return (
<form
onSubmit={handleSubmit}
noValidate
aria-label="聯絡表單"
className="accessible-form"
>
{/* 狀態宣告區域(對螢幕閱讀器可見) */}
<div
id="form-status"
role="status"
aria-live="polite"
aria-atomic="true"
className="sr-only"
/>
{/* 成功訊息 */}
{submitStatus === 'success' && (
<div
role="alert"
className="success-message"
tabIndex={-1}
>
✓ 表單已成功提交!我們會盡快回覆您。
</div>
)}
{/* 錯誤摘要 */}
{Object.keys(errors).length > 0 && submitStatus === 'error' && (
<div
role="alert"
className="error-summary"
tabIndex={-1}
>
<h3>表單包含以下錯誤:</h3>
<ul>
{Object.entries(errors).map(([field, error]) => (
<li key={field}>
<a href={`#${field}`}>{error}</a>
</li>
))}
</ul>
</div>
)}
{/* 姓名欄位 */}
<div className="form-group">
<label htmlFor="name" className="required">
姓名
</label>
<input
type="text"
id="name"
name="name"
value={formData.name}
onChange={handleChange}
onBlur={() => handleBlur('name')}
aria-required="true"
aria-invalid={!!errors.name}
aria-describedby={errors.name ? 'name-error' : undefined}
className={errors.name ? 'error' : ''}
/>
{errors.name && (
<span id="name-error" role="alert" className="error-message">
{errors.name}
</span>
)}
</div>
{/* 電子郵件欄位 */}
<div className="form-group">
<label htmlFor="email" className="required">
電子郵件
</label>
<input
type="email"
id="email"
name="email"
value={formData.email}
onChange={handleChange}
onBlur={() => handleBlur('email')}
aria-required="true"
aria-invalid={!!errors.email}
aria-describedby={errors.email ? 'email-error' : undefined}
className={errors.email ? 'error' : ''}
/>
{errors.email && (
<span id="email-error" role="alert" className="error-message">
{errors.email}
</span>
)}
</div>
{/* 訊息欄位 */}
<div className="form-group">
<label htmlFor="message" className="required">
訊息內容
</label>
<textarea
id="message"
name="message"
value={formData.message}
onChange={handleChange}
onBlur={() => handleBlur('message')}
rows={5}
aria-required="true"
aria-invalid={!!errors.message}
aria-describedby={errors.message ? 'message-error message-hint' : 'message-hint'}
className={errors.message ? 'error' : ''}
/>
<span id="message-hint" className="hint">
請輸入至少 10 個字元
</span>
{errors.message && (
<span id="message-error" role="alert" className="error-message">
{errors.message}
</span>
)}
</div>
{/* 訂閱選項 */}
<div className="form-group checkbox-group">
<input
type="checkbox"
id="subscribe"
name="subscribe"
checked={formData.subscribe}
onChange={handleChange}
/>
<label htmlFor="subscribe">
我願意訂閱電子報以接收最新資訊
</label>
</div>
{/* 提交按鈕 */}
<button
type="submit"
className="submit-button"
aria-label="提交聯絡表單"
>
提交表單
</button>
</form>
);
}
表單無障礙關鍵特性:
<label> 並通過 htmlFor 關聯aria-required 和視覺指示器aria-invalid 和 aria-describedby 關聯錯誤訊息role="alert" 宣告錯誤aria-live 區域宣告表單狀態變化瀏覽器擴充套件,可以自動掃描頁面並報告無障礙問題。
# 在專案中使用 axe-core
npm install --save-dev axe-core
// 在測試中使用 axe
import { axe, toHaveNoViolations } from 'jest-axe';
expect.extend(toHaveNoViolations);
test('應該沒有無障礙違規', async () => {
const { container } = render(<AccessibleForm />);
const results = await axe(container);
expect(results).toHaveNoViolations();
});
Chrome DevTools 內建的審計工具,包含無障礙評分。
# 使用 Lighthouse CLI
npm install -g lighthouse
lighthouse https://example.com --only-categories=accessibility
命令列工具,可以整合到 CI/CD 流程中。
npm install -g pa11y
pa11y https://example.com
Tab 鍵可以訪問所有互動元素Enter 或 Space 啟動按鈕和連結Escape 關閉模態對話框和選單推薦的螢幕閱讀器:
測試要點:
無障礙的本質: Web 無障礙設計不是額外的功能,而是 Web 平台的基本特性,確保所有人都能平等地訪問資訊。
POUR 原則: 可感知、可操作、可理解、強健性是無障礙設計的四大支柱,所有實踐都應圍繞這些原則展開。
技術實踐: 語義化 HTML、ARIA 屬性、鍵盤導航、色彩對比、表單設計等都有具體的技術規範和最佳實踐。
測試與驗證: 結合自動化工具和手動測試,特別是真實的螢幕閱讀器測試,才能確保無障礙品質。
alt="",功能性圖片需要有意義的描述平衡問題: 在某些情況下,無障礙要求可能與設計美學或使用者體驗產生衝突。你會如何平衡這些考量?
全球化視角: 不同文化和地區對無障礙的理解和要求可能不同。如何設計一個真正全球化的無障礙方案?
AI 與無障礙: 人工智慧技術(如自動字幕生成、圖片描述等)如何改變無障礙設計的未來?但同時也要警惕 AI 可能帶來的新的排他性問題。
商業價值量化: 如何向管理層和利益相關者證明無障礙投資的商業價值?除了法律合規,還有哪些可量化的收益?