Communitygithub.com

前端无障碍模式

针对 React 和 Next.js 的无障碍模式,涵盖语义化 HTML、ARIA 属性、表单标签、键盘导航、焦点管理及屏幕阅读器支持。在构建或审查表单、模态框、下拉菜单、工具提示或选项卡,修复 a11y lint 或代码审查结果,或配置键盘导航与焦点管理时使用。

前端无障碍模式 是什么?

该技能为 React 和 Next.js 开发者提供了一套全面且实用的无障碍(a11y)模式库,重点解决代码审查中最常遇到的问题:表单标签缺失、ARIA 使用不当、非语义化交互元素以及键盘导航失效。它通过丰富的代码示例(包括完整的表单、下拉菜单、模态框等)演示了如何正确连接标签、标记必填字段、关联错误信息,以及使用语义化 HTML 替代 <div>/<span> 加 onClick 的模式。此外,技能详细讲解了 ARIA 属性(如 aria-label、aria-describedby、aria-live 和 aria-expanded)的正确使用时机,并提供了自定义键盘导航和焦点管理的实现方案,包括模态框打开/关闭时的焦点保存与恢复。该技能还涵盖了图片和图标的无障碍处理、尊重用户减少动画偏好的方法,并总结了常见反模式。适用于在任何代码编辑器(通过 Cursor、Codex 或 Claude Code 等 AI 工具)中开发 React/Next.js 组件时,作为实时参考和代码生成指南。

兼容平台~Claude Code~Codex CLI~Cursor
npx skills add https://github.com/junksidetm/darkside-toolkit/tree/HEAD/skills/frontend-a11y

在你喜欢的 AI 中提问

打开一个已预加载此 Agent Skill 的新对话。

预览

来自技能 README

前端无障碍模式 preview
前端无障碍模式 preview
前端无障碍模式 preview

文档

前端无障碍模式 是做什么的?

针对 React 和 Next.js 的实用无障碍模式。涵盖了代码审查中最常被指出的问题:表单标签缺失、ARIA 使用错误、非语义化交互元素以及损坏的键盘导航。

何时激活

  • 构建或审查表单组件(<input>、<select>、<textarea>)
  • 创建交互元素(模态框、下拉菜单、工具提示、选项卡)
  • 使用带有 onClick 的 <div> 或 <span>
  • 为任何元素添加 aria-* 属性
  • 实现键盘导航或焦点管理
  • 收到来自代码审查工具(CodeRabbit、ESLint a11y)的无障碍反馈
  • 构建必须支持屏幕阅读器的组件

表单无障碍

htmlFor / id 配对缺失以及错误消息未关联是代码审查中最常见的问题。

标签关联

// 错误:标签与输入框没有关联——屏幕阅读器无法将它们联系起来
<label>Email</label>
<input type="email" />

// 正确:htmlFor 与 input id 匹配
<label htmlFor="email">Email</label>
<input id="email" type="email" />

必填字段

// 错误:仅视觉上的星号无法传达给屏幕阅读器
<label htmlFor="email">Email *</label>
<input id="email" type="email" />

// 正确:required 启用原生浏览器验证;aria-required 向屏幕阅读器发出信号
<label htmlFor="email">
  Email <span aria-hidden="true">*</span>
</label>
<input id="email" type="email" required aria-required="true" />

错误消息

// 错误:错误文本在视觉上存在,但未链接到输入框
<input id="email" type="email" />
<span className="error">Invalid email address</span>

// 正确:aria-describedby 将输入框与其错误消息关联
// aria-invalid 向屏幕阅读器指示无效状态
<input
  id="email"
  type="email"
  aria-describedby="email-error"
  aria-invalid={!!error}
/>
{error && (
  <span id="email-error" role="alert">
    {error}
  </span>
)}

完整无障碍表单

interface LoginFormProps {
  onSubmit: (email: string, password: string) => void;
}

export function LoginForm({ onSubmit }: LoginFormProps) {
  const [email, setEmail] = useState('');
  const [password, setPassword] = useState('');
  const [errors, setErrors] = useState<{ email?: string; password?: string }>({});

  const handleSubmit = (e: React.FormEvent) => {
    e.preventDefault();
    const newErrors: typeof errors = {};
    if (!email) newErrors.email = 'Email is required';
    if (!password) newErrors.password = 'Password is required';
    if (Object.keys(newErrors).length) {
      setErrors(newErrors);
      return;
    }
    onSubmit(email, password);
  };

  return (
    <form onSubmit={handleSubmit} noValidate>
      <div>
        <label htmlFor="email">
          Email <span aria-hidden="true">*</span>
        </label>
        <input
          id="email"
          type="email"
          value={email}
          onChange={e => setEmail(e.target.value)}
          aria-required="true"
          aria-describedby={errors.email ? 'email-error' : undefined}
          aria-invalid={!!errors.email}
          autoComplete="email"
        />
        {errors.email && (
          <span id="email-error" role="alert">
            {errors.email}
          </span>
        )}
      </div>

      <div>
        <label htmlFor="password">
          Password <span aria-hidden="true">*</span>
        </label>
        <input
          id="password"
          type="password"
          value={password}
          onChange={e => setPassword(e.target.value)}
          aria-required="true"
          aria-describedby={errors.password ? 'password-error' : undefined}
          aria-invalid={!!errors.password}
          autoComplete="current-password"
        />
        {errors.password && (
          <span id="password-error" role="alert">
            {errors.password}
          </span>
        )}
      </div>

      <button type="submit">Log in</button>
    </form>
  );
}

语义化 HTML

使用与意图匹配的元素。屏幕阅读器和键盘用户依赖原生语义。

// 错误:div 没有角色、没有键盘支持、没有可访问名称
<div onClick={handleClick}>Submit</div>

// 正确:button 可聚焦,在 Enter/Space 上激活,并宣布为“按钮”
<button type="button" onClick={handleClick}>Submit</button>
// 错误:非语义化导航
<div onClick={() => navigate('/home')}>Home</div>

// 正确:锚点支持右键单击、中键单击和键盘导航
<a href="/home">Home</a>
// 错误:标题层级跳过(h1 到 h4)
<h1>Dashboard</h1>
<h4>Recent Activity</h4>

// 正确:连续的标题层级
<h1>Dashboard</h1>
<h2>Recent Activity</h2>

ARIA 属性

仅在原生 HTML 语义不足时使用 ARIA。错误的 ARIA 比没有 ARIA 更糟糕。

aria-label 与 aria-labelledby

// aria-label:内联字符串标签——在没有可见标签文本时使用
<button aria-label="Close modal">
  <XIcon />
</button>

// aria-labelledby:引用另一个元素的文本——当存在可见标签时使用
<section aria-labelledby="section-title">
  <h2 id="section-title">Recent Orders</h2>
  {/* 内容 */}
</section>

aria-describedby

// 提供标签之外的补充描述
<button
  aria-describedby="delete-warning"
  onClick={handleDelete}
> Delete account
</button>
<p id="delete-warning">This action cannot be undone.</p>

动态内容的 aria-live

// 使用 aria-live 来宣布无需重新加载页面即可更新的内容
// polite:等待用户完成当前操作后再宣布
// assertive:立即中断——仅在紧急错误时使用

export function StatusMessage({ message, isError }: { message: string; isError?: boolean }) {
  return (
    <div role="status" aria-live={isError ? 'assertive' : 'polite'} aria-atomic="true">
      {message}
    </div>
  );
}

aria-expanded 和 aria-controls

export function Accordion({ title, children }: { title: string; children: React.ReactNode }) {
  const [isOpen, setIsOpen] = useState(false);
  const contentId = useId();

  return (
    <div>
      <button aria-expanded={isOpen} aria-controls={contentId} onClick={() => setIsOpen(prev => !prev)}>
        {title}
      </button>
      <div id={contentId} hidden={!isOpen}>
        {children}
      </div>
    </div>
  );
}

键盘导航

每个交互元素都必须能够单独通过键盘到达和操作。

自定义下拉菜单

export function Dropdown({ options, onSelect }: { options: string[]; onSelect: (value: string) => void }) {
  const [isOpen, setIsOpen] = useState(false);
  const [activeIndex, setActiveIndex] = useState(0);
  const listId = useId();

  if (!options.length) return null;

  const handleKeyDown = (e: React.KeyboardEvent) => {
    switch (e.key) {
      case 'ArrowDown':
        e.preventDefault();
        setActiveIndex(i => Math.min(i + 1, options.length - 1));
        break;
      case 'ArrowUp':
        e.preventDefault();
        setActiveIndex(i => Math.max(i - 1, 0));
        break;
      case 'Enter':
      case ' ':
        e.preventDefault();
        if (isOpen) onSelect(options[activeIndex]);
        setIsOpen(prev => !prev);
        break;
      case 'Escape':
        setIsOpen(false);
        break;
    }
  };

  return (
    <div
      role="combobox"
      aria-expanded={isOpen}
      aria-haspopup="listbox"
      aria-controls={listId}
      tabIndex={0}
      onKeyDown={handleKeyDown}
      onClick={() => setIsOpen(prev => !prev)}
    >
      <span>{options[activeIndex]}</span>
      {isOpen && (
        <ul id={listId} role="listbox">
          {options.map((option, index) => (
            <li
              key={option}
              role="option"
              aria-selected={index === activeIndex}
              onClick={() => {
                onSelect(option);
                setIsOpen(false);
              }}
            >
              {option}
            </li>
          ))}
        </ul>
      )}
    </div>
  );
}

焦点管理

当 UI 状态发生变化时,焦点必须逻辑地移动——特别是对于模态框和路由切换。

模态框焦点恢复

此示例涵盖了初始焦点和恢复。对于完整的焦点陷阱(Tab/Shift+Tab 在模态框内循环),请使用像 focus-trap-react 这样的库,它处理了动态内容和嵌套 portal 等边缘情况。

export function Modal({ isOpen, onClose, title, children }: { isOpen: boolean; onClose: () => void; title: string; children: React.ReactNode }) {
  const modalRef = useRef<HTMLDivElement>(null);
  const previousFocusRef = useRef<HTMLElement | null>(null);

  useEffect(() => {
    if (isOpen) {
      // 保存当前聚焦的元素并将焦点移入模态框
      previousFocusRef.current = document.activeElement as HTMLElement;
      modalRef.current?.focus();
    } else {
      // 将焦点恢复到打开模态框的元素
      previousFocusRef.current?.focus();
    }
  }, [isOpen]);

  if (!isOpen) return null;

  return (
    <div ref={modalRef} role="dialog" aria-modal="true" aria-labelledby="modal-title" tabIndex={-1} onKeyDown={e => e.key === 'Escape' && onClose()}>
      <h2 id="modal-title">{title}</h2>
      {children}
      <button onClick={onClose}>Close</button>
    </div>
  );
}

图片和图标

// 错误:装饰性图标被宣布为未标记的图像
<img src="/icon.svg" />

// 正确:对屏幕阅读器隐藏装饰性图像
<img src="/decoration.png" alt="" aria-hidden="true" />

// 正确:具有描述性替代文本的有意义图像
<img src="/chart.png" alt="Monthly revenue increased 23% from January to March" />

// 正确:带有可访问标签的图标按钮
<button aria-label="Delete item">
  <TrashIcon aria-hidden="true" />
</button>

减少动画

尊重那些在操作系统设置中请求减少动画的用户。

export function useReducedMotion(): boolean {
  const [prefersReduced, setPrefersReduced] = useState(false);

  useEffect(() => {
    const mq = window.matchMedia('(prefers-reduced-motion: reduce)');
    setPrefersReduced(mq.matches);
    const handler = (e: MediaQueryListEvent) => setPrefersReduced(e.matches);
    mq.addEventListener('change', handler);
    return () => mq.removeEventListener('change', handler);
  }, []);

  return prefersReduced;
}

// 使用示例
export function AnimatedCard({ children }: { children: React.ReactNode }) {
  const reduceMotion = useReducedMotion();

  return (
    <div
      style={{
        transition: reduceMotion ? 'none' : 'transform 300ms ease'
      }}
    >
      {children}
    </div>
  );
}

反模式

// 错误:在非交互元素上使用 onClick,无键盘支持
<div onClick={handleClick}>Click me</div>

// 错误:在没有角色的 div 上使用 aria-label
<div aria-label="Navigation">...</div>

// 错误:使用 placeholder 替代标签
<input placeholder="Enter your email" />

// 错误:正数 tabIndex 导致不可预测的 Tab 顺序
<button tabIndex={3}>Submit</button>

// 错误:在可聚焦元素上使用 aria-hidden——键盘用户会被困住
<button aria-hidden="true">Open</button>

// 错误:在 div 上使用 role="button" 但没有键盘处理程序
<div role="button" onClick={handleClick}>Submit</div>
// 缺少:tabIndex={0},以及用于 Enter/Space 的 onKeyDown

检查清单

在提交任何交互式组件进行审查之前:

  • 每个 <input>、<select> 和 <textarea> 都通过 htmlFor/id 关联了 <label>
  • 错误消息通过 aria-describedby 链接并标记为 role="alert"
  • 没有在 <div> 或 <span> 上使用 onClick 而不带 role、tabIndex 和 onKeyDown
  • 纯图标按钮具有 aria-label
  • 装饰性图像使用 alt="" 和 aria-hidden="true"
  • 模态框在关闭时恢复焦点(如需完整的 Tab/Shift+Tab 焦点循环,请使用像 focus-trap-react 这样的库)
  • 动态内容更新使用 aria-live
  • 对于动画,尊重 prefers-reduced-motion

相关技能

  • frontend-patterns — 通用 React 组件和状态模式
  • design-system — 设计令牌和组件一致性
  • motion-foundations 和 motion-patterns:考虑无障碍的动画模式

Individual skills in this repo

This repo contains 2 individual skills — each has its own dedicated page.

相关技能