accessibility-a11y

Compare original and translation side by side

🇺🇸

Original

English
🇨🇳

Translation

Chinese

Accessibility (a11y)

无障碍访问(a11y)

Semantic HTML

语义化HTML

tsx
// Use semantic elements
<header>       {/* Site header */}
<nav>          {/* Navigation */}
<main>         {/* Main content - one per page */}
<article>      {/* Self-contained content */}
<section>      {/* Thematic grouping with heading */}
<aside>        {/* Sidebar content */}
<footer>       {/* Site footer */}

// Correct heading hierarchy
<h1>Page Title</h1>           {/* One per page */}
  <h2>Section</h2>
    <h3>Subsection</h3>
  <h2>Another Section</h2>

// Lists for navigation
<nav>
  <ul>
    <li><a href="/">Home</a></li>
    <li><a href="/about">About</a></li>
  </ul>
</nav>
tsx
// 使用语义化元素
<header>       {/* 站点页眉 */}
<nav>          {/* 导航栏 */}
<main>         {/* 主内容 - 每页一个 */}
<article>      {/* 独立内容块 */}
<section>      {/* 带标题的主题分组 */}
<aside>        {/* 侧边栏内容 */}
<footer>       {/* 站点页脚 */}

// 正确的标题层级
<h1>页面标题</h1>           {/* 每页一个 */}
  <h2>章节</h2>
    <h3>子章节</h3>
  <h2>另一章节</h2>

// 导航使用列表
<nav>
  <ul>
    <li><a href="/">首页</a></li>
    <li><a href="/about">关于我们</a></li>
  </ul>
</nav>

Skip Link

跳转链接

tsx
// components/layout/SkipLink.tsx
export function SkipLink() {
  return (
    <a
      href="#main-content"
      className="
        sr-only focus:not-sr-only
        focus:absolute focus:top-4 focus:left-4
        focus:z-50 focus:px-4 focus:py-2
        focus:bg-primary focus:text-primary-foreground
        focus:rounded
      "
    >
      Skip to main content
    </a>
  );
}

// In layout
<body>
  <SkipLink />
  <Header />
  <main id="main-content" tabIndex={-1}>
    {children}
  </main>
</body>
tsx
// components/layout/SkipLink.tsx
export function SkipLink() {
  return (
    <a
      href="#main-content"
      className="
        sr-only focus:not-sr-only
        focus:absolute focus:top-4 focus:left-4
        focus:z-50 focus:px-4 focus:py-2
        focus:bg-primary focus:text-primary-foreground
        focus:rounded
      "
    >
      跳转到主内容
    </a>
  );
}

// 在布局中使用
<body>
  <SkipLink />
  <Header />
  <main id="main-content" tabIndex={-1}>
    {children}
  </main>
</body>

Focus Management

焦点管理

tsx
// Visible focus states (Tailwind)
<button className="
  focus:outline-none
  focus-visible:ring-2
  focus-visible:ring-ring
  focus-visible:ring-offset-2
">

// Focus trap for modals
import { useEffect, useRef } from 'react';

function useFocusTrap(isOpen: boolean) {
  const containerRef = useRef<HTMLDivElement>(null);

  useEffect(() => {
    if (!isOpen) return;
    
    const container = containerRef.current;
    if (!container) return;

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

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

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

    firstElement?.focus();
    container.addEventListener('keydown', handleKeyDown);
    
    return () => container.removeEventListener('keydown', handleKeyDown);
  }, [isOpen]);

  return containerRef;
}
tsx
// 可见的焦点状态(Tailwind)
<button className="
  focus:outline-none
  focus-visible:ring-2
  focus-visible:ring-ring
  focus-visible:ring-offset-2
">

// 模态框的焦点陷阱
import { useEffect, useRef } from 'react';

function useFocusTrap(isOpen: boolean) {
  const containerRef = useRef<HTMLDivElement>(null);

  useEffect(() => {
    if (!isOpen) return;
    
    const container = containerRef.current;
    if (!container) return;

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

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

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

    firstElement?.focus();
    container.addEventListener('keydown', handleKeyDown);
    
    return () => container.removeEventListener('keydown', handleKeyDown);
  }, [isOpen]);

  return containerRef;
}

ARIA Labels

ARIA标签

tsx
// Buttons with icons only
<button aria-label="Close menu">
  <X className="w-5 h-5" />
</button>

// Loading states
<button disabled aria-busy="true">
  <Spinner aria-hidden="true" />
  <span>Submitting...</span>
</button>

// Live regions for dynamic content
<div aria-live="polite" aria-atomic="true">
  {statusMessage}
</div>

// Form errors
<input
  id="email"
  aria-invalid={hasError}
  aria-describedby={hasError ? 'email-error' : undefined}
/>
{hasError && (
  <p id="email-error" role="alert">
    Please enter a valid email
  </p>
)}

// Current page in navigation
<nav aria-label="Main navigation">
  <a href="/" aria-current={isHome ? 'page' : undefined}>Home</a>
  <a href="/about" aria-current={isAbout ? 'page' : undefined}>About</a>
</nav>
tsx
// 仅含图标按钮
<button aria-label="关闭菜单">
  <X className="w-5 h-5" />
</button>

// 加载状态
<button disabled aria-busy="true">
  <Spinner aria-hidden="true" />
  <span>提交中...</span>
</button>

// 动态内容的实时区域
<div aria-live="polite" aria-atomic="true">
  {statusMessage}
</div>

// 表单错误
<input
  id="email"
  aria-invalid={hasError}
  aria-describedby={hasError ? 'email-error' : undefined}
/>
{hasError && (
  <p id="email-error" role="alert">
    请输入有效的邮箱地址
  </p>
)}

// 导航中的当前页
<nav aria-label="主导航">
  <a href="/" aria-current={isHome ? 'page' : undefined}>首页</a>
  <a href="/about" aria-current={isAbout ? 'page' : undefined}>关于我们</a>
</nav>

Keyboard Navigation

键盘导航

tsx
// Custom keyboard handlers
function handleKeyDown(e: React.KeyboardEvent) {
  switch (e.key) {
    case 'Enter':
    case ' ':
      e.preventDefault();
      handleSelect();
      break;
    case 'Escape':
      handleClose();
      break;
    case 'ArrowDown':
      e.preventDefault();
      focusNext();
      break;
    case 'ArrowUp':
      e.preventDefault();
      focusPrevious();
      break;
  }
}

// Roving tabindex for menu items
function MenuItem({ isSelected, ...props }) {
  return (
    <button
      role="menuitem"
      tabIndex={isSelected ? 0 : -1}
      {...props}
    />
  );
}
tsx
// 自定义键盘事件处理器
function handleKeyDown(e: React.KeyboardEvent) {
  switch (e.key) {
    case 'Enter':
    case ' ':
      e.preventDefault();
      handleSelect();
      break;
    case 'Escape':
      handleClose();
      break;
    case 'ArrowDown':
      e.preventDefault();
      focusNext();
      break;
    case 'ArrowUp':
      e.preventDefault();
      focusPrevious();
      break;
  }
}

// 菜单项的移动tabindex
function MenuItem({ isSelected, ...props }) {
  return (
    <button
      role="menuitem"
      tabIndex={isSelected ? 0 : -1}
      {...props}
    />
  );
}

Color Contrast

颜色对比度

tsx
// WCAG AA requirements:
// - Normal text: 4.5:1 ratio
// - Large text (18px+ or 14px+ bold): 3:1 ratio
// - UI components: 3:1 ratio

// Use contrast-safe color combinations
// ✅ Good
<p className="text-foreground bg-background">       {/* High contrast */}
<p className="text-muted-foreground bg-background"> {/* Adequate for large text */}

// ❌ Avoid
<p className="text-gray-400 bg-gray-100">           {/* Poor contrast */}

// Test with browser DevTools → Accessibility panel
tsx
// WCAG AA标准要求:
// - 普通文本: 4.5:1 对比度
// - 大文本(18px以上或14px以上粗体): 3:1 对比度
// - UI组件: 3:1 对比度

// 使用对比度安全的配色组合
// ✅ 良好
<p className="text-foreground bg-background">       {/* 高对比度 */}
<p className="text-muted-foreground bg-background"> {/* 大文本足够 */}

// ❌ 避免
<p className="text-gray-400 bg-gray-100">           {/* 低对比度 */}

// 使用浏览器开发者工具 → 无障碍面板测试

Screen Reader Text

屏幕阅读器文本

tsx
// Visually hidden but announced
<span className="sr-only">
  Opens in new tab
</span>

// Icon with hidden label
<a href="/facebook" aria-label="Facebook">
  <Facebook aria-hidden="true" />
</a>

// Decorative images
<img src="/decoration.svg" alt="" aria-hidden="true" />

// Meaningful images
<img src="/team.jpg" alt="Our team of certified instructors" />
tsx
// 视觉隐藏但可被朗读
<span className="sr-only">
  在新标签页打开
</span>

// 带隐藏标签的图标
<a href="/facebook" aria-label="Facebook">
  <Facebook aria-hidden="true" />
</a>

// 装饰性图片
<img src="/decoration.svg" alt="" aria-hidden="true" />

// 有意义的图片
<img src="/team.jpg" alt="我们的认证讲师团队" />

Form Accessibility

表单无障碍访问

tsx
// Complete accessible form
<form onSubmit={handleSubmit} aria-labelledby="form-title">
  <h2 id="form-title">Contact Us</h2>
  
  <div>
    <label htmlFor="name">
      Name <span aria-hidden="true">*</span>
      <span className="sr-only">(required)</span>
    </label>
    <input
      id="name"
      name="name"
      type="text"
      required
      aria-required="true"
      aria-invalid={errors.name ? 'true' : 'false'}
      aria-describedby={errors.name ? 'name-error' : 'name-hint'}
    />
    <p id="name-hint" className="text-sm text-muted-foreground">
      Enter your full name
    </p>
    {errors.name && (
      <p id="name-error" role="alert" className="text-destructive">
        {errors.name}
      </p>
    )}
  </div>
  
  <button type="submit">
    Submit
  </button>
</form>
tsx
// 完整的无障碍表单
<form onSubmit={handleSubmit} aria-labelledby="form-title">
  <h2 id="form-title">联系我们</h2>
  
  <div>
    <label htmlFor="name">
      姓名 <span aria-hidden="true">*</span>
      <span className="sr-only">(必填)</span>
    </label>
    <input
      id="name"
      name="name"
      type="text"
      required
      aria-required="true"
      aria-invalid={errors.name ? 'true' : 'false'}
      aria-describedby={errors.name ? 'name-error' : 'name-hint'}
    />
    <p id="name-hint" className="text-sm text-muted-foreground">
      请输入您的全名
    </p>
    {errors.name && (
      <p id="name-error" role="alert" className="text-destructive">
        {errors.name}
      </p>
    )}
  </div>
  
  <button type="submit">
    提交
  </button>
</form>

Accordion Accessibility

折叠面板无障碍访问

tsx
// Accessible accordion pattern
function Accordion({ items }) {
  const [openIndex, setOpenIndex] = useState<number | null>(null);

  return (
    <div>
      {items.map((item, index) => (
        <div key={index}>
          <h3>
            <button
              id={`accordion-header-${index}`}
              aria-expanded={openIndex === index}
              aria-controls={`accordion-panel-${index}`}
              onClick={() => setOpenIndex(openIndex === index ? null : index)}
              className="w-full text-left"
            >
              {item.title}
            </button>
          </h3>
          <div
            id={`accordion-panel-${index}`}
            role="region"
            aria-labelledby={`accordion-header-${index}`}
            hidden={openIndex !== index}
          >
            {item.content}
          </div>
        </div>
      ))}
    </div>
  );
}
tsx
// 无障碍折叠面板模式
function Accordion({ items }) {
  const [openIndex, setOpenIndex] = useState<number | null>(null);

  return (
    <div>
      {items.map((item, index) => (
        <div key={index}>
          <h3>
            <button
              id={`accordion-header-${index}`}
              aria-expanded={openIndex === index}
              aria-controls={`accordion-panel-${index}`}
              onClick={() => setOpenIndex(openIndex === index ? null : index)}
              className="w-full text-left"
            >
              {item.title}
            </button>
          </h3>
          <div
            id={`accordion-panel-${index}`}
            role="region"
            aria-labelledby={`accordion-header-${index}`}
            hidden={openIndex !== index}
          >
            {item.content}
          </div>
        </div>
      ))}
    </div>
  );
}

Reduced Motion

减少动画

tsx
// Respect user preferences
import { useReducedMotion } from 'framer-motion';

function AnimatedComponent() {
  const shouldReduceMotion = useReducedMotion();

  return (
    <motion.div
      animate={{ y: 0, opacity: 1 }}
      transition={{
        duration: shouldReduceMotion ? 0 : 0.5,
      }}
    >
      Content
    </motion.div>
  );
}

// CSS approach
@media (prefers-reduced-motion: reduce) {
  * {
    animation-duration: 0.01ms !important;
    transition-duration: 0.01ms !important;
  }
}
tsx
// 尊重用户偏好
import { useReducedMotion } from 'framer-motion';

function AnimatedComponent() {
  const shouldReduceMotion = useReducedMotion();

  return (
    <motion.div
      animate={{ y: 0, opacity: 1 }}
      transition={{
        duration: shouldReduceMotion ? 0 : 0.5,
      }}
    >
      内容
    </motion.div>
  );
}

// CSS实现方式
@media (prefers-reduced-motion: reduce) {
  * {
    animation-duration: 0.01ms !important;
    transition-duration: 0.01ms !important;
  }
}

Testing Checklist

测试清单

  1. Navigate entire site with keyboard only (Tab, Enter, Escape, Arrows)
  2. Test with screen reader (VoiceOver, NVDA)
  3. Check color contrast ratios
  4. Verify focus indicators are visible
  5. Test at 200% zoom
  6. Check heading hierarchy
  7. Verify form labels and error messages
  8. Test with reduced motion preference
  1. 仅使用键盘(Tab、Enter、Escape、方向键)导航整个站点
  2. 使用屏幕阅读器测试(VoiceOver、NVDA)
  3. 检查颜色对比度
  4. 验证焦点指示器可见
  5. 测试200%缩放效果
  6. 检查标题层级
  7. 验证表单标签和错误提示
  8. 测试减少动画偏好设置