12 KiB
12 KiB
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)
<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>
键盘导航规范
焦点管理
// 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))
}
// 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 }
}
键盘快捷键
// 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 组件)
模态框
<!-- 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>
加载状态
<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>
表单无障碍
<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>
数据表格
<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)
<!-- ✅ 图标按钮 -->
<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>
颜色对比度
// 确保颜色对比度 ≥ 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>
}
动效规范(减少动效)
/* 尊重用户减少动效偏好 */
@media (prefers-reduced-motion: reduce) {
*,
::before,
::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
}
}
// src/composables/useReducedMotion.ts
export function useReducedMotion() {
const preferReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)')
return computed(() => preferReducedMotion.matches)
}
自动化测试
// 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'] },
})
})
# 安装 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