Files
2026-07-13 21:36:46 +08:00

14 KiB
Raw Permalink Blame History

框架特定的无障碍模式

React / Next.js

常见问题与修复

图片替代文本:

// ❌ 错误
<img src="/hero.jpg" />
<Image src="/hero.jpg" width={800} height={400} />

// ✅ 正确
<img src="/hero.jpg" alt="在办公室协作的团队" />
<Image src="/hero.jpg" width={800} height={400} alt="在办公室协作的团队" />

// ✅ 装饰性图片
<img src="/divider.svg" alt="" role="presentation" />

表单标签:

// ❌ 错误 — 用占位符代替标签
<input placeholder="Email" type="email" />

// ✅ 正确 — 显式标签
<label htmlFor="email">Email</label>
<input id="email" type="email" placeholder="user@example.com" />

// ✅ 正确 — 纯图标输入框使用 aria-label
<input type="search" aria-label="搜索产品" />

div 上的点击处理:

// ❌ 错误 — 不支持键盘访问
<div onClick={handleClick}>点击我</div>

// ✅ 正确 — 使用 button
<button onClick={handleClick}>点击我</button>

// ✅ 如果必须使用 div — 添加键盘支持
<div
  role="button"
  tabIndex={0}
  onClick={handleClick}
  onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') handleClick(); }}
>
  点击我
</div>

SPA 路由播报(Next.js App Router):

// Layout 组件 — 播报页面切换
'use client';
import { usePathname } from 'next/navigation';
import { useEffect, useState } from 'react';

export function RouteAnnouncer() {
  const pathname = usePathname();
  const [announcement, setAnnouncement] = useState('');

  useEffect(() => {
    const title = document.title;
    setAnnouncement(`已导航到 ${title}`);
  }, [pathname]);

  return (
    <div aria-live="assertive" role="status" className="sr-only">
      {announcement}
    </div>
  );
}

动态内容后的焦点管理:

// 向列表添加项目后,播报该操作
const [items, setItems] = useState([]);
const statusRef = useRef(null);

const addItem = (item) => {
  setItems([...items, item]);
  // 向屏幕阅读器播报
  statusRef.current.textContent = `${item.name} 已添加到列表`;
};

return (
  <>
    <div ref={statusRef} aria-live="polite" className="sr-only" />
    {/* 列表内容 */}
  </>
);

React 特定库

  • @radix-ui/* — 可访问的原语(Dialog、Tabs、Select 等)
  • @headlessui/react — 无样式可访问组件
  • react-aria — Adobe 的无障碍钩子
  • eslint-plugin-jsx-a11y — JSX 无障碍的 lint 规则

Vue 3

常见问题与修复

动态内容播报:

<template>
  <div aria-live="polite" class="sr-only">
    {{ announcement }}
  </div>
  <button @click="search">搜索</button>
  <ul v-if="results.length">
    <li v-for="r in results" :key="r.id">{{ r.name }}</li>
  </ul>
</template>

<script setup>
import { ref } from 'vue';
const results = ref([]);
const announcement = ref('');

async function search() {
  results.value = await fetchResults();
  announcement.value = `找到 ${results.value.length} 条结果`;
}
</script>

带焦点的条件渲染:

<template>
  <button @click="showForm = true">添加项目</button>
  <form v-if="showForm" ref="formRef">
    <label for="name">名称</label>
    <input id="name" ref="nameInput" />
  </form>
</template>

<script setup>
import { ref, nextTick } from 'vue';
const showForm = ref(false);
const nameInput = ref(null);

watch(showForm, async (val) => {
  if (val) {
    await nextTick();
    nameInput.value?.focus();
  }
});
</script>

Vue 特定库

  • vue-announcer — 路由切换播报
  • @headlessui/vue — 可访问组件
  • eslint-plugin-vuejs-accessibility — 无障碍 lint 规则

Angular

常见问题与修复

CDK 无障碍工具:

import { LiveAnnouncer } from '@angular/cdk/a11y';
import { FocusTrapFactory } from '@angular/cdk/a11y';

@Component({...})
export class MyComponent {
  constructor(
    private liveAnnouncer: LiveAnnouncer,
    private focusTrapFactory: FocusTrapFactory
  ) {}

  addItem(item: Item) {
    this.items.push(item);
    this.liveAnnouncer.announce(`${item.name} 已添加`);
  }

  openDialog(element: HTMLElement) {
    const focusTrap = this.focusTrapFactory.create(element);
    focusTrap.focusInitialElement();
  }
}

模板驱动表单:

<!-- ❌ 错误 -->
<input [formControl]="email" placeholder="Email" />

<!-- ✅ 正确 -->
<label for="email">电子邮件地址</label>
<input id="email" [formControl]="email"
       [attr.aria-invalid]="email.invalid && email.touched"
       [attr.aria-describedby]="email.invalid ? 'email-error' : null" />
<div id="email-error" *ngIf="email.invalid && email.touched" role="alert">
  请输入有效的电子邮件地址。
</div>

Angular 特定工具

  • @angular/cdk/a11yFocusTrapLiveAnnouncerFocusMonitor
  • codelyzer — Angular 模板的无障碍 lint 规则

Svelte / SvelteKit

常见问题与修复

<!-- ❌ 错误 — on:click 无键盘支持 -->
<div on:click={handleClick}>操作</div>

<!-- ✅ 正确 — Svelte 内置 a11y 警告 -->
<button on:click={handleClick}>操作</button>

<!-- ✅ 可访问的切换按钮 -->
<button
  on:click={() => isOpen = !isOpen}
  aria-expanded={isOpen}
  aria-controls="panel"
>
  {isOpen ? '关闭' : '打开'}详情
</button>

{#if isOpen}
  <div id="panel" role="region" aria-labelledby="toggle-btn">
    面板内容
  </div>
{/if}

注意: Svelte 编译器内置了 a11y 警告——它会在构建时标记缺失的替代文本、无键盘支持的点击处理以及其他常见问题。

纯 HTML

静态站点检查清单

<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>描述性页面标题</title>
</head>
<body>
  <!-- 跳转链接 -->
  <a href="#main" class="skip-link">跳转到主内容</a>

  <header>
    <nav aria-label="主导航">
      <ul>
        <li><a href="/">首页</a></li>
        <li><a href="/about" aria-current="page">关于</a></li>
      </ul>
    </nav>
  </header>

  <main id="main" tabindex="-1">
    <h1>页面标题</h1>
    <!-- 每页仅一个 h1 -->
    <!-- 标题层级不跳级(h1 → h2 → h3,绝不能 h1 → h3 -->
  </main>

  <footer>
    <p>&copy; 2026 公司名称</p>
  </footer>
</body>
</html>

CSS 无障碍模式

焦点指示器

/* ❌ 错误 — 完全移除焦点指示器 */
:focus { outline: none; }

/* ✅ 正确 — 自定义焦点指示器 */
:focus-visible {
  outline: 2px solid #005fcc;
  outline-offset: 2px;
}

/* ✅ 正确 — 高对比度模式增强 */
@media (forced-colors: active) {
  :focus-visible {
    outline: 2px solid ButtonText;
  }
}

减少动效

/* ✅ 尊重 prefers-reduced-motion */
@media (prefers-reduced-motion: reduce) {
  *, *::before, *::after {
    animation-duration: 0.01ms !important;
    animation-iteration-count: 1 !important;
    transition-duration: 0.01ms !important;
  }
}

仅屏幕阅读器可见

.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;
}

修复模式目录

React / Next.js 修复模式

缺失替代文本(1.1.1

// 之前
<img src={hero} />

// 之后 - 信息性图片
<img src={hero} alt="团队在白板前协作" />

// 之后 - 装饰性图片
<img src={divider} alt="" role="presentation" />

非交互元素带点击处理(2.1.1

// 之前
<div onClick={handleClick}>点击我</div>

// 之后 - 如果是导航
<Link href="/destination">点击我</Link>

// 之后 - 如果是执行操作
<button type="button" onClick={handleClick}>点击我</button>

模态框中缺失焦点管理(2.4.3

// 之前
function Modal({ isOpen, onClose, children }) {
  if (!isOpen) return null;
  return <div className="modal-overlay">{children}</div>;
}

// 之后
import { useEffect, useRef } from 'react';

function Modal({ isOpen, onClose, children, title }) {
  const modalRef = useRef(null);
  const previousFocus = useRef(null);

  useEffect(() => {
    if (isOpen) {
      previousFocus.current = document.activeElement;
      modalRef.current?.focus();
    } else {
      previousFocus.current?.focus();
    }
  }, [isOpen]);

  useEffect(() => {
    if (!isOpen) return;
    const handleKeydown = (e) => {
      if (e.key === 'Escape') onClose();
      if (e.key === 'Tab') {
        const focusable = modalRef.current?.querySelectorAll(
          '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 className="modal-overlay" onClick={onClose} aria-hidden="true">
      <div
        ref={modalRef}
        role="dialog"
        aria-modal="true"
        aria-label={title}
        tabIndex={-1}
        onClick={(e) => e.stopPropagation()}
      >
        <button
          onClick={onClose}
          aria-label="关闭对话框"
          className="modal-close"
        >
          &times;
        </button>
        {children}
      </div>
    </div>
  );
}

焦点外观(2.4.11——WCAG 2.2 新增)

/* 之前 */
button:focus {
  outline: none; /* 移除默认焦点指示器 */
}

/* 之后 - 符合 WCAG 2.2 焦点外观 */
button:focus-visible {
  outline: 2px solid #005fcc;
  outline-offset: 2px;
}
// Tailwind CSS 模式
<button className="focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600">
  提交
</button>

Vue 修复模式

缺失表单标签(1.3.1

<!-- 之前 -->
<input type="text" v-model="name" placeholder="名称" />

<!-- 之后 -->
<label for="user-name">名称</label>
<input id="user-name" type="text" v-model="name" autocomplete="name" />

动态内容无活动区域(4.1.3

<!-- 之前 -->
<div v-if="status">{{ statusMessage }}</div>

<!-- 之后 -->
<div aria-live="polite" aria-atomic="true">
  <p v-if="status">{{ statusMessage }}</p>
</div>

Vue Router 导航播报(2.4.2

// router/index.ts
router.afterEach((to) => {
  const title = to.meta.title || '页面';
  document.title = `${title} | 我的应用`;

  // 向屏幕阅读器播报路由切换
  const announcer = document.getElementById('route-announcer');
  if (announcer) {
    announcer.textContent = `已导航到 ${title}`;
  }
});
<!-- App.vue - 添加播报元素 -->
<div
  id="route-announcer"
  role="status"
  aria-live="assertive"
  aria-atomic="true"
  class="sr-only"
></div>

Angular 修复模式

自定义组件上缺失 ARIA(4.1.2)

// 之前
@Component({
  selector: 'app-dropdown',
  template: `
    <div (click)="toggle()">{{ selected }}</div>
    <div *ngIf="isOpen">
      <div *ngFor="let opt of options" (click)="select(opt)">{{ opt }}</div>
    </div>
  `
})

// 之后
@Component({
  selector: 'app-dropdown',
  template: `
    <button
      role="combobox"
      [attr.aria-expanded]="isOpen"
      aria-haspopup="listbox"
      [attr.aria-label]="label"
      (click)="toggle()"
      (keydown)="handleKeydown($event)"
    >
      {{ selected }}
    </button>
    <ul *ngIf="isOpen" role="listbox" [attr.aria-label]="label + ' 选项'">
      <li
        *ngFor="let opt of options; let i = index"
        role="option"
        [attr.aria-selected]="opt === selected"
        [attr.id]="'option-' + i"
        (click)="select(opt)"
        (keydown)="handleOptionKeydown($event, opt, i)"
        tabindex="-1"
      >
        {{ opt }}
      </li>
    </ul>
  `
})

Angular CDK A11y 模块集成

// 在对话框中使用 Angular CDK 焦点陷阱
import { A11yModule } from '@angular/cdk/a11y';

@Component({
  template: `
    <div cdkTrapFocus cdkTrapFocusAutoCapture>
      <h2 id="dialog-title">编辑个人资料</h2>
      <!-- 对话框内容 -->
    </div>
  `
})

Svelte 修复模式

可访问的播报(4.1.3

<!-- 之前 -->
{#if message}
  <p class="toast">{message}</p>
{/if}

<!-- 之后 -->
<div aria-live="polite" class="sr-only">
  {#if message}
    <p>{message}</p>
  {/if}
</div>
<div class="toast" aria-hidden="true">
  {#if message}
    <p>{message}</p>
  {/if}
</div>

SvelteKit 页面标题(2.4.2

<!-- +page.svelte -->
<svelte:head>
  <title>仪表盘 | 我的应用</title>
</svelte:head>

纯 HTML 修复模式

跳过导航链接(2.4.1

<!-- 之前 -->
<body>
  <nav><!-- 长导航 --></nav>
  <main><!-- 内容 --></main>
</body>

<!-- 之后 -->
<body>
  <a href="#main-content" class="skip-link">跳转到主内容</a>
  <nav aria-label="主导航"><!-- 长导航 --></nav>
  <main id="main-content" tabindex="-1"><!-- 内容 --></main>
</body>
.skip-link {
  position: absolute;
  top: -40px;
  left: 0;
  padding: 8px 16px;
  background: #005fcc;
  color: #fff;
  z-index: 1000;
  transition: top 0.2s;
}
.skip-link:focus {
  top: 0;
}

可访问的数据表格(1.3.1

<!-- 之前 -->
<table>
  <tr><td>姓名</td><td>邮箱</td><td>角色</td></tr>
  <tr><td>张三</td><td>zhangsan@co.com</td><td>管理员</td></tr>
</table>

<!-- 之后 -->
<table aria-label="团队成员">
  <caption class="sr-only">团队成员及其角色列表</caption>
  <thead>
    <tr>
      <th scope="col">姓名</th>
      <th scope="col">邮箱</th>
      <th scope="col">角色</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <th scope="row">张三</th>
      <td>zhangsan@co.com</td>
      <td>管理员</td>
    </tr>
  </tbody>
</table>