🚀 快速安装

复制以下命令并运行,立即安装此 Skill:

npx @anthropic-ai/skills install supercent-io/skills-template/web-accessibility

💡 提示:需要 Node.js 和 NPM

Web 可访问性 (A11y)

何时使用此技能

  • 新 UI 组件开发:设计可访问的组件
  • 可访问性审计:识别和修复现有站点的可访问性问题
  • 表单实现:编写对屏幕阅读器友好的表单
  • 模态框/下拉菜单:焦点管理和防止键盘陷阱
  • WCAG 合规性:满足法律要求或标准

输入格式

必需信息

  • 框架:React、Vue、Svelte、原生 JavaScript 等
  • 组件类型:按钮、表单、模态框、下拉菜单、导航等
  • WCAG 等级:A、AA、AAA(默认:AA)

可选信息

  • 屏幕阅读器:NVDA、JAWS、VoiceOver(用于测试)
  • 自动化测试工具:axe-core、Pa11y、Lighthouse(默认:axe-core)
  • 浏览器:Chrome、Firefox、Safari(默认:Chrome)

输入示例

创建一个可访问的 React 模态框组件:
- 框架:React + TypeScript
- WCAG 等级:AA
- 要求:
  - 焦点陷阱(焦点保持在模态框内)
  - 按 ESC 键关闭
  - 点击背景关闭
  - 标题/描述能被屏幕阅读器读出

指示

步骤 1:使用语义化 HTML

使用有意义的 HTML 元素使结构清晰。

任务

  • 使用语义化标签:<button><nav><main><header><footer>
  • 避免过度使用 <div><span>
  • 正确使用标题层级(<h1> ~ <h6>
  • <label><input> 关联

示例(❌ 反面示例 vs ✅ 正确示例):

<!-- ❌ 反面示例:仅使用 div 和 span -->
<div class="header">
  <span class="title">我的应用</span>
  <div class="nav">
    <div class="nav-item" onclick="navigate()">首页</div>
    <div class="nav-item" onclick="navigate()">关于</div>
  </div>
</div>

<!-- ✅ 正确示例:语义化 HTML -->
<header>
  <h1>我的应用</h1>
  <nav aria-label="主导航">
    <ul>
      <li><a href="/">首页</a></li>
      <li><a href="/about">关于</a></li>
    </ul>
  </nav>
</header>

表单示例

<!-- ❌ 反面示例:没有 label -->
<input type="text" placeholder="输入您的姓名">

<!-- ✅ 正确示例:label 已关联 -->
<label for="name">姓名:</label>
<input type="text" id="name" name="name" required>

<!-- 或者用 label 包裹 -->
<label>
  邮箱:
  <input type="email" name="email" required>
</label>

步骤 2:实现键盘导航

确保所有功能无需鼠标即可使用。

任务

  • 使用 Tab 和 Shift+Tab 移动焦点
  • 使用 Enter/空格键激活按钮
  • 使用箭头键导航列表/菜单
  • 使用 ESC 键关闭模态框/下拉菜单
  • 恰当地使用 tabindex

决策标准

  • 交互式元素 → tabindex="0"(可获得焦点)
  • 排除在焦点顺序外 → tabindex="-1"(只能通过编程方式获得焦点)
  • 不要更改焦点顺序 → 避免使用 tabindex="1+"

示例(React 下拉菜单):

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

interface DropdownProps {
  label: string;
  options: { value: string; label: string }[];
  onChange: (value: string) => void;
}

function AccessibleDropdown({ label, options, onChange }: DropdownProps) {
  const [isOpen, setIsOpen] = useState(false);
  const [selectedIndex, setSelectedIndex] = useState(0);
  const buttonRef = useRef<HTMLButtonElement>(null);
  const listRef = useRef<HTMLUListElement>(null);

  // 键盘处理器
  const handleKeyDown = (e: React.KeyboardEvent) => {
    switch (e.key) {
      case 'ArrowDown':
        e.preventDefault();
        if (!isOpen) {
          setIsOpen(true);
        } else {
          setSelectedIndex((prev) => (prev + 1) % options.length);
        }
        break;

      case 'ArrowUp':
        e.preventDefault();
        if (!isOpen) {
          setIsOpen(true);
        } else {
          setSelectedIndex((prev) => (prev - 1 + options.length) % options.length);
        }
        break;

      case 'Enter':
      case ' ':
        e.preventDefault();
        if (isOpen) {
          onChange(options[selectedIndex].value);
          setIsOpen(false);
          buttonRef.current?.focus();
        } else {
          setIsOpen(true);
        }
        break;

      case 'Escape':
        e.preventDefault();
        setIsOpen(false);
        buttonRef.current?.focus();
        break;
    }
  };

  return (
    <div className="dropdown">
      <button
        ref={buttonRef}
        onClick={() => setIsOpen(!isOpen)}
        onKeyDown={handleKeyDown}
        aria-haspopup="listbox"
        aria-expanded={isOpen}
        aria-labelledby="dropdown-label"
      >
        {label}
      </button>

      {isOpen && (
        <ul
          ref={listRef}
          role="listbox"
          aria-labelledby="dropdown-label"
          onKeyDown={handleKeyDown}
          tabIndex={-1}
        >
          {options.map((option, index) => (
            <li
              key={option.value}
              role="option"
              aria-selected={index === selectedIndex}
              onClick={() => {
                onChange(option.value);
                setIsOpen(false);
              }}
            >
              {option.label}
            </li>
          ))}
        </ul>
      )}
    </div>
  );
}

步骤 3:添加 ARIA 属性

为屏幕阅读器提供额外的上下文信息。

任务

  • aria-label:定义元素的名称
  • aria-labelledby:引用另一个元素作为标签
  • aria-describedby:提供额外的描述
  • aria-live:宣布动态内容变化
  • aria-hidden:对屏幕阅读器隐藏

检查清单

  • 所有交互式元素都有清晰的标签
  • 按钮目的清晰(例如,“提交表单”而不是“点击”)
  • 状态变化通告(aria-live)
  • 装饰性图片使用 alt=”” 或 aria-hidden=”true”

示例(模态框):

function AccessibleModal({ isOpen, onClose, title, children }) {
  const modalRef = useRef<HTMLDivElement>(null);

  // 模态框打开时的焦点陷阱
  useEffect(() => {
    if (isOpen) {
      modalRef.current?.focus();
    }
  }, [isOpen]);

  if (!isOpen) return null;

  return (
    <div
      role="dialog"
      aria-modal="true"
      aria-labelledby="modal-title"
      aria-describedby="modal-description"
      ref={modalRef}
      tabIndex={-1}
      onKeyDown={(e) => {
        if (e.key === 'Escape') {
          onClose();
        }
      }}
    >
      <div className="modal-overlay" onClick={onClose} aria-hidden="true" />

      <div className="modal-content">
        <h2 id="modal-title">{title}</h2>
        <div id="modal-description">
          {children}
        </div>

        <button onClick={onClose} aria-label="关闭模态框">
          <span aria-hidden="true">×</span>
        </button>
      </div>
    </div>
  );
}

aria-live 示例(通知):

function Notification({ message, type }: { message: string; type: 'success' | 'error' }) {
  return (
    <div
      role="alert"
      aria-live="assertive"  // 立即宣布(错误),"polite" 会按序宣布
      aria-atomic="true"     // 阅读整个内容
      className={`notification notification-${type}`}
    >
      {type === 'error' && <span aria-label="错误">⚠️</span>}
      {type === 'success' && <span aria-label="成功"></span>}
      {message}
    </div>
  );
}

步骤 4:颜色对比度和视觉可访问性

确保为有视觉障碍的用户提供足够的对比度。

任务

  • WCAG AA:普通文本 4.5:1,大号文本 3:1
  • WCAG AAA:普通文本 7:1,大号文本 4.5:1
  • 不要仅通过颜色传达信息(同时使用图标、图案)
  • 清晰指示焦点(轮廓)

示例(CSS):

/* ✅ 足够的对比度(黑色文本在白色背景上 = 21:1) */
.button {
  background-color: #0066cc;
  color: #ffffff;  /* 对比度 7.7:1 */
}

/* ✅ 焦点指示器 */
button:focus,
a:focus {
  outline: 3px solid #0066cc;
  outline-offset: 2px;
}

/* ❌ 禁止使用 outline: none! */
button:focus {
  outline: none;  /* 切勿使用 */
}

/* ✅ 使用颜色 + 图标指示状态 */
.error-message {
  color: #d32f2f;
  border-left: 4px solid #d32f2f;
}

.error-message::before {
  content: '⚠️';
  margin-right: 8px;
}

步骤 5:可访问性测试

通过自动化和手动测试验证可访问性。

任务

  • 使用 axe DevTools 进行自动化扫描
  • 检查 Lighthouse 可访问性得分
  • 仅使用键盘测试所有功能
  • 屏幕阅读器测试(NVDA、VoiceOver)

示例(Jest + axe-core):

import { render } from '@testing-library/react';
import { axe, toHaveNoViolations } from 'jest-axe';
import AccessibleButton from './AccessibleButton';

expect.extend(toHaveNoViolations);

describe('AccessibleButton', () => {
  it('应无可访问性违规', async () => {
    const { container } = render(
      <AccessibleButton onClick={() => {}}>
        点击我
      </AccessibleButton>
    );

    const results = await axe(container);
    expect(results).toHaveNoViolations();
  });

  it('应可通过键盘访问', () => {
    const handleClick = jest.fn();
    const { getByRole } = render(
      <AccessibleButton onClick={handleClick}>
        点击我
      </AccessibleButton>
    );

    const button = getByRole('button');

    // Enter 键
    button.focus();
    fireEvent.keyDown(button, { key: 'Enter' });
    expect(handleClick).toHaveBeenCalled();

    // 空格键
    fireEvent.keyDown(button, { key: ' ' });
    expect(handleClick).toHaveBeenCalledTimes(2);
  });
});

输出格式

基础检查清单

## 可访问性检查清单

### 语义化 HTML
- [x] 使用语义化 HTML 标签(`<button>``<nav>``<main>` 等)
- [x] 标题层级正确(h1 → h2 → h3)
- [x] 所有表单标签都已关联

### 键盘导航
- [x] 所有交互元素均可通过 Tab 键访问
- [x] 按钮可通过 Enter/空格键激活
- [x] 模态框/下拉菜单可通过 ESC 键关闭
- [x] 焦点指示器清晰可见(轮廓)

### ARIA
- [x] 恰当地使用 `role`
- [x] 提供了 `aria-label``aria-labelledby`
- [x] 对动态内容使用了 `aria-live`
- [x] 装饰性元素使用了 `aria-hidden="true"`

### 视觉方面
- [x] 颜色对比度符合 WCAG AA 标准(4.5:1)
- [x] 信息不仅通过颜色传达
- [x] 文本大小可以调整
- [x] 响应式设计

### 测试
- [x] 0 个 axe DevTools 违规
- [x] Lighthouse 可访问性得分 90+
- [x] 键盘测试通过
- [x] 屏幕阅读器测试完成

约束条件

必需规则(必须遵守)

  1. 键盘可访问性:所有功能必须无需鼠标即可使用
    • 支持 Tab、Enter、空格、箭头键和 ESC 键
    • 实现焦点陷阱(用于模态框)
  2. 替代文本:所有图片必须包含 alt 属性
    • 有意义的图片:描述性的 alt 文本
    • 装饰性图片:alt=""(屏幕阅读器忽略)
  3. 清晰的标签:所有表单输入必须有相关联的标签
    • <label for="...">aria-label
    • 不要仅使用占位符替代标签

禁止事项(不得违反)

  1. 不得移除轮廓:切勿使用 outline: none
    • 对键盘用户是灾难性的
    • 必须提供自定义焦点样式
  2. 不得使用 tabindex > 0:避免更改焦点顺序
    • 保持 DOM 顺序符合逻辑
    • 例外:仅在特殊原因时使用
  3. 不得仅通过颜色传达信息:应配以图标或文本
    • 考虑色盲用户
    • 例如:“点击红色项” → “点击 ⚠️ 错误项”

示例

示例 1:可访问表单

function AccessibleContactForm() {
  const [errors, setErrors] = useState<Record<string, string>>({});
  const [submitStatus, setSubmitStatus] = useState<'idle' | 'success' | 'error'>('idle');

  return (
    <form onSubmit={handleSubmit} noValidate>
      <h2 id="form-title">联系我们</h2>
      <p id="form-description">请填写以下表单以便与我们取得联系。</p>

      {/* 姓名 */}
      <div className="form-group">
        <label htmlFor="name">
          姓名 <span aria-label="必填">*</span>
        </label>
        <input
          type="text"
          id="name"
          name="name"
          required
          aria-required="true"
          aria-invalid={!!errors.name}
          aria-describedby={errors.name ? 'name-error' : undefined}
        />
        {errors.name && (
          <span id="name-error" role="alert" className="error">
            {errors.name}
          </span>
        )}
      </div>

      {/* 邮箱 */}
      <div className="form-group">
        <label htmlFor="email">
          邮箱 <span aria-label="必填">*</span>
        </label>
        <input
          type="email"
          id="email"
          name="email"
          required
          aria-required="true"
          aria-invalid={!!errors.email}
          aria-describedby={errors.email ? 'email-error' : 'email-hint'}
        />
        <span id="email-hint" className="hint">
          我们不会分享您的邮箱。
        </span>
        {errors.email && (
          <span id="email-error" role="alert" className="error">
            {errors.email}
          </span>
        )}
      </div>

      {/* 提交按钮 */}
      <button type="submit" disabled={submitStatus === 'loading'}>
        {submitStatus === 'loading' ? '提交中...' : '提交'}
      </button>

      {/* 成功/失败消息 */}
      {submitStatus === 'success' && (
        <div role="alert" aria-live="polite" className="success">
          ✅ 表单提交成功!
        </div>
      )}

      {submitStatus === 'error' && (
        <div role="alert" aria-live="assertive" className="error">
          ⚠️ 发生错误。请重试。
        </div>
      )}
    </form>
  );
}

示例 2:可访问标签页 UI

function AccessibleTabs({ tabs }: { tabs: { id: string; label: string; content: React.ReactNode }[] }) {
  const [activeTab, setActiveTab] = useState(0);

  const handleKeyDown = (e: React.KeyboardEvent, index: number) => {
    switch (e.key) {
      case 'ArrowRight':
        e.preventDefault();
        setActiveTab((index + 1) % tabs.length);
        break;
      case 'ArrowLeft':
        e.preventDefault();
        setActiveTab((index - 1 + tabs.length) % tabs.length);
        break;
      case 'Home':
        e.preventDefault();
        setActiveTab(0);
        break;
      case 'End':
        e.preventDefault();
        setActiveTab(tabs.length - 1);
        break;
    }
  };

  return (
    <div>
      {/* 标签页列表 */}
      <div role="tablist" aria-label="内容区域">
        {tabs.map((tab, index) => (
          <button
            key={tab.id}
            role="tab"
            id={`tab-${tab.id}`}
            aria-selected={activeTab === index}
            aria-controls={`panel-${tab.id}`}
            tabIndex={activeTab === index ? 0 : -1}
            onClick={() => setActiveTab(index)}
            onKeyDown={(e) => handleKeyDown(e, index)}
          >
            {tab.label}
          </button>
        ))}
      </div>

      {/* 标签页面板 */}
      {tabs.map((tab, index) => (
        <div
          key={tab.id}
          role="tabpanel"
          id={`panel-${tab.id}`}
          aria-labelledby={`tab-${tab.id}`}
          hidden={activeTab !== index}
          tabIndex={0}
        >
          {tab.content}
        </div>
      ))}
    </div>
  );
}

最佳实践

  1. 语义化 HTML 优先:ARIA 是最后的手段
    • 使用正确的 HTML 元素可以使 ARIA 变得不必要
    • 例如:<button> 对比 <div role="button">
  2. 焦点管理:在单页应用中管理页面切换时的焦点
    • 在新页面加载时将焦点移到主要内容
    • 提供跳过链接(“跳转到主要内容”)
  3. 错误消息:清晰且有用的错误消息

参考资料

元数据

版本

  • 当前版本:1.0.0
  • 最后更新:2025-01-01
  • 兼容平台:Claude, ChatGPT, Gemini

相关技能

标签

#可访问性 #a11y #WCAG #ARIA #屏幕阅读器 #键盘导航 #前端

📄 原始文档

完整文档(英文):

https://skills.sh/supercent-io/skills-template/web-accessibility

💡 提示:点击上方链接查看 skills.sh 原始英文文档,方便对照翻译。

声明:本站所有文章,如无特殊说明或标注,均为本站原创发布。任何个人或组织,在未征得本站同意时,禁止复制、盗用、采集、发布本站内容到任何网站、书籍等各类媒体平台。如若本站内容侵犯了原著者的合法权益,可联系我们进行处理。