【前端无障碍】键盘导航:确保所有用户都能操作你的应用
【前端无障碍】键盘导航:确保所有用户都能操作你的应用
前言
大家好,我是cannonmonster01!今天咱们来聊聊键盘导航这个重要话题。想象一下,一个无法使用鼠标的用户,只能通过键盘来操作你的应用。如果你的应用不支持键盘导航,那他们将无法使用任何功能。
为什么键盘导航很重要
- 可访问性:为无法使用鼠标的用户提供访问途径
- 效率:许多用户更喜欢使用键盘快捷键
- 合规性:符合WCAG 2.1标准的要求
键盘导航基础
Tab键导航
<!-- 原生可聚焦元素 --> <a href="/">链接</a> <button>按钮</button> <input type="text"> <select> <option>选项</option> </select> <textarea></textarea>Tabindex属性
<!-- 默认tab顺序 --> <input tabindex="0"> <!-- 跳过tab顺序 --> <input tabindex="-1"> <!-- 自定义tab顺序(不推荐) --> <input tabindex="1"> <input tabindex="2">Enter键激活
// 按钮点击 const button = document.querySelector('button'); button.addEventListener('click', handleClick); // 自定义元素需要处理键盘事件 const customButton = document.querySelector('[role="button"]'); customButton.addEventListener('keydown', (e) => { if (e.key === 'Enter' || e.key === ' ') { handleClick(); } });键盘导航模式
1. 线性导航
<!-- 线性tab顺序 --> <form> <input type="text" placeholder="用户名"> <input type="password" placeholder="密码"> <button type="submit">登录</button> </form>2. 模态导航
<!-- 模态框捕获焦点 --> <div role="dialog" aria-modal="true"> <button>确定</button> <button>取消</button> </div>// 模态框焦点管理 const modal = document.querySelector('[role="dialog"]'); const focusableElements = modal.querySelectorAll('button, input'); modal.addEventListener('keydown', (e) => { if (e.key === 'Tab') { // 循环焦点 if (e.shiftKey && document.activeElement === focusableElements[0]) { e.preventDefault(); focusableElements[focusableElements.length - 1].focus(); } else if (!e.shiftKey && document.activeElement === focusableElements[focusableElements.length - 1]) { e.preventDefault(); focusableElements[0].focus(); } } else if (e.key === 'Escape') { closeModal(); } });3. 树形导航
<!-- 树形结构 --> <ul role="tree"> <li role="treeitem" aria-expanded="true"> <span>文件夹1</span> <ul role="group"> <li role="treeitem">文件1</li> <li role="treeitem">文件2</li> </ul> </li> </ul>// 树形导航键盘处理 const treeItems = document.querySelectorAll('[role="treeitem"]'); treeItems.forEach((item) => { item.addEventListener('keydown', (e) => { switch(e.key) { case 'ArrowDown': e.preventDefault(); // 移动到下一项 break; case 'ArrowUp': e.preventDefault(); // 移动到上一项 break; case 'ArrowRight': e.preventDefault(); // 展开子项 break; case 'ArrowLeft': e.preventDefault(); // 折叠子项 break; } }); });跳过链接
<!-- 跳过导航链接 --> <a href="#main" class="skip-link">跳转到主要内容</a> <nav>导航菜单...</nav> <main id="main">主要内容</main>/* 跳过链接样式 */ .skip-link { position: absolute; top: -40px; left: 0; background: #000; color: white; padding: 8px; z-index: 100; } .skip-link:focus { top: 0; }键盘快捷键
// 全局快捷键 document.addEventListener('keydown', (e) => { // Ctrl/Cmd + S 保存 if ((e.ctrlKey || e.metaKey) && e.key === 's') { e.preventDefault(); saveDocument(); } // Escape 关闭模态框 if (e.key === 'Escape' && isModalOpen) { closeModal(); } // Ctrl/Cmd + K 打开搜索 if ((e.ctrlKey || e.metaKey) && e.key === 'k') { e.preventDefault(); openSearch(); } });焦点管理
焦点样式
/* 不要移除焦点样式! */ button:focus { outline: 2px solid #5470c6; outline-offset: 2px; } /* 自定义焦点样式 */ button:focus-visible { box-shadow: 0 0 0 3px rgba(84, 112, 198, 0.3); }焦点陷阱
// 焦点陷阱实现 class FocusTrap { constructor(element) { this.element = element; this.focusableElements = element.querySelectorAll( 'button, input, select, textarea, [tabindex]:not([tabindex="-1"])' ); this.firstElement = this.focusableElements[0]; this.lastElement = this.focusableElements[this.focusableElements.length - 1]; } activate() { this.firstElement.focus(); this.element.addEventListener('keydown', this.handleKeydown.bind(this)); } deactivate() { this.element.removeEventListener('keydown', this.handleKeydown.bind(this)); } handleKeydown(e) { if (e.key === 'Tab') { if (e.shiftKey && document.activeElement === this.firstElement) { e.preventDefault(); this.lastElement.focus(); } else if (!e.shiftKey && document.activeElement === this.lastElement) { e.preventDefault(); this.firstElement.focus(); } } } } // 使用 const modal = document.querySelector('[role="dialog"]'); const trap = new FocusTrap(modal); trap.activate();实践案例
无障碍下拉菜单
<div class="dropdown"> <button aria-haspopup="true" aria-expanded="false" aria-controls="dropdown-menu" > 菜单 </button> <ul id="dropdown-menu" role="menu" hidden> <li role="menuitem"> <a href="/item1">菜单项1</a> </li> <li role="menuitem"> <a href="/item2">菜单项2</a> </li> </ul> </div>const dropdownButton = document.querySelector('.dropdown button'); const dropdownMenu = document.getElementById('dropdown-menu'); dropdownButton.addEventListener('click', () => { const isExpanded = dropdownButton.getAttribute('aria-expanded') === 'true'; dropdownButton.setAttribute('aria-expanded', !isExpanded); dropdownMenu.hidden = isExpanded; if (!isExpanded) { dropdownMenu.querySelector('[role="menuitem"] a').focus(); } }); dropdownMenu.addEventListener('keydown', (e) => { const items = dropdownMenu.querySelectorAll('[role="menuitem"] a'); const currentIndex = Array.from(items).indexOf(document.activeElement); switch(e.key) { case 'ArrowDown': e.preventDefault(); items[currentIndex + 1]?.focus() || items[0].focus(); break; case 'ArrowUp': e.preventDefault(); items[currentIndex - 1]?.focus() || items[items.length - 1].focus(); break; case 'Escape': dropdownButton.click(); dropdownButton.focus(); break; } });无障碍滑块
<div role="slider" tabindex="0" aria-valuenow="50" aria-valuemin="0" aria-valuemax="100" aria-label="音量" > <span>50%</span> </div>const slider = document.querySelector('[role="slider"]'); let value = 50; slider.addEventListener('keydown', (e) => { switch(e.key) { case 'ArrowLeft': e.preventDefault(); value = Math.max(0, value - 5); updateSlider(); break; case 'ArrowRight': e.preventDefault(); value = Math.min(100, value + 5); updateSlider(); break; case 'Home': e.preventDefault(); value = 0; updateSlider(); break; case 'End': e.preventDefault(); value = 100; updateSlider(); break; } }); function updateSlider() { slider.setAttribute('aria-valuenow', value); slider.querySelector('span').textContent = `${value}%`; }测试键盘导航
手动测试清单
- ✅ 所有交互元素都可以通过Tab键访问
- ✅ 焦点顺序逻辑正确
- ✅ Enter键可以激活按钮和链接
- ✅ 空格键可以激活按钮
- ✅ Escape键可以关闭模态框
- ✅ 跳过链接正常工作
- ✅ 焦点样式可见
自动化测试
import { test, expect } from '@playwright/test'; test('键盘导航测试', async ({ page }) => { await page.goto('/'); // 测试Tab键导航 await page.keyboard.press('Tab'); await page.keyboard.press('Tab'); // 验证焦点位置 const focusedElement = await page.evaluate(() => document.activeElement.tagName); expect(focusedElement).toBe('BUTTON'); // 测试Enter键激活 await page.keyboard.press('Enter'); // 验证结果 const pageTitle = await page.title(); expect(pageTitle).toBe('预期页面'); });常见问题
Q1: 如何处理复杂的自定义组件?
使用ARIA角色和键盘事件处理来模拟原生行为。
Q2: 焦点样式太丑怎么办?
自定义焦点样式,但不要完全移除它。使用:focus-visible选择器。
Q3: 如何管理模态框的焦点?
使用焦点陷阱技术,确保焦点在模态框内循环。
总结
键盘导航是无障碍设计的重要组成部分,通过今天的学习,相信你已经掌握了:
- Tab键导航和tabindex属性
- Enter键和空格键激活
- 焦点管理和焦点陷阱
- 跳过链接的实现
- 键盘快捷键
- 实践案例和测试方法
让我们一起创建键盘友好的Web应用!
