构建符合 WCAG 标准的无障碍健康数据可视化
概述
健康数据可视化应该让所有用户都能访问,包括视力障碍、色盲或其他残疾的用户。本文将介绍如何按照 WCAG 2.1 标准构建无障碍的数据可视化组件。
WCAG 2.1 核心原则
WCAG 2.1 定义了四个核心原则,简称 POUR:
- Perceivable(可感知):信息必须以用户能够感知的方式呈现
- Operable(可操作):UI 组件必须可操作
- Understandable(可理解):信息和操作必须可理解
- Robust(健壮):内容必须足够健壮,可被各种用户代理(包括辅助技术)解释
颜色与对比度
对比度要求
WCAG AA 级别要求:
- 普通文本:至少 4.5:1
- 大文本(18pt+ 或 14pt 粗体+):至少 3:1
- 图形对象:至少 3:1
无障碍调色板
code
// lib/colors.ts
/**
* 无障碍健康数据可视化调色板
* 所有颜色组合均符合 WCAG AA 标准(对比度 >= 4.5:1)
*/
export const ACCESSIBLE_COLORS = {
// 主色调 - 与白色背景对比度 >= 4.5:1
primary: {
blue: '#0066CC', // 对比度 7.6:1
green: '#008000', // 对比度 7.4:1
orange: '#B86E00', // 对比度 4.6:1
red: '#C90000', // 对比度 6.3:1
purple: '#6B2E9E', // 对比度 5.8:1
teal: '#007870', // 对比度 5.2:1
},
// 浅色调 - 与白色背景对比度 >= 3:1(用于大文本)
light: {
blue: '#5DA3D9', // 对比度 3.1:1
green: '#4DB87A', // 对比度 3.0:1
orange: '#E89400', // 对比度 3.0:1
red: '#E53935', // 对比度 3.1:1
},
// 色盲友好调色板
colorblindSafe: {
blue: '#0072B2', // 蓝色
orange: '#D55E00', // 橙色
green: '#009E73', // 绿色
yellow: '#F0E442', // 黄色
darkBlue: '#56B4E9', // 浅蓝色
vermillion: '#CC79A7', // 猩红色
},
// 灰度
grayscale: {
black: '#000000', // 对比度 21:1
darkGray: '#333333', // 对比度 12.6:1
gray: '#666666', // 对比度 7.0:1
lightGray: '#999999', // 对比度 3.1:1
},
// 边框和分隔线
borders: {
light: '#E0E0E0', // 与白色对比度 1.2:1(仅用于装饰)
medium: '#BDBDBD', // 与白色对比度 1.6:1
dark: '#757575', // 与白色对比度 3.9:1
}
} as const;
/**
* 对比度计算工具
*/
export function getContrastRatio(foreground: string, background: string): number {
const getLuminance = (hex: string): number => {
const rgb = hexToRgb(hex);
const [r, g, b] = [rgb.r, rgb.g, rgb.b].map(val => {
val = val / 255;
return val <= 0.03928 ? val / 12.92 : Math.pow((val + 0.055) / 1.055, 2.4);
});
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
};
const lum1 = getLuminance(foreground);
const lum2 = getLuminance(background);
const brightest = Math.max(lum1, lum2);
const darkest = Math.min(lum1, lum2);
return (brightest + 0.05) / (darkest + 0.05);
}
function hexToRgb(hex: string): { r: number; g: number; b: number } {
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)
} : { r: 0, g: 0, b: 0 };
}
Code collapsed
颜色不止一种方式传达信息
code
// components/charts/AccessibleBarChart.tsx
import React from 'react';
import { Bar, BarChart, CartesianGrid, XAxis, YAxis, Tooltip, Legend, ResponsiveContainer } from 'recharts';
import { ACCESSIBLE_COLORS } from '@/lib/colors';
interface AccessibleBarChartProps {
data: Array<{
name: string;
value: number;
category: string;
}>;
title: string;
description: string;
}
export const AccessibleBarChart: React.FC<AccessibleBarChartProps> = ({
data,
title,
description
}) => {
// 使用图案和颜色组合
const patterns = {
dots: 'url(#dots)',
lines: 'url(#lines)',
crosshatch: 'url(#crosshatch)'
};
return (
<div role="region" aria-labelledby="chart-title" aria-describedby="chart-desc">
{/* 屏幕阅读器专用标题 */}
<h2 id="chart-title" className="sr-only">
{title}
</h2>
<p id="chart-desc" className="sr-only">
{description}
</p>
<ResponsiveContainer width="100%" height={300}>
<BarChart data={data} margin={{ top: 20, right: 30, left: 20, bottom: 5 }}>
<defs>
{/* 图案定义 */}
<pattern id="dots" x="0" y="0" width="10" height="10" patternUnits="userSpaceOnUse">
<circle cx="5" cy="5" r="1" fill="#666" opacity="0.3" />
</pattern>
<pattern id="lines" x="0" y="0" width="10" height="10" patternUnits="userSpaceOnUse">
<path d="M0,5 L10,5" stroke="#666" strokeWidth="1" opacity="0.3" />
</pattern>
<pattern id="crosshatch" x="0" y="0" width="10" height="10" patternUnits="userSpaceOnUse">
<path d="M0,0 L10,10 M10,0 L0,10" stroke="#666" strokeWidth="1" opacity="0.3" />
</pattern>
</defs>
<CartesianGrid strokeDasharray="3 3" stroke={ACCESSIBLE_COLORS.borders.light} />
<XAxis
dataKey="name"
stroke={ACCESSIBLE_COLORS.grayscale.darkGray}
tick={{ fill: ACCESSIBLE_COLORS.grayscale.black }}
/>
<YAxis
stroke={ACCESSIBLE_COLORS.grayscale.darkGray}
tick={{ fill: ACCESSIBLE_COLORS.grayscale.black }}
/>
<Tooltip
contentStyle={{
backgroundColor: '#FFFFFF',
border: `1px solid ${ACCESSIBLE_COLORS.borders.medium}`,
color: ACCESSIBLE_COLORS.grayscale.black
}}
/>
<Legend />
{/* 使用颜色和图案的组合 */}
<Bar
dataKey="value"
fill={ACCESSIBLE_COLORS.primary.blue}
name="数值"
>
{data.map((entry, index) => (
<rect
key={`pattern-${index}`}
fill={index % 3 === 0 ? patterns.dots : index % 3 === 1 ? patterns.lines : patterns.crosshatch}
/>
))}
</Bar>
</BarChart>
</ResponsiveContainer>
{/* 数据表格作为替代方案 */}
<details className="mt-4">
<summary className="cursor-pointer text-blue-600 hover:text-blue-800">
查看数据表格
</summary>
<AccessibleDataTable data={data} />
</details>
</div>
);
};
const AccessibleDataTable: React.FC<{ data: Array<{ name: string; value: number; category: string }> }> = ({ data }) => {
return (
<table className="w-full mt-4 border-collapse">
<thead>
<tr className="border-b">
<th className="text-left p-2">名称</th>
<th className="text-left p-2">类别</th>
<th className="text-right p-2">数值</th>
</tr>
</thead>
<tbody>
{data.map((item, index) => (
<tr key={index} className="border-b">
<td className="p-2">{item.name}</td>
<td className="p-2">{item.category}</td>
<td className="p-2 text-right">{item.value}</td>
</tr>
))}
</tbody>
</table>
);
};
Code collapsed
键盘导航
焦点管理
code
// components/charts/KeyboardNavigableChart.tsx
import React, { useState, useRef, useCallback } from 'react';
import { useKeyboardNavigation } from '@/hooks/useKeyboardNavigation';
interface KeyboardNavigableChartProps {
data: Array<{ label: string; value: number }>;
}
export const KeyboardNavigableChart: React.FC<KeyboardNavigableChartProps> = ({ data }) => {
const [focusedIndex, setFocusedIndex] = useState(-1);
const containerRef = useRef<HTMLDivElement>(null);
const handleKeyDown = useCallback((event: React.KeyboardEvent) => {
switch (event.key) {
case 'ArrowRight':
case 'ArrowDown':
event.preventDefault();
setFocusedIndex(prev => Math.min(prev + 1, data.length - 1));
break;
case 'ArrowLeft':
case 'ArrowUp':
event.preventDefault();
setFocusedIndex(prev => Math.max(prev - 1, 0));
break;
case 'Home':
event.preventDefault();
setFocusedIndex(0);
break;
case 'End':
event.preventDefault();
setFocusedIndex(data.length - 1);
break;
case 'Enter':
case ' ':
event.preventDefault();
if (focusedIndex >= 0) {
// 激活选中的数据点
handleDataPointActivate(data[focusedIndex]);
}
break;
case 'Escape':
event.preventDefault();
setFocusedIndex(-1);
break;
}
}, [focusedIndex, data]);
const handleDataPointActivate = (item: typeof data[0]) => {
console.log('Activated:', item);
// 处理激活逻辑
};
return (
<div
ref={containerRef}
role="application"
aria-label="交互式图表"
tabIndex={0}
onKeyDown={handleKeyDown}
className="relative"
>
{/* 图表内容 */}
<svg viewBox="0 0 400 200" className="w-full">
{data.map((item, index) => (
<g key={index}>
{/* 数据条 */}
<rect
x={index * 50 + 20}
y={200 - item.value * 2}
width="30"
height={item.value * 2}
fill={
focusedIndex === index
? ACCESSIBLE_COLORS.primary.orange
: ACCESSIBLE_COLORS.primary.blue
}
aria-label={`${item.label}: ${item.value}`}
tabIndex={focusedIndex === index ? 0 : -1}
onFocus={() => setFocusedIndex(index)}
/>
{/* 标签 */}
<text
x={index * 50 + 35}
y={195}
textAnchor="middle"
fill={ACCESSIBLE_COLORS.grayscale.black}
fontSize="12"
>
{item.label}
</text>
{/* 焦点指示器 */}
{focusedIndex === index && (
<rect
x={index * 50 + 15}
y={200 - item.value * 2 - 5}
width="40"
height={item.value * 2 + 25}
fill="none"
stroke={ACCESSIBLE_COLORS.primary.orange}
strokeWidth="2"
strokeDasharray="5,5"
/>
)}
</g>
))}
</svg>
{/* 焦点提示 */}
{focusedIndex >= 0 && (
<div
className="absolute top-0 right-0 bg-gray-900 text-white p-3 rounded shadow-lg"
role="status"
aria-live="polite"
>
<p className="font-semibold">{data[focusedIndex].label}</p>
<p className="text-2xl font-bold">{data[focusedIndex].value}</p>
<p className="text-xs text-gray-400 mt-2">
使用方向键导航,Enter 激活,Esc 取消
</p>
</div>
)}
</div>
);
};
Code collapsed
屏幕阅读器支持
ARIA 标签和实时更新
code
// components/charts/ScreenReaderFriendlyChart.tsx
import React, { useState } from 'react';
interface ScreenReaderFriendlyChartProps {
title: string;
data: Array<{
label: string;
value: number;
change?: number;
}>;
}
export const ScreenReaderFriendlyChart: React.FC<ScreenReaderFriendlyChartProps> = ({
title,
data
}) => {
const [hoveredItem, setHoveredItem] = useState<typeof data[0] | null>(null);
// 计算统计摘要
const summary = {
total: data.reduce((sum, item) => sum + item.value, 0),
average: data.reduce((sum, item) => sum + item.value, 0) / data.length,
max: Math.max(...data.map(item => item.value)),
min: Math.min(...data.map(item => item.value))
};
return (
<div
role="region"
aria-labelledby="chart-title"
aria-describedby="chart-summary"
>
{/* 可见标题 */}
<h2 id="chart-title" className="text-xl font-bold mb-4">
{title}
</h2>
{/* 屏幕阅读器摘要 */}
<div id="chart-summary" className="sr-only">
{title}。包含 {data.length} 个数据点。
总计 {summary.total.toFixed(0)},平均值 {summary.average.toFixed(1)}。
最高 {summary.max},最低 {summary.min}。
{data.map((item, index) =>
`${item.label}为${item.value}。${item.change ? `与上期相比${item.change > 0 ? '增加' : '减少'}${Math.abs(item.change)}。` : ''}`
).join(' ')}
</div>
{/* 图表容器 */}
<div
className="relative"
role="img"
aria-label={`${title}图表`}
>
<svg viewBox="0 0 400 200" className="w-full">
{data.map((item, index) => (
<g key={index}>
<circle
cx={(index + 1) * (400 / (data.length + 1))}
cy={200 - (item.value / summary.max) * 150}
r="8"
fill={ACCESSIBLE_COLORS.primary.blue}
onMouseEnter={() => setHoveredItem(item)}
onMouseLeave={() => setHoveredItem(null)}
onFocus={() => setHoveredItem(item)}
onBlur={() => setHoveredItem(null)}
tabIndex={0}
role="graphics-symbol"
aria-label={`${item.label}: ${item.value}`}
/>
</g>
))}
</svg>
{/* 实时更新区域 */}
<div
role="status"
aria-live="polite"
aria-atomic="true"
className="sr-only"
>
{hoveredItem && `${hoveredItem.label}: ${hoveredItem.value}`}
</div>
</div>
{/* 数据表格 */}
<div className="mt-6">
<h3 className="font-semibold mb-2">详细数据</h3>
<table className="w-full">
<thead>
<tr className="border-b">
<th className="text-left p-2">项目</th>
<th className="text-right p-2">数值</th>
<th className="text-right p-2">变化</th>
</tr>
</thead>
<tbody>
{data.map((item, index) => (
<tr key={index} className="border-b">
<td className="p-2">{item.label}</td>
<td className="p-2 text-right">{item.value}</td>
<td className="p-2 text-right">
{item.change !== undefined && (
<span className={
item.change >= 0 ? 'text-green-600' : 'text-red-600'
}>
{item.change >= 0 ? '+' : ''}{item.change}
</span>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
};
Code collapsed
缩放与响应式设计
code
// components/charts/ResponsiveAccessibleChart.tsx
import React, { useState, useEffect } from 'react';
interface ResponsiveAccessibleChartProps {
data: Array<{ label: string; value: number }>;
title: string;
}
export const ResponsiveAccessibleChart: React.FC<ResponsiveAccessibleChartProps> = ({
data,
title
}) => {
const [fontSize, setFontSize] = useState(14);
const [containerWidth, setContainerWidth] = useState(0);
const containerRef = useRef<HTMLDivElement>(null);
// 响应式字体大小
useEffect(() => {
const updateSize = () => {
if (containerRef.current) {
const width = containerRef.current.offsetWidth;
setContainerWidth(width);
// 根据容器宽度调整字体大小
setFontSize(width < 600 ? 12 : 14);
}
};
updateSize();
window.addEventListener('resize', updateSize);
return () => window.removeEventListener('resize', updateSize);
}, []);
// 用户控制的缩放
const [zoomLevel, setZoomLevel] = useState(1);
return (
<div ref={containerRef} className="w-full">
{/* 缩放控制 */}
<div className="flex items-center gap-2 mb-4" role="group" aria-label="缩放控制">
<button
onClick={() => setZoomLevel(Math.max(0.5, zoomLevel - 0.1))}
aria-label="缩小"
className="px-3 py-1 border rounded"
>
缩小
</button>
<span aria-live="polite" className="min-w-[3rem] text-center">
{Math.round(zoomLevel * 100)}%
</span>
<button
onClick={() => setZoomLevel(Math.min(2, zoomLevel + 0.1))}
aria-label="放大"
className="px-3 py-1 border rounded"
>
放大
</button>
<button
onClick={() => setZoomLevel(1)}
aria-label="重置缩放"
className="px-3 py-1 border rounded"
>
重置
</button>
</div>
{/* 图表 */}
<div
style={{
transform: `scale(${zoomLevel})`,
transformOrigin: 'top left',
fontSize: `${fontSize * zoomLevel}px`
}}
>
<svg
viewBox="0 0 400 200"
className="w-full"
role="img"
aria-label={title}
>
{/* 图表内容 */}
{data.map((item, index) => (
<text
key={index}
x={index * 50 + 35}
y={190}
textAnchor="middle"
fontSize={fontSize}
fill={ACCESSIBLE_COLORS.grayscale.black}
>
{item.label}
</text>
))}
</svg>
</div>
</div>
);
};
Code collapsed
无障碍测试清单
code
// lib/accessibility-checklist.ts
export const ACCESSIBILITY_CHECKLIST = {
colors: {
items: [
'所有文本与背景对比度至少 4.5:1',
'大文本(18pt+)对比度至少 3:1',
'不单独使用颜色传达信息',
'支持色盲模式的颜色方案',
'提供高对比度模式'
]
},
keyboard: {
items: [
'所有交互元素可通过键盘访问',
'焦点指示器清晰可见',
'支持标准快捷键(方向键、Enter、Esc)',
'焦点顺序符合逻辑',
'没有键盘陷阱'
]
},
screenReader: {
items: [
'提供有意义的 aria-label',
'图表有文本摘要',
'数据变化有 aria-live 通知',
'提供数据表格替代方案',
'使用语义化 HTML'
]
},
responsive: {
items: [
'支持 200% 缩放',
'移动设备可用',
'横竖屏切换正常',
'文本可调整大小',
'触摸目标足够大(44x44px)'
]
}
};
// 无障碍测试组件
export const AccessibilityTestPanel: React.FC = () => {
return (
<div className="fixed bottom-4 right-4 bg-white rounded-lg shadow-xl p-4 max-w-sm">
<h3 className="font-bold mb-2">无障碍测试面板</h3>
{Object.entries(ACCESSIBILITY_CHECKLIST).map(([category, { items }]) => (
<details key={category} className="mb-2">
<summary className="cursor-pointer font-semibold capitalize">
{category}
</summary>
<ul className="ml-4 mt-2 space-y-1">
{items.map((item, index) => (
<li key={index} className="flex items-start gap-2">
<input type="checkbox" className="mt-1" />
<span className="text-sm">{item}</span>
</li>
))}
</ul>
</details>
))}
</div>
);
};
Code collapsed