Skip to content

设计系统中的无障碍设计实践

无障碍设计(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>

最佳实践总结

设计阶段

  1. 包容性思维 - 从设计开始就考虑无障碍需求
  2. 多样化测试 - 使用不同的辅助技术测试
  3. 用户研究 - 邀请残障用户参与测试
  4. 持续改进 - 定期评估和改进无障碍性

开发阶段

  1. 语义化 HTML - 使用正确的 HTML 元素
  2. ARIA 支持 - 适当使用 ARIA 属性
  3. 键盘导航 - 确保所有功能可通过键盘操作
  4. 自动化测试 - 集成无障碍测试到 CI/CD

维护阶段

  1. 定期审计 - 定期进行无障碍审计
  2. 用户反馈 - 建立用户反馈机制
  3. 团队培训 - 提高团队的无障碍意识
  4. 文档更新 - 保持无障碍文档的更新

结语

无障碍设计不是额外的负担,而是优秀用户体验的基础。通过系统性的方法和持续的努力,我们可以创造出真正包容的数字产品。

记住,无障碍设计的受益者不仅仅是残障用户,而是所有用户。一个无障碍的产品往往也是一个更易用、更健壮的产品。

让我们共同努力,构建一个更加包容的数字世界!

Released under the MIT License.