name: 网络可访问性 description: 构建遵循WCAG指南的可访问网络应用程序。用于实现ARIA模式、键盘导航、屏幕阅读器支持或确保可访问性合规。触发词:可访问性、a11y、WCAG、ARIA、屏幕阅读器、键盘导航。
网络可访问性 (WCAG 2.1)
构建对所有人可访问的网络应用程序。
ARIA模式
按钮
<button
type="button"
aria-pressed={isPressed}
aria-disabled={isDisabled}
onClick={handleClick}
>
切换功能
</button>
模态对话框
<div
role="dialog"
aria-modal="true"
aria-labelledby="modal-title"
aria-describedby="modal-description"
>
<h2 id="modal-title">确认操作</h2>
<p id="modal-description">您确定要继续吗?</p>
<button onClick={onConfirm}>确认</button>
<button onClick={onCancel}>取消</button>
</div>
导航菜单
<nav aria-label="主导航">
<ul role="menubar">
<li role="none">
<a role="menuitem" href="/home">首页</a>
</li>
<li role="none">
<button
role="menuitem"
aria-haspopup="true"
aria-expanded={isOpen}
>
产品
</button>
{isOpen && (
<ul role="menu" aria-label="产品子菜单">
<li role="none">
<a role="menuitem" href="/products/new">新建</a>
</li>
</ul>
)}
</li>
</ul>
</nav>
键盘导航
焦点管理
import { useEffect, useRef } from 'react';
function Modal({ isOpen, onClose, children }) {
const modalRef = useRef<HTMLDivElement>(null);
const previousFocus = useRef<HTMLElement | null>(null);
useEffect(() => {
if (isOpen) {
previousFocus.current = document.activeElement as HTMLElement;
modalRef.current?.focus();
} else {
previousFocus.current?.focus();
}
}, [isOpen]);
// 在模态框内捕获焦点
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Escape') {
onClose();
}
if (e.key === 'Tab') {
const focusable = modalRef.current?.querySelectorAll(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
);
if (focusable && focusable.length > 0) {
const first = focusable[0] as HTMLElement;
const last = focusable[focusable.length - 1] as HTMLElement;
if (e.shiftKey && document.activeElement === first) {
e.preventDefault();
last.focus();
} else if (!e.shiftKey && document.activeElement === last) {
e.preventDefault();
first.focus();
}
}
}
};
if (!isOpen) return null;
return (
<div
ref={modalRef}
role="dialog"
aria-modal="true"
tabIndex={-1}
onKeyDown={handleKeyDown}
>
{children}
</div>
);
}
颜色对比度
最小对比度比(WCAG AA):
- 正常文本:4.5:1
- 大文本(18点以上):3:1
- UI组件:3:1
function getContrastRatio(color1: string, color2: string): number {
const lum1 = getLuminance(color1);
const lum2 = getLuminance(color2);
const lighter = Math.max(lum1, lum2);
const darker = Math.min(lum1, lum2);
return (lighter + 0.05) / (darker + 0.05);
}
function getLuminance(hex: string): number {
const rgb = hexToRgb(hex);
const [r, g, b] = rgb.map((c) => {
c = c / 255;
return c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4);
});
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
}
可访问表单
<form onSubmit={handleSubmit}>
<div>
<label htmlFor="email">
电子邮件地址
<span aria-hidden="true">*</span>
<span className="sr-only">(必填)</span>
</label>
<input
id="email"
type="email"
aria-required="true"
aria-invalid={errors.email ? 'true' : 'false'}
aria-describedby={errors.email ? 'email-error' : undefined}
/>
{errors.email && (
<p id="email-error" role="alert" className="error">
{errors.email}
</p>
)}
</div>
<button type="submit">提交</button>
</form>
屏幕阅读器专用内容
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
测试
# 自动化测试
npm install -D axe-core @axe-core/react
# 在测试中
import { axe, toHaveNoViolations } from 'jest-axe';
expect.extend(toHaveNoViolations);
test('组件可访问', async () => {
const { container } = render(<MyComponent />);
const results = await axe(container);
expect(results).toHaveNoViolations();
});
资源
- WCAG 2.1指南: https://www.w3.org/WAI/WCAG21/quickref/
- ARIA编写实践: https://www.w3.org/WAI/ARIA/apg/