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

如何为React Select组件添加键盘导航功能(上下箭头、Tab、Enter)

实现React Select组件的键盘导航功能

先修正基础问题:你的状态初始值类型不符合需求,isOpen应该是布尔值,focusedIndex作为选项索引应该用数字(初始设为-1表示未选中任何选项),先把这部分调整正确:

const [isOpen, setIsOpen] = useState(false);
const [search, setSearch] = useState("");
const [focusedIndex, setFocusedIndex] = useState(-1);

接下来完善键盘导航逻辑,处理所有需要的按键行为:

const keyboardNavigation = (event: React.KeyboardEvent<HTMLDivElement>) => {
  // 阻止默认行为,避免页面滚动、表单提交等干扰
  const preventDefault = () => {
    event.preventDefault();
    event.stopPropagation();
  };

  switch (event.key) {
    case "ArrowDown":
      preventDefault();
      setIsOpen(true);
      // 循环切换到下一个选项,到末尾后回到开头
      setFocusedIndex(prev => {
        const nextIndex = prev + 1;
        return nextIndex >= searchResults.length ? 0 : nextIndex;
      });
      break;
    case "ArrowUp":
      preventDefault();
      setIsOpen(true);
      // 循环切换到上一个选项,到开头后回到末尾
      setFocusedIndex(prev => {
        const prevIndex = prev - 1;
        return prevIndex < 0 ? searchResults.length - 1 : prevIndex;
      });
      break;
    case "Escape":
      preventDefault();
      setIsOpen(false);
      setFocusedIndex(-1);
      // 把焦点放回输入框
      (event.currentTarget.querySelector("input") as HTMLInputElement)?.focus();
      break;
    case "Enter":
      preventDefault();
      // 有选中选项时触发选择逻辑
      if (focusedIndex >= 0 && focusedIndex < searchResults.length) {
        const selectedOption = searchResults[focusedIndex];
        setSearch(selectedOption.value.toString());
        setIsOpen(false);
        setFocusedIndex(-1);
      }
      break;
    case "Tab":
      // Tab键默认切换焦点,这里仅关闭下拉框
      setIsOpen(false);
      setFocusedIndex(-1);
      break;
  }
};

然后优化选项渲染,添加焦点样式、处理选项自身的键盘事件,同时在选中索引变化时自动滚动到对应选项提升体验:

首先添加自动滚动的副作用:

useEffect(() => {
  if (isOpen && focusedIndex >= 0) {
    const optionElement = document.querySelector(`[data-index="${focusedIndex}"]`);
    optionElement?.scrollIntoView({ block: "nearest" });
  }
}, [isOpen, focusedIndex]);

修改选项渲染代码:

{searchResults.map((option, index) => {
  const isFocused = index === focusedIndex;
  return (
    <div
      key={option.label}
      data-index={index}
      tabIndex={0}
      className={`option-item ${isFocused ? "focused" : ""}`}
      onClick={() => {
        setIsOpen(false);
        setSearch(option.value.toString());
        setFocusedIndex(-1);
      }}
      onKeyDown={(e) => {
        // 选项上按Enter也触发选择
        if (e.key === "Enter") {
          setIsOpen(false);
          setSearch(option.value.toString());
          setFocusedIndex(-1);
        }
      }}
    >
      {option.value}
    </div>
  );
})}

可以补充简单的CSS样式区分选中状态:

.option-item {
  padding: 8px;
  cursor: pointer;
}
.option-item.focused {
  background-color: #e6f7ff;
  outline: none;
}

最后整合完整的组件代码:

import { useState, useMemo, useEffect } from "react";

type OptionType = {
  label: string;
  value: string | number;
};

type SelectProps = {
  options: OptionType[];
  // 其他props按需补充
};

export function SelectComponent({ options }: SelectProps) {
  const [isOpen, setIsOpen] = useState(false);
  const [search, setSearch] = useState("");
  const [focusedIndex, setFocusedIndex] = useState(-1);

  const searchResults = useMemo(() => {
    if (!search) return options;

    return options.filter(item =>
      item.value.toString().toLowerCase().includes(search.toLowerCase())
    );
  }, [options, search]);

  useEffect(() => {
    if (isOpen && focusedIndex >= 0) {
      const optionElement = document.querySelector(`[data-index="${focusedIndex}"]`);
      optionElement?.scrollIntoView({ block: "nearest" });
    }
  }, [isOpen, focusedIndex]);

  const keyboardNavigation = (event: React.KeyboardEvent<HTMLDivElement>) => {
    const preventDefault = () => {
      event.preventDefault();
      event.stopPropagation();
    };

    switch (event.key) {
      case "ArrowDown":
        preventDefault();
        setIsOpen(true);
        setFocusedIndex(prev => {
          const nextIndex = prev + 1;
          return nextIndex >= searchResults.length ? 0 : nextIndex;
        });
        break;
      case "ArrowUp":
        preventDefault();
        setIsOpen(true);
        setFocusedIndex(prev => {
          const prevIndex = prev - 1;
          return prevIndex < 0 ? searchResults.length - 1 : prevIndex;
        });
        break;
      case "Escape":
        preventDefault();
        setIsOpen(false);
        setFocusedIndex(-1);
        (event.currentTarget.querySelector("input") as HTMLInputElement)?.focus();
        break;
      case "Enter":
        preventDefault();
        if (focusedIndex >= 0 && focusedIndex < searchResults.length) {
          const selectedOption = searchResults[focusedIndex];
          setSearch(selectedOption.value.toString());
          setIsOpen(false);
          setFocusedIndex(-1);
        }
        break;
      case "Tab":
        setIsOpen(false);
        setFocusedIndex(-1);
        break;
    }
  };

  return (
    <div onKeyDown={keyboardNavigation} style={{ position: "relative", width: "200px" }}>
      <input
        onClick={() => setIsOpen(!isOpen)}
        value={search}
        onChange={event => setSearch(event.target.value)}
        style={{ width: "100%", padding: "8px" }}
      />
      {isOpen && (
        <div style={{ 
          position: "absolute", 
          top: "100%", 
          left: 0, 
          right: 0, 
          border: "1px solid #ddd", 
          maxHeight: "200px", 
          overflowY: "auto",
          backgroundColor: "#fff"
        }}>
          {searchResults.map((option, index) => {
            const isFocused = index === focusedIndex;
            return (
              <div
                key={option.label}
                data-index={index}
                tabIndex={0}
                className={`option-item ${isFocused ? "focused" : ""}`}
                onClick={() => {
                  setIsOpen(false);
                  setSearch(option.value.toString());
                  setFocusedIndex(-1);
                }}
                onKeyDown={(e) => {
                  if (e.key === "Enter") {
                    setIsOpen(false);
                    setSearch(option.value.toString());
                    setFocusedIndex(-1);
                  }
                }}
              >
                {option.value}
              </div>
            );
          })}
        </div>
      )}
    </div>
  );
}

这样就完整实现了箭头上下导航、Enter选中、Escape关闭、Tab切换时关闭下拉框的功能,同时包含焦点样式和自动滚动的体验优化。

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

相关产品推荐
方舟 Agent Plan

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

最近更新时间:2026.08.03 07:55:19