发布于

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

作者

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无障碍开发的核心要点:

  1. WCAG标准:遵循可感知、可操作、可理解、健壮性原则
  2. ARIA应用:正确使用角色、属性和状态
  3. 键盘导航:完整的键盘访问支持和焦点管理
  4. 屏幕阅读器:语义化标记和实时区域
  5. 测试验证:自动化检查和真实用户测试

无障碍开发不仅是技术要求,更是社会责任。通过系统性的无障碍设计,我们可以为所有用户创造更好的Web体验。