443 lines
12 KiB
Markdown
443 lines
12 KiB
Markdown
# 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 键手动测试 |
|
||
| **可理解** | 语言声明;错误提示清晰;一致导航 | 屏幕阅读器测试 |
|
||
| **健壮性** | 语义化 HTML;ARIA 正确使用 | W3C Validator |
|
||
|
||
---
|
||
|
||
## 语义化 HTML(Vue 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
|