14 KiB
14 KiB
框架特定的无障碍模式
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/a11y—FocusTrap、LiveAnnouncer、FocusMonitorcodelyzer— 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>© 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"
>
×
</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>