12k
All articles

改善 Web 应用键盘导航的技巧

修复 Tab 顺序问题,实现模态框焦点捕获,并结合语义化 HTML 应用 ARIA 属性,构建完整支持键盘访问的 Web 应用。

OpenReplay Team
OpenReplay Team
改善 Web 应用键盘导航的技巧

构建键盘无障碍的 Web 应用不仅仅是为了合规性——而是为了创建适用于所有人的界面。然而,许多开发者在焦点管理、破损的 Tab 序列和不可访问的自定义组件方面遇到困难。本指南提供了针对实际开发中常见键盘导航无障碍挑战的实用解决方案。

关键要点

  • 构建 DOM 结构以匹配视觉 Tab 顺序,而非 CSS 布局
  • 使用语义化 HTML 元素获得内置键盘支持
  • 永远不要在没有提供自定义替代方案的情况下移除焦点指示器
  • 为模态对话框实现焦点陷阱,并在关闭时恢复焦点
  • 手动测试键盘导航并使用自动化工具
  • 对自定义交互元素使用 tabindex="0",避免正值

理解焦点管理基础

Tab 顺序问题

键盘导航无障碍最关键的方面是建立逻辑的 Tab 顺序。您的 DOM 结构直接决定焦点序列,而不是 CSS 布局。这种脱节会导致严重的可用性问题。

常见错误:

<!-- 视觉顺序:Logo, Nav, Content, Sidebar -->
<div class="layout">
  <div class="sidebar">...</div>  <!-- 首先获得焦点 -->
  <div class="content">...</div>  <!-- 其次获得焦点 -->
  <nav class="navigation">...</nav> <!-- 第三获得焦点 -->
  <div class="logo">...</div>     <!-- 最后获得焦点 -->
</div>

更好的方法:

<!-- DOM 顺序匹配视觉流程 -->
<div class="layout">
  <div class="logo">...</div>
  <nav class="navigation">...</nav>
  <div class="content">...</div>
  <div class="sidebar">...</div>
</div>

使用 CSS Grid 或 Flexbox 控制视觉定位,同时保持逻辑的 DOM 顺序。

使用语义化 HTML 元素改善导航

原生 HTML 元素提供内置键盘支持。使用它们而不是用 div 和 span 重新创建功能。

开箱即用的交互元素:

  • <button> 用于操作
  • <a href="..."> 用于导航
  • <input><select><textarea> 用于表单控件
  • <details><summary> 用于可展开内容

避免这种模式:

<div class="button" onclick="handleClick()">提交</div>

使用这种方式:

<button type="button" onclick="handleClick()">提交</button>

当原生 HTML 无法提供所需行为时,使用 ARIA 属性 添加无障碍语义——但始终优先考虑语义化元素。

保持可见的焦点样式

焦点指示器危机

许多开发者使用 outline: none 移除焦点指示器而不提供替代方案。这完全破坏了键盘导航的无障碍性。

永远不要在没有替代方案的情况下这样做:

button:focus {
  outline: none; /* 移除焦点指示器 */
}

提供自定义焦点样式:

button:focus {
  outline: 2px solid #0066cc;
  outline-offset: 2px;
}

/* 或使用 focus-visible 获得更好的用户体验 */
button:focus-visible {
  outline: 2px solid #0066cc;
  outline-offset: 2px;
}

使用 :focus-visible 进行现代焦点管理

:focus-visible 伪类仅在检测到键盘导航时显示焦点指示器,改善键盘和鼠标用户的体验。

/* 基础样式 */
.interactive-element {
  outline: none;
}

/* 仅键盘焦点 */
.interactive-element:focus-visible {
  outline: 2px solid #0066cc;
  outline-offset: 2px;
  box-shadow: 0 0 0 4px rgba(0, 102, 204, 0.2);
}

避免常见的 tabindex 错误

tabindex 陷阱

使用大于 0 的 tabindex 值会创建令人困惑的导航模式。坚持使用这三个值:

  • tabindex="0" - 使元素在自然 Tab 顺序中可获得焦点
  • tabindex="-1" - 使元素可通过程序获得焦点但从 Tab 顺序中移除
  • 无 tabindex - 使用默认行为

有问题的方法:

<div tabindex="1">第一个</div>
<div tabindex="3">第三个</div>
<div tabindex="2">第二个</div>
<button>第四个(自然顺序)</button>

更好的解决方案:

<div tabindex="0">第一个</div>
<div tabindex="0">第二个</div>
<div tabindex="0">第三个</div>
<button>第四个</button>

使自定义组件可获得焦点

构建自定义交互元素时,添加 tabindex="0" 和键盘事件处理器:

// 自定义下拉组件
const dropdown = document.querySelector('.custom-dropdown');
dropdown.setAttribute('tabindex', '0');

dropdown.addEventListener('keydown', (e) => {
  switch(e.key) {
    case 'Enter':
    case ' ':
      toggleDropdown();
      break;
    case 'Escape':
      closeDropdown();
      break;
    case 'ArrowDown':
      openDropdown();
      focusFirstOption();
      break;
  }
});

防止模态框中的键盘陷阱

焦点陷阱实现

模态对话框必须陷阱焦点以防止键盘用户 Tab 到背景内容。这里是一个强大的实现:

class FocusTrap {
  constructor(element) {
    this.element = element;
    this.focusableElements = this.getFocusableElements();
    this.firstFocusable = this.focusableElements[0];
    this.lastFocusable = this.focusableElements[this.focusableElements.length - 1];
  }

  getFocusableElements() {
    const selectors = [
      'button:not([disabled])',
      'input:not([disabled])',
      'select:not([disabled])',
      'textarea:not([disabled])',
      'a[href]',
      '[tabindex]:not([tabindex="-1"])'
    ].join(', ');
    
    return Array.from(this.element.querySelectorAll(selectors));
  }

  activate() {
    this.element.addEventListener('keydown', this.handleKeyDown.bind(this));
    this.firstFocusable?.focus();
  }

  handleKeyDown(e) {
    if (e.key === 'Tab') {
      if (e.shiftKey) {
        if (document.activeElement === this.firstFocusable) {
          e.preventDefault();
          this.lastFocusable.focus();
        }
      } else {
        if (document.activeElement === this.lastFocusable) {
          e.preventDefault();
          this.firstFocusable.focus();
        }
      }
    }
    
    if (e.key === 'Escape') {
      this.deactivate();
    }
  }

  deactivate() {
    this.element.removeEventListener('keydown', this.handleKeyDown);
  }
}

模态框关闭后恢复焦点

始终将焦点返回到打开模态框的元素:

let previousFocus;

function openModal() {
  previousFocus = document.activeElement;
  const modal = document.getElementById('modal');
  const focusTrap = new FocusTrap(modal);
  focusTrap.activate();
}

function closeModal() {
  focusTrap.deactivate();
  previousFocus?.focus();
}

测试您的键盘导航

手动测试检查清单

  1. Tab 遍历整个界面 - 您能到达所有交互元素吗?
  2. 检查焦点指示器 - 它们是否可见且清晰?
  3. 测试模态对话框 - 焦点陷阱是否正常工作?
  4. 验证跳过链接 - 用户能否绕过重复导航?
  5. 测试表单交互 - 所有表单控件是否都能用键盘操作?

浏览器测试工具

使用这些工具识别键盘导航问题:

自动化测试集成

将键盘导航测试添加到您的测试套件中:

// Testing Library 示例
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';

test('模态框正确陷阱焦点', async () => {
  const user = userEvent.setup();
  render(<ModalComponent />);
  
  const openButton = screen.getByText('打开模态框');
  await user.click(openButton);
  
  const modal = screen.getByRole('dialog');
  const firstButton = screen.getByText('第一个按钮');
  const lastButton = screen.getByText('最后一个按钮');
  
  // 焦点应该在第一个元素上
  expect(firstButton).toHaveFocus();
  
  // Tab 到最后一个元素并验证陷阱
  await user.tab();
  expect(lastButton).toHaveFocus();
  
  await user.tab();
  expect(firstButton).toHaveFocus(); // 应该循环回来
});

处理复杂组件

下拉菜单和组合框

为自定义下拉菜单实现正确的键盘导航:

class AccessibleDropdown {
  constructor(element) {
    this.dropdown = element;
    this.trigger = element.querySelector('.dropdown-trigger');
    this.menu = element.querySelector('.dropdown-menu');
    this.options = Array.from(element.querySelectorAll('.dropdown-option'));
    this.currentIndex = -1;
    
    this.bindEvents();
  }

  bindEvents() {
    this.trigger.addEventListener('keydown', (e) => {
      switch(e.key) {
        case 'Enter':
        case ' ':
        case 'ArrowDown':
          e.preventDefault();
          this.open();
          break;
      }
    });

    this.menu.addEventListener('keydown', (e) => {
      switch(e.key) {
        case 'ArrowDown':
          e.preventDefault();
          this.focusNext();
          break;
        case 'ArrowUp':
          e.preventDefault();
          this.focusPrevious();
          break;
        case 'Enter':
          this.selectCurrent();
          break;
        case 'Escape':
          this.close();
          break;
      }
    });
  }

  focusNext() {
    this.currentIndex = (this.currentIndex + 1) % this.options.length;
    this.options[this.currentIndex].focus();
  }

  focusPrevious() {
    this.currentIndex = this.currentIndex <= 0 
      ? this.options.length - 1 
      : this.currentIndex - 1;
    this.options[this.currentIndex].focus();
  }
}

带键盘导航的数据表格

大型数据表格需要高效的键盘导航模式:

// 表格导航的漫游 tabindex
class AccessibleTable {
  constructor(table) {
    this.table = table;
    this.cells = Array.from(table.querySelectorAll('td, th'));
    this.currentCell = null;
    this.setupRovingTabindex();
  }

  setupRovingTabindex() {
    this.cells.forEach(cell => {
      cell.setAttribute('tabindex', '-1');
      cell.addEventListener('keydown', this.handleKeyDown.bind(this));
    });
    
    // 第一个单元格获得初始焦点
    if (this.cells[0]) {
      this.cells[0].setAttribute('tabindex', '0');
      this.currentCell = this.cells[0];
    }
  }

  handleKeyDown(e) {
    const { key } = e;
    let newCell = null;

    switch(key) {
      case 'ArrowRight':
        newCell = this.getNextCell();
        break;
      case 'ArrowLeft':
        newCell = this.getPreviousCell();
        break;
      case 'ArrowDown':
        newCell = this.getCellBelow();
        break;
      case 'ArrowUp':
        newCell = this.getCellAbove();
        break;
    }

    if (newCell) {
      e.preventDefault();
      this.moveFocus(newCell);
    }
  }

  moveFocus(newCell) {
    this.currentCell.setAttribute('tabindex', '-1');
    newCell.setAttribute('tabindex', '0');
    newCell.focus();
    this.currentCell = newCell;
  }
}

结论

有效的键盘导航无障碍需要关注焦点管理、语义化 HTML 使用和适当的测试。从逻辑的 DOM 结构开始,保持焦点指示器,避免大于 0 的 tabindex 值,并为模态框实现焦点陷阱。使用实际键盘导航进行定期测试将揭示自动化工具可能遗漏的问题。

准备改善您的 Web 应用键盘导航无障碍性了吗? 首先使用 Tab 键审计您当前的界面,识别焦点管理问题,并实施本指南中概述的模式。您的用户会感谢您创建了更包容的体验。

常见问题

:focus 和 :focus-visible CSS 伪类有什么区别?

:focus 伪类在元素获得焦点时应用,无论是如何获得焦点的(鼠标、键盘或程序化)。:focus-visible 伪类仅在浏览器确定焦点应该可见时应用,通常是在使用键盘导航时。这允许您仅在需要时显示焦点指示器,为鼠标用户改善体验的同时保持键盘用户的无障碍性。

如何在不同浏览器中测试键盘导航?

在 Chrome、Firefox、Safari 和 Edge 中通过 Tab 键手动测试您的界面。每个浏览器可能以不同方式处理焦点。对于自动化测试,使用 axe DevTools、WAVE 或 Lighthouse 等工具。特别注意焦点指示器,因为它们在不同浏览器间差异很大。考虑使用 :focus-visible 获得一致的跨浏览器焦点样式。

如果我的 CSS 布局破坏了逻辑 Tab 顺序该怎么办?

重构您的 HTML 以匹配视觉流程,然后使用 CSS Grid 或 Flexbox 控制定位。避免使用正的 tabindex 值来修复 Tab 顺序问题,因为这会产生更多问题。如果您必须使用 CSS 在视觉上重新排列元素,请确保 DOM 顺序对键盘和屏幕阅读器用户仍然具有逻辑意义。

如何在单页应用中处理键盘导航?

当路由改变时通过将焦点移动到主内容区域或页面标题来管理焦点。使用焦点管理库或实现自定义焦点恢复。确保动态内容更新不会破坏 Tab 序列,并且新添加的交互元素可以正确获得焦点。考虑使用跨路由变化跟踪焦点状态的焦点管理系统。

为什么我的自定义组件对键盘用户不可访问?

用 div 和 span 元素构建的自定义组件缺乏原生键盘支持。添加 tabindex='0' 使它们可获得焦点,为 Enter、Space 和方向键实现键盘事件处理器,并确保它们具有适当的 ARIA 属性。始终优先考虑使用语义化 HTML 元素,因为它们默认提供键盘无障碍性。

Listen to your bugs 🧘, with OpenReplay

See how users use your app and resolve issues fast.
Loved by thousands of developers

We use cookies to improve your experience. By using our site, you accept cookies.