初始化
This commit is contained in:
442
.cursor/rules/references/023-accessibility-deep.md
Normal file
442
.cursor/rules/references/023-accessibility-deep.md
Normal file
@@ -0,0 +1,442 @@
|
||||
# 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
|
||||
Reference in New Issue
Block a user