mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-07-29 09:37:37 +08:00
修复 添加供应商页面 搜索预设后无法点击选中搜索结果 (#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:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user