界面已本地化;技能介绍与说明文档按所选语言由 AI 自动翻译,代码、命令、路径和专业术语保留原文,以原作者文档为准。
探索技能
效率与协作 · SKILL.md

keyboard-navigation-focus-management

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 快照 · 自动分类 · 以原作者文档为准

技能说明

GitHub

name: keyboard-navigation-focus-management description: "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." license: MIT compatibility: opencode metadata: version: "1.0.0" domain: coding role: implementation scope: implementation output-format: code content-types: - code - patterns - examples triggers: keyboard navigation, focus management, roving tabindex, focus trap, visible focus, tabindex, Escape key, keyboard events related-skills: semantic-html-aria-accessibility-tree, react-accessibility-components-patterns archetypes: - tactical - implementation anti_triggers: - mouse-only design - brainstorming - styling-only response_profile: verbosity: low directive_strength: high abstraction_level: operational

Keyboard Navigation & Focus Management

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.

TL;DR Checklist

  • Make all interactive elements reachable via Tab key
  • Implement visible :focus-visible indicator (never outline: none)
  • Use tabindex="0" to include in natural tab order
  • Use tabindex="-1" for elements focusable by script only
  • Implement roving tabindex for lists/grids (only one in tab order)
  • Trap focus in modals (Tab at end jumps to first)
  • Restore focus when modal closes
  • Handle Escape key to close modals/dropdowns
  • Implement skip links for keyboard users
  • Test with Tab, Shift+Tab, Arrow keys, Enter, Space, Escape

When to Use

Use this skill when:

  • Building interactive components (menu, dialog, tabs, listbox, combobox)
  • Fixing keyboard navigation issues in existing components
  • Implementing focus management in modals or complex UIs
  • Adding keyboard shortcut handlers
  • Ensuring Tab key navigates through page logically
  • Testing keyboard-only navigation

When NOT to Use

Avoid this skill for:

  • Basic HTML forms (native behavior sufficient)
  • Styling focus indicators (that's CSS-focused)
  • Framework-specific patterns (use React/Vue-specific skills)
  • Screen reader testing (different skill)

Keyboard Navigation Foundation

Understanding Focus

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>
  • Elements with tabindex="0" or positive tabindex
  • Elements with tabindex="-1" (focusable by script only)

Not focusable by default:

  • <div>, <span>, <p>, <h1> (etc.)
  • Disabled form elements

Tab Order

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 -->

Implementation Patterns

Pattern 1: Focus Visible Indicator

/* ✅ 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).

Pattern 2: Making Elements Focusable

<!-- 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>

Pattern 3: Roving Tabindex for Lists/Grids

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:

  • Only active item has tabindex="0" (in tab order)
  • Other items have tabindex="-1" (focusable by script)
  • Arrow keys change which item is active
  • Active item automatically focused

Pattern 4: Focus Trap in Modal

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>
    )
  );
}

Pattern 5: Focus Restoration on Close

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>
      )}
    </>
  );
}

Pattern 6: Keyboard Event Handlers

// 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>
  );
}

Pattern 7: Skip Links

<!-- 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>

Keyboard Event Reference

Standard Keys for Web Components

KeyElementAction
EnterButton, LinkActivate
SpaceButton, CheckboxToggle/Activate
EscapeModal, Menu, PopupClose
TabAnyMove focus to next
Shift+TabAnyMove focus to previous
↑ ↓ ← →Menu, List, Tree, TabsNavigate
HomeMenu, ListJump to first
EndMenu, ListJump to last

Preventing Default Behavior

function handleKeyDown(e: React.KeyboardEvent) {
  if (e.key === 'Enter') {
    e.preventDefault(); // Prevent form submission
    // Custom behavior here
  }
}

Focus Management Checklist

Implementation:

  • All interactive elements keyboard accessible
  • Tab order logical (matches visual layout)
  • Focus indicator visible (3:1 contrast minimum)
  • No keyboard traps (Tab can always escape)
  • Modals trap focus (Tab loops within modal)
  • Focus restored when modal closes
  • Arrow keys work in lists/menus (roving tabindex)
  • Escape closes modals/dropdowns
  • Skip link bypasses navigation

Testing:

  • Tab through entire page
  • Verify tab order matches visual order
  • Test focus trap in modal (Tab doesn't escape)
  • Verify focus restoration on close
  • Test keyboard shortcuts (Enter, Space, Escape, Arrows)
  • Test with keyboard only (no mouse)
  • Test with screen reader (focus announcements)

Constraints

MUST DO

  • Make all interactive elements keyboard accessible (Tab key)
  • Implement visible :focus-visible indicator (never just outline: none)
  • Use tabindex="0" for elements that need to be in tab order
  • Use tabindex="-1" for elements focusable by script only
  • Trap focus in modals (Tab wraps to first/last)
  • Restore focus when closing modals
  • Handle Escape key to close modals/dropdowns
  • Test keyboard-only navigation thoroughly

MUST NOT DO

  • Remove focus indicators without replacement (breaks keyboard access)
  • Use tabindex > 0 (causes confusion; fix DOM order instead)
  • Trap focus without providing escape (Escape key must work)
  • Assume mouse users find all interactive elements
  • Leave modals without focus management
  • Skip testing keyboard navigation in QA
  • Use onBlur to prevent focus management (breaks assistive tech)

Common Keyboard Navigation Bugs

Bug 1: No Visible Focus Indicator

/* ❌ BAD: Focus invisible -->
button:focus { outline: none; }

/* ✅ FIX: Provide visible indicator -->
button:focus-visible { outline: 3px solid #2563eb; }

Bug 2: Keyboard Trap (Can't Escape)

// ❌ 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>;
}

Bug 3: Focus Not Restored

// ❌ 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();
};

Testing Keyboard Navigation

Manual Testing Steps

  1. Tab through entire page — Verify order matches visual layout
  2. Use only keyboard — Close trackpad, use only keyboard
  3. Test modals — Tab in modal, Escape closes, focus restores
  4. Test menus — Arrow keys navigate, Enter selects, Escape closes
  5. Test lists — Home/End jump to edges, Arrow keys navigate
  6. Verify focus visible — Can always see current focus

Automated Testing

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();
});
我的收藏