康心伴Logo
康心伴WellAlly
无障碍开发

构建符合 WCAG 标准的无障碍健康数据仪表板

深入学习如何使用 React 和 D3.js 构建符合 WCAG 2.1 AA 标准的无障碍健康数据仪表板。包含键盘导航、屏幕阅读器支持、颜色对比度和焦点管理等完整实践。

W
WellAlly 开发团队
2026-03-08
18 分钟阅读

关键要点

  • 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 级关键要求

  1. 颜色对比度:正文文本至少 4.5:1,大文本至少 3:1
  2. 键盘可访问:所有功能可通过键盘完成
  3. 焦点可见:键盘焦点始终清晰可见
  4. 错误标识:错误输入必须清晰标识并说明
  5. 状态通知:状态变化必须通知用户

步骤 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 标准的无障碍健康数据仪表板:

  1. 理解 WCAG 2.1 四大原则和 AA 级要求
  2. 创建无障碍的颜色系统和对比度验证
  3. 使用 D3.js 构建可访问的图表组件
  4. 实现完整的键盘导航和屏幕阅读器支持
  5. 添加实时无障碍通知

最佳实践

  • 尽早测试:在开发初期就开始使用屏幕阅读器测试
  • 真实用户测试:邀请残障用户参与测试
  • 持续改进:无障碍是一个持续的过程
  • 文档化:记录无障碍设计决策和测试结果

参考资料

相关文章

#

文章标签

react
d3
accessibility
wcag
a11y

觉得这篇文章有帮助?

立即体验康心伴,开始您的健康管理之旅