可访问性概述
为什么需要可访问性
┌──────────────────────────────────────────────────────────────┐
│ 可访问性的价值 │
├──────────────────────────────────────────────────────────────┤
│ │
│ 残障人士 │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ • 全球约 15% 人口有某种形式的残障 │ │
│ │ • 4% 的人口有视力障碍 │ │
│ │ • 1% 的人口有红绿色盲 │ │
│ │ • 0.6% 的人口有运动障碍 │ │
│ └─────────────────────────────────────────────────────┘ │
│ │
│ 临时性障碍 │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ • 手臂受伤无法使用鼠标 │ │
│ │ • 眼睛手术后的临时视力下降 │ │
│ │ • 手机在阳光下看不清屏幕 │ │
│ └─────────────────────────────────────────────────────┘ │
│ │
│ 情境性限制 │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ • 嘈杂环境中无法听音频 │ │
│ │ • 嘈杂环境中无法通话 │ │
│ │ • 开车时只能用手操作 │ │
│ │ • 网速慢时无法加载图片 │ │
│ └─────────────────────────────────────────────────────┘ │
│ │
└──────────────────────────────────────────────────────────────┘
WCAG 规范
Web Content Accessibility Guidelines(网页内容可访问性指南):
| 版本 |
发布时间 |
核心内容 |
| WCAG 1.0 |
1999 |
最初的指南 |
| WCAG 2.0 |
2008 |
12 条指南,4 项原则 |
| WCAG 2.1 |
2018 |
新增移动端、低视力、认知障碍支持 |
| WCAG 2.2 |
2023 |
新增焦点指示器、拖拽、认证等 |
四项核心原则(POUR)
┌──────────────────────────────────────────────────────────────┐
│ WCAG 四项核心原则 │
├──────────────────────────────────────────────────────────────┤
│ │
│ P - Perceivable (可感知) │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ 信息和 UI 组件必须以用户能感知的方式呈现 │ │
│ │ • 提供替代文本描述图片 │ │
│ │ • 提供字幕给视频 │ │
│ │ • 内容可以用不同方式呈现而不丢失信息 │ │
│ └─────────────────────────────────────────────────────┘ │
│ │
│ O - Operable (可操作) │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ UI 组件和导航必须是可操作的 │ │
│ │ • 所有功能都可以用键盘操作 │ │
│ │ • 给用户足够的时间阅读内容 │ │
│ │ • 不以引起癫痫的方式呈现内容 │ │
│ └─────────────────────────────────────────────────────┘ │
│ │
│ U - Understandable (可理解) │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ 信息和 UI 操作必须是可以理解的 │ │
│ │ • 文本可读可理解 │ │
│ │ • 操作可预测 │ │
│ │ • 帮助用户避免和纠正错误 │ │
│ └─────────────────────────────────────────────────────┘ │
│ │
│ R - Robust (健壮) │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ 内容必须能被各种用户代理可靠地解释 │ │
│ │ • 符合规范 │ │
│ │ • 组件有可访问性名称和角色 │ │
│ │ • 状态有可访问性状态信息 │ │
│ └─────────────────────────────────────────────────────┘ │
│ │
└──────────────────────────────────────────────────────────────┘
合规级别
| 级别 |
说明 |
目标 |
| A |
基础可访问性 |
必须满足 |
| AA |
实际应用标准 |
法规要求通常是 AA |
| AAA |
最高标准 |
根据情况追求 |
语义化 HTML
正确的语义标签
<header>
<nav aria-label="主导航">
<ul>
<li><a href="/">首页</a></li>
<li><a href="/about">关于</a></li>
</ul>
</nav>
</header>
<main>
<article>
<header>
<h1>文章标题</h1>
<p>发布时间:2024-01-15</p>
</header>
<section>
<h2>章节标题</h2>
<p>章节内容...</p>
</section>
</article>
<aside>
<h2>相关推荐</h2>
<ul>
<li><a href="/article/1">相关文章1</a></li>
<li><a href="/article/2">相关文章2</a></li>
</ul>
</aside>
</main>
<footer>
<p>© 2024 公司名称</p>
</footer>
表单语义
<label for="email">邮箱地址</label>
<input
type="email"
id="email"
name="email"
aria-describedby="email-hint"
required
>
<span id="email-hint">我们将发送确认邮件到这个地址</span>
<fieldset>
<legend>配送地址</legend>
<label for="street">街道地址</label>
<input type="text" id="street" name="street" required>
<label for="city">城市</label>
<input type="text" id="city" name="city" required>
</fieldset>
列表语义
<ol>
<li>第一步:注册账号</li>
<li>第二步:完成实名认证</li>
<li>第三步:开始使用</li>
</ol>
<ul>
<li>功能特点一</li>
<li>功能特点二</li>
<li>功能特点三</li>
</ul>
<nav aria-label="面包屑">
<ol>
<li><a href="/">首页</a></li>
<li><a href="/products">产品</a></li>
<li>当前页面</li>
</ol>
</nav>
ARIA 属性
ARIA 角色
<header role="banner">头部</header>
<nav role="navigation">导航</nav>
<main role="main">主要内容</main>
<aside role="complementary">侧边栏</aside>
<footer role="contentinfo">底部信息</footer>
<button role="button">点击我</button>
<a href="#" role="link">链接</a>
<div role="menu">菜单</div>
<div role="menuitem">菜单项</div>
<div role="tablist">标签页列表</div>
<div role="tab">标签</div>
<div role="tabpanel">标签面板</div>
ARIA 属性
<button aria-label="关闭对话框">
<svg aria-hidden="true"></svg>
</button>
<div role="dialog" aria-labelledby="dialog-title">
<h2 id="dialog-title">对话框标题</h2>
<p>对话框内容...</p>
</div>
<input
type="text"
id="password"
aria-describedby="password-hint"
>
<span id="password-hint">密码至少8个字符,包含大小写字母和数字</span>
<div aria-hidden="true">
<span>装饰性图标</span>
</div>
<button aria-expanded="false" aria-controls="menu">
菜单
</button>
<ul id="menu" hidden>
<li>选项1</li>
<li>选项2</li>
</ul>
<div aria-live="polite" aria-atomic="true">
</div>
状态属性
<div role="checkbox" aria-checked="true">已选</div>
<div role="tab" aria-selected="true">当前标签</div>
<button aria-disabled="true" disabled>禁用按钮</button>
<input type="text" aria-invalid="true" aria-describedby="error-msg">
<span id="error-msg">请输入有效值</span>
<input type="text" aria-required="true">
键盘导航
可聚焦元素
:focus {
outline: 2px solid blue;
outline-offset: 2px;
}
:focus:not(:focus-visible) {
outline: none;
}
:focus-visible {
outline: 2px solid blue;
outline-offset: 2px;
}
Tab 顺序
<button>第一个</button>
<button tabindex="0">第二个</button>
<button>第三个</button>
<button id="skip-link" tabindex="-1">跳转到内容</button>
<button tabindex="3">最后</button>
<button tabindex="1">第一个</button>
<button tabindex="2">第二个</button>
键盘交互模式
<button
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
handleClick();
}
}}
>
点击我
</button>
<div
role="menu"
onKeyDown={(e) => {
switch (e.key) {
case 'ArrowDown':
e.preventDefault();
moveFocusToNextItem();
break;
case 'ArrowUp':
e.preventDefault();
moveFocusToPrevItem();
break;
case 'Escape':
e.preventDefault();
closeMenu();
break;
case 'Home':
e.preventDefault();
moveFocusToFirstItem();
break;
case 'End':
e.preventDefault();
moveFocusToLastItem();
break;
}
}}
>
</div>
跳过链接
<head>
<style>
.skip-link {
position: absolute;
top: -100px;
left: 0;
background: #000;
color: #fff;
padding: 8px 16px;
z-index: 100;
}
.skip-link:focus {
top: 0;
}
</style>
</head>
<body>
<a href="#main-content" class="skip-link">
跳转到主要内容
</a>
<nav></nav>
<main id="main-content" tabindex="-1">
</main>
</body>
颜色与对比度
对比度要求
| 文本类型 |
AA 级要求 |
AAA 级要求 |
| 普通文本 |
4.5:1 |
7:1 |
| 大文本 (≥18px 或 14px 粗体) |
3:1 |
4.5:1 |
| UI 组件和图形对象 |
3:1 |
不适用 |
.good-contrast {
background: #1a1a2e;
color: #ffffff;
}
.poor-contrast {
background: #666666;
color: #888888;
}
不要仅依赖颜色传达信息
<span style="color: red;">错误</span>
<span style="color: green;">成功</span>
<span class="status status--error">
<svg aria-hidden="true"></svg>
<span>错误:用户名不能为空</span>
</span>
<span class="status status--success">
<svg aria-hidden="true"></svg>
<span>成功:表单已提交</span>
</span>
高对比度模式支持
@media (prefers-contrast: high) {
:root {
--text-color: #000000;
--background-color: #ffffff;
--link-color: #0000EE;
--focus-outline: 3px solid #000000;
}
}
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
}
}
屏幕阅读器支持
测试工具
| 工具 |
平台 |
说明 |
| NVDA |
Windows |
免费开源 |
| JAWS |
Windows |
商业软件 |
| VoiceOver |
macOS/iOS |
Apple 内置 |
| TalkBack |
Android |
Google 内置 |
| axe DevTools |
浏览器扩展 |
自动检测问题 |
朗读顺序测试
<article>
<h2>标题</h2>
<p>第一段</p>
<p>第二段</p>
</article>
<style>
.sidebar {
float: right;
}
</style>
<article>
<div class="sidebar">侧边栏</div>
<p>主要内容 - 在屏幕上显示在侧边栏左边</p>
</style>
动态内容通知
function showNotification(message) {
const notification = document.getElementById('notification');
notification.textContent = '';
setTimeout(() => {
notification.textContent = message;
}, 100);
}
<div
id="notification"
role="status"
aria-live="polite"
aria-atomic="true"
></div>
表单可访问性
完整的表单示例
<form action="/submit" method="post">
<fieldset>
<legend>登录信息</legend>
<div class="form-group">
<label for="username">
用户名
<span aria-hidden="true">*</span>
<span class="sr-only">(必填)</span>
</label>
<input
type="text"
id="username"
name="username"
autocomplete="username"
required
aria-required="true"
aria-describedby="username-hint username-error"
>
<p id="username-hint" class="hint">
3-20 个字符,支持字母、数字、下划线
</p>
<p id="username-error" class="error" role="alert" hidden>
</p>
</div>
<div class="form-group">
<label for="password">
密码
<span aria-hidden="true">*</span>
<span class="sr-only">(必填)</span>
</label>
<input
type="password"
id="password"
name="password"
autocomplete="current-password"
required
aria-required="true"
aria-describedby="password-requirements"
>
<ul id="password-requirements" class="requirements">
<li id="req-length">至少 8 个字符</li>
<li id="req-upper">包含大写字母</li>
<li id="req-lower">包含小写字母</li>
<li id="req-number">包含数字</li>
</ul>
</div>
</fieldset>
<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;
}
错误处理
function showError(inputId, message) {
const input = document.getElementById(inputId);
const errorEl = document.getElementById(`${inputId}-error`);
input.setAttribute('aria-invalid', 'true');
input.setAttribute('aria-describedby', `${inputId}-error`);
errorEl.textContent = message;
errorEl.hidden = false;
input.focus();
}
function clearError(inputId) {
const input = document.getElementById(inputId);
const errorEl = document.getElementById(`${inputId}-error`);
input.removeAttribute('aria-invalid');
errorEl.textContent = '';
errorEl.hidden = true;
}
多媒体可访问性
图片替代文本
<img
src="chart.png"
alt="2024 年销售额增长图表:Q1 100万,Q2 150万,Q3 200万,Q4 280万"
>
<a href="/home">
<img src="logo.png" alt="公司 Logo,点击返回首页">
</a>
<img src="divider.png" alt="">
<figure>
<img src="complex-diagram.png" alt="系统架构图">
<figcaption>
<h3>图 1:系统架构</h3>
<p>描述:用户通过负载均衡器连接到应用服务器,...</p>
<p>详细说明请参见<a href="/docs/architecture">架构文档</a></p>
</figcaption>
</figure>
视频字幕
<video controls>
<source src="video.mp4" type="video/mp4">
<track
kind="captions"
label="中文字幕"
srclang="zh"
src="captions.vtt"
default
>
<track
kind="descriptions"
label="音频描述"
srclang="zh"
src="descriptions.vtt"
>
</video>
<!-- captions.vtt 示例 -->
WEBVTT
00:00:00.000 --> 00:00:04.000
欢迎观看本教程。
00:00:04.500 --> 00:00:08.000
首先,打开设置菜单。
00:00:08.500 --> 00:00:12.000
点击"新建项目"按钮。
React 可访问性
常用库
| 库 |
说明 |
| @react-aria |
Headless 组件和 Hooks |
| Radix UI |
无样式、可访问的组件 |
| Headless UI |
Tailwind 官方 |
| Chakra UI |
可访问的组件库 |
Headless UI 示例
import { Listbox } from '@headlessui/react';
function Select({ options, value, onChange }) {
return (
<Listbox value={value} onChange={onChange}>
<Listbox.Label className="sr-only">选择颜色</Listbox.Label>
<Listbox.Button
className="flex items-center justify-between w-full px-4 py-2 border rounded"
aria-describedby="color-hint"
>
{value.label}
</Listbox.Button>
<span id="color-hint" className="sr-only">
当前选择:{value.label}
</span>
<Listbox.Options className="mt-1 border rounded shadow">
{options.map((option) => (
<Listbox.Option
key={option.id}
value={option}
className={({ active, selected }) =>
`px-4 py-2 cursor-pointer ${
active ? 'bg-blue-100' : ''
} ${selected ? 'font-bold' : ''}`
}
>
{({ selected }) => (
<>
{option.label}
{selected && (
<span className="sr-only">(当前选中)</span>
)}
</>
)}
</Listbox.Option>
))}
</Listbox.Options>
</Listbox>
);
}
可访问性测试
axe-core 集成
import { axe, toHaveNoViolations } from 'jest-axe';
import { render, screen } from '@testing-library/react';
import { Button } from './Button';
expect.extend(toHaveNoViolations);
test('Button has no accessibility violations', async () => {
const { container } = render(<Button>Click me</Button>);
const results = await axe(container);
expect(results).toHaveNoViolations();
});
Playwright 自动化测试
import { test, expect } from '@playwright/test';
import AxeBuilder from '@axe-core/playwright';
test('page has no accessibility violations', async ({ page }) => {
await page.goto('/');
const accessibilityScanResults = await new AxeBuilder({ page })
.withTags(['wcag2a', 'wcag2aa'])
.analyze();
expect(accessibilityScanResults.violations).toEqual([]);
});
这一章想说的
可访问性让 Web 对所有人都可用:
- WCAG 规范:四项核心原则(POUR)指导所有实践
- 语义化 HTML:正确的标签是最基础的可访问性
- ARIA 属性:增强原生语义,覆盖边缘情况
- 键盘导航:确保所有功能可用键盘操作
- 对比度:足够的颜色对比度,不单靠颜色传达信息
- 屏幕阅读器:测试朗读顺序,使用 aria-live 通知变化
可访问性不是负担,而是质量的一部分。
延展阅读