关键要点
- WCAG 2.1 AA 标准是基础:确保颜色对比度至少 4.5:1,所有交互元素可键盘访问
- 语义化 HTML 是关键:使用正确的 HTML 元素(
<button>,<nav>,<main>)而非<div> - ARIA 属性补充语义:当 HTML 无法传达足够信息时,使用
role,aria-label,aria-describedby等 - 键盘导航完整支持:确保所有功能可通过 Tab、方向键等键盘操作完成
- 屏幕阅读器测试必不可少:使用 NVDA、JAWS 或 VoiceOver 定期测试
健康数据仪表板的服务对象包括视力障碍、运动障碍用户。构建无障碍的仪表板不仅是法律要求(ADA、EU 无障碍法案),更是产品包容性的体现。
前置条件:
- React 和 D3.js 基础
- 了解 WCAG 2.1 原则
- 基本的 ARIA 属性知识
WCAG 2.1 快速回顾
四大原则(POUR)
| 原则 | 英文 | 说明 |
|---|---|---|
| 可感知 | Perceivable | 用户必须能够感知信息和使用界面组件 |
| 可操作 | Operable | 用户必须能够操作界面组件和导航 |
| 可理解 | Understandable | 用户必须能够理解信息和界面操作 |
| 健壮性 | Robust | 内容必须足够健壮,能被各种用户代理(包括辅助技术)可靠地解读 |
AA 级关键要求
- 颜色对比度:正文文本至少 4.5:1,大文本至少 3:1
- 键盘可访问:所有功能可通过键盘完成
- 焦点可见:键盘焦点始终清晰可见
- 错误标识:错误输入必须清晰标识并说明
- 状态通知:状态变化必须通知用户
步骤 1:项目设置
code
npx create-react-app accessible-dashboard --template typescript
cd accessible-dashboard
npm install d3 @types/d3 axios
npm install -D @axe-core/react eslint-plugin-jsx-a11y
Code collapsed
配置 ESLint 检查无障碍问题:
code
// .eslintrc.js
module.exports = {
extends: [
'plugin:jsx-a11y/recommended',
// ...其他配置
],
rules: {
'jsx-a11y/click-events-have-key-events': 'error',
'jsx-a11y/no-static-element-interactions': 'error',
'jsx-a11y/anchor-is-valid': 'error',
},
};
Code collapsed
步骤 2:创建无障碍颜色系统
code
// theme/colors.ts
// WCAG AA 标准颜色组合
export const accessibleColors = {
// 主色(确保足够对比度)
primary: {
DEFAULT: '#0066cc',
light: '#3385d6',
dark: '#0052a3',
// 白色背景上的对比度:6.1:1 ✓
contrast: '#0066cc',
},
// 成功色
success: {
DEFAULT: '#00875a',
light: '#2da160',
// 对比度:4.6:1 ✓
},
// 警告色
warning: {
DEFAULT: '#e97f02',
// 对比度:4.5:1 ✓
},
// 错误色
error: {
DEFAULT: '#de350b',
// 对比度:4.5:1 ✓
},
// 中性色
gray: {
50: '#f9fafb',
100: '#f3f4f6',
200: '#e5e7eb',
300: '#d1d5db',
400: '#9ca3af',
500: '#6b7280', // 对比度:5.7:1 ✓
600: '#4b5563',
700: '#374151',
800: '#1f2937',
900: '#111827',
},
// 图表色盲友好调色板
chartColors: [
'#0066cc', // 蓝色
'#00875a', // 绿色
'#ffab00', // 琥珀色
'#de350b', // 红色
'#6554c0', // 紫色
'#00a3bf', // 青色
],
};
// 对比度计算工具
export function calculateContrastRatio(
foreground: string,
background: string
): number {
const hexToRgb = (hex: string) => {
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;
};
const luminance = (r: number, g: number, b: number) => {
const a = [r, g, b].map((v) => {
v /= 255;
return v <= 0.03928 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4);
});
return a[0] * 0.2126 + a[1] * 0.7152 + a[2] * 0.0722;
};
const fg = hexToRgb(foreground);
const bg = hexToRgb(background);
if (!fg || !bg) return 0;
const fgLum = luminance(fg.r, fg.g, fg.b);
const bgLum = luminance(bg.r, bg.g, bg.b);
const lighter = Math.max(fgLum, bgLum);
const darker = Math.min(fgLum, bgLum);
return (lighter + 0.05) / (darker + 0.05);
}
// 验证颜色对比度是否符合 WCAG AA
export function validateWCAG_AA(
foreground: string,
background: string,
largeText: boolean = false
): { valid: boolean; ratio: number } {
const ratio = calculateContrastRatio(foreground, background);
const threshold = largeText ? 3 : 4.5;
return {
valid: ratio >= threshold,
ratio: Math.round(ratio * 10) / 10,
};
}
Code collapsed
步骤 3:创建无障碍 D3.js 组件
可访问的折线图
code
// components/AccessibleLineChart.tsx
import React, { useEffect, useRef } from 'react';
import * as d3 from 'd3';
import { accessibleColors } from '@/theme/colors';
interface DataPoint {
date: string;
value: number;
label?: string;
}
interface AccessibleLineChartProps {
data: DataPoint[];
title: string;
description: string;
width?: number;
height?: number;
yLabel?: string;
xLabel?: string;
}
export const AccessibleLineChart: React.FC<AccessibleLineChartProps> = ({
data,
title,
description,
width = 600,
height = 300,
yLabel = '值',
xLabel = '日期',
}) => {
const svgRef = useRef<SVGSVGElement>(null);
const chartId = useRef(`chart-${Math.random().toString(36).substr(2, 9)}`);
// 创建数据表格(屏幕阅读器使用)
const dataTable = (
<table className: "sr-only" aria-label={`${title} - 数据表格`}>
<caption>{description}</caption>
<thead>
<tr>
<th scope: "col">{xLabel}</th>
<th scope: "col">{yLabel}</th>
</tr>
</thead>
<tbody>
{data.map((point, index) => (
<tr key={index}>
<td>{point.date}</td>
<td>{point.value}</td>
</tr>
))}
</tbody>
</table>
);
useEffect(() => {
if (!svgRef.current || data.length === 0) return;
const svg = d3.select(svgRef.current);
svg.selectAll('*').remove();
const margin = { top: 20, right: 30, bottom: 40, left: 50 };
const innerWidth = width - margin.left - margin.right;
const innerHeight = height - margin.top - margin.bottom;
const g = svg
.append('g')
.attr('transform', `translate(${margin.left},${margin.top})`);
// 比例尺
const xScale = d3
.scaleTime()
.domain([
new Date(Math.min(...data.map((d) => new Date(d.date).valueOf()))),
new Date(Math.max(...data.map((d) => new Date(d.date).valueOf()))),
])
.range([0, innerWidth]);
const yScale = d3
.scaleLinear()
.domain([0, d3.max(data, (d) => d.value) || 0])
.range([innerHeight, 0])
.nice();
// 线生成器
const line = d3
.line<DataPoint>()
.x((d) => xScale(new Date(d.date)))
.y((d) => yScale(d.value))
.curve(d3.curveMonotoneX);
// 绘制网格线
const yAxisGrid = d3
.axisLeft(yScale)
.ticks(5)
.tickSize(-innerWidth)
.tickFormat(() => '');
g.append('g')
.attr('class', 'grid grid--y')
.attr('aria-hidden', 'true')
.call(yAxisGrid)
.attr('stroke', accessibleColors.gray[200])
.attr('stroke-dasharray', '2,2');
// X 轴
g.append('g')
.attr('class', 'axis axis--x')
.attr('transform', `translate(0,${innerHeight})`)
.call(d3.axisBottom(xScale).ticks(5).tickFormat(d3.timeFormat('%m/%d')))
.attr('aria-hidden', 'true');
// Y 轴
g.append('g')
.attr('class', 'axis axis--y')
.call(d3.axisLeft(yScale).ticks(5))
.attr('aria-hidden', 'true');
// Y 轴标签
g.append('text')
.attr('transform', 'rotate(-90)')
.attr('y', 0 - margin.left)
.attr('x', 0 - innerHeight / 2)
.attr('dy', '1em')
.style('text-anchor', 'middle')
.style('fill', accessibleColors.gray[700])
.style('font-size', '12px')
.text(yLabel);
// 绘制线条
const path = g
.append('path')
.datum(data)
.attr('fill', 'none')
.attr('stroke', accessibleColors.primary.DEFAULT)
.attr('stroke-width', 2)
.attr('d', line);
// 添加动画
const totalLength = path.node()?.getTotalLength() || 0;
path
.attr('stroke-dasharray', totalLength + ' ' + totalLength)
.attr('stroke-dashoffset', totalLength)
.transition()
.duration(1000)
.ease(d3.easeCubicOut)
.attr('stroke-dashoffset', 0);
// 添加数据点(带键盘焦点)
const dots = g
.selectAll('.dot')
.data(data)
.enter()
.append('g')
.attr('class', 'dot')
.attr('tabindex', '0')
.attr('role', 'graphics-symbol')
.attr('aria-label', (d) => `${d.date}: ${d.value}`)
.style('cursor', 'pointer');
dots
.append('circle')
.attr('cx', (d) => xScale(new Date(d.date)))
.attr('cy', (d) => yScale(d.value))
.attr('r', 4)
.attr('fill', accessibleColors.primary.DEFAULT)
.attr('stroke', '#fff')
.attr('stroke-width', 2);
// 焦点样式
dots.on('focus', function () {
d3.select(this)
.select('circle')
.transition()
.attr('r', 6)
.attr('stroke-width', 3);
});
dots.on('blur', function () {
d3.select(this)
.select('circle')
.transition()
.attr('r', 4)
.attr('stroke-width', 2);
});
// 添加 tooltip(仅键盘/鼠标)
const tooltip = d3
.select('body')
.append('div')
.attr('class', 'tooltip')
.style('position', 'absolute')
.style('visibility', 'hidden')
.style('background', accessibleColors.gray[800])
.style('color', '#fff')
.style('padding', '8px 12px')
.style('border-radius', '4px')
.style('font-size', '12px')
.style('pointer-events', 'none')
.attr('role', 'tooltip');
dots
.on('mouseenter focus', function (event, d) {
tooltip
.style('visibility', 'visible')
.html(`<strong>${d.date}</strong><br/>${yLabel}: ${d.value}`);
})
.on('mousemove', function (event) {
tooltip
.style('top', event.pageY - 10 + 'px')
.style('left', event.pageX + 10 + 'px');
})
.on('mouseleave blur', function () {
tooltip.style('visibility', 'hidden');
});
return () => {
tooltip.remove();
};
}, [data, width, height, xLabel, yLabel]);
return (
<figure className: "accessible-chart" role: "group" aria-labelledby={`${chartId.current}-title`}>
<figcaption id={`${chartId.current}-title`} className: "text-lg font-semibold mb-4">
{title}
</figcaption>
<p id={`${chartId.current}-desc`} className: "sr-only">
{description}
</p>
{/* SVG 图表 */}
<svg
ref={svgRef}
width={width}
height={height}
role: "img"
aria-labelledby={`${chartId.current}-title ${chartId.current}-desc`}
focusable: "false"
/>
{/* 数据表格(屏幕阅读器) */}
{dataTable}
<style>{`
.accessible-chart .dot:focus circle {
outline: 3px solid ${accessibleColors.primary.DEFAULT};
outline-offset: 2px;
}
.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;
}
`}</style>
</figure>
);
};
Code collapsed
可访问的柱状图
code
// components/AccessibleBarChart.tsx
import React, { useRef, useEffect } from 'react';
import * as d3 from 'd3';
import { accessibleColors } from '@/theme/colors';
interface BarData {
category: string;
value: number;
description?: string;
}
interface AccessibleBarChartProps {
data: BarData[];
title: string;
description: string;
width?: number;
height?: number;
}
export const AccessibleBarChart: React.FC<AccessibleBarChartProps> = ({
data,
title,
description,
width = 600,
height = 400,
}) => {
const svgRef = useRef<SVGSVGElement>(null);
const chartId = useRef(`bar-${Math.random().toString(36).substr(2, 9)}`);
useEffect(() => {
if (!svgRef.current || data.length === 0) return;
const svg = d3.select(svgRef.current);
svg.selectAll('*').remove();
const margin = { top: 20, right: 20, bottom: 60, left: 60 };
const innerWidth = width - margin.left - margin.right;
const innerHeight = height - margin.top - margin.bottom;
const g = svg
.append('g')
.attr('transform', `translate(${margin.left},${margin.top})`);
// 比例尺
const xScale = d3
.scaleBand()
.domain(data.map((d) => d.category))
.range([0, innerWidth])
.padding(0.2);
const yScale = d3
.scaleLinear()
.domain([0, d3.max(data, (d) => d.value) || 0])
.range([innerHeight, 0])
.nice();
// X 轴
g.append('g')
.attr('class', 'axis axis--x')
.attr('transform', `translate(0,${innerHeight})`)
.call(d3.axisBottom(xScale))
.selectAll('text')
.style('text-anchor', 'end')
.attr('dx', '-.8em')
.attr('dy', '.15em')
.attr('transform', 'rotate(-45)')
.attr('aria-hidden', 'true');
// Y 轴
g.append('g')
.attr('class', 'axis axis--y')
.call(d3.axisLeft(yScale).ticks(5))
.attr('aria-hidden', 'true');
// 绘制柱状图
g.selectAll('.bar')
.data(data)
.enter()
.append('rect')
.attr('class', 'bar')
.attr('x', (d) => xScale(d.category) || 0)
.attr('y', innerHeight)
.attr('width', xScale.bandwidth())
.attr('height', 0)
.attr('fill', accessibleColors.primary.DEFAULT)
.attr('tabindex', '0')
.attr('role', 'graphics-symbol')
.attr('aria-label', (d) => `${d.category}: ${d.value}`)
.on('focus', function () {
d3.select(this).attr('fill', accessibleColors.primary.light);
})
.on('blur', function () {
d3.select(this).attr('fill', accessibleColors.primary.DEFAULT);
})
.transition()
.duration(750)
.ease(d3.easeCubicOut)
.attr('y', (d) => yScale(d.value))
.attr('height', (d) => innerHeight - yScale(d.value));
// 在柱状图顶部添加数值标签
g.selectAll('.bar-label')
.data(data)
.enter()
.append('text')
.attr('class', 'bar-label')
.attr('x', (d) => (xScale(d.category) || 0) + xScale.bandwidth() / 2)
.attr('y', innerHeight)
.attr('text-anchor', 'middle')
.attr('aria-hidden', 'true')
.text((d) => d.value.toString())
.transition()
.delay(500)
.duration(500)
.attr('y', (d) => yScale(d.value) - 5);
}, [data, width, height]);
return (
<figure
className: "accessible-bar-chart"
role: "group"
aria-labelledby={`${chartId.current}-title`}
>
<figcaption id={`${chartId.current}-title`} className: "text-lg font-semibold mb-4">
{title}
</figcaption>
<p id={`${chartId.current}-desc`} className: "sr-only">
{description}
</p>
<svg
ref={svgRef}
width={width}
height={height}
role: "img"
aria-labelledby={`${chartId.current}-title ${chartId.current}-desc`}
focusable: "false"
/>
{/* 替代数据表格 */}
<div className: "sr-only" role: "region" aria-label: "图表数据">
<table>
<caption>{description}</caption>
<thead>
<tr>
<th scope: "col">类别</th>
<th scope: "col">数值</th>
</tr>
</thead>
<tbody>
{data.map((item, index) => (
<tr key={index}>
<td>{item.category}</td>
<td>{item.value}</td>
</tr>
))}
</tbody>
</table>
</div>
<style>{`
.accessible-bar-chart .bar:focus {
outline: 3px solid ${accessibleColors.warning};
outline-offset: 2px;
}
`}</style>
</figure>
);
};
Code collapsed
步骤 4:创建无障碍仪表板布局
code
// components/AccessibleDashboard.tsx
import React from 'react';
import { AccessibleLineChart } from './AccessibleLineChart';
import { AccessibleBarChart } from './AccessibleBarChart';
export const AccessibleDashboard: React.FC = () => {
const heartRateData = [
{ date: '2026-03-01', value: 72 },
{ date: '2026-03-02', value: 75 },
{ date: '2026-03-03', value: 68 },
{ date: '2026-03-04', value: 80 },
{ date: '2026-03-05', value: 74 },
{ date: '2026-03-06', value: 71 },
{ date: '2026-03-07', value: 76 },
];
const stepsData = [
{ category: '周一', value: 6500, description: '周一走了6500步' },
{ category: '周二', value: 8200, description: '周二走了8200步' },
{ category: '周三', value: 7800, description: '周三走了7800步' },
{ category: '周四', value: 9100, description: '周四走了9100步' },
{ category: '周五', value: 5400, description: '周五走了5400步' },
];
return (
<div className: "min-h-screen bg-gray-50">
{/* 跳过导航链接 */}
<a
href: "#main-content"
className: "sr-only focus:not-sr-only focus:absolute focus:top-4 focus:left-4 bg-blue-600 text-white px-4 py-2 rounded z-50"
>
跳转到主要内容
</a>
{/* 页眉 */}
<header className: "bg-white shadow">
<div className: "max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className: "flex justify-between items-center h-16">
<h1 className: "text-2xl font-bold text-gray-900">
健康数据仪表板
</h1>
<nav aria-label: "主导航">
<ul className: "flex space-x-4">
<li>
<a href: "#overview" className: "text-gray-700 hover:text-gray-900">
概览
</a>
</li>
<li>
<a href: "#trends" className: "text-gray-700 hover:text-gray-900">
趋势
</a>
</li>
<li>
<a href: "#reports" className: "text-gray-700 hover:text-gray-900">
报告
</a>
</li>
</ul>
</nav>
</div>
</div>
</header>
{/* 主要内容 */}
<main id: "main-content" className: "max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
{/* 概览区域 */}
<section id: "overview" aria-labelledby: "overview-heading">
<h2 id: "overview-heading" className: "text-xl font-semibold text-gray-900 mb-6">
今日概览
</h2>
<div className: "grid grid-cols-1 md:grid-cols-3 gap-6">
{/* 心率卡片 */}
<article className: "bg-white rounded-lg shadow p-6">
<h3 className: "text-lg font-medium text-gray-900 mb-2">心率</h3>
<p className: "text-4xl font-bold text-blue-600" aria-live: "polite">
72 <span className: "text-lg font-normal text-gray-500">BPM</span>
</p>
<p className: "text-sm text-gray-500 mt-2">
范围: 60-100 BPM
</p>
</article>
{/* 步数卡片 */}
<article className: "bg-white rounded-lg shadow p-6">
<h3 className: "text-lg font-medium text-gray-900 mb-2">步数</h3>
<p className: "text-4xl font-bold text-green-600" aria-live: "polite">
8,234
</p>
<p className: "text-sm text-gray-500 mt-2">
目标: 10,000 步
</p>
<div
role: "progressbar"
aria-valuenow={82}
aria-valuemin={0}
aria-valuemax={100}
aria-label: "步数进度"
className: "mt-4 bg-gray-200 rounded-full h-2"
>
<div
className: "bg-green-600 h-2 rounded-full"
style={{ width: '82%' }}
/>
</div>
</article>
{/* 卡路里卡片 */}
<article className: "bg-white rounded-lg shadow p-6">
<h3 className: "text-lg font-medium text-gray-900 mb-2">卡路里</h3>
<p className: "text-4xl font-bold text-orange-600" aria-live: "polite">
1,850 <span className: "text-lg font-normal text-gray-500">kcal</span>
</p>
<p className: "text-sm text-gray-500 mt-2">
目标: 2,000 kcal
</p>
</article>
</div>
</section>
{/* 趋势区域 */}
<section id: "trends" aria-labelledby: "trends-heading" className: "mt-12">
<h2 id: "trends-heading" className: "text-xl font-semibold text-gray-900 mb-6">
健康趋势
</h2>
<div className: "grid grid-cols-1 lg:grid-cols-2 gap-8">
<AccessibleLineChart
data={heartRateData}
title: "本周心率变化"
description: "显示过去7天的平均心率数据,单位为每分钟心跳数"
yLabel: "心率 (BPM)"
xLabel: "日期"
/>
<AccessibleBarChart
data={stepsData}
title: "本周每日步数"
description: "显示工作日每日步数统计,目标为每天10000步"
/>
</div>
</section>
</main>
{/* 页脚 */}
<footer className: "bg-white border-t mt-12">
<div className: "max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
<p className: "text-sm text-gray-500">
数据更新时间: 2026年3月8日 14:30
</p>
</div>
</footer>
</div>
);
};
Code collapsed
步骤 5:添加实时无障碍通知
code
// components/AccessibleAnnouncer.tsx
import React, { useEffect, useRef } from 'react';
interface AnnouncementProps {
message: string;
priority?: 'polite' | 'assertive';
duration?: number;
}
export const AccessibleAnnouncer: React.FC<AnnouncementProps> = ({
message,
priority = 'polite',
duration = 5000,
}) => {
const announcementRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (announcementRef.current && message) {
announcementRef.current.textContent = '';
// 使用 setTimeout 确保屏幕阅读器能检测到内容变化
const timeoutId = setTimeout(() => {
if (announcementRef.current) {
announcementRef.current.textContent = message;
}
}, 100);
// 清除消息
const clearId = setTimeout(() => {
if (announcementRef.current) {
announcementRef.current.textContent = '';
}
}, duration);
return () => {
clearTimeout(timeoutId);
clearTimeout(clearId);
};
}
}, [message, duration]);
return (
<div
ref={announcementRef}
role: "status"
aria-live={priority}
aria-atomic: "true"
className: "sr-only"
/>
);
};
// 使用示例
const App = () => {
const [notification, setNotification] = React.useState('');
const handleDataUpdate = () => {
setNotification('健康数据已更新');
};
return (
<>
<button onClick={handleDataUpdate}>刷新数据</button>
<AccessibleAnnouncer message={notification} priority: "polite" />
</>
);
};
Code collapsed
步骤 6:键盘导航增强
code
// hooks/useKeyboardNavigation.ts
import { useEffect, useRef } from 'react';
interface KeyboardNavigationOptions {
onEscape?: () => void;
onArrowUp?: () => void;
onArrowDown?: () => void;
onArrowLeft?: () => void;
onArrowRight?: () => void;
onEnter?: () => void;
onHome?: () => void;
onEnd?: () => void;
}
export function useKeyboardNavigation(
options: KeyboardNavigationOptions
) {
const ref = useRef<HTMLElement>(null);
useEffect(() => {
const element = ref.current;
if (!element) return;
const handleKeyDown = (event: KeyboardEvent) => {
switch (event.key) {
case 'Escape':
event.preventDefault();
options.onEscape?.();
break;
case 'ArrowUp':
event.preventDefault();
options.onArrowUp?.();
break;
case 'ArrowDown':
event.preventDefault();
options.onArrowDown?.();
break;
case 'ArrowLeft':
event.preventDefault();
options.onArrowLeft?.();
break;
case 'ArrowRight':
event.preventDefault();
options.onArrowRight?.();
break;
case 'Enter':
case ' ':
if (event.key === ' ') event.preventDefault();
options.onEnter?.();
break;
case 'Home':
event.preventDefault();
options.onHome?.();
break;
case 'End':
event.preventDefault();
options.onEnd?.();
break;
}
};
element.addEventListener('keydown', handleKeyDown);
return () => {
element.removeEventListener('keydown', handleKeyDown);
};
}, [options]);
return ref;
}
Code collapsed
测试清单
code
// __tests__/accessibility.test.ts
import { render, screen } from '@testing-library/react';
import { axe, toHaveNoViolations } from 'jest-axe';
expect.extend(toHaveNoViolations);
describe('Accessibility Tests', () => {
it('should not have any accessibility violations', async () => {
const { container } = render(<AccessibleDashboard />);
const results = await axe(container);
expect(results).toHaveNoViolations();
});
it('should have proper heading hierarchy', () => {
render(<AccessibleDashboard />);
const h1 = screen.getAllByRole('heading', { level: 1 });
const h2 = screen.getAllByRole('heading', { level: 2 });
expect(h1).toHaveLength(1);
expect(h2.length).toBeGreaterThan(0);
});
it('should have focusable interactive elements', () => {
render(<AccessibleDashboard />);
const links = screen.getAllByRole('link');
links.forEach(link => {
expect(link).toHaveAttribute('href');
});
});
it('should have proper ARIA labels', () => {
render(<AccessibleDashboard />);
const progressBars = screen.getAllByRole('progressbar');
progressBars.forEach(bar => {
expect(bar).toHaveAttribute('aria-valuenow');
expect(bar).toHaveAttribute('aria-valuemin');
expect(bar).toHaveAttribute('aria-valuemax');
});
});
});
Code collapsed
总结
通过本教程,你学会了如何构建符合 WCAG 2.1 AA 标准的无障碍健康数据仪表板:
- 理解 WCAG 2.1 四大原则和 AA 级要求
- 创建无障碍的颜色系统和对比度验证
- 使用 D3.js 构建可访问的图表组件
- 实现完整的键盘导航和屏幕阅读器支持
- 添加实时无障碍通知
最佳实践
- 尽早测试:在开发初期就开始使用屏幕阅读器测试
- 真实用户测试:邀请残障用户参与测试
- 持续改进:无障碍是一个持续的过程
- 文档化:记录无障碍设计决策和测试结果