修复 添加供应商页面 搜索预设后无法点击选中搜索结果 (#4315)

* 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 <farion1231@gmail.com>
This commit is contained in:
Ruixe Wolf
2026-06-16 17:51:23 +08:00
committed by GitHub
parent caa912e3a3
commit 81d6002ace
3 changed files with 141 additions and 55 deletions
+8 -2
View File
@@ -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<string, Provider>;
@@ -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(() => {
@@ -123,7 +123,7 @@ export function ProviderPresetSelector({
onUniversalPresetSelect,
onManageUniversalProviders,
category,
}: ProviderPresetSelectorProps) {
}: Readonly<ProviderPresetSelectorProps>) {
const { t } = useTranslation();
const [searchOpen, setSearchOpen] = useState(false);
const [searchQuery, setSearchQuery] = useState("");
@@ -131,6 +131,7 @@ export function ProviderPresetSelector({
PresetSortMode.Original,
);
const searchContainerRef = useRef<HTMLDivElement>(null);
const searchInputRef = useRef<HTMLInputElement>(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 (
<div className="space-y-3">
<div ref={searchContainerRef} className="space-y-3">
<div className="flex items-center justify-between gap-2">
<FormLabel>{t("providerPreset.label")}</FormLabel>
<div ref={searchContainerRef} className="flex items-center gap-2">
<div className="flex items-center gap-2">
{searchOpen && (
<Input
ref={searchInputRef}
value={searchQuery}
onChange={(event) => 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({
</div>
{onUniversalPresetSelect && universalProviderPresets.length > 0 && (
<>
<div className="grid grid-cols-[repeat(auto-fill,minmax(150px,1fr))] gap-2">
{universalProviderPresets.map((preset) => (
<button
key={`universal-${preset.providerType}`}
type="button"
onClick={() => onUniversalPresetSelect(preset)}
className="inline-flex items-center justify-start gap-2 px-3 py-2 rounded-lg text-sm font-medium transition-colors bg-accent text-muted-foreground hover:bg-accent/80 relative w-full"
title={t("universalProvider.hint", {
defaultValue:
"跨应用统一配置,自动同步到 Claude/Codex/Gemini",
<div className="grid grid-cols-[repeat(auto-fill,minmax(150px,1fr))] gap-2">
{universalProviderPresets.map((preset) => (
<button
key={`universal-${preset.providerType}`}
type="button"
onClick={() => onUniversalPresetSelect(preset)}
className="inline-flex items-center justify-start gap-2 px-3 py-2 rounded-lg text-sm font-medium transition-colors bg-accent text-muted-foreground hover:bg-accent/80 relative w-full"
title={t("universalProvider.hint", {
defaultValue: "跨应用统一配置,自动同步到 Claude/Codex/Gemini",
})}
>
<ProviderIcon
icon={preset.icon}
name={preset.name}
size={14}
className="flex-shrink-0"
/>
<span className="truncate">{preset.name}</span>
<span className="absolute -top-1 -right-1 flex items-center gap-0.5 rounded-full bg-gradient-to-r from-indigo-500 to-purple-500 px-1.5 py-0.5 text-[10px] font-bold text-white shadow-md">
<Layers className="h-2.5 w-2.5" />
</span>
</button>
))}
{onManageUniversalProviders && (
<button
type="button"
onClick={onManageUniversalProviders}
className="inline-flex items-center justify-start gap-2 px-3 py-2 rounded-lg text-sm font-medium transition-colors bg-accent text-muted-foreground hover:bg-accent/80 w-full"
title={t("universalProvider.manage", {
defaultValue: "管理统一供应商",
})}
>
<Settings2 className="h-4 w-4 flex-shrink-0" />
<span className="truncate">
{t("universalProvider.manage", {
defaultValue: "管理",
})}
>
<ProviderIcon
icon={preset.icon}
name={preset.name}
size={14}
className="flex-shrink-0"
/>
<span className="truncate">{preset.name}</span>
<span className="absolute -top-1 -right-1 flex items-center gap-0.5 rounded-full bg-gradient-to-r from-indigo-500 to-purple-500 px-1.5 py-0.5 text-[10px] font-bold text-white shadow-md">
<Layers className="h-2.5 w-2.5" />
</span>
</button>
))}
{onManageUniversalProviders && (
<button
type="button"
onClick={onManageUniversalProviders}
className="inline-flex items-center justify-start gap-2 px-3 py-2 rounded-lg text-sm font-medium transition-colors bg-accent text-muted-foreground hover:bg-accent/80 w-full"
title={t("universalProvider.manage", {
defaultValue: "管理统一供应商",
})}
>
<Settings2 className="h-4 w-4 flex-shrink-0" />
<span className="truncate">
{t("universalProvider.manage", {
defaultValue: "管理",
})}
</span>
</button>
)}
</div>
</>
</span>
</button>
)}
</div>
)}
<p className="text-xs text-muted-foreground">{getCategoryHint()}</p>
@@ -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+FsetSearchOpen(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 (
<Form {...form}>
<ProviderPresetSelector
selectedPresetId="custom"
presetEntries={presetEntries}
presetCategoryLabels={presetCategoryLabels}
onPresetChange={vi.fn()}
/>
<div data-testid="outside">Outside</div>
</Form>
);
};
render(<Wrapper />);
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", {