You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

React自定义搜索建议下拉框的Tab键导航可访问性实现

实现带Tab键导航可访问性的React搜索建议下拉框

要实现符合无障碍要求的Tab键导航,需要结合ARIA语义属性、手动焦点管理和键盘事件处理,以下是完整的实现方案:

核心实现思路

  • 用ARIA角色明确搜索框与下拉建议的关联关系,让屏幕阅读器能正确识别组件功能
  • 跟踪下拉框展开状态与当前焦点选项的索引,手动控制焦点流转
  • 处理Tab/Shift+Tab/上下箭头等键盘事件,实现符合预期的导航逻辑

完整代码示例

import "./styles.css";
import { useState, useRef, useEffect } from 'react';

export default function Search() {
  const [results, setResults] = useState([]);
  const [isDropdownOpen, setIsDropdownOpen] = useState(false);
  const [focusedIndex, setFocusedIndex] = useState(-1);
  const searchInputRef = useRef(null);

  // 输入框键盘事件处理
  const handleInputKeyDown = (e) => {
    if (!isDropdownOpen || results.length === 0) return;

    switch (e.key) {
      case 'Tab':
        // 按Tab时,将焦点转移到第一个建议项
        e.preventDefault();
        setFocusedIndex(0);
        break;
      case 'ArrowDown':
        e.preventDefault();
        // 下箭头:焦点移到下一个选项,循环到开头
        setFocusedIndex(prev => prev === results.length - 1 ? 0 : prev + 1);
        break;
      case 'ArrowUp':
        e.preventDefault();
        // 上箭头:焦点移到上一个选项,循环到末尾
        setFocusedIndex(prev => prev <= 0 ? results.length - 1 : prev - 1);
        break;
    }
  };

  // 建议项键盘事件处理
  const handleSuggestionKeyDown = (e, index) => {
    switch (e.key) {
      case 'Tab':
        e.preventDefault();
        if (index === results.length - 1) {
          // 最后一个选项按Tab:焦点移到搜索框后的下一个元素
          const nextElement = searchInputRef.current?.nextElementSibling;
          nextElement?.focus();
          setIsDropdownOpen(false);
          setFocusedIndex(-1);
        } else {
          // 非最后一个选项:焦点移到下一个建议项
          setFocusedIndex(index + 1);
        }
        break;
      case 'Shift':
        // 单独按Shift不处理,避免干扰Shift+Tab
        break;
      case 'Shift+Tab':
        e.preventDefault();
        if (index === 0) {
          // 第一个选项按Shift+Tab:焦点回到搜索框
          searchInputRef.current?.focus();
        } else {
          // 焦点移到上一个建议项
          setFocusedIndex(index - 1);
        }
        break;
      case 'ArrowDown':
        e.preventDefault();
        setFocusedIndex(prev => prev === results.length - 1 ? 0 : prev + 1);
        break;
      case 'ArrowUp':
        e.preventDefault();
        setFocusedIndex(prev => prev <= 0 ? results.length - 1 : prev - 1);
        break;
      case 'Enter':
        // 按Enter选中当前建议项
        handleSuggestionClick(results[index]);
        break;
    }
  };

  // 点击建议项处理
  const handleSuggestionClick = (suggestion) => {
    searchInputRef.current.value = suggestion;
    setIsDropdownOpen(false);
    setFocusedIndex(-1);
    // 可添加触发搜索等后续逻辑
  };

  // 监听搜索框失焦,若焦点不在下拉框内则关闭下拉
  useEffect(() => {
    const handleBlur = (e) => {
      const dropdown = document.getElementById('suggestion-dropdown');
      if (!dropdown?.contains(e.relatedTarget)) {
        setIsDropdownOpen(false);
        setFocusedIndex(-1);
      }
    };

    searchInputRef.current?.addEventListener('blur', handleBlur);
    return () => searchInputRef.current?.removeEventListener('blur', handleBlur);
  }, []);

  return (
    <div>
      <input
        ref={searchInputRef}
        placeholder="search"
        onChange={(e) => {
          const searchTerm = e.target.value.trim();
          // 模拟搜索接口调用,替换为你的实际逻辑
          if (searchTerm) {
            setResults(['First suggestion', 'Second suggestion', 'Third suggestion']);
            setIsDropdownOpen(true);
          } else {
            setResults([]);
            setIsDropdownOpen(false);
          }
        }}
        onKeyDown={handleInputKeyDown}
        type="text"
        id="search-input"
        aria-expanded={isDropdownOpen}
        aria-controls="suggestion-dropdown"
      />
      {isDropdownOpen && results.length > 0 && (
        <div
          className="my-suggestion-dropdown"
          role="listbox"
          id="suggestion-dropdown"
          aria-labelledby="suggestions-heading"
        >
          <h4 id="suggestions-heading">Suggestions</h4>
          {results.map((suggestion, index) => (
            <div
              key={index}
              role="option"
              aria-selected={focusedIndex === index}
              tabIndex={focusedIndex === index ? 0 : -1}
              onClick={() => handleSuggestionClick(suggestion)}
              onKeyDown={(e) => handleSuggestionKeyDown(e, index)}
              ref={(el) => {
                // 当焦点索引更新时,自动聚焦对应选项
                if (focusedIndex === index) el?.focus();
              }}
              style={focusedIndex === index ? { backgroundColor: '#f0f0f0' } : {}}
            >
              {suggestion}
            </div>
          ))}
        </div>
      )}
      {/* 示例:搜索框后的元素,用于测试Tab流转 */}
      <button id="next-element">搜索框后的按钮</button>
    </div>
  );
}

关键细节说明

  • ARIA语义化:

    • aria-expanded:告知屏幕阅读器下拉框的展开状态
    • aria-controls:关联搜索框与下拉框ID,明确控制关系
    • role="listbox"和role="option":将下拉框标记为标准选项列表,符合无障碍规范
    • aria-selected:标识当前聚焦的选项,让屏幕阅读器用户知晓选中状态
  • 焦点管理:

    • 动态设置tabIndex:仅当前聚焦的选项设为0(可被Tab聚焦),其余为-1(不可直接聚焦)
    • 自动聚焦:通过ref在焦点索引更新时,自动将焦点移到对应选项
    • Tab流转逻辑:遍历完所有选项后,焦点自然跳转到搜索框后的页面元素,符合用户预期
  • 键盘导航增强:

    • 支持上下箭头循环切换选项
    • Enter键可直接选中当前聚焦的建议
    • 搜索框失焦且焦点不在下拉框内时,自动关闭下拉

内容的提问来源于stack exchange,提问作者saim2025

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.07.15 02:32:37