accessibility-auditor

Compare original and translation side by side

🇺🇸

Original

English
🇨🇳

Translation

Chinese

Accessibility Auditor

可访问性审计工具

Build inclusive web experiences with WCAG 2.1 compliance and comprehensive a11y patterns.
构建符合WCAG 2.1标准、具备全面可访问性模式的包容性网页体验。

Core Workflow

核心工作流程

  1. Audit existing code: Identify accessibility issues
  2. Check WCAG compliance: Verify against success criteria
  3. Fix semantic HTML: Use proper elements and landmarks
  4. Add ARIA attributes: Enhance assistive technology support
  5. Implement keyboard nav: Ensure full keyboard accessibility
  6. Test with tools: Automated and manual testing
  7. Verify with screen readers: Real-world testing
  1. 审计现有代码:识别可访问性问题
  2. 检查WCAG合规性:对照成功标准进行验证
  3. 修复语义化HTML:使用正确的元素和地标
  4. 添加ARIA属性:增强辅助技术支持
  5. 实现键盘导航:确保完全的键盘可访问性
  6. 工具测试:自动化与手动测试结合
  7. 屏幕阅读器验证:真实场景测试

WCAG 2.1 Quick Reference

WCAG 2.1 快速参考

Compliance Levels

合规等级

LevelDescriptionRequirement
AMinimum accessibilityMust have
AAStandard complianceIndustry standard
AAAEnhanced accessibilityNice to have
等级描述要求
A基础可访问性必须满足
AA标准合规性行业通用标准
AAA增强可访问性建议满足

Four Principles (POUR)

四大原则(POUR)

  1. Perceivable: Content must be presentable to all senses
  2. Operable: Interface must be navigable by all users
  3. Understandable: Content must be clear and predictable
  4. Robust: Content must work with assistive technologies
  1. 可感知性:内容需适配所有感官呈现
  2. 可操作性:界面需支持所有用户导航
  3. 可理解性:内容需清晰且可预测
  4. 健壮性:内容需兼容各类辅助技术

Semantic HTML

语义化HTML

Use Proper Elements

使用正确元素

html
<!-- Bad: Divs for everything -->
<div class="header">
  <div class="nav">
    <div onclick="navigate()">Home</div>
  </div>
</div>

<!-- Good: Semantic elements -->
<header>
  <nav aria-label="Main navigation">
    <a href="/">Home</a>
  </nav>
</header>
html
<!-- 不良示例:所有内容用Div -->
<div class="header">
  <div class="nav">
    <div onclick="navigate()">首页</div>
  </div>
</div>

<!-- 良好示例:语义化元素 -->
<header>
  <nav aria-label="主导航">
    <a href="/">首页</a>
  </nav>
</header>

Document Landmarks

文档地标

html
<body>
  <header>
    <nav aria-label="Main">...</nav>
  </header>

  <main id="main-content">
    <article>
      <h1>Page Title</h1>
      <section aria-labelledby="section-heading">
        <h2 id="section-heading">Section</h2>
      </section>
    </article>
    <aside aria-label="Related content">...</aside>
  </main>

  <footer>...</footer>
</body>
html
<body>
  <header>
    <nav aria-label="主导航">...</nav>
  </header>

  <main id="main-content">
    <article>
      <h1>页面标题</h1>
      <section aria-labelledby="section-heading">
        <h2 id="section-heading">章节</h2>
      </section>
    </article>
    <aside aria-label="相关内容">...</aside>
  </main>

  <footer>...</footer>
</body>

Heading Hierarchy

标题层级

html
<!-- Correct heading order -->
<h1>Page Title</h1>
  <h2>Section</h2>
    <h3>Subsection</h3>
    <h3>Subsection</h3>
  <h2>Section</h2>
    <h3>Subsection</h3>

<!-- Never skip levels -->
<!-- Bad: h1 → h3 (skipped h2) -->
html
<!-- 正确的标题顺序 -->
<h1>页面标题</h1>
  <h2>章节</h2>
    <h3>子章节</h3>
    <h3>子章节</h3>
  <h2>章节</h2>
    <h3>子章节</h3>

<!-- 切勿跳过层级 -->
<!-- 不良示例:h1 → h3(跳过h2) -->

ARIA Patterns

ARIA模式

Buttons

按钮

tsx
// Interactive element that looks like a button
<button type="button" onClick={handleClick}>
  Click me
</button>

// If you must use a div (avoid if possible)
<div
  role="button"
  tabIndex={0}
  onClick={handleClick}
  onKeyDown={(e) => {
    if (e.key === 'Enter' || e.key === ' ') {
      e.preventDefault();
      handleClick();
    }
  }}
>
  Click me
</div>
tsx
// 外观类似按钮的交互元素
<button type="button" onClick={handleClick}>
  点击我
</button>

// 万不得已才用div(尽量避免)
<div
  role="button"
  tabIndex={0}
  onClick={handleClick}
  onKeyDown={(e) => {
    if (e.key === 'Enter' || e.key === ' ') {
      e.preventDefault();
      handleClick();
    }
  }}
>
  点击我
</div>

Modals / Dialogs

模态框/对话框

tsx
// components/Modal.tsx
import { useEffect, useRef } from 'react';

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

export function Modal({ isOpen, onClose, title, children }: ModalProps) {
  const modalRef = useRef<HTMLDivElement>(null);
  const previousActiveElement = useRef<Element | null>(null);

  useEffect(() => {
    if (isOpen) {
      // Store current focus
      previousActiveElement.current = document.activeElement;
      // Focus modal
      modalRef.current?.focus();
      // Prevent body scroll
      document.body.style.overflow = 'hidden';
    } else {
      // Restore focus
      (previousActiveElement.current as HTMLElement)?.focus();
      document.body.style.overflow = '';
    }

    return () => {
      document.body.style.overflow = '';
    };
  }, [isOpen]);

  // Handle escape key
  useEffect(() => {
    const handleEscape = (e: KeyboardEvent) => {
      if (e.key === 'Escape' && isOpen) {
        onClose();
      }
    };

    document.addEventListener('keydown', handleEscape);
    return () => document.removeEventListener('keydown', handleEscape);
  }, [isOpen, onClose]);

  if (!isOpen) return null;

  return (
    <div
      className="fixed inset-0 z-50 flex items-center justify-center"
      role="presentation"
    >
      {/* Backdrop */}
      <div
        className="absolute inset-0 bg-black/50"
        onClick={onClose}
        aria-hidden="true"
      />

      {/* Modal */}
      <div
        ref={modalRef}
        role="dialog"
        aria-modal="true"
        aria-labelledby="modal-title"
        tabIndex={-1}
        className="relative z-10 bg-white rounded-lg p-6 max-w-md w-full"
      >
        <h2 id="modal-title" className="text-xl font-bold">
          {title}
        </h2>

        <div className="mt-4">{children}</div>

        <button
          onClick={onClose}
          className="absolute top-4 right-4"
          aria-label="Close modal"
        >
          ×
        </button>
      </div>
    </div>
  );
}
tsx
// components/Modal.tsx
import { useEffect, useRef } from 'react';

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

export function Modal({ isOpen, onClose, title, children }: ModalProps) {
  const modalRef = useRef<HTMLDivElement>(null);
  const previousActiveElement = useRef<Element | null>(null);

  useEffect(() => {
    if (isOpen) {
      // 存储当前焦点
      previousActiveElement.current = document.activeElement;
      // 聚焦模态框
      modalRef.current?.focus();
      // 禁止页面滚动
      document.body.style.overflow = 'hidden';
    } else {
      // 恢复焦点
      (previousActiveElement.current as HTMLElement)?.focus();
      document.body.style.overflow = '';
    }

    return () => {
      document.body.style.overflow = '';
    };
  }, [isOpen]);

  // 处理ESC键
  useEffect(() => {
    const handleEscape = (e: KeyboardEvent) => {
      if (e.key === 'Escape' && isOpen) {
        onClose();
      }
    };

    document.addEventListener('keydown', handleEscape);
    return () => document.removeEventListener('keydown', handleEscape);
  }, [isOpen, onClose]);

  if (!isOpen) return null;

  return (
    <div
      className="fixed inset-0 z-50 flex items-center justify-center"
      role="presentation"
    >
      {/* 遮罩层 */}
      <div
        className="absolute inset-0 bg-black/50"
        onClick={onClose}
        aria-hidden="true"
      />

      {/* 模态框 */}
      <div
        ref={modalRef}
        role="dialog"
        aria-modal="true"
        aria-labelledby="modal-title"
        tabIndex={-1}
        className="relative z-10 bg-white rounded-lg p-6 max-w-md w-full"
      >
        <h2 id="modal-title" className="text-xl font-bold">
          {title}
        </h2>

        <div className="mt-4">{children}</div>

        <button
          onClick={onClose}
          className="absolute top-4 right-4"
          aria-label="关闭模态框"
        >
          ×
        </button>
      </div>
    </div>
  );
}

Tabs

标签页

tsx
// components/Tabs.tsx
import { useState, useRef, KeyboardEvent } from 'react';

interface Tab {
  id: string;
  label: string;
  content: React.ReactNode;
}

export function Tabs({ tabs }: { tabs: Tab[] }) {
  const [activeTab, setActiveTab] = useState(tabs[0].id);
  const tabRefs = useRef<(HTMLButtonElement | null)[]>([]);

  const handleKeyDown = (e: KeyboardEvent, index: number) => {
    let newIndex = index;

    switch (e.key) {
      case 'ArrowLeft':
        newIndex = index === 0 ? tabs.length - 1 : index - 1;
        break;
      case 'ArrowRight':
        newIndex = index === tabs.length - 1 ? 0 : index + 1;
        break;
      case 'Home':
        newIndex = 0;
        break;
      case 'End':
        newIndex = tabs.length - 1;
        break;
      default:
        return;
    }

    e.preventDefault();
    setActiveTab(tabs[newIndex].id);
    tabRefs.current[newIndex]?.focus();
  };

  return (
    <div>
      <div role="tablist" aria-label="Content tabs" className="flex border-b">
        {tabs.map((tab, index) => (
          <button
            key={tab.id}
            ref={(el) => (tabRefs.current[index] = el)}
            role="tab"
            id={`tab-${tab.id}`}
            aria-selected={activeTab === tab.id}
            aria-controls={`panel-${tab.id}`}
            tabIndex={activeTab === tab.id ? 0 : -1}
            onClick={() => setActiveTab(tab.id)}
            onKeyDown={(e) => handleKeyDown(e, index)}
            className={`px-4 py-2 ${
              activeTab === tab.id
                ? 'border-b-2 border-blue-500'
                : 'text-gray-500'
            }`}
          >
            {tab.label}
          </button>
        ))}
      </div>

      {tabs.map((tab) => (
        <div
          key={tab.id}
          role="tabpanel"
          id={`panel-${tab.id}`}
          aria-labelledby={`tab-${tab.id}`}
          hidden={activeTab !== tab.id}
          tabIndex={0}
          className="p-4"
        >
          {tab.content}
        </div>
      ))}
    </div>
  );
}
tsx
// components/Tabs.tsx
import { useState, useRef, KeyboardEvent } from 'react';

interface Tab {
  id: string;
  label: string;
  content: React.ReactNode;
}

export function Tabs({ tabs }: { tabs: Tab[] }) {
  const [activeTab, setActiveTab] = useState(tabs[0].id);
  const tabRefs = useRef<(HTMLButtonElement | null)[]>([]);

  const handleKeyDown = (e: KeyboardEvent, index: number) => {
    let newIndex = index;

    switch (e.key) {
      case 'ArrowLeft':
        newIndex = index === 0 ? tabs.length - 1 : index - 1;
        break;
      case 'ArrowRight':
        newIndex = index === tabs.length - 1 ? 0 : index + 1;
        break;
      case 'Home':
        newIndex = 0;
        break;
      case 'End':
        newIndex = tabs.length - 1;
        break;
      default:
        return;
    }

    e.preventDefault();
    setActiveTab(tabs[newIndex].id);
    tabRefs.current[newIndex]?.focus();
  };

  return (
    <div>
      <div role="tablist" aria-label="内容标签页" className="flex border-b">
        {tabs.map((tab, index) => (
          <button
            key={tab.id}
            ref={(el) => (tabRefs.current[index] = el)}
            role="tab"
            id={`tab-${tab.id}`}
            aria-selected={activeTab === tab.id}
            aria-controls={`panel-${tab.id}`}
            tabIndex={activeTab === tab.id ? 0 : -1}
            onClick={() => setActiveTab(tab.id)}
            onKeyDown={(e) => handleKeyDown(e, index)}
            className={`px-4 py-2 ${
              activeTab === tab.id
                ? 'border-b-2 border-blue-500'
                : 'text-gray-500'
            }`}
          >
            {tab.label}
          </button>
        ))}
      </div>

      {tabs.map((tab) => (
        <div
          key={tab.id}
          role="tabpanel"
          id={`panel-${tab.id}`}
          aria-labelledby={`tab-${tab.id}`}
          hidden={activeTab !== tab.id}
          tabIndex={0}
          className="p-4"
        >
          {tab.content}
        </div>
      ))}
    </div>
  );
}

Dropdown Menu

下拉菜单

tsx
// components/Dropdown.tsx
import { useState, useRef, useEffect, KeyboardEvent } from 'react';

interface MenuItem {
  id: string;
  label: string;
  onClick: () => void;
}

export function Dropdown({ label, items }: { label: string; items: MenuItem[] }) {
  const [isOpen, setIsOpen] = useState(false);
  const [activeIndex, setActiveIndex] = useState(-1);
  const menuRef = useRef<HTMLUListElement>(null);
  const buttonRef = useRef<HTMLButtonElement>(null);

  const handleKeyDown = (e: KeyboardEvent) => {
    switch (e.key) {
      case 'ArrowDown':
        e.preventDefault();
        if (!isOpen) {
          setIsOpen(true);
          setActiveIndex(0);
        } else {
          setActiveIndex((prev) => (prev + 1) % items.length);
        }
        break;
      case 'ArrowUp':
        e.preventDefault();
        setActiveIndex((prev) => (prev - 1 + items.length) % items.length);
        break;
      case 'Enter':
      case ' ':
        e.preventDefault();
        if (isOpen && activeIndex >= 0) {
          items[activeIndex].onClick();
          setIsOpen(false);
          buttonRef.current?.focus();
        } else {
          setIsOpen(true);
        }
        break;
      case 'Escape':
        setIsOpen(false);
        buttonRef.current?.focus();
        break;
    }
  };

  // Close on outside click
  useEffect(() => {
    const handleClickOutside = (e: MouseEvent) => {
      if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
        setIsOpen(false);
      }
    };

    document.addEventListener('mousedown', handleClickOutside);
    return () => document.removeEventListener('mousedown', handleClickOutside);
  }, []);

  return (
    <div className="relative">
      <button
        ref={buttonRef}
        aria-haspopup="true"
        aria-expanded={isOpen}
        aria-controls="dropdown-menu"
        onClick={() => setIsOpen(!isOpen)}
        onKeyDown={handleKeyDown}
        className="px-4 py-2 bg-gray-100 rounded"
      >
        {label}
      </button>

      {isOpen && (
        <ul
          ref={menuRef}
          id="dropdown-menu"
          role="menu"
          aria-labelledby="dropdown-button"
          onKeyDown={handleKeyDown}
          className="absolute mt-1 bg-white border rounded shadow-lg"
        >
          {items.map((item, index) => (
            <li
              key={item.id}
              role="menuitem"
              tabIndex={-1}
              onClick={() => {
                item.onClick();
                setIsOpen(false);
              }}
              className={`px-4 py-2 cursor-pointer ${
                index === activeIndex ? 'bg-blue-100' : ''
              }`}
            >
              {item.label}
            </li>
          ))}
        </ul>
      )}
    </div>
  );
}
tsx
// components/Dropdown.tsx
import { useState, useRef, useEffect, KeyboardEvent } from 'react';

interface MenuItem {
  id: string;
  label: string;
  onClick: () => void;
}

export function Dropdown({ label, items }: { label: string; items: MenuItem[] }) {
  const [isOpen, setIsOpen] = useState(false);
  const [activeIndex, setActiveIndex] = useState(-1);
  const menuRef = useRef<HTMLUListElement>(null);
  const buttonRef = useRef<HTMLButtonElement>(null);

  const handleKeyDown = (e: KeyboardEvent) => {
    switch (e.key) {
      case 'ArrowDown':
        e.preventDefault();
        if (!isOpen) {
          setIsOpen(true);
          setActiveIndex(0);
        } else {
          setActiveIndex((prev) => (prev + 1) % items.length);
        }
        break;
      case 'ArrowUp':
        e.preventDefault();
        setActiveIndex((prev) => (prev - 1 + items.length) % items.length);
        break;
      case 'Enter':
      case ' ':
        e.preventDefault();
        if (isOpen && activeIndex >= 0) {
          items[activeIndex].onClick();
          setIsOpen(false);
          buttonRef.current?.focus();
        } else {
          setIsOpen(true);
        }
        break;
      case 'Escape':
        setIsOpen(false);
        buttonRef.current?.focus();
        break;
    }
  };

  // 点击外部关闭菜单
  useEffect(() => {
    const handleClickOutside = (e: MouseEvent) => {
      if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
        setIsOpen(false);
      }
    };

    document.addEventListener('mousedown', handleClickOutside);
    return () => document.removeEventListener('mousedown', handleClickOutside);
  }, []);

  return (
    <div className="relative">
      <button
        ref={buttonRef}
        aria-haspopup="true"
        aria-expanded={isOpen}
        aria-controls="dropdown-menu"
        onClick={() => setIsOpen(!isOpen)}
        onKeyDown={handleKeyDown}
        className="px-4 py-2 bg-gray-100 rounded"
      >
        {label}
      </button>

      {isOpen && (
        <ul
          ref={menuRef}
          id="dropdown-menu"
          role="menu"
          aria-labelledby="dropdown-button"
          onKeyDown={handleKeyDown}
          className="absolute mt-1 bg-white border rounded shadow-lg"
        >
          {items.map((item, index) => (
            <li
              key={item.id}
              role="menuitem"
              tabIndex={-1}
              onClick={() => {
                item.onClick();
                setIsOpen(false);
              }}
              className={`px-4 py-2 cursor-pointer ${
                index === activeIndex ? 'bg-blue-100' : ''
              }`}
            >
              {item.label}
            </li>
          ))}
        </ul>
      )}
    </div>
  );
}

Focus Management

焦点管理

Skip Links

跳转链接

html
<!-- First element in body -->
<a href="#main-content" class="sr-only focus:not-sr-only focus:absolute focus:p-4 focus:bg-white focus:z-50">
  Skip to main content
</a>

<!-- Main content target -->
<main id="main-content" tabindex="-1">
  ...
</main>
html
<!-- body中第一个元素 -->
<a href="#main-content" class="sr-only focus:not-sr-only focus:absolute focus:p-4 focus:bg-white focus:z-50">
  跳转到主内容
</a>

<!-- 主内容目标 -->
<main id="main-content" tabindex="-1">
  ...
</main>

Focus Trap for Modals

模态框焦点陷阱

tsx
// hooks/useFocusTrap.ts
import { useEffect, useRef } from 'react';

export function useFocusTrap<T extends HTMLElement>(isActive: boolean) {
  const containerRef = useRef<T>(null);

  useEffect(() => {
    if (!isActive || !containerRef.current) return;

    const container = containerRef.current;
    const focusableElements = container.querySelectorAll<HTMLElement>(
      'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
    );
    const firstElement = focusableElements[0];
    const lastElement = focusableElements[focusableElements.length - 1];

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

      if (e.shiftKey) {
        if (document.activeElement === firstElement) {
          e.preventDefault();
          lastElement?.focus();
        }
      } else {
        if (document.activeElement === lastElement) {
          e.preventDefault();
          firstElement?.focus();
        }
      }
    };

    container.addEventListener('keydown', handleTab);
    firstElement?.focus();

    return () => container.removeEventListener('keydown', handleTab);
  }, [isActive]);

  return containerRef;
}
tsx
// hooks/useFocusTrap.ts
import { useEffect, useRef } from 'react';

export function useFocusTrap<T extends HTMLElement>(isActive: boolean) {
  const containerRef = useRef<T>(null);

  useEffect(() => {
    if (!isActive || !containerRef.current) return;

    const container = containerRef.current;
    const focusableElements = container.querySelectorAll<HTMLElement>(
      'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
    );
    const firstElement = focusableElements[0];
    const lastElement = focusableElements[focusableElements.length - 1];

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

      if (e.shiftKey) {
        if (document.activeElement === firstElement) {
          e.preventDefault();
          lastElement?.focus();
        }
      } else {
        if (document.activeElement === lastElement) {
          e.preventDefault();
          firstElement?.focus();
        }
      }
    };

    container.addEventListener('keydown', handleTab);
    firstElement?.focus();

    return () => container.removeEventListener('keydown', handleTab);
  }, [isActive]);

  return containerRef;
}

Focus Visible Styles

焦点可见样式

css
/* Only show focus ring for keyboard users */
:focus {
  outline: none;
}

:focus-visible {
  outline: 2px solid #3b82f6;
  outline-offset: 2px;
}

/* Tailwind equivalent */
.focus-visible:focus-visible {
  @apply outline-none ring-2 ring-blue-500 ring-offset-2;
}
css
/* 仅为键盘用户显示焦点环 */
:focus {
  outline: none;
}

:focus-visible {
  outline: 2px solid #3b82f6;
  outline-offset: 2px;
}

/* Tailwind 等效写法 */
.focus-visible:focus-visible {
  @apply outline-none ring-2 ring-blue-500 ring-offset-2;
}

Color Contrast

颜色对比度

WCAG Contrast Requirements

WCAG 对比度要求

LevelNormal TextLarge Text
AA4.5:13:1
AAA7:14.5:1
Large text = 18pt+ (24px) or 14pt+ bold (18.5px)
等级常规文本大文本
AA4.5:13:1
AAA7:14.5:1
大文本 = 18pt以上(24px)或14pt以上粗体(18.5px)

Accessible Color Pairs

可访问颜色搭配

css
/* High contrast pairs */
:root {
  /* Text on white background */
  --text-primary: #1f2937;   /* gray-800, 12.6:1 contrast */
  --text-secondary: #4b5563; /* gray-600, 7.0:1 contrast */
  --text-tertiary: #6b7280;  /* gray-500, 4.6:1 contrast (AA only) */

  /* Links */
  --link-color: #1d4ed8;     /* blue-700, 7.3:1 contrast */

  /* Errors */
  --error-text: #dc2626;     /* red-600, 4.5:1 contrast */
}
css
/* 高对比度搭配 */
:root {
  /* 白色背景上的文本 */
  --text-primary: #1f2937;   /* gray-800,对比度12.6:1 */
  --text-secondary: #4b5563; /* gray-600,对比度7.0:1 */
  --text-tertiary: #6b7280;  /* gray-500,对比度4.6:1(仅满足AA级) */

  /* 链接 */
  --link-color: #1d4ed8;     /* blue-700,对比度7.3:1 */

  /* 错误提示 */
  --error-text: #dc2626;     /* red-600,对比度4.5:1 */
}

Testing Contrast

对比度测试

tsx
// Utility to check contrast ratio
function getContrastRatio(color1: string, color2: string): number {
  const getLuminance = (hex: string): number => {
    const rgb = parseInt(hex.slice(1), 16);
    const r = (rgb >> 16) & 0xff;
    const g = (rgb >> 8) & 0xff;
    const b = (rgb >> 0) & 0xff;

    const [rs, gs, bs] = [r, g, b].map((c) => {
      c /= 255;
      return c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4);
    });

    return 0.2126 * rs + 0.7152 * gs + 0.0722 * bs;
  };

  const l1 = getLuminance(color1);
  const l2 = getLuminance(color2);
  const lighter = Math.max(l1, l2);
  const darker = Math.min(l1, l2);

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

// Usage
const ratio = getContrastRatio('#1f2937', '#ffffff'); // 12.6
const passesAA = ratio >= 4.5;
const passesAAA = ratio >= 7;
tsx
// 对比度比值检查工具
function getContrastRatio(color1: string, color2: string): number {
  const getLuminance = (hex: string): number => {
    const rgb = parseInt(hex.slice(1), 16);
    const r = (rgb >> 16) & 0xff;
    const g = (rgb >> 8) & 0xff;
    const b = (rgb >> 0) & 0xff;

    const [rs, gs, bs] = [r, g, b].map((c) => {
      c /= 255;
      return c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4);
    });

    return 0.2126 * rs + 0.7152 * gs + 0.0722 * bs;
  };

  const l1 = getLuminance(color1);
  const l2 = getLuminance(color2);
  const lighter = Math.max(l1, l2);
  const darker = Math.min(l1, l2);

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

// 使用示例
const ratio = getContrastRatio('#1f2937', '#ffffff'); // 12.6
const passesAA = ratio >= 4.5;
const passesAAA = ratio >= 7;

Forms

表单

Accessible Form Fields

可访问表单字段

tsx
// components/FormField.tsx
interface FormFieldProps {
  id: string;
  label: string;
  error?: string;
  required?: boolean;
  description?: string;
  children: React.ReactNode;
}

export function FormField({
  id,
  label,
  error,
  required,
  description,
  children,
}: FormFieldProps) {
  const descriptionId = description ? `${id}-description` : undefined;
  const errorId = error ? `${id}-error` : undefined;

  return (
    <div className="space-y-1">
      <label htmlFor={id} className="block font-medium">
        {label}
        {required && (
          <span className="text-red-500 ml-1" aria-hidden="true">
            *
          </span>
        )}
        {required && <span className="sr-only">(required)</span>}
      </label>

      {description && (
        <p id={descriptionId} className="text-sm text-gray-500">
          {description}
        </p>
      )}

      {/* Clone child and add aria attributes */}
      {React.cloneElement(children as React.ReactElement, {
        id,
        'aria-required': required,
        'aria-invalid': !!error,
        'aria-describedby': [descriptionId, errorId].filter(Boolean).join(' ') || undefined,
      })}

      {error && (
        <p id={errorId} className="text-sm text-red-600" role="alert">
          {error}
        </p>
      )}
    </div>
  );
}
tsx
// components/FormField.tsx
interface FormFieldProps {
  id: string;
  label: string;
  error?: string;
  required?: boolean;
  description?: string;
  children: React.ReactNode;
}

export function FormField({
  id,
  label,
  error,
  required,
  description,
  children,
}: FormFieldProps) {
  const descriptionId = description ? `${id}-description` : undefined;
  const errorId = error ? `${id}-error` : undefined;

  return (
    <div className="space-y-1">
      <label htmlFor={id} className="block font-medium">
        {label}
        {required && (
          <span className="text-red-500 ml-1" aria-hidden="true">
            *
          </span>
        )}
        {required && <span className="sr-only">(必填)</span>}
      </label>

      {description && (
        <p id={descriptionId} className="text-sm text-gray-500">
          {description}
        </p>
      )}

      {/* 克隆子元素并添加ARIA属性 */}
      {React.cloneElement(children as React.ReactElement, {
        id,
        'aria-required': required,
        'aria-invalid': !!error,
        'aria-describedby': [descriptionId, errorId].filter(Boolean).join(' ') || undefined,
      })}

      {error && (
        <p id={errorId} className="text-sm text-red-600" role="alert">
          {error}
        </p>
      )}
    </div>
  );
}

Error Announcements

错误提示播报

tsx
// components/LiveRegion.tsx
export function LiveRegion({ message }: { message: string }) {
  return (
    <div
      role="alert"
      aria-live="polite"
      aria-atomic="true"
      className="sr-only"
    >
      {message}
    </div>
  );
}

// Usage: Announce form submission result
const [announcement, setAnnouncement] = useState('');

const handleSubmit = async () => {
  try {
    await submitForm();
    setAnnouncement('Form submitted successfully');
  } catch {
    setAnnouncement('Error submitting form. Please try again.');
  }
};
tsx
// components/LiveRegion.tsx
export function LiveRegion({ message }: { message: string }) {
  return (
    <div
      role="alert"
      aria-live="polite"
      aria-atomic="true"
      className="sr-only"
    >
      {message}
    </div>
  );
}

// 使用示例:播报表单提交结果
const [announcement, setAnnouncement] = useState('');

const handleSubmit = async () => {
  try {
    await submitForm();
    setAnnouncement('表单提交成功');
  } catch {
    setAnnouncement('表单提交失败,请重试。');
  }
};

Images and Media

图片与媒体

Image Alt Text

图片替代文本

html
<!-- Informative image -->
<img src="chart.png" alt="Sales increased 25% from Q1 to Q2 2024" />

<!-- Decorative image -->
<img src="decoration.svg" alt="" role="presentation" />

<!-- Complex image with long description -->
<figure>
  <img src="infographic.png" alt="Company growth infographic" aria-describedby="infographic-desc" />
  <figcaption id="infographic-desc">
    Detailed description of the infographic...
  </figcaption>
</figure>
html
<!-- 信息类图片 -->
<img src="chart.png" alt="2024年Q1到Q2销售额增长25%" />

<!-- 装饰类图片 -->
<img src="decoration.svg" alt="" role="presentation" />

<!-- 复杂图片配详细说明 -->
<figure>
  <img src="infographic.png" alt="公司增长信息图" aria-describedby="infographic-desc" />
  <figcaption id="infographic-desc">
    信息图详细说明...
  </figcaption>
</figure>

Video Accessibility

视频可访问性

html
<video controls>
  <source src="video.mp4" type="video/mp4" />
  <track kind="captions" src="captions-en.vtt" srclang="en" label="English" default />
  <track kind="descriptions" src="descriptions.vtt" srclang="en" label="Audio descriptions" />
</video>
html
<video controls>
  <source src="video.mp4" type="video/mp4" />
  <track kind="captions" src="captions-en.vtt" srclang="en" label="英文" default />
  <track kind="descriptions" src="descriptions.vtt" srclang="en" label="音频描述" />
</video>

Screen Reader Utilities

屏幕阅读器工具

Tailwind SR-Only Classes

Tailwind 仅屏幕可见类

css
/* Already in Tailwind */
.sr-only {
  position: absolute;
  width: 1px;
  height: 1px;
  padding: 0;
  margin: -1px;
  overflow: hidden;
  clip: rect(0, 0, 0, 0);
  white-space: nowrap;
  border-width: 0;
}

.not-sr-only {
  position: static;
  width: auto;
  height: auto;
  padding: 0;
  margin: 0;
  overflow: visible;
  clip: auto;
  white-space: normal;
}
css
/* Tailwind 内置样式 */
.sr-only {
  position: absolute;
  width: 1px;
  height: 1px;
  padding: 0;
  margin: -1px;
  overflow: hidden;
  clip: rect(0, 0, 0, 0);
  white-space: nowrap;
  border-width: 0;
}

.not-sr-only {
  position: static;
  width: auto;
  height: auto;
  padding: 0;
  margin: 0;
  overflow: visible;
  clip: auto;
  white-space: normal;
}

Screen Reader Only Text

仅屏幕可见文本

tsx
// components/VisuallyHidden.tsx
export function VisuallyHidden({ children }: { children: React.ReactNode }) {
  return <span className="sr-only">{children}</span>;
}

// Usage
<button>
  <TrashIcon aria-hidden="true" />
  <VisuallyHidden>Delete item</VisuallyHidden>
</button>
tsx
// components/VisuallyHidden.tsx
export function VisuallyHidden({ children }: { children: React.ReactNode }) {
  return <span className="sr-only">{children}</span>;
}

// 使用示例
<button>
  <TrashIcon aria-hidden="true" />
  <VisuallyHidden>删除项目</VisuallyHidden>
</button>

Testing Tools

测试工具

Automated Testing

自动化测试

typescript
// jest-axe for unit tests
import { axe, toHaveNoViolations } from 'jest-axe';
import { render } from '@testing-library/react';

expect.extend(toHaveNoViolations);

test('component has no accessibility violations', async () => {
  const { container } = render(<MyComponent />);
  const results = await axe(container);
  expect(results).toHaveNoViolations();
});
typescript
// jest-axe 单元测试
import { axe, toHaveNoViolations } from 'jest-axe';
import { render } from '@testing-library/react';

expect.extend(toHaveNoViolations);

test('组件无任何可访问性违规', async () => {
  const { container } = render(<MyComponent />);
  const results = await axe(container);
  expect(results).toHaveNoViolations();
});

Playwright a11y Testing

Playwright 可访问性测试

typescript
// tests/a11y.spec.ts
import { test, expect } from '@playwright/test';
import AxeBuilder from '@axe-core/playwright';

test('homepage has no accessibility violations', async ({ page }) => {
  await page.goto('/');

  const accessibilityScanResults = await new AxeBuilder({ page }).analyze();

  expect(accessibilityScanResults.violations).toEqual([]);
});

test('keyboard navigation works', async ({ page }) => {
  await page.goto('/');

  // Tab through interactive elements
  await page.keyboard.press('Tab');
  const firstFocused = await page.evaluate(() => document.activeElement?.tagName);
  expect(['A', 'BUTTON', 'INPUT']).toContain(firstFocused);

  // Test skip link
  await page.keyboard.press('Enter');
  await expect(page.locator('#main-content')).toBeFocused();
});
typescript
// tests/a11y.spec.ts
import { test, expect } from '@playwright/test';
import AxeBuilder from '@axe-core/playwright';

test('首页无任何可访问性违规', async ({ page }) => {
  await page.goto('/');

  const accessibilityScanResults = await new AxeBuilder({ page }).analyze();

  expect(accessibilityScanResults.violations).toEqual([]);
});

test('键盘导航正常工作', async ({ page }) => {
  await page.goto('/');

  // 遍历所有交互元素
  await page.keyboard.press('Tab');
  const firstFocused = await page.evaluate(() => document.activeElement?.tagName);
  expect(['A', 'BUTTON', 'INPUT']).toContain(firstFocused);

  // 测试跳转链接
  await page.keyboard.press('Enter');
  await expect(page.locator('#main-content')).toBeFocused();
});

Manual Testing Checklist

手动测试清单

  • Navigate entire page with keyboard only
  • Test with screen reader (VoiceOver, NVDA)
  • Zoom to 200% - layout still usable
  • Check color contrast with browser tools
  • Verify focus indicators are visible
  • Test with reduced motion preference
  • Verify form error announcements
  • 仅使用键盘完成整页导航
  • 使用屏幕阅读器测试(VoiceOver、NVDA)
  • 缩放至200% - 布局仍可正常使用
  • 用浏览器工具检查颜色对比度
  • 验证焦点指示器可见
  • 测试减少动画偏好设置
  • 验证表单错误提示播报

Best Practices

最佳实践

  1. Semantic HTML first: Use native elements before ARIA
  2. Focus management: Never remove focus outlines without replacement
  3. Announce changes: Use live regions for dynamic content
  4. Test with users: Include disabled users in testing
  5. Progressive enhancement: Core functionality without JavaScript
  6. Color independence: Don't rely on color alone for meaning
  7. Touch targets: Minimum 44x44px for mobile
  8. Animation: Respect
    prefers-reduced-motion
  1. 优先使用语义化HTML:先用原生元素,再考虑ARIA
  2. 焦点管理:移除焦点轮廓前必须提供替代方案
  3. 变更播报:对动态内容使用实时区域
  4. 用户测试:邀请残障用户参与测试
  5. 渐进增强:核心功能无需JavaScript也可使用
  6. 颜色独立性:不要仅依赖颜色传递信息
  7. 触摸目标:移动端最小尺寸44x44px
  8. 动画:遵循
    prefers-reduced-motion
    设置

Output Checklist

输出检查清单

Every accessibility audit should verify:
  • Semantic HTML used throughout
  • Proper heading hierarchy (h1 → h2 → h3)
  • All interactive elements keyboard accessible
  • Focus visible on all focusable elements
  • Images have appropriate alt text
  • Form fields have associated labels
  • Error messages linked with aria-describedby
  • Color contrast meets WCAG AA (4.5:1)
  • Skip link to main content
  • ARIA attributes used correctly
  • Modal focus trap implemented
  • Dynamic content announced to screen readers
  • Tested with axe-core or similar
  • Manual screen reader testing completed
每次可访问性审计都需验证:
  • 全程使用语义化HTML
  • 标题层级正确(h1 → h2 → h3)
  • 所有交互元素支持键盘访问
  • 所有可聚焦元素的焦点可见
  • 图片配有合适的替代文本
  • 表单字段关联对应标签
  • 错误提示通过aria-describedby关联
  • 颜色对比度满足WCAG AA级(4.5:1)
  • 包含跳转到主内容的链接
  • ARIA属性使用正确
  • 实现模态框焦点陷阱
  • 动态内容可被屏幕阅读器播报
  • 使用axe-core或类似工具测试
  • 完成手动屏幕阅读器测试