📚 现代前端开发 目录
阶段4:UI与体验
第 25 / 35 课

无障碍(a11y)

为所有人设计

📖 核心概念

💻 代码实现

无障碍组件实现 ✅ tsx
// 无障碍模态框
import { useEffect, useRef } from 'react';

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

  // 焦点管理:打开时记住之前的焦点,关闭时恢复
  useEffect(() => {
    if (isOpen) {
      previousFocusRef.current = document.activeElement as HTMLElement;
      // 将焦点移到模态框内第一个可聚焦元素
      const firstFocusable = modalRef.current?.querySelector<HTMLElement>(
        'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
      );
      firstFocusable?.focus();
    } else {
      previousFocusRef.current?.focus();
    }
  }, [isOpen]);

  // 焦点陷阱:Tab键不会离开模态框
  useEffect(() => {
    if (!isOpen) return;
    const handleKeyDown = (e: KeyboardEvent) => {
      if (e.key === 'Escape') { onClose(); return; }
      if (e.key !== 'Tab') return;

      const focusable = modalRef.current?.querySelectorAll<HTMLElement>(
        'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
      );
      if (!focusable?.length) return;

      const first = focusable[0];
      const last = focusable[focusable.length - 1];

      if (e.shiftKey && document.activeElement === first) {
        e.preventDefault();
        last.focus();
      } else if (!e.shiftKey && document.activeElement === last) {
        e.preventDefault();
        first.focus();
      }
    };
    document.addEventListener('keydown', handleKeyDown);
    return () => document.removeEventListener('keydown', handleKeyDown);
  }, [isOpen, onClose]);

  if (!isOpen) return null;

  return (
    <div
      role="dialog"
      aria-modal="true"
      aria-labelledby="modal-title"
      ref={modalRef}
      className="modal-overlay"
      onClick={(e) => { if (e.target === e.currentTarget) onClose(); }}
    >
      <div className="modal-content">
        <h2 id="modal-title">{title}</h2>
        {children}
        <button onClick={onClose} aria-label="关闭对话框">✕</button>
      </div>
    </div>
  );
}
ARIA模式 ✅ tsx
// 无障碍手风琴
function Accordion({ items }: { items: { title: string; content: string }[] }) {
  const [openIndex, setOpenIndex] = useState<number | null>(null);

  return (
    <div className="accordion">
      {items.map((item, index) => (
        <div key={index} className="accordion-item">
          <h3>
            <button
              aria-expanded={openIndex === index}
              aria-controls={\`panel-\${index}\`}
              id={\`trigger-\${index}\`}
              onClick={() => setOpenIndex(openIndex === index ? null : index)}
            >
              {item.title}
              <span aria-hidden="true">{openIndex === index ? '−' : '+'}</span>
            </button>
          </h3>
          <div
            id={\`panel-\${index}\`}
            role="region"
            aria-labelledby={\`trigger-\${index}\`}
            hidden={openIndex !== index}
          >
            <p>{item.content}</p>
          </div>
        </div>
      ))}
    </div>
  );
}

// 活动区域 (aria-live)
function NotificationToast() {
  const [messages, setMessages] = useState<string[]>([]);

  return (
    <div>
      {/* aria-live="polite": 屏幕阅读器在空闲时播报 */}
      <div aria-live="polite" aria-atomic="true" className="sr-only">
        {messages[messages.length - 1]}
      </div>

      {/* aria-live="assertive": 立即打断播报(错误提示等) */}
      {/* <div aria-live="assertive">...</div> */}
    </div>
  );
}

// 跳过导航链接
function SkipNav() {
  return (
    <a href="#main-content" className="skip-link">
      跳到主要内容
    </a>
  );
}

/* CSS */
/* .skip-link {
/*   position: absolute; left: -9999px; top: 0;
/*   background: #06b6d4; color: white; padding: 8px 16px; z-index: 100;
/* }
/* .skip-link:focus { left: 0; }
/*
/* .sr-only {
/*   position: absolute; width: 1px; height: 1px;
/*   padding: 0; margin: -1px; overflow: hidden;
/*   clip: rect(0, 0, 0, 0); border: 0;
/* }
/* */

// 无障碍检查清单
const a11yChecklist = [
  '✅ 所有图片有alt文本',
  '✅ 表单有label关联',
  '✅ 颜色对比度 ≥ 4.5:1',
  '✅ 键盘可完全操作',
  '✅ 焦点顺序符合逻辑',
  '✅ ARIA角色正确使用',
  '✅ 跳过导航链接',
  '✅ aria-live通知动态内容',
  '✅ 文字可缩放至200%',
  '✅ 触摸目标 ≥ 44×44px',
];

🎯 练习任务

🏆 成就解锁
无障碍倡导者 — 掌握Web无障碍开发,让每个用户都能使用你的产品