Skip to content

键盘导航优化 - 完整键盘可访问性指南

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-react
tsx
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. 总结

键盘导航优化的关键要点:

  1. 完全可访问: 所有功能必须可键盘操作
  2. 标准交互: 遵循ARIA Authoring Practices
  3. 焦点管理: 正确的Tab顺序和焦点陷阱
  4. 视觉反馈: 清晰的焦点指示器
  5. 快捷键: 提供高效的键盘快捷键
  6. 测试验证: 持续测试键盘可访问性

通过优化键盘导航,可以让应用对所有用户都易于使用。