引言

Web无障碍(Accessibility,简称A11y)确保所有人,无论是否有残疾,都能平等访问和使用Web内容。据世界卫生组织统计,全球超过10亿人患有某种形式的残疾。作为开发者,我们有责任创建包容性应用。本文将深入探讨Web无障碍的实现方法,让你的应用惠及更多用户。

WCAG原则概述

WCAG(Web Content Accessibility Guidelines)提出了四个核心原则,简称POUR:

1. 可感知(Perceivable)

信息必须以用户能够感知的方式呈现。

2. 可操作(Operable)

界面组件必须是可操作的。

3. 可理解(Understandable)

信息和UI操作必须是可理解的。

4. 稳健(Robust)

内容必须足够健壮,能够被各种用户代理(包括辅助技术)可靠地解析。

语义化HTML基础

使用正确的HTML元素

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
<!-- ❌ 不好的实践 -->
<div class="header">
  <div class="nav-item">首页</div>
  <div class="nav-item">关于</div>
</div>

<div class="article">
  <div class="title">文章标题</div>
  <div class="content">文章内容...</div>
</div>

<!-- ✅ 好的实践 -->
<header>
  <nav>
    <a href="/">首页</a>
    <a href="/about">关于</a>
  </nav>
</header>

<main>
  <article>
    <h1>文章标题</h1>
    <p>文章内容...</p>
  </article>
</main>

表单语义化

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
<form method="post" action="/submit">
  <fieldset>
    <legend>用户信息</legend>

    <div class="form-group">
      <label for="name">
        姓名 <span class="required" aria-label="必填">*</span>
      </label>
      <input
        type="text"
        id="name"
        name="name"
        required
        aria-describedby="name-help"
        autocomplete="name"
      >
      <div id="name-help" class="help-text">
        请输入您的真实姓名
      </div>
    </div>

    <div class="form-group">
      <label for="email">电子邮箱</label>
      <input
        type="email"
        id="email"
        name="email"
        required
        aria-invalid="false"
        aria-describedby="email-error"
      >
      <div id="email-error" class="error-text" role="alert" hidden>
        请输入有效的邮箱地址
      </div>
    </div>

    <div class="form-group">
      <label for="message">留言</label>
      <textarea
        id="message"
        name="message"
        rows="4"
        aria-describedby="message-counter"
      ></textarea>
      <div id="message-counter" aria-live="polite">
        还可以输入 <span id="count">200</span> 个字符
      </div>
    </div>
  </fieldset>

  <button type="submit">提交</button>
</form>

跳过链接

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
<!-- 为键盘用户提供快速导航 -->
<a href="#main-content" class="skip-link">
  跳到主内容
</a>

<a href="#navigation" class="skip-link">
  跳到导航
</a>

<!-- CSS样式 -->
<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属性应用

ARIA角色(Roles)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
<!-- 定义主要区域角色 -->
<header role="banner">
  <h1>网站标题</h1>
</header>

<nav role="navigation" aria-label="主导航">
  <ul>
    <li><a href="/" aria-current="page">首页</a></li>
    <li><a href="/products">产品</a></li>
    <li><a href="/contact">联系我们</a></li>
  </ul>
</nav>

<main role="main" id="main-content">
  <h2>主要内容</h2>
</main>

<aside role="complementary" aria-label="侧边栏">
  <h3>相关链接</h3>
</aside>

<footer role="contentinfo">
  <p>&copy; 2024 公司名称</p>
</footer>

<!-- 自定义组件使用ARIA -->
<div role="tablist" aria-label="设置选项">
  <button
    role="tab"
    aria-selected="true"
    aria-controls="general-panel"
    id="general-tab"
  >
    常规设置
  </button>
  <button
    role="tab"
    aria-selected="false"
    aria-controls="advanced-panel"
    id="advanced-tab"
  >
    高级设置
  </button>
</div>

<div
  role="tabpanel"
  id="general-panel"
  aria-labelledby="general-tab"
>
  常规设置内容
</div>

<div
  role="tabpanel"
  id="advanced-panel"
  aria-labelledby="advanced-tab"
  hidden
>
  高级设置内容
</div>

动态内容更新

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
<div class="search-container">
  <label for="search">搜索</label>
  <input
    type="search"
    id="search"
    aria-describedby="search-results-count"
  >

  <div
    id="search-results"
    role="region"
    aria-live="polite"
    aria-label="搜索结果"
  >
    <!-- 动态加载搜索结果 -->
  </div>

  <div id="search-results-count" aria-live="polite" class="sr-only">
    找到 0 个结果
  </div>
</div>

<script>
// 搜索结果更新时
function updateSearchResults(results) {
  const resultsContainer = document.getElementById('search-results')
  const resultsCount = document.getElementById('search-results-count')

  resultsContainer.innerHTML = results.map(item => `
    <div role="option">${item.title}</div>
  `).join('')

  resultsCount.textContent = `找到 ${results.length} 个结果`
}
</script>

加载状态反馈

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
<button id="save-button" onclick="saveData()">
  保存数据
</button>

<div id="saving-status" role="status" aria-live="polite" class="sr-only"></div>

<script>
async function saveData() {
  const button = document.getElementById('save-button')
  const status = document.getElementById('saving-status')

  // 禁用按钮并显示加载状态
  button.disabled = true
  button.setAttribute('aria-busy', 'true')
  status.textContent = '正在保存...'

  try {
    await performSave()
    status.textContent = '保存成功'

    // 3秒后清除状态
    setTimeout(() => {
      status.textContent = ''
    }, 3000)
  } catch (error) {
    status.textContent = '保存失败,请重试'
  } finally {
    button.disabled = false
    button.removeAttribute('aria-busy')
  }
}
</script>

键盘导航

焦点管理

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
/* 可见焦点指示器 */
:focus {
  outline: 3px solid #005fcc;
  outline-offset: 2px;
}

/* 为鼠标用户移除轮廓 */
:focus:not(:focus-visible) {
  outline: none;
}

/* 支持focus-visible的浏览器 */
:focus-visible {
  outline: 3px solid #005fcc;
  outline-offset: 2px;
}

/* 跳过链接样式 */
.skip-link:focus {
  position: absolute;
  top: 6px;
  left: 6px;
  z-index: 1000;
}
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
// 模态框焦点管理
class ModalManager {
  constructor(modalElement) {
    this.modal = modalElement
    this.previousFocus = null
    this.focusableElements = null
  }

  open() {
    // 保存当前焦点元素
    this.previousFocus = document.activeElement

    // 显示模态框
    this.modal.style.display = 'block'
    this.modal.setAttribute('aria-hidden', 'false')

    // 获取可聚焦元素
    this.focusableElements = this.modal.querySelectorAll(
      'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
    )

    // 设置焦点到第一个元素
    if (this.focusableElements.length > 0) {
      this.focusableElements[0].focus()
    }

    // 限制Tab在模态框内
    this.trapFocus()
  }

  close() {
    this.modal.style.display = 'none'
    this.modal.setAttribute('aria-hidden', 'true')

    // 恢复之前的焦点
    if (this.previousFocus) {
      this.previousFocus.focus()
    }

    // 移除焦点陷阱
    this.removeFocusTrap()
  }

  trapFocus() {
    this.handleKeyDown = (e) => {
      if (e.key === 'Tab') {
        const firstElement = this.focusableElements[0]
        const lastElement = this.focusableElements[this.focusableElements.length - 1]

        if (e.shiftKey) {
          if (document.activeElement === firstElement) {
            lastElement.focus()
            e.preventDefault()
          }
        } else {
          if (document.activeElement === lastElement) {
            firstElement.focus()
            e.preventDefault()
          }
        }
      }
    }

    document.addEventListener('keydown', this.handleKeyDown)
  }

  removeFocusTrap() {
    if (this.handleKeyDown) {
      document.removeEventListener('keydown', this.handleKeyDown)
    }
  }
}

// 使用示例
const modal = new ModalManager(document.getElementById('my-modal'))
document.getElementById('open-modal').addEventListener('click', () => modal.open())
document.getElementById('close-modal').addEventListener('click', () => modal.close())

自定义组件键盘支持

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
// 可访问的下拉菜单
class AccessibleDropdown {
  constructor(trigger, menu) {
    this.trigger = trigger
    this.menu = menu
    this.isOpen = false
    this.currentFocus = -1
    this.items = menu.querySelectorAll('[role="menuitem"]')

    this.init()
  }

  init() {
    // 设置ARIA属性
    this.trigger.setAttribute('aria-haspopup', 'true')
    this.trigger.setAttribute('aria-expanded', 'false')
    this.menu.setAttribute('role', 'menu')

    this.items.forEach((item, index) => {
      item.setAttribute('role', 'menuitem')
      item.setAttribute('tabindex', '-1')
    })

    // 事件监听
    this.trigger.addEventListener('click', () => this.toggle())
    this.trigger.addEventListener('keydown', (e) => this.handleTriggerKeydown(e))
    this.menu.addEventListener('keydown', (e) => this.handleMenuKeydown(e))

    // 点击外部关闭
    document.addEventListener('click', (e) => {
      if (!this.trigger.contains(e.target) && !this.menu.contains(e.target)) {
        this.close()
      }
    })
  }

  toggle() {
    this.isOpen ? this.close() : this.open()
  }

  open() {
    this.isOpen = true
    this.menu.style.display = 'block'
    this.trigger.setAttribute('aria-expanded', 'true')
    this.items[0].focus()
    this.currentFocus = 0
  }

  close() {
    this.isOpen = false
    this.menu.style.display = 'none'
    this.trigger.setAttribute('aria-expanded', 'false')
    this.trigger.focus()
    this.currentFocus = -1
  }

  handleTriggerKeydown(e) {
    if (e.key === 'ArrowDown' || e.key === 'Enter' || e.key === ' ') {
      e.preventDefault()
      this.open()
    }
  }

  handleMenuKeydown(e) {
    switch (e.key) {
      case 'ArrowDown':
        e.preventDefault()
        this.focusNext()
        break
      case 'ArrowUp':
        e.preventDefault()
        this.focusPrevious()
        break
      case 'Escape':
        e.preventDefault()
        this.close()
        break
      case 'Enter':
      case ' ':
        e.preventDefault()
        this.selectCurrent()
        break
    }
  }

  focusNext() {
    this.currentFocus = (this.currentFocus + 1) % this.items.length
    this.items[this.currentFocus].focus()
  }

  focusPrevious() {
    this.currentFocus = (this.currentFocus - 1 + this.items.length) % this.items.length
    this.items[this.currentFocus].focus()
  }

  selectCurrent() {
    this.items[this.currentFocus].click()
    this.close()
  }
}

颜色和对比度

对比度检查工具集成

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
// 对比度计算函数
function getContrastRatio(color1, color2) {
  const luminance = (c) => {
    const rgb = c.replace('#', '').match(/.{2}/g).map(x => {
      const val = parseInt(x, 16) / 255
      return val <= 0.03928 ? val / 12.92 : Math.pow((val + 0.055) / 1.055, 2.4)
    })
    return 0.2126 * rgb[0] + 0.7152 * rgb[1] + 0.0722 * rgb[2]
  }

  const lum1 = luminance(color1)
  const lum2 = luminance(color2)

  return (Math.max(lum1, lum2) + 0.05) / (Math.min(lum1, lum2) + 0.05)
}

// 检查是否满足WCAG标准
function checkContrastRatio(foreground, background) {
  const ratio = getContrastRatio(foreground, background)

  return {
    ratio: ratio.toFixed(2),
    aa_normal: ratio >= 4.5,
    aa_large: ratio >= 3.0,
    aaa_normal: ratio >= 7.0,
    aaa_large: ratio >= 4.5
  }
}

// 使用示例
const result = checkContrastRatio('#000000', '#ffffff')
console.log(`对比度: ${result.ratio}:1`)
console.log(`AA标准: ${result.aa_normal ? '通过' : '失败'}`)

不仅仅是颜色的设计

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
/* 不要仅依赖颜色传递信息 */
.error {
  color: #d32f2f;
  border: 2px solid #d32f2f;
}

.success {
  color: #388e3c;
  border: 2px solid #388e3c;
}

/* 添加图标或文本 */
.error::before {
  content: "❌ ";
  margin-right: 0.5em;
}

.success::before {
  content: "✅ ";
  margin-right: 0.5em;
}

/* 状态指示 */
.status-indicator {
  position: relative;
}

.status-indicator::after {
  content: "";
  position: absolute;
  top: 0;
  right: -10px;
  width: 8px;
  height: 8px;
  border-radius: 50%;
}

.status-indicator.online::after {
  background-color: #388e3c;
}

.status-indicator.offline::after {
  background-color: #d32f2f;
  animation: pulse 2s infinite;
}

@keyframes pulse {
  0% { opacity: 1; }
  50% { opacity: 0.5; }
  100% { opacity: 1; }
}

多媒体无障碍

图片替代文本

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
<!-- 有意义的图片 -->
<img src="team-photo.jpg" alt="开发团队在办公室的合影,共5人正在讨论项目">

<!-- 装饰性图片 -->
<img src="decorative-border.png" alt="" role="presentation">

<!-- 或使用CSS背景图片 -->
<div class="decorative-border"></div>

<style>
.decorative-border {
  background-image: url('decorative-border.png');
  background-repeat: no-repeat;
  width: 100px;
  height: 20px;
}
</style>

<!-- 功能性图片 -->
<img src="pdf-icon.png" alt="下载PDF文件">

<!-- 复杂图片提供详细描述 -->
<img src="chart.png" alt="2024年销售趋势图表,显示第二季度增长显著" longdesc="chart-details.html">

<!-- 图表数据表 -->
<div role="img" aria-labelledby="chart-title">
  <h2 id="chart-title">月度销售数据</h2>
  <canvas id="sales-chart" aria-hidden="true"></canvas>

  <!-- 为屏幕阅读器提供数据表 -->
  <table class="sr-only">
    <caption>月度销售数据表</caption>
    <thead>
      <tr>
        <th>月份</th>
        <th>销售额(万元)</th>
        <th>增长率</th>
      </tr>
    </thead>
    <tbody>
      <tr>
        <td>1月</td>
        <td>120</td>
        <td>-</td>
      </tr>
      <tr>
        <td>2月</td>
        <td>135</td>
        <td>12.5%</td>
      </tr>
      <!-- 更多数据行 -->
    </tbody>
  </table>
</div>

视频和音频

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
<!-- 带字幕的视频 -->
<video controls>
  <source src="video.mp4" type="video/mp4">
  <source src="video.webm" type="video/webm">

  <!-- 字幕 -->
  <track
    kind="subtitles"
    src="subtitles-zh.vtt"
    srclang="zh"
    label="中文字幕"
  >
  <track
    kind="subtitles"
    src="subtitles-en.vtt"
    srclang="en"
    label="English Subtitles"
  >

  <!-- 字幕(针对听障用户) -->
  <track
    kind="captions"
    src="captions-zh.vtt"
    srclang="zh"
    label="中文字幕(包含声音描述)"
  >

  <!-- 音频描述 -->
  <track
    kind="descriptions"
    src="descriptions-zh.vtt"
    srclang="zh"
    label="音频描述"
  >

  <!-- 降级内容 -->
  <p>
    您的浏览器不支持视频播放。
    <a href="video.mp4">下载视频文件</a>
  </p>
</video>

<!-- 音频提供转录文本 -->
<audio controls>
  <source src="podcast.mp3" type="audio/mpeg">

  <!-- 提供完整转录 -->
  <details>
    <summary>音频转录</summary>
    <div class="transcript">
      <p>主持人:大家好,欢迎收听本期节目...</p>
      <!-- 完整转录内容 -->
    </div>
  </details>
</audio>

移动端无障碍

响应式设计考虑

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
/* 确保触摸目标足够大 */
button, a, input, select, textarea {
  min-height: 44px;
  min-width: 44px;
}

/* 小屏幕调整 */
@media (max-width: 768px) {
  .button {
    padding: 12px 24px;
    font-size: 16px;
  }

  /* 避免元素过近 */
  .nav-item {
    margin-right: 10px;
  }
}

/* 横屏模式调整 */
@media (orientation: landscape) and (max-height: 500px) {
  .modal {
    max-height: 90vh;
    overflow-y: auto;
  }
}

手势替代方案

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
// 为滑动手势添加按钮控制
class AccessibleSlider {
  constructor(slider) {
    this.slider = slider
    this.slides = slider.querySelectorAll('.slide')
    this.currentIndex = 0

    this.createControls()
    this.initKeyboardSupport()
    this.initTouchSupport()
  }

  createControls() {
    const controls = document.createElement('div')
    controls.className = 'slider-controls'
    controls.setAttribute('role', 'group')
    controls.setAttribute('aria-label', '幻灯片控制')

    const prevButton = document.createElement('button')
    prevButton.textContent = '上一张'
    prevButton.setAttribute('aria-label', '查看上一张幻灯片')
    prevButton.addEventListener('click', () => this.prev())

    const nextButton = document.createElement('button')
    nextButton.textContent = '下一张'
    nextButton.setAttribute('aria-label', '查看下一张幻灯片')
    nextButton.addEventListener('click', () => this.next())

    const indicators = document.createElement('div')
    indicators.className = 'slide-indicators'
    indicators.setAttribute('role', 'tablist')

    this.slides.forEach((slide, index) => {
      const indicator = document.createElement('button')
      indicator.setAttribute('role', 'tab')
      indicator.setAttribute('aria-label', `幻灯片 ${index + 1}`)
      indicator.setAttribute('aria-selected', index === 0 ? 'true' : 'false')
      indicator.addEventListener('click', () => this.goTo(index))

      if (index === 0) {
        indicator.classList.add('active')
      }

      indicators.appendChild(indicator)
    })

    controls.appendChild(prevButton)
    controls.appendChild(indicators)
    controls.appendChild(nextButton)

    this.slider.parentNode.insertBefore(controls, this.slider)
    this.controls = { prevButton, nextButton, indicators }
  }

  initKeyboardSupport() {
    this.slider.setAttribute('tabindex', '0')

    this.slider.addEventListener('keydown', (e) => {
      switch (e.key) {
        case 'ArrowLeft':
          e.preventDefault()
          this.prev()
          break
        case 'ArrowRight':
          e.preventDefault()
          this.next()
          break
        case 'Home':
          e.preventDefault()
          this.goTo(0)
          break
        case 'End':
          e.preventDefault()
          this.goTo(this.slides.length - 1)
          break
      }
    })
  }

  initTouchSupport() {
    let startX = 0
    let currentX = 0

    this.slider.addEventListener('touchstart', (e) => {
      startX = e.touches[0].clientX
    })

    this.slider.addEventListener('touchmove', (e) => {
      currentX = e.touches[0].clientX
    })

    this.slider.addEventListener('touchend', () => {
      const diff = startX - currentX

      if (Math.abs(diff) > 50) {
        if (diff > 0) {
          this.next()
        } else {
          this.prev()
        }
      }
    })
  }

  updateIndicators() {
    const indicators = this.controls.indicators.querySelectorAll('[role="tab"]')
    indicators.forEach((indicator, index) => {
      indicator.setAttribute('aria-selected', index === this.currentIndex ? 'true' : 'false')
      indicator.classList.toggle('active', index === this.currentIndex)
    })
  }

  goTo(index) {
    this.currentIndex = index
    this.updateSlider()
    this.updateIndicators()
  }

  next() {
    this.currentIndex = (this.currentIndex + 1) % this.slides.length
    this.updateSlider()
    this.updateIndicators()
  }

  prev() {
    this.currentIndex = (this.currentIndex - 1 + this.slides.length) % this.slides.length
    this.updateSlider()
    this.updateIndicators()
  }

  updateSlider() {
    const offset = -this.currentIndex * 100
    this.slider.style.transform = `translateX(${offset}%)`

    // 更新ARIA标签
    this.slider.setAttribute('aria-label', `当前显示第 ${this.currentIndex + 1} 张幻灯片`)
  }
}

测试工具和自动化

自动化测试集成

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
// 使用axe-core进行无障碍测试
const { injectAxe, checkA11y } = require('axe-playwright')

// Playwright测试示例
const { test, expect } = require('@playwright/test')

test.describe('Accessibility Tests', () => {
  test.beforeEach(async ({ page }) => {
    await injectAxe(page)
  })

  test('Home page should be accessible', async ({ page }) => {
    await page.goto('/')
    await checkA11y(page)
  })

  test('Form should be accessible', async ({ page }) => {
    await page.goto('/contact')
    await checkA11y(page, null, {
      detailedReport: true,
      detailedReportOptions: { html: true }
    })
  })

  test('Dynamic content should be accessible', async ({ page }) => {
    await page.goto('/search')

    // 输入搜索并等待结果
    await page.fill('#search', 'test')
    await page.waitForSelector('#search-results')

    // 仅测试新的动态内容
    await checkA11y(page, '#search-results', {
      includedImpacts: ['minor', 'moderate', 'serious', 'critical']
    })
  })
})

持续集成配置

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
# .github/workflows/accessibility.yml
name: Accessibility Tests

on:
  push:
    branches: [ main ]
  pull_request:
    branches: [ main ]

jobs:
  accessibility:
    runs-on: ubuntu-latest

    steps:
    - uses: actions/checkout@v3

    - name: Setup Node.js
      uses: actions/setup-node@v3
      with:
        node-version: '18'
        cache: 'npm'

    - name: Install dependencies
      run: npm ci

    - name: Build application
      run: npm run build

    - name: Run accessibility tests
      run: npm run test:a11y

    - name: Upload accessibility report
      uses: actions/upload-artifact@v3
      if: failure()
      with:
        name: accessibility-report
        path: a11y-report.html

实时无障碍检查

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
// 开发环境实时检查
if (process.env.NODE_ENV === 'development') {
  import('axe-core').then(axe => {
    axe.default.run(document, (err, results) => {
      if (err) throw err

      const violations = results.violations
      if (violations.length > 0) {
        console.group('🚨 Accessibility Violations Found')
        violations.forEach(violation => {
          console.error(`❌ ${violation.impact}: ${violation.description}`)
          console.error('  Affected elements:', violation.nodes.length)
        })
        console.groupEnd()
      }
    })
  })
}

总结

Web无障碍是每个开发者的责任,通过实施这些最佳实践:

核心原则:

  1. 遵循WCAG 2.1 AA标准
  2. 使用语义化HTML
  3. 确保键盘可访问
  4. 提供多种信息传递方式

技术实现:

  1. 正确使用ARIA属性
  2. 管理焦点状态
  3. 保持足够对比度
  4. 提供替代文本

测试验证:

  1. 使用自动化测试工具
  2. 手动键盘测试
  3. 屏幕阅读器测试
  4. 持续集成检查

记住,无障碍不是额外负担,而是构建高质量Web应用的基础。通过这些实践,我们将创造一个更加包容的网络世界。


相关资源: