Appearance
键盘导航优化 - 完整键盘可访问性指南
1. 键盘导航基础
1.1 为什么键盘导航重要
键盘导航对以下用户群体至关重要:
- 运动障碍用户: 无法使用鼠标
- 盲人用户: 依赖屏幕阅读器和键盘
- 效率用户: 键盘操作更快捷
- 临时残疾: 手臂受伤等情况
1.2 标准键盘操作
typescript
const standardKeyboardActions = {
Tab: '移动到下一个可聚焦元素',
'Shift + Tab': '移动到上一个可聚焦元素',
Enter: '激活链接或按钮',
Space: '激活按钮或复选框',
ArrowKeys: '在组件内导航',
Escape: '关闭对话框或菜单',
Home: '移动到开始',
End: '移动到结束',
PageUp: '向上翻页',
PageDown: '向下翻页'
};2. Tab顺序管理
2.1 tabindex属性
html
<!-- tabindex="0": 自然tab顺序,可聚焦 -->
<div tabindex="0">可聚焦的div</div>
<!-- tabindex="-1": 不在tab顺序中,但可编程聚焦 -->
<div tabindex="-1" id="target">编程聚焦</div>
<button onclick="document.getElementById('target').focus()">聚焦到div</button>
<!-- ❌ tabindex="1+"避免使用,会打乱自然顺序 -->
<button tabindex="1">第一个</button> <!-- 不推荐 -->
<button tabindex="2">第二个</button> <!-- 不推荐 -->
<!-- ✅ 使用自然DOM顺序 -->
<button>第一个</button>
<button>第二个</button>2.2 React中的Tab顺序
tsx
// TabOrderManager.tsx
export function TabOrderManager({ children }: { children: React.ReactNode }) {
// 确保模态框内容优先于背景内容
useEffect(() => {
const mainContent = document.getElementById('main-content');
if (mainContent) {
mainContent.setAttribute('inert', 'true'); // 使主内容不可交互
}
return () => {
mainContent?.removeAttribute('inert');
};
}, []);
return <div>{children}</div>;
}
// SkipLink - 跳过导航链接
export function SkipLink() {
return (
<a href="#main-content" className="skip-link">
跳转到主内容
</a>
);
}
// CSS
.skip-link {
position: absolute;
top: -40px;
left: 0;
padding: 8px;
background: #000;
color: #fff;
z-index: 100;
}
.skip-link:focus {
top: 0;
}2.3 动态内容的Tab顺序
tsx
// DynamicTabOrder.tsx
export function DynamicList() {
const [items, setItems] = useState(['Item 1', 'Item 2']);
const newItemRef = useRef<HTMLButtonElement>(null);
const addItem = () => {
setItems([...items, `Item ${items.length + 1}`]);
// 聚焦到新添加的项
setTimeout(() => {
newItemRef.current?.focus();
}, 0);
};
return (
<div>
<button onClick={addItem}>添加项目</button>
<ul>
{items.map((item, index) => (
<li key={index}>
<button
ref={index === items.length - 1 ? newItemRef : null}
onClick={() => console.log(item)}
>
{item}
</button>
</li>
))}
</ul>
</div>
);
}3. 键盘事件处理
3.1 基础键盘事件
tsx
// KeyboardHandler.tsx
export function KeyboardHandler() {
const handleKeyDown = (e: React.KeyboardEvent) => {
console.log('Key:', e.key);
console.log('Code:', e.code);
console.log('Ctrl:', e.ctrlKey);
console.log('Shift:', e.shiftKey);
console.log('Alt:', e.altKey);
console.log('Meta:', e.metaKey);
// 阻止默认行为
if (e.key === 'Enter') {
e.preventDefault();
handleSubmit();
}
};
return (
<div onKeyDown={handleKeyDown} tabIndex={0}>
按键测试区域
</div>
);
}
// 自定义快捷键
export function ShortcutHandler() {
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
// Ctrl/Cmd + S 保存
if ((e.ctrlKey || e.metaKey) && e.key === 's') {
e.preventDefault();
handleSave();
}
// Ctrl/Cmd + K 搜索
if ((e.ctrlKey || e.metaKey) && e.key === 'k') {
e.preventDefault();
openSearch();
}
// Escape 关闭
if (e.key === 'Escape') {
handleClose();
}
};
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}, []);
return <div>...</div>;
}3.2 组合键Hook
tsx
// useKeyboardShortcut.ts
export function useKeyboardShortcut(
key: string,
callback: () => void,
options: {
ctrl?: boolean;
shift?: boolean;
alt?: boolean;
meta?: boolean;
} = {}
) {
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
const matchesKey = e.key.toLowerCase() === key.toLowerCase();
const matchesCtrl = !options.ctrl || e.ctrlKey;
const matchesShift = !options.shift || e.shiftKey;
const matchesAlt = !options.alt || e.altKey;
const matchesMeta = !options.meta || e.metaKey;
if (matchesKey && matchesCtrl && matchesShift && matchesAlt && matchesMeta) {
e.preventDefault();
callback();
}
};
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}, [key, callback, options]);
}
// 使用
function MyComponent() {
useKeyboardShortcut('s', handleSave, { ctrl: true });
useKeyboardShortcut('k', openSearch, { ctrl: true });
useKeyboardShortcut('Escape', handleClose);
return <div>...</div>;
}4. 常见组件的键盘导航
4.1 下拉菜单
tsx
// Dropdown.tsx
export function Dropdown({
trigger,
items
}: {
trigger: string;
items: Array<{ label: string; onClick: () => void }>;
}) {
const [isOpen, setIsOpen] = useState(false);
const [activeIndex, setActiveIndex] = useState(0);
const triggerRef = useRef<HTMLButtonElement>(null);
const menuRef = useRef<HTMLDivElement>(null);
const handleKeyDown = (e: React.KeyboardEvent) => {
switch (e.key) {
case 'Enter':
case ' ':
if (!isOpen) {
e.preventDefault();
setIsOpen(true);
} else {
e.preventDefault();
items[activeIndex].onClick();
setIsOpen(false);
triggerRef.current?.focus();
}
break;
case 'Escape':
setIsOpen(false);
triggerRef.current?.focus();
break;
case 'ArrowDown':
e.preventDefault();
if (!isOpen) {
setIsOpen(true);
} else {
setActiveIndex(prev => (prev + 1) % items.length);
}
break;
case 'ArrowUp':
e.preventDefault();
if (!isOpen) {
setIsOpen(true);
} else {
setActiveIndex(prev => (prev - 1 + items.length) % items.length);
}
break;
case 'Home':
if (isOpen) {
e.preventDefault();
setActiveIndex(0);
}
break;
case 'End':
if (isOpen) {
e.preventDefault();
setActiveIndex(items.length - 1);
}
break;
}
};
return (
<div className="dropdown">
<button
ref={triggerRef}
aria-haspopup="true"
aria-expanded={isOpen}
onClick={() => setIsOpen(!isOpen)}
onKeyDown={handleKeyDown}
>
{trigger}
</button>
{isOpen && (
<div
ref={menuRef}
role="menu"
onKeyDown={handleKeyDown}
>
{items.map((item, index) => (
<button
key={index}
role="menuitem"
tabIndex={index === activeIndex ? 0 : -1}
className={index === activeIndex ? 'active' : ''}
onClick={() => {
item.onClick();
setIsOpen(false);
triggerRef.current?.focus();
}}
>
{item.label}
</button>
))}
</div>
)}
</div>
);
}4.2 标签页
tsx
// Tabs.tsx
export function Tabs({
tabs
}: {
tabs: Array<{ id: string; label: string; content: React.ReactNode }>;
}) {
const [activeTab, setActiveTab] = useState(0);
const tabRefs = useRef<(HTMLButtonElement | null)[]>([]);
const handleKeyDown = (e: React.KeyboardEvent, index: number) => {
let newIndex = index;
switch (e.key) {
case 'ArrowLeft':
newIndex = index === 0 ? tabs.length - 1 : index - 1;
break;
case 'ArrowRight':
newIndex = index === tabs.length - 1 ? 0 : index + 1;
break;
case 'Home':
newIndex = 0;
break;
case 'End':
newIndex = tabs.length - 1;
break;
default:
return;
}
e.preventDefault();
setActiveTab(newIndex);
tabRefs.current[newIndex]?.focus();
};
return (
<div>
<div role="tablist" aria-label="内容标签">
{tabs.map((tab, index) => (
<button
key={tab.id}
ref={el => tabRefs.current[index] = el}
role="tab"
id={`tab-${tab.id}`}
aria-selected={index === activeTab}
aria-controls={`panel-${tab.id}`}
tabIndex={index === activeTab ? 0 : -1}
onClick={() => setActiveTab(index)}
onKeyDown={(e) => handleKeyDown(e, index)}
>
{tab.label}
</button>
))}
</div>
{tabs.map((tab, index) => (
<div
key={tab.id}
role="tabpanel"
id={`panel-${tab.id}`}
aria-labelledby={`tab-${tab.id}`}
hidden={index !== activeTab}
tabIndex={0}
>
{tab.content}
</div>
))}
</div>
);
}4.3 模态框
tsx
// Modal.tsx
export function Modal({
isOpen,
onClose,
title,
children
}: {
isOpen: boolean;
onClose: () => void;
title: string;
children: React.ReactNode;
}) {
const modalRef = useRef<HTMLDivElement>(null);
const previousFocusRef = useRef<HTMLElement | null>(null);
useEffect(() => {
if (isOpen) {
// 保存之前的焦点
previousFocusRef.current = document.activeElement as HTMLElement;
// 聚焦到模态框
modalRef.current?.focus();
// 处理Escape键
const handleEscape = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
onClose();
}
};
document.addEventListener('keydown', handleEscape);
return () => {
document.removeEventListener('keydown', handleEscape);
// 恢复焦点
previousFocusRef.current?.focus();
};
}
}, [isOpen, onClose]);
// 焦点陷阱
useEffect(() => {
if (!isOpen) return;
const modal = modalRef.current;
if (!modal) return;
const focusableElements = modal.querySelectorAll<HTMLElement>(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
);
const firstElement = focusableElements[0];
const lastElement = focusableElements[focusableElements.length - 1];
const handleTab = (e: KeyboardEvent) => {
if (e.key !== 'Tab') return;
if (e.shiftKey) {
if (document.activeElement === firstElement) {
e.preventDefault();
lastElement?.focus();
}
} else {
if (document.activeElement === lastElement) {
e.preventDefault();
firstElement?.focus();
}
}
};
modal.addEventListener('keydown', handleTab);
return () => modal.removeEventListener('keydown', handleTab);
}, [isOpen]);
if (!isOpen) return null;
return (
<div
ref={modalRef}
role="dialog"
aria-modal="true"
aria-labelledby="modal-title"
tabIndex={-1}
className="modal"
>
<div className="modal-content">
<h2 id="modal-title">{title}</h2>
{children}
<button onClick={onClose} aria-label="关闭">
关闭
</button>
</div>
</div>
);
}4.4 列表框
tsx
// Listbox.tsx
export function Listbox({
options,
value,
onChange
}: {
options: Array<{ value: string; label: string }>;
value: string;
onChange: (value: string) => void;
}) {
const [activeIndex, setActiveIndex] = useState(
options.findIndex(opt => opt.value === value)
);
const listboxRef = useRef<HTMLDivElement>(null);
const handleKeyDown = (e: React.KeyboardEvent) => {
switch (e.key) {
case 'ArrowDown':
e.preventDefault();
setActiveIndex(prev => {
const next = (prev + 1) % options.length;
onChange(options[next].value);
return next;
});
break;
case 'ArrowUp':
e.preventDefault();
setActiveIndex(prev => {
const next = (prev - 1 + options.length) % options.length;
onChange(options[next].value);
return next;
});
break;
case 'Home':
e.preventDefault();
setActiveIndex(0);
onChange(options[0].value);
break;
case 'End':
e.preventDefault();
const lastIndex = options.length - 1;
setActiveIndex(lastIndex);
onChange(options[lastIndex].value);
break;
default:
// 字母跳转
const char = e.key.toLowerCase();
if (char.length === 1) {
const index = options.findIndex(
opt => opt.label.toLowerCase().startsWith(char)
);
if (index !== -1) {
setActiveIndex(index);
onChange(options[index].value);
}
}
}
};
return (
<div
ref={listboxRef}
role="listbox"
tabIndex={0}
aria-activedescendant={`option-${activeIndex}`}
onKeyDown={handleKeyDown}
>
{options.map((option, index) => (
<div
key={option.value}
id={`option-${index}`}
role="option"
aria-selected={index === activeIndex}
onClick={() => {
setActiveIndex(index);
onChange(option.value);
}}
className={index === activeIndex ? 'active' : ''}
>
{option.label}
</div>
))}
</div>
);
}5. 焦点陷阱(Focus Trap)
5.1 基础焦点陷阱
tsx
// useFocusTrap.ts
export function useFocusTrap(
ref: React.RefObject<HTMLElement>,
isActive: boolean
) {
useEffect(() => {
if (!isActive) return;
const element = ref.current;
if (!element) return;
const focusableSelector =
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])';
const focusableElements = element.querySelectorAll<HTMLElement>(focusableSelector);
const firstElement = focusableElements[0];
const lastElement = focusableElements[focusableElements.length - 1];
// 初始聚焦
firstElement?.focus();
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key !== 'Tab') return;
if (e.shiftKey) {
// Shift + Tab
if (document.activeElement === firstElement) {
e.preventDefault();
lastElement?.focus();
}
} else {
// Tab
if (document.activeElement === lastElement) {
e.preventDefault();
firstElement?.focus();
}
}
};
element.addEventListener('keydown', handleKeyDown);
return () => element.removeEventListener('keydown', handleKeyDown);
}, [ref, isActive]);
}
// 使用
function Dialog() {
const dialogRef = useRef<HTMLDivElement>(null);
const [isOpen, setIsOpen] = useState(false);
useFocusTrap(dialogRef, isOpen);
return (
<div ref={dialogRef} role="dialog">
{/* 对话框内容 */}
</div>
);
}5.2 focus-trap-react库
bash
npm install focus-trap-reacttsx
import FocusTrap from 'focus-trap-react';
export function Dialog({ isOpen, onClose }: DialogProps) {
if (!isOpen) return null;
return (
<FocusTrap
focusTrapOptions={{
initialFocus: '#dialog-title',
onDeactivate: onClose,
clickOutsideDeactivates: true
}}
>
<div role="dialog" aria-modal="true">
<h2 id="dialog-title">对话框标题</h2>
<button onClick={onClose}>关闭</button>
</div>
</FocusTrap>
);
}6. 可访问的自定义组件
6.1 自定义按钮
tsx
// CustomButton.tsx
export function CustomButton({
onClick,
children,
disabled = false
}: {
onClick: () => void;
children: React.ReactNode;
disabled?: boolean;
}) {
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
if (!disabled) {
onClick();
}
}
};
return (
<div
role="button"
tabIndex={disabled ? -1 : 0}
onClick={disabled ? undefined : onClick}
onKeyDown={handleKeyDown}
aria-disabled={disabled}
className={`custom-button ${disabled ? 'disabled' : ''}`}
>
{children}
</div>
);
}6.2 自定义复选框
tsx
// CustomCheckbox.tsx
export function CustomCheckbox({
checked,
onChange,
label
}: {
checked: boolean;
onChange: (checked: boolean) => void;
label: string;
}) {
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === ' ') {
e.preventDefault();
onChange(!checked);
}
};
return (
<div className="custom-checkbox-container">
<div
role="checkbox"
aria-checked={checked}
aria-label={label}
tabIndex={0}
onClick={() => onChange(!checked)}
onKeyDown={handleKeyDown}
className={`custom-checkbox ${checked ? 'checked' : ''}`}
>
{checked && <CheckIcon />}
</div>
<label onClick={() => onChange(!checked)}>
{label}
</label>
</div>
);
}6.3 自定义滑块
tsx
// CustomSlider.tsx
export function CustomSlider({
value,
min = 0,
max = 100,
step = 1,
onChange,
label
}: {
value: number;
min?: number;
max?: number;
step?: number;
onChange: (value: number) => void;
label: string;
}) {
const handleKeyDown = (e: React.KeyboardEvent) => {
let newValue = value;
switch (e.key) {
case 'ArrowRight':
case 'ArrowUp':
newValue = Math.min(value + step, max);
break;
case 'ArrowLeft':
case 'ArrowDown':
newValue = Math.max(value - step, min);
break;
case 'Home':
newValue = min;
break;
case 'End':
newValue = max;
break;
case 'PageUp':
newValue = Math.min(value + step * 10, max);
break;
case 'PageDown':
newValue = Math.max(value - step * 10, min);
break;
default:
return;
}
e.preventDefault();
onChange(newValue);
};
const percentage = ((value - min) / (max - min)) * 100;
return (
<div className="slider-container">
<label id="slider-label">{label}</label>
<div
role="slider"
aria-labelledby="slider-label"
aria-valuemin={min}
aria-valuemax={max}
aria-valuenow={value}
aria-valuetext={`${value}`}
tabIndex={0}
onKeyDown={handleKeyDown}
className="slider"
>
<div className="slider-track">
<div className="slider-fill" style={{ width: `${percentage}%` }} />
<div
className="slider-thumb"
style={{ left: `${percentage}%` }}
/>
</div>
</div>
</div>
);
}7. 键盘导航提示
7.1 快捷键提示面板
tsx
// KeyboardShortcuts.tsx
export function KeyboardShortcuts() {
const [isOpen, setIsOpen] = useState(false);
useKeyboardShortcut('?', () => setIsOpen(true), { shift: true });
const shortcuts = [
{ keys: ['?'], description: '显示快捷键' },
{ keys: ['Ctrl', 'S'], description: '保存' },
{ keys: ['Ctrl', 'K'], description: '搜索' },
{ keys: ['Escape'], description: '关闭' },
{ keys: ['Tab'], description: '下一个元素' },
{ keys: ['Shift', 'Tab'], description: '上一个元素' }
];
return (
<>
<button onClick={() => setIsOpen(true)} aria-label="查看快捷键">
?
</button>
{isOpen && (
<Modal isOpen={isOpen} onClose={() => setIsOpen(false)} title="键盘快捷键">
<dl className="shortcuts-list">
{shortcuts.map((shortcut, index) => (
<div key={index} className="shortcut-item">
<dt>
{shortcut.keys.map((key, i) => (
<kbd key={i}>{key}</kbd>
))}
</dt>
<dd>{shortcut.description}</dd>
</div>
))}
</dl>
</Modal>
)}
</>
);
}7.2 视觉焦点指示器
css
/* 全局焦点样式 */
*:focus {
outline: 2px solid #0066cc;
outline-offset: 2px;
}
/* 不同类型元素的焦点样式 */
button:focus,
a:focus {
outline: 3px solid #0066cc;
outline-offset: 2px;
}
input:focus,
textarea:focus,
select:focus {
outline: 2px solid #0066cc;
border-color: #0066cc;
}
/* ❌ 不要移除焦点指示器 */
button:focus {
outline: none; /* 不好 */
}
/* ✅ 如果需要自定义,确保有清晰的视觉指示 */
button:focus {
outline: none;
box-shadow: 0 0 0 3px rgba(0, 102, 204, 0.5);
}8. 测试键盘可访问性
8.1 手动测试清单
typescript
const keyboardTestChecklist = {
basic: [
'所有功能可以仅用键盘操作',
'Tab键可以访问所有交互元素',
'焦点顺序符合逻辑',
'焦点可见且清晰',
'Escape键可以关闭对话框/菜单'
],
navigation: [
'箭头键在组件内导航正常',
'Home/End键跳转到开始/结束',
'模态框有焦点陷阱',
'关闭对话框后焦点返回触发元素',
'Skip links工作正常'
],
forms: [
'Tab键按逻辑顺序在表单字段间移动',
'Enter键提交表单',
'Space键切换复选框',
'下拉菜单可用箭头键选择',
'错误提示可被读取'
]
};8.2 自动化测试
typescript
// keyboard.test.tsx
import { render, fireEvent } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
describe('Keyboard Navigation', () => {
it('should handle Tab navigation', async () => {
const user = userEvent.setup();
const { getByRole } = render(<MyForm />);
await user.tab();
expect(getByRole('textbox', { name: 'Name' })).toHaveFocus();
await user.tab();
expect(getByRole('textbox', { name: 'Email' })).toHaveFocus();
});
it('should activate button with Enter key', async () => {
const handleClick = jest.fn();
const { getByRole } = render(<button onClick={handleClick}>Click</button>);
const button = getByRole('button');
button.focus();
fireEvent.keyDown(button, { key: 'Enter' });
expect(handleClick).toHaveBeenCalled();
});
it('should trap focus in modal', async () => {
const { getByRole } = render(<Modal isOpen={true} />);
const closeButton = getByRole('button', { name: 'Close' });
const firstButton = getByRole('button', { name: 'First' });
const lastButton = getByRole('button', { name: 'Last' });
lastButton.focus();
// Tab应该循环到第一个元素
fireEvent.keyDown(lastButton, { key: 'Tab' });
expect(firstButton).toHaveFocus();
});
});9. 最佳实践
typescript
const keyboardBestPractices = {
general: [
'所有功能必须可键盘访问',
'使用标准的键盘交互模式',
'提供清晰的焦点指示器',
'避免使用tabindex正值',
'保持合理的Tab顺序'
],
shortcuts: [
'不要覆盖浏览器快捷键',
'提供查看快捷键的方式',
'允许用户自定义快捷键',
'文档化所有快捷键',
'考虑国际键盘布局'
],
focus: [
'模态框使用焦点陷阱',
'关闭对话框恢复焦点',
'动态内容适当管理焦点',
'跳过重复内容的链接',
'确保焦点永远可见'
],
testing: [
'拔掉鼠标测试',
'使用自动化测试',
'测试所有交互路径',
'验证ARIA属性',
'检查焦点管理'
]
};10. 总结
键盘导航优化的关键要点:
- 完全可访问: 所有功能必须可键盘操作
- 标准交互: 遵循ARIA Authoring Practices
- 焦点管理: 正确的Tab顺序和焦点陷阱
- 视觉反馈: 清晰的焦点指示器
- 快捷键: 提供高效的键盘快捷键
- 测试验证: 持续测试键盘可访问性
通过优化键盘导航,可以让应用对所有用户都易于使用。