计划执行与检查节点
executing-plans
按既定实施计划完成工作,保持过程检查和反馈。
keyboard-navigation-focus-management【效率与协作】Implements keyboard accessibility and focus management: focus APIs, roving tabindex, focus trapping in modals, focus restoration on close, visible focus indicators, keyboard event handlers for Enter/Space/Escape/Arrow keys, and skip links.
独立 GitHub 快照 · 自动分类 · 以原作者文档为准
Implements keyboard accessibility and focus management for interactive components: focus APIs (focus(), setFocusVisible), focus-visible pseudo-class, tabindex patterns (0/-1), roving tabindex for lists, focus trapping in modals, focus restoration on close, keyboard event handlers for Enter/Space/Escape/Arrow keys, and skip links. Load when implementing interactive components, fixing keyboard navigation issues, or managing focus in complex UIs.
:focus-visible indicator (never outline: none)tabindex="0" to include in natural tab ordertabindex="-1" for elements focusable by script onlyUse this skill when:
Avoid this skill for:
Focus is the current interactive element that receives keyboard input. Only one element can have focus at a time.
Focusable elements by default:
<button>, <a href>, <input>, <textarea>, <select>tabindex="0" or positive tabindextabindex="-1" (focusable by script only)Not focusable by default:
<div>, <span>, <p>, <h1> (etc.)Default tab order follows DOM order. Can be influenced by tabindex:
<!-- DOM Order (natural tab order) -->
<button>1</button> <!-- Tab 1st -->
<input> <!-- Tab 2nd -->
<button> <!-- Tab 3rd -->
<!-- With tabindex (avoid if possible!) -->
<button tabindex="2"> <!-- Tab 2nd -->
<input tabindex="1"> <!-- Tab 1st -->
<button tabindex="3"> <!-- Tab 3rd -->
<!-- ⚠️ AVOID tabindex > 0 — causes confusion and maintenance burden -->
/* ✅ GOOD: Visible focus indicator using :focus-visible */
button:focus-visible {
outline: 3px solid #2563eb;
outline-offset: 2px;
}
input:focus-visible {
border-color: #2563eb;
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.1);
}
a:focus-visible {
outline: 2px dashed #2563eb;
}
/* High contrast mode support */
@media (prefers-contrast: more) {
button:focus-visible {
outline-width: 4px;
}
}
/* ❌ BAD: Removing focus indicator without replacement -->
button:focus {
outline: none; /* Keyboard users can't see focus! */
}
/* Remove default outline if replacing with custom */
button {
outline: none;
}
button:focus-visible {
/* Custom indicator here */
border: 2px solid #2563eb;
}
Key pattern: Use :focus-visible (shows for keyboard) not :focus (shows for all).
<!-- Focusable by default (no tabindex needed) -->
<button>Click me</button>
<a href="/">Link</a>
<input type="text">
<!-- Make div focusable by script (tabindex="-1") -->
<div tabindex="-1" id="message">
Dynamic message (can be focused programmatically)
</div>
<!-- Include in tab order (tabindex="0") -->
<div tabindex="0" role="button" @click="handleClick">
Custom button (now in tab order)
</div>
<!-- ❌ AVOID: Positive tabindex (causes confusion) -->
<div tabindex="1">Too early</div>
<div tabindex="2">Too late</div>
<!-- ✅ BETTER: Fix DOM order instead -->
<div>Correct order</div>
<div>Without tabindex needed</div>
Roving tabindex pattern: Only one item in tab order, Arrow keys move focus within list.
// Roving tabindex hook
export function useRovingTabindex(items: string[]) {
const [activeIndex, setActiveIndex] = useState(0);
const itemRefs = useRef<HTMLElement[]>([]);
useEffect(() => {
itemRefs.current[activeIndex]?.focus();
}, [activeIndex]);
const handleKeyDown = (e: React.KeyboardEvent, index: number) => {
switch (e.key) {
case 'ArrowDown':
case 'ArrowRight':
e.preventDefault();
setActiveIndex((prev) => (prev + 1) % items.length);
break;
case 'ArrowUp':
case 'ArrowLeft':
e.preventDefault();
setActiveIndex((prev) => (prev - 1 + items.length) % items.length);
break;
case 'Home':
e.preventDefault();
setActiveIndex(0);
break;
case 'End':
e.preventDefault();
setActiveIndex(items.length - 1);
break;
}
};
return {
activeIndex,
itemRefs,
handleKeyDown,
};
}
// Usage in list component
export function AccessibleList({ items }: { items: string[] }) {
const { activeIndex, itemRefs, handleKeyDown } = useRovingTabindex(items);
return (
<ul role="listbox">
{items.map((item, index) => (
<li key={index}>
<button
ref={(el) => {
if (el) itemRefs.current[index] = el;
}}
role="option"
aria-selected={index === activeIndex}
tabIndex={index === activeIndex ? 0 : -1}
onKeyDown={(e) => handleKeyDown(e, index)}
>
{item}
</button>
</li>
))}
</ul>
);
}
Key pattern:
tabindex="0" (in tab order)tabindex="-1" (focusable by script)export function useFocusTrap(isActive: boolean = true) {
const containerRef = useRef<HTMLDivElement>(null);
const firstElementRef = useRef<HTMLElement | null>(null);
const lastElementRef = useRef<HTMLElement | null>(null);
useEffect(() => {
if (!isActive || !containerRef.current) return;
// Get all focusable elements
const focusableSelector =
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])';
const focusable = Array.from(
containerRef.current.querySelectorAll(focusableSelector)
) as HTMLElement[];
firstElementRef.current = focusable[0];
lastElementRef.current = focusable[focusable.length - 1];
// Focus first element on mount
firstElementRef.current?.focus();
// Handle Tab/Shift+Tab at boundaries
function handleKeyDown(e: KeyboardEvent) {
if (e.key !== 'Tab') return;
if (e.shiftKey) {
// Shift+Tab at first element → jump to last
if (document.activeElement === firstElementRef.current) {
e.preventDefault();
lastElementRef.current?.focus();
}
} else {
// Tab at last element → jump to first
if (document.activeElement === lastElementRef.current) {
e.preventDefault();
firstElementRef.current?.focus();
}
}
}
containerRef.current.addEventListener('keydown', handleKeyDown);
return () => {
containerRef.current?.removeEventListener('keydown', handleKeyDown);
};
}, [isActive]);
return containerRef;
}
// Usage in modal/dialog
export function Modal({ isOpen, onClose }) {
const modalRef = useFocusTrap(isOpen);
return (
isOpen && (
<div ref={modalRef} role="dialog" aria-modal="true">
<h2>Dialog Title</h2>
<p>Dialog content</p>
<button onClick={onClose}>Close</button>
<button>Action 1</button>
<button>Action 2</button>
</div>
)
);
}
export function useRestoreFocus() {
const previousFocusRef = useRef<HTMLElement | null>(null);
const saveFocus = () => {
previousFocusRef.current = document.activeElement as HTMLElement;
};
const restoreFocus = () => {
setTimeout(() => {
previousFocusRef.current?.focus();
}, 0);
};
return { saveFocus, restoreFocus };
}
// Usage
export function Modal({ isOpen, onClose }) {
const { saveFocus, restoreFocus } = useRestoreFocus();
const handleOpen = () => {
saveFocus();
// Open modal
};
const handleClose = () => {
// Close modal
onClose();
// Restore focus to trigger button
restoreFocus();
};
return (
<>
<button onClick={handleOpen}>Open Modal</button>
{isOpen && (
<div role="dialog">
<h2>Dialog</h2>
<button onClick={handleClose}>Close</button>
</div>
)}
</>
);
}
// Generic keyboard handler
export function useKeyboardNavigation(callbacks: {
onEnter?: () => void;
onEscape?: () => void;
onSpace?: () => void;
onArrowUp?: () => void;
onArrowDown?: () => void;
onArrowLeft?: () => void;
onArrowRight?: () => void;
onHome?: () => void;
onEnd?: () => void;
}) {
const handleKeyDown = (e: React.KeyboardEvent) => {
switch (e.key) {
case 'Enter':
callbacks.onEnter?.();
break;
case ' ':
callbacks.onSpace?.();
break;
case 'Escape':
callbacks.onEscape?.();
break;
case 'ArrowUp':
callbacks.onArrowUp?.();
break;
case 'ArrowDown':
callbacks.onArrowDown?.();
break;
case 'ArrowLeft':
callbacks.onArrowLeft?.();
break;
case 'ArrowRight':
callbacks.onArrowRight?.();
break;
case 'Home':
callbacks.onHome?.();
break;
case 'End':
callbacks.onEnd?.();
break;
}
};
return handleKeyDown;
}
// Usage in menu component
export function Menu() {
const [activeIndex, setActiveIndex] = useState(0);
const handleKeyDown = useKeyboardNavigation({
onEnter: () => console.log('Select'),
onEscape: () => console.log('Close'),
onArrowDown: () => setActiveIndex((i) => i + 1),
onArrowUp: () => setActiveIndex((i) => i - 1),
});
return (
<ul onKeyDown={handleKeyDown} role="menu">
<li role="menuitem">Option 1</li>
<li role="menuitem">Option 2</li>
<li role="menuitem">Option 3</li>
</ul>
);
}
<!-- Skip link (appears on Tab, hidden visually) -->
<a href="#main" class="skip-link">Skip to main content</a>
<header>
<nav>
<!-- Navigation with many links -->
</nav>
</header>
<main id="main">
<!-- Main content -->
</main>
<style>
.skip-link {
position: absolute;
top: -40px;
left: 0;
background: #000;
color: #fff;
padding: 8px;
text-decoration: none;
z-index: 100;
}
.skip-link:focus {
top: 0;
}
</style>
| Key | Element | Action |
|---|---|---|
| Enter | Button, Link | Activate |
| Space | Button, Checkbox | Toggle/Activate |
| Escape | Modal, Menu, Popup | Close |
| Tab | Any | Move focus to next |
| Shift+Tab | Any | Move focus to previous |
| ↑ ↓ ← → | Menu, List, Tree, Tabs | Navigate |
| Home | Menu, List | Jump to first |
| End | Menu, List | Jump to last |
function handleKeyDown(e: React.KeyboardEvent) {
if (e.key === 'Enter') {
e.preventDefault(); // Prevent form submission
// Custom behavior here
}
}
Implementation:
Testing:
:focus-visible indicator (never just outline: none)tabindex="0" for elements that need to be in tab ordertabindex="-1" for elements focusable by script onlytabindex > 0 (causes confusion; fix DOM order instead)onBlur to prevent focus management (breaks assistive tech)/* ❌ BAD: Focus invisible -->
button:focus { outline: none; }
/* ✅ FIX: Provide visible indicator -->
button:focus-visible { outline: 3px solid #2563eb; }
// ❌ BAD: Tab traps in modal, Escape doesn't work
function Modal() {
return <div role="dialog"><!-- content --></div>;
}
// ✅ FIX: Add focus trap + Escape handler
function Modal() {
const ref = useFocusTrap(true);
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Escape') onClose();
};
return <div ref={ref} role="dialog" onKeyDown={handleKeyDown}>...</div>;
}
// ❌ BAD: Opening modal loses track of trigger button
<button onClick={() => setOpen(true)}>Open</button>
// ✅ FIX: Save and restore focus
const { saveFocus, restoreFocus } = useRestoreFocus();
const handleOpen = () => {
saveFocus();
setOpen(true);
};
const handleClose = () => {
setOpen(false);
restoreFocus();
};
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
test('keyboard navigation works in menu', async () => {
const user = userEvent.setup();
render(
<Menu items={['Option 1', 'Option 2', 'Option 3']} />
);
const trigger = screen.getByRole('button', { name: /open menu/i });
// Open menu with keyboard
trigger.focus();
await user.keyboard('{Enter}');
// Navigate with arrow keys
await user.keyboard('{ArrowDown}');
expect(screen.getByText('Option 2')).toHaveFocus();
await user.keyboard('{ArrowUp}');
expect(screen.getByText('Option 1')).toHaveFocus();
// Close with Escape
await user.keyboard('{Escape}');
expect(trigger).toHaveFocus();
});