前端可访问性
概览
根据WCAG指南构建可访问的Web应用程序,使用语义化HTML、ARIA属性、键盘导航和屏幕阅读器支持,以实现包容性用户体验。
何时使用
- 遵守可访问性标准
- 包容性设计要求
- 屏幕阅读器支持
- 键盘导航
- 颜色对比问题
实施示例
1. 语义HTML和ARIA
<!-- 良好的语义结构 -->
<nav aria-label="主要导航">
<ul>
<li><a href="/">首页</a></li>
<li><a href="/about">关于</a></li>
<li><a href="/contact">联系</a></li>
</ul>
</nav>
<main>
<article>
<header>
<h1>文章标题</h1>
<time datetime="2024-01-15">2024年1月15日</time>
</header>
<p>文章内容...</p>
</article>
<aside aria-label="相关文章">
<h2>相关文章</h2>
<ul>
<li><a href="/article1">文章1</a></li>
<li><a href="/article2">文章2</a></li>
</ul>
</aside>
</main>
<footer>
<p>© 2024 公司名称</p>
</footer>
<!-- 带有适当标签的表单 -->
<form>
<div class="form-group">
<label for="email">电子邮件地址</label>
<input
id="email"
type="email"
name="email"
required
aria-required="true"
aria-describedby="email-help"
/>
<small id="email-help">我们永远不会分享您的电子邮件</small>
</div>
<div class="form-group">
<label for="password">密码</label>
<input
id="password"
type="password"
name="password"
required
aria-required="true"
aria-describedby="password-requirements"
/>
<div id="password-requirements">
<ul>
<li>至少8个字符</li>
<li>一个大写字母</li>
<li>一个数字</li>
</ul>
</div>
</div>
<button type="submit">注册</button>
</form>
<!-- 带有适当ARIA的模态框 -->
<div
id="modal"
role="dialog"
aria-labelledby="modal-title"
aria-describedby="modal-description"
aria-modal="true"
>
<button aria-label="关闭模态框">×</button>
<h2 id="modal-title">确认操作</h2>
<p id="modal-description">您确定吗?</p>
<button>取消</button>
<button>确认</button>
</div>
<!-- 带有角色的警告 -->
<div role="alert" aria-live="polite">
<strong>错误:</strong>请更正高亮显示的字段
</div>
2. 键盘导航
// 支持键盘的React组件
import React, { useEffect, useRef, useState } from 'react';
interface MenuItem {
id: string;
label: string;
href: string;
}
const KeyboardNavigationMenu: React.FC<{ items: MenuItem[] }> = ({ items }) => {
const [activeIndex, setActiveIndex] = useState(0);
const menuRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
switch (e.key) {
case 'ArrowLeft':
case 'ArrowUp':
e.preventDefault();
setActiveIndex(prev =>
prev === 0 ? items.length - 1 : prev - 1
);
break;
case 'ArrowRight':
case 'ArrowDown':
e.preventDefault();
setActiveIndex(prev =>
prev === items.length - 1 ? 0 : prev + 1
);
break;
case 'Home':
e.preventDefault();
setActiveIndex(0);
break;
case 'End':
e.preventDefault();
setActiveIndex(items.length - 1);
break;
case 'Enter':
case ' ':
e.preventDefault();
const link = menuRef.current?.querySelectorAll('a')[activeIndex];
link?.click();
break;
case 'Escape':
menuRef.current?.querySelector('a')?.blur();
break;
default:
break;
}
};
menuRef.current?.addEventListener('keydown', handleKeyDown);
return () => menuRef.current?.removeEventListener('keydown', handleKeyDown);
}, [items.length, activeIndex]);
return (
<div role="menubar" ref={menuRef}>
{items.map((item, index) => (
<a
key={item.id}
href={item.href}
role="menuitem"
tabIndex={index === activeIndex ? 0 : -1}
onFocus={() => setActiveIndex(index)}
aria-current={index === activeIndex ? 'page' : undefined}
>
{item.label}
</a>
))}
</div>
);
};
3. 颜色对比度和视觉可访问性
/* 适当的颜色对比度(WCAG AA: 文本4.5:1,大文本3:1) */
:root {
--color-text: #1a1a1a; /* 黑色 - 高对比度 */
--color-background: #ffffff;
--color-primary: #0066cc; /* 蓝色,对比度良好 */
--color-success: #008000; /* 不是纯绿色 */
--color-error: #d32f2f; /* 不是纯红色 */
--color-warning: #ff8c00; /* 不是黄色 */
}
body {
color: var(--color-text);
background-color: var(--color-background);
font-size: 16px;
line-height: 1.5;
}
a {
color: var(--color-primary);
text-decoration: underline; /* 不要仅依赖颜色 */
}
button {
min-height: 44px; /* 触摸目标大小 */
min-width: 44px;
padding: 10px 20px;
border-radius: 4px;
font-size: 16px;
cursor: pointer;
}
/* 键盘导航的焦点可见 */
button:focus-visible,
a:focus-visible,
input:focus-visible {
outline: 3px solid var(--color-primary);
outline-offset: 2px;
}
/* 高对比度模式支持 */
@media (prefers-contrast: more) {
body {
font-weight: 500;
}
button {
border: 2px solid currentColor;
}
}
/* 减少运动支持 */
@media (prefers-reduced-motion: reduce) {
* {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
}
}
/* 暗色模式支持 */
@media (prefers-color-scheme: dark) {
:root {
--color-text: #e0e0e0;
--color-background: #1a1a1a;
--color-primary: #6495ed;
}
}
4. 屏幕阅读器公告
// 用于公告的LiveRegion组件
interface LiveRegionProps {
message: string;
politeness?: 'polite' | 'assertive' | 'off';
role?: 'status' | 'alert';
}
const LiveRegion: React.FC<LiveRegionProps> = ({
message,
politeness = 'polite',
role = 'status'
}) => {
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
if (message && ref.current) {
ref.current.textContent = message;
}
}, [message]);
return (
<div
ref={ref}
role={role}
aria-live={politeness}
aria-atomic="true"
className="sr-only"
/>
);
};
// 组件中的使用
const SearchResults: React.FC = () => {
const [results, setResults] = useState([]);
const [message, setMessage] = useState('');
const handleSearch = async (query: string) => {
const response = await fetch(`/api/search?q=${query}`);
const data = await response.json();
setResults(data);
setMessage(`找到 ${data.length} 个结果`);
};
return (
<>
<LiveRegion message={message} />
<input
type="text"
placeholder="搜索..."
onChange={(e) => handleSearch(e.target.value)}
aria-label="搜索结果"
/>
<ul>
{results.map(item => (
<li key={item.id}>{item.title}</li>
))}
</ul>
</>
);
};
// 跳转到主要内容链接(默认隐藏)
const skipLink = document.createElement('a');
skipLink.href = '#main-content';
skipLink.textContent = '跳转到主要内容';
skipLink.style.position = 'absolute';
skipLink.style.top = '-40px';
skipLink.style.left = '0';
skipLink.style.background = '#000';
skipLink.style.color = '#fff';
skipLink.style.padding = '8px';
skipLink.style.zIndex = '100';
skipLink.addEventListener('focus', () => {
skipLink.style.top = '0';
});
skipLink.addEventListener('blur', () => {
skipLink.style.top = '-40px';
});
document.body.insertBefore(skipLink, document.body.firstChild);
5. 可访问性测试
// jest-axe集成测试
import { render } from '@testing-library/react';
import { axe, toHaveNoViolations } from 'jest-axe';
import { Button } from './Button';
expect.extend(toHaveNoViolations);
describe('按钮可访问性', () => {
it('不应该有可访问性违规', async () => {
const { container } = render(
<Button>点击我</Button>
);
const results = await axe(container);
expect(results).toHaveNoViolations();
});
it('应该有适当的ARIA标签', async () => {
const { container } = render(
<Button aria-label="关闭对话框">×</Button>
);
const results = await axe(container);
expect(results).toHaveNoViolations();
});
});
// 可访问性检查Hook
const useAccessibilityChecker = () => {
useEffect(() => {
// 在开发中运行可访问性检查
if (process.env.NODE_ENV === 'development') {
import('axe-core').then(axe => {
axe.run((error, results) => {
if (results.violations.length > 0) {
console.warn('发现可访问性违规:', results.violations);
}
});
});
}
}, []);
};
最佳实践
- 使用语义化HTML元素
- 为图像提供有意义的alt文本
- 确保文本颜色对比度比为4.5:1
- 完全支持键盘导航
- 仅在必要时使用ARIA
- 用屏幕阅读器测试(NVDA, JAWS)
- 实施跳过链接
- 支持放大至200%
- 使用描述性链接文本
- 使用实际的辅助技术进行测试