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

12 KiB
Raw Permalink Blame History

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

<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