diff --git a/src/components/providers/forms/ProviderPresetSelector.tsx b/src/components/providers/forms/ProviderPresetSelector.tsx index 29ca675f6..56177eae4 100644 --- a/src/components/providers/forms/ProviderPresetSelector.tsx +++ b/src/components/providers/forms/ProviderPresetSelector.tsx @@ -1,19 +1,8 @@ -import { useMemo, useState, type ReactNode } from "react"; +import { useEffect, useMemo, useRef, useState, type ReactNode } from "react"; import { useTranslation } from "react-i18next"; import { FormLabel } from "@/components/ui/form"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; -import { - Popover, - PopoverContent, - PopoverTrigger, -} from "@/components/ui/popover"; -import { - Tooltip, - TooltipContent, - TooltipProvider, - TooltipTrigger, -} from "@/components/ui/tooltip"; import { ClaudeIcon, CodexIcon, GeminiIcon } from "@/components/BrandIcons"; import { ArrowUpAZ, Search, Zap, Star, Layers, Settings2 } from "lucide-react"; import type { ProviderPreset } from "@/config/claudeProviderPresets"; @@ -141,6 +130,25 @@ export function ProviderPresetSelector({ const [sortMode, setSortMode] = useState( PresetSortMode.Original, ); + const searchContainerRef = useRef(null); + + // 点击搜索区域外时收起并清空,对齐旧 Popover 的「点击外部关闭」行为 + useEffect(() => { + if (!searchOpen) return; + + const handleClickOutside = (event: MouseEvent) => { + if ( + searchContainerRef.current && + !searchContainerRef.current.contains(event.target as Node) + ) { + setSearchOpen(false); + setSearchQuery(""); + } + }; + + document.addEventListener("mousedown", handleClickOutside); + return () => document.removeEventListener("mousedown", handleClickOutside); + }, [searchOpen]); const visiblePresetEntries = useMemo( () => @@ -195,26 +203,38 @@ export function ProviderPresetSelector({ }; const renderPresetIcon = (preset: AnyPreset) => { - const iconType = preset.theme?.icon; - if (!iconType) return null; - - switch (iconType) { - case "claude": - return ; - case "codex": - return ; - case "gemini": - return ; - case "generic": - return ; - default: - return null; + if (preset.icon) { + return ( + + ); } + + const iconType = preset.theme?.icon; + if (iconType) { + switch (iconType) { + case "claude": + return ; + case "codex": + return ; + case "gemini": + return ; + case "generic": + return ; + } + } + + return ; }; const getPresetButtonClass = (isSelected: boolean, preset: AnyPreset) => { const baseClass = - "inline-flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium transition-colors"; + "inline-flex items-center justify-start gap-2 px-3 py-2 rounded-lg text-sm font-medium transition-colors w-full"; if (isSelected) { if (preset.theme?.backgroundColor) { @@ -241,101 +261,95 @@ export function ProviderPresetSelector({
{t("providerPreset.label")} - -
- - - - - - - - - {t("providerPreset.searchTooltip", { - defaultValue: "Search presets", - })} - - - - setSearchQuery(event.target.value)} - placeholder={t("providerPreset.searchPlaceholder", { - defaultValue: "Search presets...", - })} - aria-label={t("providerPreset.searchAriaLabel", { - defaultValue: "Search provider presets", - })} - autoFocus - /> - - +
+ {searchOpen && ( + setSearchQuery(event.target.value)} + onKeyDown={(event) => { + if (event.key === "Escape") { + setSearchQuery(""); + setSearchOpen(false); + } + }} + placeholder={t("providerPreset.searchPlaceholder", { + defaultValue: "Search presets...", + })} + aria-label={t("providerPreset.searchAriaLabel", { + defaultValue: "Search provider presets", + })} + className="w-48 h-8" + autoFocus + /> + )} + - - - - - - {sortMode === PresetSortMode.NameAsc - ? t("providerPreset.sortOriginalTooltip", { - defaultValue: "Restore original order", - }) - : t("providerPreset.sortNameAscTooltip", { - defaultValue: "Sort A-Z", - })} - - -
- + +
-
+
{visiblePresetEntries.length === 0 && ( -
+
{t("providerPreset.noSearchResults", { defaultValue: "No matching presets.", })} @@ -359,7 +373,9 @@ export function ProviderPresetSelector({ } > {renderPresetIcon(entry.preset)} - {getPresetDisplayName(entry.preset, t)} + + {getPresetDisplayName(entry.preset, t)} + {isPartner && ( @@ -372,20 +388,25 @@ export function ProviderPresetSelector({ {onUniversalPresetSelect && universalProviderPresets.length > 0 && ( <> -
+
{universalProviderPresets.map((preset) => ( )}
diff --git a/tests/components/ProviderPresetSelector.test.tsx b/tests/components/ProviderPresetSelector.test.tsx index 005161d2a..26c44a3a9 100644 --- a/tests/components/ProviderPresetSelector.test.tsx +++ b/tests/components/ProviderPresetSelector.test.tsx @@ -15,6 +15,29 @@ import { type PresetSortMode, } from "@/components/providers/forms/ProviderPresetSelector"; +// Mock ProviderIcon 以避免依赖图标库的实际内容 +vi.mock("@/components/ProviderIcon", () => ({ + ProviderIcon: ({ + icon, + name, + color, + size, + }: { + icon?: string; + name: string; + color?: string; + size?: number; + }) => ( + + ), +})); + const presetCategoryLabels = { official: "官方", cn_official: "国产官方", @@ -295,4 +318,130 @@ describe("ProviderPresetSelector", () => { ), ).toBeInTheDocument(); }); + + it("所有预设按钮填满网格列宽(w-full)实现等宽对齐", () => { + renderSelector(); + + const presetButtons = screen.getAllByRole("button"); + const fullWidthButtons = presetButtons.filter((btn) => + btn.className.includes("w-full"), + ); + + // 至少包含 custom + 4 个预设 = 5 个等宽按钮(搜索/排序按钮为 size-8 不计入) + expect(fullWidthButtons.length).toBeGreaterThanOrEqual(5); + }); + + it("preset.icon 存在时按钮内渲染图标元素(img/svg)", () => { + const entriesWithIcon = [ + { + id: "with-icon", + preset: { + name: "With Icon", + websiteUrl: "https://icon.example.com", + settingsConfig: {}, + category: "official" as ProviderCategory, + icon: "claude-api", + iconColor: "#D4915D", + }, + }, + ]; + + renderSelector({ entries: entriesWithIcon }); + + const button = screen.getByRole("button", { name: /with icon/i }); + const icon = button.querySelector('[data-testid="provider-icon"]'); + expect(icon).not.toBeNull(); + expect(icon?.getAttribute("data-icon")).toBe("claude-api"); + expect(icon?.getAttribute("data-color")).toBe("#D4915D"); + }); + + it("preset 无 icon 且无 theme.icon 时,按钮内仍渲染占位元素保持文字对齐", () => { + const entriesWithoutIcon = [ + { + id: "no-icon", + preset: { + name: "No Icon", + websiteUrl: "https://noicon.example.com", + settingsConfig: {}, + category: "official" as ProviderCategory, + }, + }, + ]; + + renderSelector({ entries: entriesWithoutIcon }); + + const button = screen.getByRole("button", { name: /no icon/i }); + // 占位 span(16x16)应该存在,保证文字位置与有图标的按钮对齐 + const placeholder = button.querySelector("span[aria-hidden]"); + expect(placeholder).not.toBeNull(); + }); + + it("custom 按钮同样渲染占位元素,文字与带图标的预设按钮对齐", () => { + renderSelector(); + + const customButton = screen.getByRole("button", { + name: "providerPreset.custom", + }); + const placeholder = customButton.querySelector("span[aria-hidden]"); + expect(placeholder).not.toBeNull(); + }); + + it("点击放大镜 inline 切换搜索输入框可见性,ESC 收起并清空", async () => { + const user = userEvent.setup(); + renderSelector(); + + // 初始没有搜索输入框 + expect( + screen.queryByRole("textbox", { + name: /providerPreset\.(searchInput|searchPlaceholder)|搜索预设|search/i, + }), + ).not.toBeInTheDocument(); + + // 点击放大镜展开输入框 + await user.click(getSearchButton()); + const input = getSearchInput(); + expect(input).toBeInTheDocument(); + + // 输入关键字过滤 + await user.type(input, "gateway"); + expect( + screen.getByRole("button", { name: "Beta Gateway" }), + ).toBeInTheDocument(); + + // ESC 收起输入框并清空 + await user.keyboard("{Escape}"); + expect( + screen.queryByRole("textbox", { + name: /providerPreset\.(searchInput|searchPlaceholder)|搜索预设|search/i, + }), + ).not.toBeInTheDocument(); + // 收起后所有预设恢复显示 + expect( + screen.getByRole("button", { name: "preset.gamma" }), + ).toBeInTheDocument(); + }); + + it("点击搜索区域外自动收起并清空", async () => { + const user = userEvent.setup(); + renderSelector(); + + await user.click(getSearchButton()); + await user.type(getSearchInput(), "gateway"); + expect(getSearchInput()).toBeInTheDocument(); + + // 点击搜索区域外的元素(custom 按钮)应收起搜索框 + await user.click( + screen.getByRole("button", { name: "providerPreset.custom" }), + ); + + expect( + screen.queryByRole("textbox", { + name: /providerPreset\.(searchInput|searchPlaceholder)|搜索预设|search/i, + }), + ).not.toBeInTheDocument(); + // 收起后清空 query,所有预设恢复显示 + expect( + screen.getByRole("button", { name: "preset.gamma" }), + ).toBeInTheDocument(); + }); });