Files
vibe_coding/.cursor/rules/references/023-accessibility-deep.md
2026-03-05 21:27:11 +08:00

443 lines
12 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 023-accessibility.mdc (Deep Reference)
> 该文件为原始详细规范归档,供 Tier 3 按需读取。
---
# ♿ Accessibility (A11y) — WCAG AA Standards
> **⚠️ 双前端区分**:本文件中的 `el-*` 组件示例**仅适用于管理端** (`Case-Database-Frontend-admin/`)。
> 用户端 (`Case-Database-Frontend-user/`) 使用 Headless UI + Tailwind CSS 实现同等无障碍标准,**禁止引入 Element Plus**。
> Headless UI 组件天然内置 ARIA 属性,用户端可直接使用。
## WCAG AA 基线要求
| 原则 | 要求 | 检测方法 |
|------|------|---------|
| **可感知** | 图片有 alt颜色对比度 ≥ 4.5:1 (文本) / 3:1 (大文本) | Axe DevTools / Lighthouse |
| **可操作** | 所有功能可键盘访问;无键盘陷阱;跳转链接 | Tab 键手动测试 |
| **可理解** | 语言声明;错误提示清晰;一致导航 | 屏幕阅读器测试 |
| **健壮性** | 语义化 HTMLARIA 正确使用 | W3C Validator |
---
## 语义化 HTMLVue 3
```vue
<template>
<!-- 语义化页面结构 -->
<div id="app">
<!-- 跳转链接键盘用户快速跳过导航 -->
<a href="#main-content" class="sr-only focus:not-sr-only focus:absolute focus:top-4 focus:left-4 focus:z-50 focus:px-4 focus:py-2 focus:bg-white focus:rounded">
跳转到主要内容
</a>
<header role="banner">
<nav role="navigation" aria-label="主导航">
<ul>
<li><router-link to="/dashboard">首页</router-link></li>
<li><router-link to="/orders">订单</router-link></li>
</ul>
</nav>
</header>
<main id="main-content" role="main" tabindex="-1">
<router-view />
</main>
<footer role="contentinfo">
<p>© 2025 Company Name</p>
</footer>
</div>
</template>
```
---
## 键盘导航规范
### 焦点管理
```typescript
// src/composables/useFocusTrap.ts
// 对话框焦点捕获:阻止 Tab 跳出对话框
export function useFocusTrap(containerRef: Ref<HTMLElement | null>) {
function trapFocus(event: KeyboardEvent): void {
if (!containerRef.value || event.key !== 'Tab') return
const focusable = containerRef.value.querySelectorAll<HTMLElement>(
'a[href], button:not([disabled]), input:not([disabled]), select, textarea, [tabindex]:not([tabindex="-1"])'
)
const first = focusable[0]
const last = focusable[focusable.length - 1]
if (event.shiftKey) {
if (document.activeElement === first) {
last.focus()
event.preventDefault()
}
} else {
if (document.activeElement === last) {
first.focus()
event.preventDefault()
}
}
}
onMounted(() => document.addEventListener('keydown', trapFocus))
onUnmounted(() => document.removeEventListener('keydown', trapFocus))
}
```
```typescript
// src/composables/useFocusReturn.ts
// 对话框关闭后,焦点回到触发元素
export function useFocusReturn() {
const triggerEl = ref<HTMLElement | null>(null)
function saveTrigger(): void {
triggerEl.value = document.activeElement as HTMLElement
}
function restoreFocus(): void {
nextTick(() => triggerEl.value?.focus())
}
return { saveTrigger, restoreFocus }
}
```
### 键盘快捷键
```typescript
// src/composables/useKeyboard.ts
export function useKeyboard(handlers: Record<string, () => void>) {
function handleKeydown(event: KeyboardEvent): void {
const key = [
event.ctrlKey && 'Ctrl',
event.altKey && 'Alt',
event.shiftKey && 'Shift',
event.key,
]
.filter(Boolean)
.join('+')
handlers[key]?.()
}
onMounted(() => document.addEventListener('keydown', handleKeydown))
onUnmounted(() => document.removeEventListener('keydown', handleKeydown))
}
// 使用示例
useKeyboard({
'Escape': () => closeDialog(),
'Ctrl+s': () => saveForm(),
'Ctrl+/': () => toggleHelp(),
})
```
---
## ARIA 模式库Vue 3 组件)
### 模态框
```vue
<!-- src/components/ArtDialog/index.vue -->
<template>
<Teleport to="body">
<Transition name="dialog">
<div
v-if="modelValue"
ref="dialogRef"
role="dialog"
aria-modal="true"
:aria-labelledby="titleId"
:aria-describedby="descId"
class="fixed inset-0 z-50 flex items-center justify-center"
@keydown.esc="$emit('update:modelValue', false)"
>
<!-- 背景遮罩 -->
<div class="absolute inset-0 bg-black/50" aria-hidden="true" @click="$emit('update:modelValue', false)" />
<!-- 对话框内容 -->
<div class="relative bg-white rounded-xl p-6 max-w-md w-full mx-4">
<h2 :id="titleId" class="text-xl font-semibold">{{ title }}</h2>
<p v-if="description" :id="descId" class="mt-2 text-gray-600">{{ description }}</p>
<button
class="absolute top-4 right-4 text-gray-400 hover:text-gray-600 focus:ring-2 focus:ring-primary-500 rounded"
aria-label="关闭对话框"
@click="$emit('update:modelValue', false)"
>
<el-icon><Close /></el-icon>
</button>
<slot />
</div>
</div>
</Transition>
</Teleport>
</template>
<script setup>
import { useFocusTrap } from '@/composables/useFocusTrap'
import { useFocusReturn } from '@/composables/useFocusReturn'
const props = defineProps<{ modelValue: boolean; title: string; description?: string }>()
const emit = defineEmits<{ 'update:modelValue': [value: boolean] }>()
const titleId = useId() // Vue 3.5+ 内置
const descId = useId()
const dialogRef = ref<HTMLElement | null>(null)
useFocusTrap(dialogRef)
const { saveTrigger, restoreFocus } = useFocusReturn()
watch(() => props.modelValue, (isOpen) => {
if (isOpen) {
saveTrigger()
nextTick(() => {
// 聚焦第一个可交互元素
const firstFocusable = dialogRef.value?.querySelector<HTMLElement>(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
)
firstFocusable?.focus()
})
} else {
restoreFocus()
}
})
</script>
```
### 加载状态
```vue
<template>
<!-- 加载中的按钮屏幕阅读器可感知 -->
<button
:aria-busy="isLoading"
:aria-disabled="isLoading"
:disabled="isLoading"
@click="handleSubmit"
>
<el-icon v-if="isLoading" class="animate-spin" aria-hidden="true"><Loading /></el-icon>
<span>{{ isLoading ? '提交中...' : '提交' }}</span>
</button>
<!-- 页面级加载状态 -->
<div aria-live="polite" aria-atomic="true" class="sr-only">
{{ isLoading ? '正在加载数据,请稍候' : '' }}
</div>
<!-- 骨架屏 -->
<div v-if="isLoading" role="status" aria-label="内容加载中">
<el-skeleton :rows="5" animated />
</div>
</template>
```
### 表单无障碍
```vue
<template>
<el-form
ref="formRef"
:model="form"
@submit.prevent="handleSubmit"
novalidate
>
<!-- 必填字段标注 -->
<el-form-item
label="用户名"
prop="username"
:required="true"
>
<el-input
v-model="form.username"
:aria-required="true"
:aria-describedby="`username-hint ${formErrors.username ? 'username-error' : ''}`"
:aria-invalid="!!formErrors.username"
/>
<p id="username-hint" class="text-sm text-gray-500 mt-1">
3-20 位字母数字或下划线
</p>
<!-- 错误提示aria-live 确保屏幕阅读器读取 -->
<p
v-if="formErrors.username"
id="username-error"
role="alert"
class="text-sm text-red-500 mt-1"
>
{{ formErrors.username }}
</p>
</el-form-item>
</el-form>
</template>
```
### 数据表格
```vue
<template>
<!-- 可访问的表格 -->
<div role="region" :aria-label="`${title}列表`" aria-live="polite">
<el-table
:data="tableData"
:aria-rowcount="total"
@sort-change="handleSort"
>
<!-- 选择列 -->
<el-table-column type="selection" :aria-label="'全选'" width="55" />
<!-- 数据列 -->
<el-table-column prop="name" label="名称" sortable>
<template #header>
<span>名称</span>
<el-tooltip content="按名称升序/降序排列">
<el-icon aria-hidden="true"><InfoFilled /></el-icon>
</el-tooltip>
</template>
</el-table-column>
<!-- 操作列 -->
<el-table-column label="操作" width="150">
<template #default="{ row }">
<!-- 操作按钮有明确的 aria-label -->
<el-button
type="primary"
:aria-label="`编辑 ${row.name}`"
@click="editRow(row)"
>
编辑
</el-button>
<el-button
type="danger"
:aria-label="`删除 ${row.name}`"
@click="deleteRow(row.id)"
>
删除
</el-button>
</template>
</el-table-column>
</el-table>
<!-- 分页 -->
<el-pagination
v-model:current-page="currentPage"
:total="total"
:aria-label="`分页当前第 ${currentPage} ${Math.ceil(total / pageSize)} `"
/>
</div>
</template>
```
### 图标按钮(必须有 aria-label
```vue
<!-- 图标按钮 -->
<button aria-label="关闭菜单" @click="closeMenu">
<el-icon aria-hidden="true"><Close /></el-icon>
</button>
<!-- tooltip 的图标按钮 -->
<el-tooltip content="刷新数据" placement="top">
<button aria-label="刷新数据" @click="refresh">
<el-icon aria-hidden="true"><Refresh /></el-icon>
</button>
</el-tooltip>
<!-- label 的图标按钮 -->
<button @click="closeMenu">
<el-icon><Close /></el-icon>
</button>
```
---
## 颜色对比度
```scss
// 确保颜色对比度 ≥ 4.5:1
// 工具https://webaim.org/resources/contrastchecker/
// ✅ 正文文字
.text-primary { color: #1d4ed8; } // 在白底: 7.5:1 ✓
.text-secondary { color: #374151; } // 在白底: 9.4:1 ✓
// ⚠️ 灰色文字需谨慎
.text-muted { color: #6b7280; } // 在白底: 4.6:1 ✓(刚好过 AA
// ❌ 禁止使用
.text-too-light { color: #9ca3af; } // 在白底: 2.8:1 ✗(不过 AA
// 状态颜色需同时用颜色 + 图标/文字(不只靠颜色区分)
.status-success {
color: #15803d;
// ✅ 同时用图标辅助:<el-icon><CircleCheck /></el-icon>
}
```
---
## 动效规范(减少动效)
```css
/* 尊重用户减少动效偏好 */
@media (prefers-reduced-motion: reduce) {
*,
::before,
::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
}
}
```
```typescript
// src/composables/useReducedMotion.ts
export function useReducedMotion() {
const preferReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)')
return computed(() => preferReducedMotion.matches)
}
```
---
## 自动化测试
```typescript
// tests/a11y.spec.ts — 使用 axe-playwright
import { checkA11y, injectAxe } from 'axe-playwright'
test('dashboard page has no accessibility violations', async ({ page }) => {
await page.goto('/dashboard')
await injectAxe(page)
await checkA11y(page, undefined, {
axeOptions: { runOnly: ['wcag2a', 'wcag2aa'] },
})
})
```
```bash
# 安装 axe 测试依赖
npm install -D axe-playwright @axe-core/playwright
```
---
## 规则
- 所有图片必须有 `alt`(装饰性图片用 `alt=""`
- 所有图标按钮必须有 `aria-label`,图标本身加 `aria-hidden="true"`
- 所有表单字段必须有关联的 `<label>``aria-label`
- 错误提示必须使用 `role="alert"``aria-live="polite"`
- 对话框必须有焦点陷阱focus trap和 ESC 关闭支持
- 不能只用颜色区分状态(必须加文字或图标)
- 自动化测试须包含 axe-playwright a11y 扫描
- 产品上线前须通过 Lighthouse Accessibility 评分 ≥ 90