From 81d6002ace328cf74c9b63e32b15279a7c445812 Mon Sep 17 00:00:00 2001 From: Ruixe Wolf Date: Tue, 16 Jun 2026 17:51:23 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=20=E6=B7=BB=E5=8A=A0?= =?UTF-8?q?=E4=BE=9B=E5=BA=94=E5=95=86=E9=A1=B5=E9=9D=A2=20=E6=90=9C?= =?UTF-8?q?=E7=B4=A2=E9=A2=84=E8=AE=BE=E5=90=8E=E6=97=A0=E6=B3=95=E7=82=B9?= =?UTF-8?q?=E5=87=BB=E9=80=89=E4=B8=AD=E6=90=9C=E7=B4=A2=E7=BB=93=E6=9E=9C?= =?UTF-8?q?=20(#4315)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(provider-preset-selector): after searching presets, it is impossible to select search results. feat(provider-preset-selector): add keyboard shortcut for search input and improve focus handling. * fix(provider-list): prevent keydown event from triggering when default is prevented * fix(provider-preset): keep preset clickable after search and restore keyboard UX - ProviderList: scope the typing guard to the Ctrl/Cmd+F branch so Escape still closes the search panel (the top-level early return swallowed it); reuse isTextEditableTarget instead of re-implementing the check. - ProviderPresetSelector: drop the rAF select() that raced with typing and ate the first character (gateway -> ateway), and restore the input autoFocus for the open-by-click path; refocus via rAF when Ctrl/Cmd+F is pressed while the box is already open so focus returns to the input. - Add a regression test for the re-focus-on-shortcut behavior. --------- Co-authored-by: Jason --- src/components/providers/ProviderList.tsx | 10 +- .../forms/ProviderPresetSelector.tsx | 112 ++++++++++-------- .../ProviderPresetSelector.test.tsx | 74 +++++++++++- 3 files changed, 141 insertions(+), 55 deletions(-) diff --git a/src/components/providers/ProviderList.tsx b/src/components/providers/ProviderList.tsx index a991a5564..62edad709 100644 --- a/src/components/providers/ProviderList.tsx +++ b/src/components/providers/ProviderList.tsx @@ -45,6 +45,7 @@ import { import { useCallback } from "react"; import { Input } from "@/components/ui/input"; import { Button } from "@/components/ui/button"; +import { isTextEditableTarget } from "@/utils/domUtils"; interface ProviderListProps { providers: Record; @@ -245,8 +246,13 @@ export function ProviderList({ useEffect(() => { const handleKeyDown = (event: KeyboardEvent) => { + if (event.defaultPrevented) return; + const key = event.key.toLowerCase(); if ((event.metaKey || event.ctrlKey) && key === "f") { + // 正在输入框/可编辑区域中时不抢占 Ctrl+F(例如添加供应商表单里 + // ProviderPresetSelector 的搜索框),避免与其同名快捷键冲突。 + if (isTextEditableTarget(document.activeElement)) return; event.preventDefault(); setIsSearchOpen(true); return; @@ -257,8 +263,8 @@ export function ProviderList({ } }; - window.addEventListener("keydown", handleKeyDown); - return () => window.removeEventListener("keydown", handleKeyDown); + globalThis.addEventListener("keydown", handleKeyDown); + return () => globalThis.removeEventListener("keydown", handleKeyDown); }, []); useEffect(() => { diff --git a/src/components/providers/forms/ProviderPresetSelector.tsx b/src/components/providers/forms/ProviderPresetSelector.tsx index 56177eae4..f283b4b05 100644 --- a/src/components/providers/forms/ProviderPresetSelector.tsx +++ b/src/components/providers/forms/ProviderPresetSelector.tsx @@ -123,7 +123,7 @@ export function ProviderPresetSelector({ onUniversalPresetSelect, onManageUniversalProviders, category, -}: ProviderPresetSelectorProps) { +}: Readonly) { const { t } = useTranslation(); const [searchOpen, setSearchOpen] = useState(false); const [searchQuery, setSearchQuery] = useState(""); @@ -131,6 +131,7 @@ export function ProviderPresetSelector({ PresetSortMode.Original, ); const searchContainerRef = useRef(null); + const searchInputRef = useRef(null); // 点击搜索区域外时收起并清空,对齐旧 Popover 的「点击外部关闭」行为 useEffect(() => { @@ -150,6 +151,25 @@ export function ProviderPresetSelector({ return () => document.removeEventListener("mousedown", handleClickOutside); }, [searchOpen]); + // 键盘快捷键: Ctrl/Cmd+F 打开搜索并聚焦输入框。 + // 使用捕获阶段并阻止冒泡,避免背后 ProviderList 的同名快捷键被意外触发。 + // 首次打开靠 Input 的 autoFocus 聚焦;若搜索已打开(例如点击 preset 后焦点 + // 停在按钮上),setSearchOpen(true) 同值不会重渲染、autoFocus 不重触发, + // 这里用 rAF 命令式地把焦点移回搜索框(不 select,避免吞掉随后输入的首字符)。 + useEffect(() => { + const handleKeyDown = (event: KeyboardEvent) => { + if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === "f") { + event.preventDefault(); + event.stopPropagation(); + setSearchOpen(true); + requestAnimationFrame(() => searchInputRef.current?.focus()); + } + }; + + globalThis.addEventListener("keydown", handleKeyDown, true); + return () => globalThis.removeEventListener("keydown", handleKeyDown, true); + }, []); + const visiblePresetEntries = useMemo( () => getVisiblePresetEntries(presetEntries, { @@ -258,12 +278,13 @@ export function ProviderPresetSelector({ }; return ( -
+
{t("providerPreset.label")} -
+
{searchOpen && ( setSearchQuery(event.target.value)} onKeyDown={(event) => { @@ -278,7 +299,7 @@ export function ProviderPresetSelector({ aria-label={t("providerPreset.searchAriaLabel", { defaultValue: "Search provider presets", })} - className="w-48 h-8" + className="w-60 h-8" autoFocus /> )} @@ -387,50 +408,47 @@ export function ProviderPresetSelector({
{onUniversalPresetSelect && universalProviderPresets.length > 0 && ( - <> -
- {universalProviderPresets.map((preset) => ( - + ))} + {onManageUniversalProviders && ( + - ))} - {onManageUniversalProviders && ( - - )} -
- + + + )} +
)}

{getCategoryHint()}

diff --git a/tests/components/ProviderPresetSelector.test.tsx b/tests/components/ProviderPresetSelector.test.tsx index 26c44a3a9..a084667ad 100644 --- a/tests/components/ProviderPresetSelector.test.tsx +++ b/tests/components/ProviderPresetSelector.test.tsx @@ -1,4 +1,4 @@ -import { render, screen } from "@testing-library/react"; +import { render, screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { describe, expect, it, vi } from "vitest"; import type { TFunction } from "i18next"; @@ -421,18 +421,80 @@ describe("ProviderPresetSelector", () => { ).toBeInTheDocument(); }); - it("点击搜索区域外自动收起并清空", async () => { + it("按 Ctrl+F 快捷键打开搜索输入框", async () => { + const user = userEvent.setup(); + renderSelector(); + + // 初始没有搜索输入框 + expect( + screen.queryByRole("textbox", { + name: /providerPreset\.(searchInput|searchPlaceholder)|搜索预设|search/i, + }), + ).not.toBeInTheDocument(); + + // 按 Ctrl+F 展开输入框 + await user.keyboard("{Control>}f{/Control}"); + expect(getSearchInput()).toBeInTheDocument(); + }); + + it("搜索后点击预设按钮可选中预设且不清空搜索关键词", async () => { + const user = userEvent.setup(); + const onPresetChange = vi.fn(); + renderSelector({ onPresetChange }); + + await user.click(getSearchButton()); + await user.type(getSearchInput(), "gateway"); + + await user.click(screen.getByRole("button", { name: "Beta Gateway" })); + + expect(onPresetChange).toHaveBeenCalledWith("beta"); + // 搜索框仍展开、关键词保留 + expect(getSearchInput()).toBeInTheDocument(); + expect(getSearchInput()).toHaveValue("gateway"); + }); + + it("搜索已打开、焦点在别处时再次 Ctrl+F 把焦点移回搜索框且保留关键词", async () => { const user = userEvent.setup(); renderSelector(); + await user.click(getSearchButton()); + await user.type(getSearchInput(), "gateway"); + + // 选中 preset 后焦点离开搜索框(搜索框仍展开、关键词保留) + await user.click(screen.getByRole("button", { name: "Beta Gateway" })); + expect(getSearchInput()).not.toHaveFocus(); + + // 再次 Ctrl+F:setSearchOpen(true) 同值不重渲染、autoFocus 不重触发, + // 需靠快捷键命中时的命令式聚焦把焦点移回搜索框,且不清空关键词 + await user.keyboard("{Control>}f{/Control}"); + await waitFor(() => expect(getSearchInput()).toHaveFocus()); + expect(getSearchInput()).toHaveValue("gateway"); + }); + + it("点击组件外区域自动收起并清空", async () => { + const user = userEvent.setup(); + const Wrapper = () => { + const form = useForm(); + return ( +
+ +
Outside
+ + ); + }; + render(); + await user.click(getSearchButton()); await user.type(getSearchInput(), "gateway"); expect(getSearchInput()).toBeInTheDocument(); - // 点击搜索区域外的元素(custom 按钮)应收起搜索框 - await user.click( - screen.getByRole("button", { name: "providerPreset.custom" }), - ); + // 点击组件外的元素应收起搜索框 + await user.click(screen.getByTestId("outside")); expect( screen.queryByRole("textbox", {