- 发布于
Web无障碍开发实战指南:构建包容性的用户体验
- 作者

- 姓名
- 全能波
- GitHub
- @weicracker
Web无障碍开发实战指南:构建包容性的用户体验
Web无障碍(Accessibility)是现代Web开发的重要组成部分,确保所有用户都能平等地访问和使用Web内容。本文将深入探讨无障碍开发的核心技术和实践。
无障碍基础概念
WCAG指导原则
// 无障碍检查工具类
class AccessibilityChecker {
constructor() {
this.violations = []
this.warnings = []
this.passes = []
}
// 检查图片alt属性
checkImageAlt() {
const images = document.querySelectorAll('img')
images.forEach((img, index) => {
const alt = img.getAttribute('alt')
const src = img.getAttribute('src')
if (!alt && alt !== '') {
this.violations.push({
type: 'missing-alt',
element: img,
message: 'Image missing alt attribute',
severity: 'error',
wcag: '1.1.1'
})
} else if (alt === '' && !img.hasAttribute('role')) {
// 装饰性图片应该有空alt或role="presentation"
this.warnings.push({
type: 'decorative-image',
element: img,
message: 'Decorative image should have role="presentation"',
severity: 'warning'
})
} else if (alt && alt.length > 125) {
this.warnings.push({
type: 'long-alt',
element: img,
message: 'Alt text should be concise (under 125 characters)',
severity: 'warning'
})
}
})
}
// 检查标题结构
checkHeadingStructure() {
const headings = document.querySelectorAll('h1, h2, h3, h4, h5, h6')
let previousLevel = 0
headings.forEach((heading, index) => {
const level = parseInt(heading.tagName.charAt(1))
if (index === 0 && level !== 1) {
this.violations.push({
type: 'missing-h1',
element: heading,
message: 'Page should start with h1',
severity: 'error',
wcag: '1.3.1'
})
}
if (level - previousLevel > 1) {
this.violations.push({
type: 'skipped-heading',
element: heading,
message: `Heading level skipped from h${previousLevel} to h${level}`,
severity: 'error',
wcag: '1.3.1'
})
}
previousLevel = level
})
}
// 检查表单标签
checkFormLabels() {
const inputs = document.querySelectorAll('input, select, textarea')
inputs.forEach(input => {
const id = input.getAttribute('id')
const type = input.getAttribute('type')
// 跳过隐藏和提交按钮
if (type === 'hidden' || type === 'submit' || type === 'button') {
return
}
const label = id ? document.querySelector(`label[for="${id}"]`) : null
const ariaLabel = input.getAttribute('aria-label')
const ariaLabelledby = input.getAttribute('aria-labelledby')
if (!label && !ariaLabel && !ariaLabelledby) {
this.violations.push({
type: 'missing-label',
element: input,
message: 'Form control missing accessible label',
severity: 'error',
wcag: '1.3.1'
})
}
})
}
// 检查颜色对比度
async checkColorContrast() {
const textElements = document.querySelectorAll('p, span, div, h1, h2, h3, h4, h5, h6, a, button')
for (const element of textElements) {
const styles = window.getComputedStyle(element)
const fontSize = parseFloat(styles.fontSize)
const fontWeight = styles.fontWeight
const textColor = this.parseColor(styles.color)
const backgroundColor = this.getBackgroundColor(element)
if (textColor && backgroundColor) {
const contrast = this.calculateContrast(textColor, backgroundColor)
const isLargeText = fontSize >= 18 || (fontSize >= 14 && (fontWeight === 'bold' || fontWeight >= 700))
const minContrast = isLargeText ? 3 : 4.5
if (contrast < minContrast) {
this.violations.push({
type: 'low-contrast',
element: element,
message: `Color contrast ratio ${contrast.toFixed(2)} is below minimum ${minContrast}`,
severity: 'error',
wcag: '1.4.3'
})
}
}
}
}
// 检查键盘导航
checkKeyboardNavigation() {
const focusableElements = document.querySelectorAll(
'a[href], button, input, select, textarea, [tabindex]:not([tabindex="-1"])'
)
focusableElements.forEach(element => {
const tabIndex = element.getAttribute('tabindex')
// 检查正tabindex
if (tabIndex && parseInt(tabIndex) > 0) {
this.warnings.push({
type: 'positive-tabindex',
element: element,
message: 'Avoid positive tabindex values',
severity: 'warning',
wcag: '2.4.3'
})
}
// 检查焦点指示器
const styles = window.getComputedStyle(element, ':focus')
if (styles.outline === 'none' && !styles.boxShadow && !styles.border) {
this.warnings.push({
type: 'no-focus-indicator',
element: element,
message: 'Element should have visible focus indicator',
severity: 'warning',
wcag: '2.4.7'
})
}
})
}
// 解析颜色值
parseColor(colorStr) {
const div = document.createElement('div')
div.style.color = colorStr
document.body.appendChild(div)
const computedColor = window.getComputedStyle(div).color
document.body.removeChild(div)
const match = computedColor.match(/rgb\((\d+),\s*(\d+),\s*(\d+)\)/)
if (match) {
return {
r: parseInt(match[1]),
g: parseInt(match[2]),
b: parseInt(match[3])
}
}
return null
}
// 获取背景颜色
getBackgroundColor(element) {
let current = element
while (current && current !== document.body) {
const bgColor = window.getComputedStyle(current).backgroundColor
if (bgColor !== 'rgba(0, 0, 0, 0)' && bgColor !== 'transparent') {
return this.parseColor(bgColor)
}
current = current.parentElement
}
return { r: 255, g: 255, b: 255 } // 默认白色背景
}
// 计算对比度
calculateContrast(color1, color2) {
const l1 = this.getLuminance(color1)
const l2 = this.getLuminance(color2)
const lighter = Math.max(l1, l2)
const darker = Math.min(l1, l2)
return (lighter + 0.05) / (darker + 0.05)
}
// 获取亮度
getLuminance(color) {
const { r, g, b } = color
const [rs, gs, bs] = [r, g, b].map(c => {
c = c / 255
return c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4)
})
return 0.2126 * rs + 0.7152 * gs + 0.0722 * bs
}
// 运行所有检查
async runAllChecks() {
this.violations = []
this.warnings = []
this.passes = []
this.checkImageAlt()
this.checkHeadingStructure()
this.checkFormLabels()
this.checkKeyboardNavigation()
await this.checkColorContrast()
return {
violations: this.violations,
warnings: this.warnings,
passes: this.passes
}
}
// 生成报告
generateReport() {
const report = {
summary: {
violations: this.violations.length,
warnings: this.warnings.length,
passes: this.passes.length
},
details: {
violations: this.violations,
warnings: this.warnings,
passes: this.passes
}
}
return report
}
}
// 使用无障碍检查器
const checker = new AccessibilityChecker()
// 页面加载完成后检查
document.addEventListener('DOMContentLoaded', async () => {
const results = await checker.runAllChecks()
const report = checker.generateReport()
console.log('Accessibility Report:', report)
// 在开发环境显示结果
if (process.env.NODE_ENV === 'development') {
displayAccessibilityResults(results)
}
})
function displayAccessibilityResults(results) {
const panel = document.createElement('div')
panel.id = 'a11y-panel'
panel.style.cssText = `
position: fixed;
top: 10px;
right: 10px;
width: 300px;
max-height: 400px;
overflow-y: auto;
background: white;
border: 2px solid #333;
border-radius: 8px;
padding: 16px;
font-family: monospace;
font-size: 12px;
z-index: 10000;
box-shadow: 0 4px 12px rgba(0,0,0,0.3);
`
const violations = results.violations.length
const warnings = results.warnings.length
panel.innerHTML = `
<h3 style="margin: 0 0 12px 0; color: #333;">
Accessibility Report
</h3>
<div style="margin-bottom: 12px;">
<span style="color: #d73a49; font-weight: bold;">
${violations} Violations
</span>
<span style="color: #f66a0a; font-weight: bold; margin-left: 12px;">
${warnings} Warnings
</span>
</div>
<div style="max-height: 300px; overflow-y: auto;">
${results.violations.map(v => `
<div style="margin-bottom: 8px; padding: 8px; background: #ffeaea; border-left: 3px solid #d73a49;">
<strong>${v.type}</strong><br>
${v.message}<br>
<small>WCAG: ${v.wcag || 'N/A'}</small>
</div>
`).join('')}
${results.warnings.map(w => `
<div style="margin-bottom: 8px; padding: 8px; background: #fff4e6; border-left: 3px solid #f66a0a;">
<strong>${w.type}</strong><br>
${w.message}
</div>
`).join('')}
</div>
<button onclick="this.parentElement.remove()" style="
position: absolute;
top: 8px;
right: 8px;
background: none;
border: none;
font-size: 16px;
cursor: pointer;
">×</button>
`
document.body.appendChild(panel)
}
ARIA标准应用
ARIA属性与角色
// ARIA增强组件库
class ARIAComponent {
constructor(element) {
this.element = element
this.setupARIA()
}
setupARIA() {
// 子类实现
}
// 设置ARIA属性
setARIA(attribute, value) {
this.element.setAttribute(`aria-${attribute}`, value)
}
// 获取ARIA属性
getARIA(attribute) {
return this.element.getAttribute(`aria-${attribute}`)
}
// 切换ARIA属性
toggleARIA(attribute, value1, value2) {
const current = this.getARIA(attribute)
this.setARIA(attribute, current === value1 ? value2 : value1)
}
}
// 可折叠面板组件
class CollapsiblePanel extends ARIAComponent {
constructor(element) {
super(element)
this.trigger = element.querySelector('[data-toggle]')
this.content = element.querySelector('[data-content]')
this.isExpanded = false
this.setupEvents()
}
setupARIA() {
// 为触发器设置ARIA属性
if (this.trigger) {
this.trigger.setAttribute('role', 'button')
this.trigger.setAttribute('aria-expanded', 'false')
this.trigger.setAttribute('aria-controls', this.content?.id || '')
// 确保可键盘访问
if (!this.trigger.hasAttribute('tabindex')) {
this.trigger.setAttribute('tabindex', '0')
}
}
// 为内容区域设置ARIA属性
if (this.content) {
this.content.setAttribute('role', 'region')
this.content.setAttribute('aria-hidden', 'true')
if (!this.content.id) {
this.content.id = `panel-${Date.now()}`
this.trigger?.setAttribute('aria-controls', this.content.id)
}
}
}
setupEvents() {
if (!this.trigger) return
// 点击事件
this.trigger.addEventListener('click', () => {
this.toggle()
})
// 键盘事件
this.trigger.addEventListener('keydown', (e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
this.toggle()
}
})
}
toggle() {
this.isExpanded = !this.isExpanded
// 更新ARIA属性
this.trigger?.setAttribute('aria-expanded', this.isExpanded.toString())
this.content?.setAttribute('aria-hidden', (!this.isExpanded).toString())
// 更新视觉状态
if (this.isExpanded) {
this.content?.classList.add('expanded')
this.trigger?.classList.add('expanded')
} else {
this.content?.classList.remove('expanded')
this.trigger?.classList.remove('expanded')
}
// 触发自定义事件
this.element.dispatchEvent(new CustomEvent('panel:toggle', {
detail: { expanded: this.isExpanded }
}))
}
expand() {
if (!this.isExpanded) {
this.toggle()
}
}
collapse() {
if (this.isExpanded) {
this.toggle()
}
}
}
// 模态框组件
class Modal extends ARIAComponent {
constructor(element) {
super(element)
this.isOpen = false
this.previousFocus = null
this.focusableElements = []
this.setupEvents()
}
setupARIA() {
this.element.setAttribute('role', 'dialog')
this.element.setAttribute('aria-modal', 'true')
this.element.setAttribute('aria-hidden', 'true')
// 设置标题
const title = this.element.querySelector('[data-modal-title]')
if (title) {
if (!title.id) {
title.id = `modal-title-${Date.now()}`
}
this.element.setAttribute('aria-labelledby', title.id)
}
// 设置描述
const description = this.element.querySelector('[data-modal-description]')
if (description) {
if (!description.id) {
description.id = `modal-desc-${Date.now()}`
}
this.element.setAttribute('aria-describedby', description.id)
}
}
setupEvents() {
// 关闭按钮
const closeButtons = this.element.querySelectorAll('[data-modal-close]')
closeButtons.forEach(button => {
button.addEventListener('click', () => this.close())
})
// ESC键关闭
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape' && this.isOpen) {
this.close()
}
})
// 点击背景关闭
this.element.addEventListener('click', (e) => {
if (e.target === this.element) {
this.close()
}
})
}
open() {
if (this.isOpen) return
this.isOpen = true
this.previousFocus = document.activeElement
// 更新ARIA属性
this.element.setAttribute('aria-hidden', 'false')
// 显示模态框
this.element.style.display = 'flex'
document.body.classList.add('modal-open')
// 设置焦点陷阱
this.setupFocusTrap()
// 聚焦到第一个可聚焦元素
this.focusFirstElement()
// 触发事件
this.element.dispatchEvent(new CustomEvent('modal:open'))
}
close() {
if (!this.isOpen) return
this.isOpen = false
// 更新ARIA属性
this.element.setAttribute('aria-hidden', 'true')
// 隐藏模态框
this.element.style.display = 'none'
document.body.classList.remove('modal-open')
// 恢复焦点
if (this.previousFocus) {
this.previousFocus.focus()
}
// 触发事件
this.element.dispatchEvent(new CustomEvent('modal:close'))
}
setupFocusTrap() {
this.focusableElements = this.element.querySelectorAll(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
)
if (this.focusableElements.length === 0) return
const firstElement = this.focusableElements[0]
const lastElement = this.focusableElements[this.focusableElements.length - 1]
this.element.addEventListener('keydown', (e) => {
if (e.key === 'Tab') {
if (e.shiftKey) {
// Shift + Tab
if (document.activeElement === firstElement) {
e.preventDefault()
lastElement.focus()
}
} else {
// Tab
if (document.activeElement === lastElement) {
e.preventDefault()
firstElement.focus()
}
}
}
})
}
focusFirstElement() {
if (this.focusableElements.length > 0) {
this.focusableElements[0].focus()
}
}
}
// 下拉菜单组件
class Dropdown extends ARIAComponent {
constructor(element) {
super(element)
this.trigger = element.querySelector('[data-dropdown-trigger]')
this.menu = element.querySelector('[data-dropdown-menu]')
this.items = this.menu?.querySelectorAll('[role="menuitem"]') || []
this.isOpen = false
this.currentIndex = -1
this.setupEvents()
}
setupARIA() {
if (this.trigger) {
this.trigger.setAttribute('aria-haspopup', 'true')
this.trigger.setAttribute('aria-expanded', 'false')
if (this.menu?.id) {
this.trigger.setAttribute('aria-controls', this.menu.id)
}
}
if (this.menu) {
this.menu.setAttribute('role', 'menu')
this.menu.setAttribute('aria-hidden', 'true')
}
this.items.forEach((item, index) => {
item.setAttribute('role', 'menuitem')
item.setAttribute('tabindex', '-1')
})
}
setupEvents() {
if (!this.trigger) return
// 触发器事件
this.trigger.addEventListener('click', () => {
this.toggle()
})
this.trigger.addEventListener('keydown', (e) => {
switch (e.key) {
case 'Enter':
case ' ':
case 'ArrowDown':
e.preventDefault()
this.open()
this.focusItem(0)
break
case 'ArrowUp':
e.preventDefault()
this.open()
this.focusItem(this.items.length - 1)
break
}
})
// 菜单项事件
this.items.forEach((item, index) => {
item.addEventListener('click', () => {
this.selectItem(index)
})
item.addEventListener('keydown', (e) => {
switch (e.key) {
case 'ArrowDown':
e.preventDefault()
this.focusItem((index + 1) % this.items.length)
break
case 'ArrowUp':
e.preventDefault()
this.focusItem((index - 1 + this.items.length) % this.items.length)
break
case 'Enter':
case ' ':
e.preventDefault()
this.selectItem(index)
break
case 'Escape':
e.preventDefault()
this.close()
this.trigger?.focus()
break
}
})
})
// 点击外部关闭
document.addEventListener('click', (e) => {
if (!this.element.contains(e.target)) {
this.close()
}
})
}
toggle() {
this.isOpen ? this.close() : this.open()
}
open() {
if (this.isOpen) return
this.isOpen = true
this.trigger?.setAttribute('aria-expanded', 'true')
this.menu?.setAttribute('aria-hidden', 'false')
this.menu?.classList.add('open')
}
close() {
if (!this.isOpen) return
this.isOpen = false
this.currentIndex = -1
this.trigger?.setAttribute('aria-expanded', 'false')
this.menu?.setAttribute('aria-hidden', 'true')
this.menu?.classList.remove('open')
}
focusItem(index) {
if (index < 0 || index >= this.items.length) return
this.currentIndex = index
this.items[index].focus()
}
selectItem(index) {
const item = this.items[index]
if (!item) return
// 触发选择事件
this.element.dispatchEvent(new CustomEvent('dropdown:select', {
detail: { item, index }
}))
this.close()
this.trigger?.focus()
}
}
// 自动初始化组件
document.addEventListener('DOMContentLoaded', () => {
// 初始化可折叠面板
document.querySelectorAll('[data-component="collapsible"]').forEach(element => {
new CollapsiblePanel(element)
})
// 初始化模态框
document.querySelectorAll('[data-component="modal"]').forEach(element => {
new Modal(element)
})
// 初始化下拉菜单
document.querySelectorAll('[data-component="dropdown"]').forEach(element => {
new Dropdown(element)
})
})
键盘导航与焦点管理
焦点管理系统
// 焦点管理器
class FocusManager {
constructor() {
this.focusHistory = []
this.focusTraps = new Map()
this.skipLinks = []
this.setupGlobalKeyboardHandlers()
this.setupSkipLinks()
}
// 设置全局键盘处理
setupGlobalKeyboardHandlers() {
document.addEventListener('keydown', (e) => {
// Tab键导航增强
if (e.key === 'Tab') {
this.handleTabNavigation(e)
}
// 快捷键处理
if (e.altKey || e.ctrlKey || e.metaKey) {
this.handleShortcuts(e)
}
})
// 焦点变化跟踪
document.addEventListener('focusin', (e) => {
this.trackFocus(e.target)
})
}
// 处理Tab导航
handleTabNavigation(e) {
const activeElement = document.activeElement
// 检查是否在焦点陷阱中
for (const [container, trap] of this.focusTraps) {
if (container.contains(activeElement)) {
if (trap.handleTab(e)) {
return // 陷阱处理了Tab键
}
}
}
// 跳过隐藏元素
const focusableElements = this.getFocusableElements()
const currentIndex = focusableElements.indexOf(activeElement)
if (currentIndex !== -1) {
const nextIndex = e.shiftKey ? currentIndex - 1 : currentIndex + 1
const nextElement = focusableElements[nextIndex]
if (nextElement && !this.isVisible(nextElement)) {
e.preventDefault()
this.focusNextVisible(focusableElements, nextIndex, e.shiftKey)
}
}
}
// 处理快捷键
handleShortcuts(e) {
const shortcuts = {
// Alt + 1: 跳转到主内容
'Alt+1': () => this.focusMainContent(),
// Alt + 2: 跳转到导航
'Alt+2': () => this.focusNavigation(),
// Alt + 3: 跳转到搜索
'Alt+3': () => this.focusSearch(),
// Ctrl + /: 显示快捷键帮助
'Ctrl+/': () => this.showShortcutHelp()
}
const key = this.getShortcutKey(e)
const handler = shortcuts[key]
if (handler) {
e.preventDefault()
handler()
}
}
// 获取快捷键字符串
getShortcutKey(e) {
const parts = []
if (e.ctrlKey) parts.push('Ctrl')
if (e.altKey) parts.push('Alt')
if (e.shiftKey) parts.push('Shift')
if (e.metaKey) parts.push('Meta')
parts.push(e.key)
return parts.join('+')
}
// 跟踪焦点历史
trackFocus(element) {
if (element && element !== document.body) {
this.focusHistory.push({
element,
timestamp: Date.now()
})
// 限制历史记录长度
if (this.focusHistory.length > 50) {
this.focusHistory.shift()
}
}
}
// 获取可聚焦元素
getFocusableElements() {
const selector = [
'a[href]',
'button:not([disabled])',
'input:not([disabled])',
'select:not([disabled])',
'textarea:not([disabled])',
'[tabindex]:not([tabindex="-1"])',
'[contenteditable="true"]'
].join(', ')
return Array.from(document.querySelectorAll(selector))
.filter(el => this.isVisible(el))
}
// 检查元素是否可见
isVisible(element) {
const style = window.getComputedStyle(element)
return style.display !== 'none' &&
style.visibility !== 'hidden' &&
style.opacity !== '0' &&
element.offsetWidth > 0 &&
element.offsetHeight > 0
}
// 聚焦下一个可见元素
focusNextVisible(elements, startIndex, reverse = false) {
const direction = reverse ? -1 : 1
let index = startIndex
while (index >= 0 && index < elements.length) {
const element = elements[index]
if (this.isVisible(element)) {
element.focus()
return
}
index += direction
}
}
// 创建焦点陷阱
createFocusTrap(container) {
const trap = new FocusTrap(container)
this.focusTraps.set(container, trap)
return trap
}
// 移除焦点陷阱
removeFocusTrap(container) {
const trap = this.focusTraps.get(container)
if (trap) {
trap.destroy()
this.focusTraps.delete(container)
}
}
// 设置跳转链接
setupSkipLinks() {
const skipLinksContainer = document.createElement('div')
skipLinksContainer.className = 'skip-links'
skipLinksContainer.innerHTML = `
<a href="#main-content" class="skip-link">跳转到主内容</a>
<a href="#navigation" class="skip-link">跳转到导航</a>
<a href="#search" class="skip-link">跳转到搜索</a>
`
document.body.insertBefore(skipLinksContainer, document.body.firstChild)
// 样式
const style = document.createElement('style')
style.textContent = `
.skip-links {
position: absolute;
top: -100px;
left: 0;
z-index: 10000;
}
.skip-link {
position: absolute;
top: 0;
left: -100px;
background: #000;
color: #fff;
padding: 8px 16px;
text-decoration: none;
border-radius: 0 0 4px 0;
transition: left 0.3s;
}
.skip-link:focus {
left: 0;
top: 0;
}
`
document.head.appendChild(style)
}
// 聚焦主内容
focusMainContent() {
const main = document.querySelector('#main-content, main, [role="main"]')
if (main) {
main.focus()
main.scrollIntoView({ behavior: 'smooth' })
}
}
// 聚焦导航
focusNavigation() {
const nav = document.querySelector('#navigation, nav, [role="navigation"]')
if (nav) {
const firstLink = nav.querySelector('a, button')
if (firstLink) {
firstLink.focus()
}
}
}
// 聚焦搜索
focusSearch() {
const search = document.querySelector('#search, [role="search"] input, input[type="search"]')
if (search) {
search.focus()
}
}
// 显示快捷键帮助
showShortcutHelp() {
const helpModal = document.createElement('div')
helpModal.className = 'shortcut-help-modal'
helpModal.innerHTML = `
<div class="modal-content">
<h2>键盘快捷键</h2>
<ul>
<li><kbd>Alt + 1</kbd> - 跳转到主内容</li>
<li><kbd>Alt + 2</kbd> - 跳转到导航</li>
<li><kbd>Alt + 3</kbd> - 跳转到搜索</li>
<li><kbd>Tab</kbd> - 下一个元素</li>
<li><kbd>Shift + Tab</kbd> - 上一个元素</li>
<li><kbd>Enter</kbd> - 激活元素</li>
<li><kbd>Escape</kbd> - 关闭对话框</li>
</ul>
<button class="close-help">关闭</button>
</div>
`
document.body.appendChild(helpModal)
// 聚焦关闭按钮
const closeButton = helpModal.querySelector('.close-help')
closeButton.focus()
closeButton.addEventListener('click', () => {
document.body.removeChild(helpModal)
})
// ESC关闭
const handleEscape = (e) => {
if (e.key === 'Escape') {
document.body.removeChild(helpModal)
document.removeEventListener('keydown', handleEscape)
}
}
document.addEventListener('keydown', handleEscape)
}
}
// 焦点陷阱类
class FocusTrap {
constructor(container) {
this.container = container
this.focusableElements = []
this.firstElement = null
this.lastElement = null
this.previousFocus = null
this.updateFocusableElements()
}
updateFocusableElements() {
const selector = [
'a[href]',
'button:not([disabled])',
'input:not([disabled])',
'select:not([disabled])',
'textarea:not([disabled])',
'[tabindex]:not([tabindex="-1"])'
].join(', ')
this.focusableElements = Array.from(
this.container.querySelectorAll(selector)
).filter(el => {
const style = window.getComputedStyle(el)
return style.display !== 'none' && style.visibility !== 'hidden'
})
this.firstElement = this.focusableElements[0]
this.lastElement = this.focusableElements[this.focusableElements.length - 1]
}
activate() {
this.previousFocus = document.activeElement
this.updateFocusableElements()
if (this.firstElement) {
this.firstElement.focus()
}
}
deactivate() {
if (this.previousFocus) {
this.previousFocus.focus()
}
}
handleTab(e) {
if (this.focusableElements.length === 0) return false
const currentIndex = this.focusableElements.indexOf(document.activeElement)
if (e.shiftKey) {
// Shift + Tab
if (currentIndex <= 0) {
e.preventDefault()
this.lastElement?.focus()
return true
}
} else {
// Tab
if (currentIndex >= this.focusableElements.length - 1) {
e.preventDefault()
this.firstElement?.focus()
return true
}
}
return false
}
destroy() {
this.deactivate()
}
}
// 初始化焦点管理器
const focusManager = new FocusManager()
// 导出给全局使用
window.focusManager = focusManager
屏幕阅读器适配
屏幕阅读器优化
// 屏幕阅读器工具类
class ScreenReaderUtils {
constructor() {
this.announcements = []
this.setupLiveRegions()
}
// 设置实时区域
setupLiveRegions() {
// 创建polite实时区域
this.politeRegion = document.createElement('div')
this.politeRegion.setAttribute('aria-live', 'polite')
this.politeRegion.setAttribute('aria-atomic', 'true')
this.politeRegion.className = 'sr-only'
document.body.appendChild(this.politeRegion)
// 创建assertive实时区域
this.assertiveRegion = document.createElement('div')
this.assertiveRegion.setAttribute('aria-live', 'assertive')
this.assertiveRegion.setAttribute('aria-atomic', 'true')
this.assertiveRegion.className = 'sr-only'
document.body.appendChild(this.assertiveRegion)
// 添加屏幕阅读器专用样式
this.addScreenReaderStyles()
}
// 添加屏幕阅读器样式
addScreenReaderStyles() {
const style = document.createElement('style')
style.textContent = `
.sr-only {
position: absolute !important;
width: 1px !important;
height: 1px !important;
padding: 0 !important;
margin: -1px !important;
overflow: hidden !important;
clip: rect(0, 0, 0, 0) !important;
white-space: nowrap !important;
border: 0 !important;
}
.sr-only-focusable:focus {
position: static !important;
width: auto !important;
height: auto !important;
padding: inherit !important;
margin: inherit !important;
overflow: visible !important;
clip: auto !important;
white-space: inherit !important;
}
`
document.head.appendChild(style)
}
// 向屏幕阅读器宣布消息
announce(message, priority = 'polite') {
const region = priority === 'assertive' ? this.assertiveRegion : this.politeRegion
// 清空区域
region.textContent = ''
// 短暂延迟后设置消息,确保屏幕阅读器能检测到变化
setTimeout(() => {
region.textContent = message
// 记录宣布历史
this.announcements.push({
message,
priority,
timestamp: Date.now()
})
// 限制历史记录
if (this.announcements.length > 100) {
this.announcements.shift()
}
}, 100)
}
// 宣布页面变化
announcePageChange(title, description) {
const message = description ? `${title}. ${description}` : title
this.announce(message, 'assertive')
}
// 宣布表单错误
announceFormErrors(errors) {
if (errors.length === 0) {
this.announce('表单验证通过', 'polite')
return
}
const errorCount = errors.length
const message = `表单包含 ${errorCount} 个错误:${errors.join(',')}`
this.announce(message, 'assertive')
}
// 宣布加载状态
announceLoading(isLoading, message = '') {
if (isLoading) {
this.announce(message || '正在加载...', 'polite')
} else {
this.announce(message || '加载完成', 'polite')
}
}
// 宣布搜索结果
announceSearchResults(count, query) {
let message
if (count === 0) {
message = `没有找到"${query}"的搜索结果`
} else if (count === 1) {
message = `找到1个"${query}"的搜索结果`
} else {
message = `找到${count}个"${query}"的搜索结果`
}
this.announce(message, 'polite')
}
// 增强表格可访问性
enhanceTable(table) {
// 添加表格标题
const caption = table.querySelector('caption')
if (!caption) {
const firstRow = table.querySelector('tr')
if (firstRow) {
const newCaption = document.createElement('caption')
newCaption.textContent = '数据表格'
newCaption.className = 'sr-only'
table.insertBefore(newCaption, firstRow)
}
}
// 增强表头
const headers = table.querySelectorAll('th')
headers.forEach((header, index) => {
if (!header.id) {
header.id = `header-${Date.now()}-${index}`
}
if (!header.getAttribute('scope')) {
// 判断是行头还是列头
const row = header.parentElement
const isFirstColumn = Array.from(row.children).indexOf(header) === 0
header.setAttribute('scope', isFirstColumn ? 'row' : 'col')
}
})
// 关联数据单元格与表头
const cells = table.querySelectorAll('td')
cells.forEach(cell => {
if (!cell.getAttribute('headers')) {
const row = cell.parentElement
const cellIndex = Array.from(row.children).indexOf(cell)
const headerRow = table.querySelector('tr')
const correspondingHeader = headerRow?.children[cellIndex]
if (correspondingHeader?.id) {
cell.setAttribute('headers', correspondingHeader.id)
}
}
})
}
// 增强表单可访问性
enhanceForm(form) {
const inputs = form.querySelectorAll('input, select, textarea')
inputs.forEach(input => {
// 确保每个输入都有标签
if (!this.hasAccessibleLabel(input)) {
this.addAccessibleLabel(input)
}
// 添加必填字段指示
if (input.hasAttribute('required')) {
this.markRequired(input)
}
// 添加错误消息关联
this.setupErrorMessages(input)
})
// 添加表单提交反馈
form.addEventListener('submit', (e) => {
const errors = this.validateForm(form)
if (errors.length > 0) {
e.preventDefault()
this.announceFormErrors(errors)
this.focusFirstError(form)
}
})
}
// 检查是否有可访问的标签
hasAccessibleLabel(input) {
const id = input.id
const ariaLabel = input.getAttribute('aria-label')
const ariaLabelledby = input.getAttribute('aria-labelledby')
const label = id ? document.querySelector(`label[for="${id}"]`) : null
return !!(ariaLabel || ariaLabelledby || label)
}
// 添加可访问标签
addAccessibleLabel(input) {
const placeholder = input.getAttribute('placeholder')
const name = input.getAttribute('name')
if (placeholder) {
input.setAttribute('aria-label', placeholder)
} else if (name) {
input.setAttribute('aria-label', name)
}
}
// 标记必填字段
markRequired(input) {
const existingLabel = this.getInputLabel(input)
if (existingLabel && !existingLabel.textContent.includes('*')) {
existingLabel.innerHTML += ' <span aria-label="必填">*</span>'
}
input.setAttribute('aria-required', 'true')
}
// 获取输入框标签
getInputLabel(input) {
const id = input.id
return id ? document.querySelector(`label[for="${id}"]`) : null
}
// 设置错误消息
setupErrorMessages(input) {
const errorId = `${input.id || input.name}-error`
let errorElement = document.getElementById(errorId)
if (!errorElement) {
errorElement = document.createElement('div')
errorElement.id = errorId
errorElement.className = 'error-message sr-only'
errorElement.setAttribute('role', 'alert')
input.parentNode.insertBefore(errorElement, input.nextSibling)
}
input.setAttribute('aria-describedby', errorId)
}
// 显示输入错误
showInputError(input, message) {
const errorId = input.getAttribute('aria-describedby')
const errorElement = errorId ? document.getElementById(errorId) : null
if (errorElement) {
errorElement.textContent = message
errorElement.classList.remove('sr-only')
input.setAttribute('aria-invalid', 'true')
}
}
// 清除输入错误
clearInputError(input) {
const errorId = input.getAttribute('aria-describedby')
const errorElement = errorId ? document.getElementById(errorId) : null
if (errorElement) {
errorElement.textContent = ''
errorElement.classList.add('sr-only')
input.setAttribute('aria-invalid', 'false')
}
}
// 验证表单
validateForm(form) {
const errors = []
const inputs = form.querySelectorAll('input[required], select[required], textarea[required]')
inputs.forEach(input => {
if (!input.value.trim()) {
const label = this.getInputLabel(input)
const fieldName = label?.textContent || input.name || '字段'
errors.push(`${fieldName}不能为空`)
this.showInputError(input, `${fieldName}不能为空`)
} else {
this.clearInputError(input)
}
})
return errors
}
// 聚焦第一个错误
focusFirstError(form) {
const firstError = form.querySelector('[aria-invalid="true"]')
if (firstError) {
firstError.focus()
}
}
}
// 初始化屏幕阅读器工具
const srUtils = new ScreenReaderUtils()
// 页面加载完成后增强可访问性
document.addEventListener('DOMContentLoaded', () => {
// 增强所有表格
document.querySelectorAll('table').forEach(table => {
srUtils.enhanceTable(table)
})
// 增强所有表单
document.querySelectorAll('form').forEach(form => {
srUtils.enhanceForm(form)
})
// 宣布页面加载完成
srUtils.announcePageChange(document.title, '页面加载完成')
})
// 导出工具
window.srUtils = srUtils
总结
Web无障碍开发的核心要点:
- WCAG标准:遵循可感知、可操作、可理解、健壮性原则
- ARIA应用:正确使用角色、属性和状态
- 键盘导航:完整的键盘访问支持和焦点管理
- 屏幕阅读器:语义化标记和实时区域
- 测试验证:自动化检查和真实用户测试
无障碍开发不仅是技术要求,更是社会责任。通过系统性的无障碍设计,我们可以为所有用户创造更好的Web体验。