AccessibilityCompliance accessibility-compliance

这个技能是关于如何在Web开发中实现和确保无障碍性,包括WCAG标准、屏幕阅读器兼容性、键盘导航和无障碍测试,以提升用户体验和满足法规要求。

前端开发 0 次安装 0 次浏览 更新于 3/3/2026

name: 无障碍合规性 description: 实施WCAG 2.1/2.2无障碍标准,屏幕阅读器兼容性,键盘导航和无障碍测试。在构建包容性Web应用程序、确保法规合规性或改善残疾人用户体验时使用。

无障碍合规性

概览

按照WCAG指南实施全面的无障碍功能,确保您的应用程序可以被每个人使用,包括残疾人。

使用场景

  • 构建面向公众的Web应用程序
  • 确保WCAG 2.1/2.2 AA或AAA合规性
  • 支持屏幕阅读器(NVDA、JAWS、VoiceOver)
  • 实施仅键盘导航
  • 满足ADA、第508条款或类似法规
  • 提升SEO和整体用户体验
  • 进行无障碍审计

关键原则(POUR)

  1. 可感知 - 信息必须以用户可以感知的方式呈现
  2. 可操作 - 界面组件必须可操作
  3. 可理解 - 信息和操作必须是可理解的
  4. 健壮性 - 内容必须足够健壮,能够被辅助技术解释

实施示例

1. 语义HTML与ARIA

<!-- 不好:非语义标记 -->
<div class="button" onclick="submit()">提交</div>

<!-- 好:语义HTML -->
<button type="submit" aria-label="提交表单">提交</button>

<!-- 具有适当ARIA的自定义组件 -->
<div
  role="button"
  tabindex="0"
  aria-pressed="false"
  onclick="toggle()"
  onkeydown="handleKeyPress(event)"
>
  切换功能
</div>

<!-- 具有适当标签和错误处理的表单 -->
<form>
  <label for="email">电子邮件地址</label>
  <input
    id="email"
    type="email"
    name="email"
    aria-required="true"
    aria-invalid="false"
    aria-describedby="email-error"
  />
  <span id="email-error" role="alert" aria-live="polite"></span>
</form>

2. 带有无障碍性的React组件

import React, { useRef, useEffect, useState } from 'react';

interface ModalProps {
  isOpen: boolean;
  onClose: () => void;
  title: string;
  children: React.ReactNode;
}

const AccessibleModal: React.FC<ModalProps> = ({
  isOpen,
  onClose,
  title,
  children
}) => {
  const modalRef = useRef<HTMLDivElement>(null);
  const previousFocusRef = useRef<HTMLElement | null>(null);

  useEffect(() => {
    if (isOpen) {
      // 保存之前的焦点
      previousFocusRef.current = document.activeElement as HTMLElement;

      // 焦点模态
      modalRef.current?.focus();

      // 在模态内捕获焦点
      const trapFocus = (e: KeyboardEvent) => {
        if (e.key === 'Escape') {
          onClose();
        }
      };

      document.addEventListener('keydown', trapFocus);

      return () => {
        document.removeEventListener('keydown', trapFocus);
        // 恢复之前的焦点
        previousFocusRef.current?.focus();
      };
    }
  }, [isOpen, onClose]);

  if (!isOpen) return null;

  return (
    <div
      role="dialog"
      aria-modal="true"
      aria-labelledby="modal-title"
      ref={modalRef}
      tabIndex={-1}
      className="modal-overlay"
      onClick={onClose}
    >
      <div
        className="modal-content"
        onClick={(e) => e.stopPropagation()}
      >
        <h2 id="modal-title">{title}</h2>
        <button
          onClick={onClose}
          aria-label="关闭模态"
          className="close-button"
        >
          &times;
        </button>
        <div className="modal-body">
          {children}
        </div>
      </div>
    </div>
  );
};

export default AccessibleModal;

3. 键盘导航处理器

// 键盘导航工具
export const KeyboardNavigation = {
  // 处理列表中的箭头键导航
  handleListNavigation: (event: KeyboardEvent, items: HTMLElement[]) => {
    const currentIndex = items.findIndex(item =>
      item === document.activeElement
    );

    let nextIndex: number;

    switch (event.key) {
      case 'ArrowDown':
        event.preventDefault();
        nextIndex = Math.min(currentIndex + 1, items.length - 1);
        items[nextIndex]?.focus();
        break;

      case 'ArrowUp':
        event.preventDefault();
        nextIndex = Math.max(currentIndex - 1, 0);
        items[nextIndex]?.focus();
        break;

      case 'Home':
        event.preventDefault();
        items[0]?.focus();
        break;

      case 'End':
        event.preventDefault();
        items[items.length - 1]?.focus();
        break;
    }
  },

  // 使元素可通过键盘访问
  makeAccessible: (
    element: HTMLElement,
    onClick: () => void
  ): void => {
    element.setAttribute('tabindex', '0');
    element.setAttribute('role', 'button');

    element.addEventListener('keydown', (e) => {
      if (e.key === 'Enter' || e.key === ' ') {
        e.preventDefault();
        onClick();
      }
    });
  }
};

4. 颜色对比度验证器

from typing import Tuple
import math

def hex_to_rgb(hex_color: str) -> Tuple[int, int, int]:
    """将十六进制颜色转换为RGB。"""
    hex_color = hex_color.lstrip('#')
    return tuple(int(hex_color[i:i+2], 16) for i in (0, 2, 4))

def calculate_luminance(rgb: Tuple[int, int, int]) -> float:
    """计算相对亮度。"""
    def adjust(color: int) -> float:
        c = color / 255.0
        if c <= 0.03928:
            return c / 12.92
        return math.pow((c + 0.055) / 1.055, 2.4)

    r, g, b = rgb
    return 0.2126 * adjust(r) + 0.7152 * adjust(g) + 0.0722 * adjust(b)

def calculate_contrast_ratio(color1: str, color2: str) -> float:
    """计算WCAG对比度比率。"""
    lum1 = calculate_luminance(hex_to_rgb(color1))
    lum2 = calculate_luminance(hex_to_rgb(color2))

    lighter = max(lum1, lum2)
    darker = min(lum1, lum2)

    return (lighter + 0.05) / (darker + 0.05)

def check_wcag_compliance(
    foreground: str,
    background: str,
    level: str = 'AA',
    large_text: bool = False
) -> dict:
    """检查颜色组合是否符合WCAG标准。"""
    ratio = calculate_contrast_ratio(foreground, background)

    # WCAG 2.1要求
    requirements = {
        'AA': {'normal': 4.5, 'large': 3.0},
        'AAA': {'normal': 7.0, 'large': 4.5}
    }

    required_ratio = requirements[level]['large' if large_text else 'normal']
    passes = ratio >= required_ratio

    return {
        'ratio': round(ratio, 2),
        'required': required_ratio,
        'passes': passes,
        'level': level,
        'grade': 'Pass' if passes else 'Fail'
    }

# 使用方法
result = check_wcag_compliance('#000000', '#FFFFFF', 'AA', False)
print(f"对比度比率:{result['ratio']}:1")  # 21:1
print(f"WCAG {result['level']}:{result['grade']}")  # 通过

5. 屏幕阅读器公告

class ScreenReaderAnnouncer {
  private liveRegion: HTMLElement;

  constructor() {
    this.liveRegion = this.createLiveRegion();
  }

  private createLiveRegion(): HTMLElement {
    const region = document.createElement('div');
    region.setAttribute('role', 'status');
    region.setAttribute('aria-live', 'polite');
    region.setAttribute('aria-atomic', 'true');
    region.className = 'sr-only';
    region.style.cssText = `
      position: absolute;
      left: -10000px;
      width: 1px;
      height: 1px;
      overflow: hidden;
    `;
    document.body.appendChild(region);
    return region;
  }

  announce(message: string, priority: 'polite' | 'assertive' = 'polite'): void {
    this.liveRegion.setAttribute('aria-live', priority);

    // 清空然后设置消息以确保公告
    this.liveRegion.textContent = '';
    setTimeout(() => {
      this.liveRegion.textContent = message;
    }, 100);
  }

  cleanup(): void {
    this.liveRegion.remove();
  }
}

// 使用方法
const announcer = new ScreenReaderAnnouncer();

// 宣布表单验证错误
announcer.announce('电子邮件字段是必填项', 'assertive');

// 宣布成功的操作
announcer.announce('商品已添加到购物车', 'polite');

6. 焦点管理

class FocusManager {
  private focusableSelectors = [
    'a[href]',
    'button:not([disabled])',
    'textarea:not([disabled])',
    'input:not([disabled])',
    'select:not([disabled])',
    '[tabindex]:not([tabindex="-1"])'
  ].join(', ');

  getFocusableElements(container: HTMLElement): HTMLElement[] {
    return Array.from(
      container.querySelectorAll(this.focusableSelectors)
    ) as HTMLElement[];
  }

  trapFocus(container: HTMLElement): () => void {
    const focusable = this.getFocusableElements(container);
    const firstFocusable = focusable[0];
    const lastFocusable = focusable[focusable.length - 1];

    const handleTabKey = (e: KeyboardEvent) => {
      if (e.key !== 'Tab') return;

      if (e.shiftKey) {
        if (document.activeElement === firstFocusable) {
          lastFocusable.focus();
          e.preventDefault();
        }
      } else {
        if (document.activeElement === lastFocusable) {
          firstFocusable.focus();
          e.preventDefault();
        }
      }
    };

    container.addEventListener('keydown', handleTabKey);

    return () => container.removeEventListener('keydown', handleTabKey);
  }
}

测试工具和技术

自动化测试

// Jest + Testing Library无障碍测试
import { render, screen } from '@testing-library/react';
import { axe, toHaveNoViolations } from 'jest-axe';

expect.extend(toHaveNoViolations);

describe('无障碍性', () => {
  it('不应该有无障碍违规', async () => {
    const { container } = render(<MyComponent />);
    const results = await axe(container);
    expect(results).toHaveNoViolations();
  });

  it('应该有适当的ARIA标签', () => {
    render(<Button onClick={() => {}}>点击我</Button>);
    const button = screen.getByRole('button', { name: /点击我/i });
    expect(button).toBeInTheDocument();
  });

  it('应该是键盘可导航的', () => {
    const { container } = render(<Navigation />);
    const links = screen.getAllByRole('link');
    links.forEach(link => {
      expect(link).toHaveAttribute('href');
    });
  });
});

最佳实践

✅ 要做

  • 使用语义HTML元素
  • 为图像提供文本替代品
  • 确保足够的颜色对比度(最低4.5:1)
  • 支持键盘导航
  • 实施焦点管理
  • 用屏幕阅读器测试
  • 正确使用ARIA属性
  • 提供跳过链接
  • 使表单无障碍,带有标签
  • 支持文本放大至200%

❌ 不要做

  • 仅依赖颜色来传达信息
  • 移除焦点指示器
  • 仅使用鼠标/触摸交互
  • 无控制地自动播放媒体
  • 创建键盘陷阱
  • 使用正tabindex值
  • 覆盖用户偏好设置
  • 仅从视觉上隐藏应该从屏幕阅读器隐藏的内容

检查表

  • [ ] 所有图像都有alt文本
  • [ ] 颜色对比度符合WCAG AA标准
  • [ ] 所有交互元素都可通过键盘访问
  • [ ] 焦点指示器可见
  • [ ] 表单输入有关联标签
  • [ ] 错误消息向屏幕阅读器宣布
  • [ ] 提供跳过链接
  • [ ] 标题遵循层次顺序
  • [ ] 正确使用ARIA属性
  • [ ] 内容在200%缩放下可读
  • [ ] 仅用键盘测试
  • [ ] 用屏幕阅读器测试(NVDA、JAWS、VoiceOver)

资源