调整预设供应商按钮外观与搜索框位置 (#4183)

* 调整预设供应商按钮外观与搜索框位置

1. 调整预设供应商按钮外观,显示默认图标,大小统一;
2. 调整预设供应商搜索框位置。

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* test(provider): 新增预设按钮外观与 inline 搜索的单元测试

覆盖:
1. 所有预设按钮固定 200px 宽度,视觉对齐一致
2. preset.icon 存在时按钮内渲染 ProviderIcon
3. preset 无 icon 且无 theme.icon 时渲染占位元素保持文字对齐
4. 点击放大镜 inline 切换搜索输入框可见性,ESC 收起并清空

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* refactor(provider-preset): responsive grid layout and search polish

- Replace fixed-width preset buttons with a responsive CSS grid (auto-fill, 150px min column)
- Add a leading placeholder to the custom button so its label aligns with iconed presets
- Close the inline search box on outside click, restoring the old Popover behavior
- Span the empty-state hint across the full grid row
- Update component tests for the new layout and behaviors

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Co-authored-by: Jason <farion1231@gmail.com>
This commit is contained in:
WangJiati
2026-06-14 18:18:43 +08:00
committed by GitHub
parent 0c46efe1be
commit b7ad1c4bf8
2 changed files with 291 additions and 119 deletions
@@ -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>(
PresetSortMode.Original,
);
const searchContainerRef = useRef<HTMLDivElement>(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 <ClaudeIcon size={14} />;
case "codex":
return <CodexIcon size={14} />;
case "gemini":
return <GeminiIcon size={14} />;
case "generic":
return <Zap size={14} />;
default:
return null;
if (preset.icon) {
return (
<ProviderIcon
icon={preset.icon}
name={preset.name}
color={preset.iconColor}
size={16}
className="flex-shrink-0"
/>
);
}
const iconType = preset.theme?.icon;
if (iconType) {
switch (iconType) {
case "claude":
return <ClaudeIcon size={14} />;
case "codex":
return <CodexIcon size={14} />;
case "gemini":
return <GeminiIcon size={14} />;
case "generic":
return <Zap size={14} />;
}
}
return <span className="inline-block w-4 h-4 flex-shrink-0" aria-hidden />;
};
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({
<div className="space-y-3">
<div className="flex items-center justify-between gap-2">
<FormLabel>{t("providerPreset.label")}</FormLabel>
<TooltipProvider delayDuration={300}>
<div className="flex items-center gap-1">
<Popover open={searchOpen} onOpenChange={setSearchOpen}>
<Tooltip>
<TooltipTrigger asChild>
<PopoverTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon"
aria-label={t("providerPreset.searchAriaLabel", {
defaultValue: "Search provider presets",
})}
className={
searchQuery.trim()
? "size-8 bg-accent text-foreground"
: "size-8"
}
>
<Search className="size-4" />
</Button>
</PopoverTrigger>
</TooltipTrigger>
<TooltipContent>
{t("providerPreset.searchTooltip", {
defaultValue: "Search presets",
})}
</TooltipContent>
</Tooltip>
<PopoverContent
align="end"
className="w-72 p-2 border-border-default"
>
<Input
value={searchQuery}
onChange={(event) => setSearchQuery(event.target.value)}
placeholder={t("providerPreset.searchPlaceholder", {
defaultValue: "Search presets...",
})}
aria-label={t("providerPreset.searchAriaLabel", {
defaultValue: "Search provider presets",
})}
autoFocus
/>
</PopoverContent>
</Popover>
<div ref={searchContainerRef} className="flex items-center gap-2">
{searchOpen && (
<Input
value={searchQuery}
onChange={(event) => 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
/>
)}
<Button
type="button"
variant="ghost"
size="icon"
aria-label={t("providerPreset.searchAriaLabel", {
defaultValue: "Search provider presets",
})}
aria-pressed={searchOpen}
onClick={() => {
setSearchOpen((v) => !v);
if (searchOpen) setSearchQuery("");
}}
title={t("providerPreset.searchTooltip", {
defaultValue: "Search presets",
})}
className={
searchOpen || searchQuery.trim()
? "size-8 bg-accent text-foreground"
: "size-8"
}
>
<Search className="size-4" />
</Button>
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon"
aria-label={t("providerPreset.sortAriaLabel", {
defaultValue: "Toggle preset sorting",
})}
aria-pressed={sortMode === PresetSortMode.NameAsc}
onClick={toggleSortMode}
className={
sortMode === PresetSortMode.NameAsc
? "size-8 bg-accent text-foreground"
: "size-8"
}
>
<ArrowUpAZ className="size-4" />
</Button>
</TooltipTrigger>
<TooltipContent>
{sortMode === PresetSortMode.NameAsc
? t("providerPreset.sortOriginalTooltip", {
defaultValue: "Restore original order",
})
: t("providerPreset.sortNameAscTooltip", {
defaultValue: "Sort A-Z",
})}
</TooltipContent>
</Tooltip>
</div>
</TooltipProvider>
<Button
type="button"
variant="ghost"
size="icon"
aria-label={t("providerPreset.sortAriaLabel", {
defaultValue: "Toggle preset sorting",
})}
aria-pressed={sortMode === PresetSortMode.NameAsc}
onClick={toggleSortMode}
title={
sortMode === PresetSortMode.NameAsc
? t("providerPreset.sortOriginalTooltip", {
defaultValue: "Restore original order",
})
: t("providerPreset.sortNameAscTooltip", {
defaultValue: "Sort A-Z",
})
}
className={
sortMode === PresetSortMode.NameAsc
? "size-8 bg-accent text-foreground"
: "size-8"
}
>
<ArrowUpAZ className="size-4" />
</Button>
</div>
</div>
<div className="flex flex-wrap gap-2">
<div className="grid grid-cols-[repeat(auto-fill,minmax(150px,1fr))] gap-2">
<button
type="button"
onClick={() => onPresetChange("custom")}
className={`inline-flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
className={`inline-flex items-center justify-start gap-2 px-3 py-2 rounded-lg text-sm font-medium transition-colors w-full ${
selectedPresetId === "custom"
? "bg-blue-500 text-white dark:bg-blue-600"
: "bg-accent text-muted-foreground hover:bg-accent/80"
}`}
>
{t("providerPreset.custom")}
<span className="inline-block w-4 h-4 flex-shrink-0" aria-hidden />
<span className="truncate">{t("providerPreset.custom")}</span>
</button>
{visiblePresetEntries.length === 0 && (
<div className="w-full rounded-md border border-dashed border-border-default px-3 py-2 text-xs text-muted-foreground">
<div className="col-span-full rounded-md border border-dashed border-border-default px-3 py-2 text-xs text-muted-foreground">
{t("providerPreset.noSearchResults", {
defaultValue: "No matching presets.",
})}
@@ -359,7 +373,9 @@ export function ProviderPresetSelector({
}
>
{renderPresetIcon(entry.preset)}
{getPresetDisplayName(entry.preset, t)}
<span className="truncate">
{getPresetDisplayName(entry.preset, t)}
</span>
{isPartner && (
<span className="absolute -top-1 -right-1 flex items-center gap-0.5 rounded-full bg-gradient-to-r from-amber-500 to-yellow-500 px-1.5 py-0.5 text-[10px] font-bold text-white shadow-md">
<Star className="h-2.5 w-2.5 fill-current" />
@@ -372,20 +388,25 @@ export function ProviderPresetSelector({
{onUniversalPresetSelect && universalProviderPresets.length > 0 && (
<>
<div className="flex flex-wrap items-center gap-2">
<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 gap-2 px-4 py-2 rounded-lg text-sm font-medium transition-colors bg-accent text-muted-foreground hover:bg-accent/80 relative"
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} />
{preset.name}
<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>
@@ -395,15 +416,17 @@ export function ProviderPresetSelector({
<button
type="button"
onClick={onManageUniversalProviders}
className="inline-flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium transition-colors bg-accent text-muted-foreground hover:bg-accent/80"
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" />
{t("universalProvider.manage", {
defaultValue: "管理",
})}
<Settings2 className="h-4 w-4 flex-shrink-0" />
<span className="truncate">
{t("universalProvider.manage", {
defaultValue: "管理",
})}
</span>
</button>
)}
</div>