设计系统中的无障碍设计实践
无障碍设计(Accessibility,简称 A11y)是现代设计系统不可或缺的一部分。本文将分享 Ghuo Design 在无障碍设计方面的实践经验,帮助开发者构建更包容的用户界面。
无障碍设计的重要性
社会价值
无障碍设计体现了技术的人文关怀:
- 包容性 - 让所有用户都能平等使用产品
- 社会责任 - 履行企业的社会责任
- 法律合规 - 满足相关法律法规要求
- 品牌形象 - 提升品牌的社会形象
商业价值
良好的无障碍设计带来实际收益:
- 扩大用户群体 - 覆盖更多潜在用户
- 提升用户体验 - 改善所有用户的使用体验
- 降低维护成本 - 减少后期的适配工作
- 增强竞争优势 - 在同类产品中脱颖而出
无障碍设计原则
WCAG 2.1 四大原则
1. 可感知(Perceivable)
信息和用户界面组件必须以用户能够感知的方式呈现:
vue
<!-- 为图片提供替代文本 -->
<template>
<img
src="/logo.png"
alt="Ghuo Design - 企业级 UI 设计语言"
/>
<!-- 为装饰性图片使用空 alt -->
<img
src="/decoration.png"
alt=""
role="presentation"
/>
</template>2. 可操作(Operable)
用户界面组件和导航必须是可操作的:
vue
<!-- 键盘导航支持 -->
<template>
<g-button
@click="handleClick"
@keydown.enter="handleClick"
@keydown.space.prevent="handleClick"
>
提交
</g-button>
</template>
<script setup>
const handleClick = () => {
// 处理点击事件
}
</script>3. 可理解(Understandable)
信息和用户界面的操作必须是可理解的:
vue
<!-- 清晰的错误提示 -->
<template>
<g-form-item
label="邮箱地址"
:validate-status="emailError ? 'error' : ''"
:help="emailError"
>
<g-input
v-model="email"
placeholder="请输入有效的邮箱地址"
:aria-describedby="emailError ? 'email-error' : undefined"
/>
<div
v-if="emailError"
id="email-error"
role="alert"
aria-live="polite"
>
{{ emailError }}
</div>
</g-form-item>
</template>4. 健壮(Robust)
内容必须足够健壮,能够被各种用户代理(包括辅助技术)可靠地解释:
vue
<!-- 语义化的 HTML 结构 -->
<template>
<nav aria-label="主导航">
<ul role="menubar">
<li role="none">
<a
href="/home"
role="menuitem"
:aria-current="currentPage === 'home' ? 'page' : undefined"
>
首页
</a>
</li>
</ul>
</nav>
</template>颜色和对比度
对比度标准
确保文本与背景的对比度符合 WCAG 标准:
css
/* 正常文本:至少 4.5:1 */
.text-normal {
color: #262626; /* 对比度 9.7:1 */
background: #ffffff;
}
/* 大文本(18px+ 或 14px+ 粗体):至少 3:1 */
.text-large {
color: #595959; /* 对比度 5.7:1 */
background: #ffffff;
font-size: 18px;
}
/* 非文本元素:至少 3:1 */
.button-border {
border: 1px solid #8c8c8c; /* 对比度 3.2:1 */
background: #ffffff;
}颜色不能作为唯一标识
vue
<!-- 错误示例:仅用颜色表示状态 -->
<template>
<div class="status-red">错误</div>
<div class="status-green">成功</div>
</template>
<!-- 正确示例:颜色 + 图标 + 文字 -->
<template>
<div class="status error">
<g-icon type="close-circle" />
<span>错误:请检查输入内容</span>
</div>
<div class="status success">
<g-icon type="check-circle" />
<span>成功:操作已完成</span>
</div>
</template>色盲友好设计
javascript
// 色盲友好的颜色选择
const colorPalette = {
// 使用蓝色和橙色的组合,对色盲用户友好
primary: '#1890ff', // 蓝色
warning: '#fa8c16', // 橙色
success: '#52c41a', // 绿色(配合图标使用)
error: '#ff4d4f', // 红色(配合图标使用)
}
// 色盲模拟测试
const simulateColorBlindness = (color, type) => {
const filters = {
protanopia: 'url(#protanopia-filter)',
deuteranopia: 'url(#deuteranopia-filter)',
tritanopia: 'url(#tritanopia-filter)'
}
return filters[type]
}键盘导航
焦点管理
vue
<!-- 焦点陷阱(Modal 中) -->
<template>
<div
class="modal"
role="dialog"
aria-modal="true"
aria-labelledby="modal-title"
@keydown.esc="closeModal"
>
<h2 id="modal-title">确认删除</h2>
<p>此操作不可撤销,确定要删除吗?</p>
<div class="modal-actions">
<g-button
ref="cancelButton"
@click="closeModal"
>
取消
</g-button>
<g-button
type="primary"
danger
@click="confirmDelete"
>
确认删除
</g-button>
</div>
</div>
</template>
<script setup>
import { ref, onMounted, onUnmounted } from 'vue'
const cancelButton = ref()
let previousFocus = null
// 焦点陷阱实现
const trapFocus = (e) => {
const focusableElements = modal.value.querySelectorAll(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
)
const firstElement = focusableElements[0]
const lastElement = focusableElements[focusableElements.length - 1]
if (e.key === 'Tab') {
if (e.shiftKey) {
if (document.activeElement === firstElement) {
lastElement.focus()
e.preventDefault()
}
} else {
if (document.activeElement === lastElement) {
firstElement.focus()
e.preventDefault()
}
}
}
}
onMounted(() => {
previousFocus = document.activeElement
cancelButton.value.focus()
document.addEventListener('keydown', trapFocus)
})
onUnmounted(() => {
document.removeEventListener('keydown', trapFocus)
previousFocus?.focus()
})
</script>跳过链接
vue
<!-- 跳过导航链接 -->
<template>
<div class="app">
<a
href="#main-content"
class="skip-link"
@click="skipToMain"
>
跳转到主内容
</a>
<nav aria-label="主导航">
<!-- 导航内容 -->
</nav>
<main id="main-content" tabindex="-1">
<!-- 主要内容 -->
</main>
</div>
</template>
<style>
.skip-link {
position: absolute;
top: -40px;
left: 6px;
background: #000;
color: #fff;
padding: 8px;
text-decoration: none;
z-index: 1000;
}
.skip-link:focus {
top: 6px;
}
</style>屏幕阅读器支持
ARIA 属性
vue
<!-- 复杂组件的 ARIA 支持 -->
<template>
<div class="tabs">
<div
role="tablist"
aria-label="产品信息"
>
<button
v-for="(tab, index) in tabs"
:key="tab.id"
role="tab"
:aria-selected="activeTab === index"
:aria-controls="`panel-${tab.id}`"
:id="`tab-${tab.id}`"
:tabindex="activeTab === index ? 0 : -1"
@click="setActiveTab(index)"
@keydown="handleTabKeydown"
>
{{ tab.label }}
</button>
</div>
<div
v-for="(tab, index) in tabs"
:key="`panel-${tab.id}`"
:id="`panel-${tab.id}`"
role="tabpanel"
:aria-labelledby="`tab-${tab.id}`"
:hidden="activeTab !== index"
tabindex="0"
>
{{ tab.content }}
</div>
</div>
</template>
<script setup>
const handleTabKeydown = (e) => {
const { key } = e
switch (key) {
case 'ArrowLeft':
e.preventDefault()
setActiveTab(activeTab.value > 0 ? activeTab.value - 1 : tabs.length - 1)
break
case 'ArrowRight':
e.preventDefault()
setActiveTab(activeTab.value < tabs.length - 1 ? activeTab.value + 1 : 0)
break
case 'Home':
e.preventDefault()
setActiveTab(0)
break
case 'End':
e.preventDefault()
setActiveTab(tabs.length - 1)
break
}
}
</script>实时区域
vue
<!-- 状态更新通知 -->
<template>
<div>
<g-button @click="saveData">保存</g-button>
<!-- 成功消息 -->
<div
v-if="saveStatus === 'success'"
role="status"
aria-live="polite"
class="sr-only"
>
数据保存成功
</div>
<!-- 错误消息 -->
<div
v-if="saveStatus === 'error'"
role="alert"
aria-live="assertive"
class="sr-only"
>
保存失败,请重试
</div>
<!-- 加载状态 -->
<div
v-if="saveStatus === 'loading'"
role="status"
aria-live="polite"
aria-label="正在保存数据"
>
<g-spin />
<span class="sr-only">正在保存,请稍候</span>
</div>
</div>
</template>
<style>
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
</style>表单无障碍
标签关联
vue
<!-- 明确的标签关联 -->
<template>
<g-form>
<!-- 显式标签 -->
<g-form-item>
<label for="username">用户名</label>
<g-input
id="username"
v-model="form.username"
required
aria-describedby="username-help"
/>
<div id="username-help" class="help-text">
用户名长度为 3-20 个字符
</div>
</g-form-item>
<!-- 使用 aria-label -->
<g-form-item>
<g-input
v-model="form.search"
aria-label="搜索产品"
placeholder="搜索产品"
/>
</g-form-item>
<!-- 字段组 -->
<fieldset>
<legend>联系方式</legend>
<g-form-item>
<label for="email">邮箱</label>
<g-input
id="email"
type="email"
v-model="form.email"
/>
</g-form-item>
<g-form-item>
<label for="phone">电话</label>
<g-input
id="phone"
type="tel"
v-model="form.phone"
/>
</g-form-item>
</fieldset>
</g-form>
</template>错误处理
vue
<!-- 无障碍的错误处理 -->
<template>
<g-form @submit="handleSubmit">
<!-- 表单级错误摘要 -->
<div
v-if="formErrors.length > 0"
role="alert"
aria-labelledby="error-summary-title"
class="error-summary"
>
<h2 id="error-summary-title">请修正以下错误:</h2>
<ul>
<li v-for="error in formErrors" :key="error.field">
<a :href="`#${error.field}`">{{ error.message }}</a>
</li>
</ul>
</div>
<!-- 字段级错误 -->
<g-form-item
:validate-status="emailError ? 'error' : ''"
:help="emailError"
>
<label for="email">邮箱地址 *</label>
<g-input
id="email"
v-model="form.email"
type="email"
required
:aria-invalid="!!emailError"
:aria-describedby="emailError ? 'email-error' : 'email-help'"
/>
<div id="email-help" class="help-text">
我们将向此邮箱发送确认信息
</div>
<div
v-if="emailError"
id="email-error"
role="alert"
class="error-text"
>
{{ emailError }}
</div>
</g-form-item>
</g-form>
</template>动画和运动
尊重用户偏好
css
/* 尊重用户的动画偏好 */
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}
}
/* 为需要动画的用户提供丰富体验 */
@media (prefers-reduced-motion: no-preference) {
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.3s ease;
}
.slide-enter-active,
.slide-leave-active {
transition: transform 0.3s ease;
}
}焦点指示器
css
/* 清晰的焦点指示器 */
.g-button:focus-visible {
outline: 2px solid #1890ff;
outline-offset: 2px;
}
/* 高对比度模式支持 */
@media (prefers-contrast: high) {
.g-button:focus-visible {
outline: 3px solid;
outline-offset: 2px;
}
}测试和验证
自动化测试
javascript
// 使用 axe-core 进行自动化无障碍测试
import { axe, toHaveNoViolations } from 'jest-axe'
expect.extend(toHaveNoViolations)
describe('Button Accessibility', () => {
it('should not have accessibility violations', async () => {
const { container } = render(
<Button type="primary">Click me</Button>
)
const results = await axe(container)
expect(results).toHaveNoViolations()
})
it('should be keyboard accessible', async () => {
const handleClick = jest.fn()
const { getByRole } = render(
<Button onClick={handleClick}>Click me</Button>
)
const button = getByRole('button')
// 测试 Enter 键
fireEvent.keyDown(button, { key: 'Enter' })
expect(handleClick).toHaveBeenCalled()
// 测试 Space 键
fireEvent.keyDown(button, { key: ' ' })
expect(handleClick).toHaveBeenCalledTimes(2)
})
})手动测试清单
markdown
## 无障碍测试清单
### 键盘导航
- [ ] 所有交互元素可通过 Tab 键访问
- [ ] 焦点顺序符合逻辑
- [ ] 焦点指示器清晰可见
- [ ] 支持 Esc 键关闭模态框
### 屏幕阅读器
- [ ] 所有内容可被屏幕阅读器读取
- [ ] 图片有适当的替代文本
- [ ] 表单字段有明确的标签
- [ ] 状态变化有适当的通知
### 视觉设计
- [ ] 颜色对比度符合 WCAG 标准
- [ ] 不仅依赖颜色传达信息
- [ ] 文字大小至少 16px
- [ ] 点击目标至少 44x44px
### 响应式设计
- [ ] 支持 200% 缩放
- [ ] 横向滚动不超过 320px 宽度
- [ ] 内容在小屏幕上仍然可用工具和资源
开发工具
javascript
// 无障碍开发工具集成
const a11yTools = {
// 浏览器扩展
extensions: [
'axe DevTools',
'WAVE Web Accessibility Evaluator',
'Lighthouse'
],
// 测试库
testingLibraries: [
'@testing-library/jest-dom',
'jest-axe',
'@axe-core/playwright'
],
// 设计工具插件
designPlugins: [
'Stark (Figma)',
'Color Oracle',
'Sim Daltonism'
]
}检查工具
vue
<!-- 开发环境的无障碍检查组件 -->
<template>
<div v-if="isDevelopment" class="a11y-checker">
<button @click="runA11yCheck">检查无障碍性</button>
<div v-if="violations.length > 0" class="violations">
<h3>发现 {{ violations.length }} 个问题:</h3>
<ul>
<li v-for="violation in violations" :key="violation.id">
<strong>{{ violation.impact }}</strong>: {{ violation.description }}
<ul>
<li v-for="node in violation.nodes" :key="node.target">
{{ node.target.join(' > ') }}
</li>
</ul>
</li>
</ul>
</div>
</div>
</template>
<script setup>
import { ref } from 'vue'
import axe from 'axe-core'
const violations = ref([])
const isDevelopment = process.env.NODE_ENV === 'development'
const runA11yCheck = async () => {
try {
const results = await axe.run()
violations.value = results.violations
} catch (error) {
console.error('无障碍检查失败:', error)
}
}
</script>最佳实践总结
设计阶段
- 包容性思维 - 从设计开始就考虑无障碍需求
- 多样化测试 - 使用不同的辅助技术测试
- 用户研究 - 邀请残障用户参与测试
- 持续改进 - 定期评估和改进无障碍性
开发阶段
- 语义化 HTML - 使用正确的 HTML 元素
- ARIA 支持 - 适当使用 ARIA 属性
- 键盘导航 - 确保所有功能可通过键盘操作
- 自动化测试 - 集成无障碍测试到 CI/CD
维护阶段
- 定期审计 - 定期进行无障碍审计
- 用户反馈 - 建立用户反馈机制
- 团队培训 - 提高团队的无障碍意识
- 文档更新 - 保持无障碍文档的更新
结语
无障碍设计不是额外的负担,而是优秀用户体验的基础。通过系统性的方法和持续的努力,我们可以创造出真正包容的数字产品。
记住,无障碍设计的受益者不仅仅是残障用户,而是所有用户。一个无障碍的产品往往也是一个更易用、更健壮的产品。
让我们共同努力,构建一个更加包容的数字世界!