Merge pull request #36995 from BerriAI/litellm_/playground-config-select-25ad99

fix(ui): anchor chips-combobox popups to the field instead of the inner input
This commit is contained in:
yuneng-jiang 2026-08-14 18:11:38 -07:00 committed by GitHub
commit a4aab2e9f3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 160 additions and 38 deletions

View file

@ -15,6 +15,7 @@ import {
ComboboxItem,
ComboboxList,
ComboboxValue,
useComboboxAnchor,
} from "@/components/ui/combobox";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
@ -68,6 +69,8 @@ interface CachePageProps {
// Helper function to deep-parse a JSON string if possible
const CacheDashboard: React.FC<CachePageProps> = ({ accessToken, token, userRole, userID, premiumUser }) => {
const anchor1 = useComboboxAnchor();
const anchor2 = useComboboxAnchor();
const [selectedApiKeys, setSelectedApiKeys] = useState<string[]>([]);
const [selectedModels, setSelectedModels] = useState<string[]>([]);
@ -194,7 +197,7 @@ const CacheDashboard: React.FC<CachePageProps> = ({ accessToken, token, userRole
value={selectedApiKeys}
onValueChange={(keys: string[]) => setSelectedApiKeys(keys)}
>
<ComboboxChips>
<ComboboxChips render={<div ref={anchor1} />}>
<ComboboxValue>
{(keys: string[]) =>
keys.map((key) => (
@ -206,7 +209,7 @@ const CacheDashboard: React.FC<CachePageProps> = ({ accessToken, token, userRole
</ComboboxValue>
<ComboboxChipsInput placeholder="Select Virtual Keys" className="border-0 bg-transparent" />
</ComboboxChips>
<ComboboxContent>
<ComboboxContent anchor={anchor1}>
<ComboboxEmpty>No virtual keys found</ComboboxEmpty>
<ComboboxList>
{(key: string) => (
@ -224,7 +227,7 @@ const CacheDashboard: React.FC<CachePageProps> = ({ accessToken, token, userRole
value={selectedModels}
onValueChange={(models: string[]) => setSelectedModels(models)}
>
<ComboboxChips>
<ComboboxChips render={<div ref={anchor2} />}>
<ComboboxValue>
{(models: string[]) =>
models.map((model) => (
@ -236,7 +239,7 @@ const CacheDashboard: React.FC<CachePageProps> = ({ accessToken, token, userRole
</ComboboxValue>
<ComboboxChipsInput placeholder="Select Models" className="border-0 bg-transparent" />
</ComboboxChips>
<ComboboxContent>
<ComboboxContent anchor={anchor2}>
<ComboboxEmpty>No models found</ComboboxEmpty>
<ComboboxList>
{(model: string) => (

View file

@ -13,6 +13,7 @@ import {
ComboboxEmpty,
ComboboxItem,
ComboboxList,
useComboboxAnchor,
} from "@/components/ui/combobox";
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
@ -190,6 +191,7 @@ interface CustomCodeModalProps {
}
const CustomCodeModal: React.FC<CustomCodeModalProps> = ({ visible, onClose, onSuccess, accessToken, editData }) => {
const anchor = useComboboxAnchor();
const isEditMode = !!editData;
const [guardrailName, setGuardrailName] = useState("");
const [mode, setMode] = useState<string[]>(["pre_call"]);
@ -524,7 +526,7 @@ const CustomCodeModal: React.FC<CustomCodeModalProps> = ({ visible, onClose, onS
onValueChange={(options: ModeOption[]) => setMode(options.map((option) => option.value))}
multiple
>
<ComboboxChips className="w-full">
<ComboboxChips render={<div ref={anchor} />} className="w-full">
{selectedModeOptions.map((option) => (
<ComboboxChip key={option.value} aria-label={option.label}>
{option.label}
@ -535,7 +537,7 @@ const CustomCodeModal: React.FC<CustomCodeModalProps> = ({ visible, onClose, onS
placeholder={mode.length === 0 ? "Select modes" : undefined}
/>
</ComboboxChips>
<ComboboxContent>
<ComboboxContent anchor={anchor}>
<ComboboxEmpty>No matching modes</ComboboxEmpty>
<ComboboxList>
{(option: ModeOption) => (

View file

@ -13,6 +13,7 @@ import {
ComboboxEmpty,
ComboboxItem,
ComboboxList,
useComboboxAnchor,
} from "@/components/ui/combobox";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
@ -41,6 +42,7 @@ export interface CategoryFilterProps {
}
export const CategoryFilter: React.FC<CategoryFilterProps> = ({ categories, selectedCategories, onChange }) => {
const anchor = useComboboxAnchor();
const categoryNames = categories.map((cat) => cat.category);
return (
@ -50,7 +52,7 @@ export const CategoryFilter: React.FC<CategoryFilterProps> = ({ categories, sele
<span className="font-medium text-muted-foreground">Filter by category</span>
</div>
<Combobox items={categoryNames} value={selectedCategories} onValueChange={onChange} multiple>
<ComboboxChips className="mb-4 w-full">
<ComboboxChips render={<div ref={anchor} />} className="mb-4 w-full">
{selectedCategories.map((category) => (
<ComboboxChip key={category} aria-label={category}>
{category}
@ -61,7 +63,7 @@ export const CategoryFilter: React.FC<CategoryFilterProps> = ({ categories, sele
placeholder={selectedCategories.length === 0 ? "Select categories to filter by" : undefined}
/>
</ComboboxChips>
<ComboboxContent>
<ComboboxContent anchor={anchor}>
<ComboboxEmpty>No matching categories</ComboboxEmpty>
<ComboboxList>
{(category: string) => (

View file

@ -15,6 +15,7 @@ import {
ComboboxItem,
ComboboxList,
ComboboxValue,
useComboboxAnchor,
} from "@/components/ui/combobox";
import { Meter, MeterIndicator, MeterTrack } from "@/components/ui/meter";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
@ -91,6 +92,7 @@ const TeamSpendBarList: React.FC<{ data: TeamSpendTotal[] }> = ({ data }) => {
};
const UsagePage: React.FC<UsagePageProps> = ({ accessToken, token, userRole, userID, keys, premiumUser }) => {
const anchor = useComboboxAnchor();
const canViewGlobalSpend = hasCapability(userRole, "viewGlobalSpend");
const currentDate = new Date();
const [keySpendData, setKeySpendData] = useState<any[]>([]);
@ -879,7 +881,7 @@ const UsagePage: React.FC<UsagePageProps> = ({ accessToken, token, userRole, use
isItemEqualToValue={(a: TagOption, b: TagOption) => a.value === b.value}
itemToStringLabel={(option: TagOption) => option.label}
>
<ComboboxChips>
<ComboboxChips render={<div ref={anchor} />}>
<ComboboxValue>
{(options: TagOption[]) =>
options.map((option) => (
@ -891,7 +893,7 @@ const UsagePage: React.FC<UsagePageProps> = ({ accessToken, token, userRole, use
</ComboboxValue>
<ComboboxChipsInput placeholder="Select tags" className="border-0 bg-transparent" />
</ComboboxChips>
<ComboboxContent>
<ComboboxContent anchor={anchor}>
<ComboboxEmpty>No tags found</ComboboxEmpty>
<ComboboxList>
{(option: TagOption) => (

View file

@ -14,6 +14,7 @@ import {
ComboboxItem,
ComboboxList,
ComboboxValue,
useComboboxAnchor,
} from "@/components/ui/combobox";
import EntityUsageExportModal from "./EntityUsageExportModal";
import type { EntitySpendData, EntityType } from "./types";
@ -51,6 +52,7 @@ const UsageExportHeader: React.FC<UsageExportHeaderProps> = ({
compactLayout = false,
teams = [],
}) => {
const anchor = useComboboxAnchor();
const [isExportModalOpen, setIsExportModalOpen] = useState(false);
const hasFilters = showFilters && filterOptions.length > 0;
@ -58,7 +60,7 @@ const UsageExportHeader: React.FC<UsageExportHeaderProps> = ({
const labelOf = (value: string) => filterOptions.find((option) => option.value === value)?.label ?? value;
const filterList = (
<ComboboxContent>
<ComboboxContent anchor={anchor}>
<ComboboxEmpty>No options found</ComboboxEmpty>
<ComboboxList>
{(value: string) => (
@ -104,7 +106,7 @@ const UsageExportHeader: React.FC<UsageExportHeaderProps> = ({
value={selectedFilters}
onValueChange={(next: string[]) => onFiltersChange?.(next)}
>
<ComboboxChips className="w-full">
<ComboboxChips render={<div ref={anchor} />} className="w-full">
<ComboboxValue>
{(selected: string[]) =>
selected.map((value) => (

View file

@ -15,6 +15,7 @@ import {
ComboboxLabel,
ComboboxList,
ComboboxValue,
useComboboxAnchor,
} from "@/components/ui/combobox";
import { Skeleton } from "@/components/ui/skeleton";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
@ -120,6 +121,7 @@ const filterModels = (
};
export const ModelSelect = (props: ModelSelectProps) => {
const anchor = useComboboxAnchor();
const { teamID, organizationID, options, context, dataTestId, value = [], onChange, style } = props;
const { showAllProxyModelsOverride, includeSpecialOptions } = options || {};
const { data: allProxyModels, isLoading: isLoadingAllProxyModels } = useAllProxyModels();
@ -234,7 +236,7 @@ export const ModelSelect = (props: ModelSelectProps) => {
isItemEqualToValue={(option: ModelOption, selected: ModelOption) => option.value === selected.value}
itemToStringLabel={(option: ModelOption) => option.label}
>
<ComboboxChips data-testid={dataTestId} style={style} className="w-full">
<ComboboxChips render={<div ref={anchor} />} data-testid={dataTestId} style={style} className="w-full">
<ComboboxValue>
{(selected: ModelOption[]) => (
<>
@ -260,7 +262,7 @@ export const ModelSelect = (props: ModelSelectProps) => {
className="h-5 min-w-24 flex-1 border-0 bg-transparent py-0 text-sm"
/>
</ComboboxChips>
<ComboboxContent>
<ComboboxContent anchor={anchor}>
<ComboboxEmpty>No models found</ComboboxEmpty>
<ComboboxList>
{(group: ModelOptionGroup) => (

View file

@ -14,6 +14,7 @@ import {
ComboboxItem,
ComboboxList,
ComboboxValue,
useComboboxAnchor,
} from "@/components/ui/combobox";
import { InputGroup, InputGroupAddon, InputGroupInput } from "@/components/ui/input-group";
import { Input } from "@/components/ui/input";
@ -110,6 +111,7 @@ const DEFAULT_VALUES: SettingsValues = {
};
const TeamSSOSettings: React.FC<TeamSSOSettingsProps> = ({ accessToken }) => {
const anchor = useComboboxAnchor();
const [loading, setLoading] = useState<boolean>(true);
const [values, setValues] = useState<SettingsValues>(DEFAULT_VALUES);
const [isEditing, setIsEditing] = useState<boolean>(false);
@ -372,7 +374,7 @@ const TeamSSOSettings: React.FC<TeamSSOSettingsProps> = ({ accessToken }) => {
value={editedValues.team_member_permissions || []}
onValueChange={(permissions: string[]) => update("team_member_permissions", permissions)}
>
<ComboboxChips>
<ComboboxChips render={<div ref={anchor} />}>
<ComboboxValue>
{(permissions: string[]) =>
permissions.map((permission) => (
@ -388,7 +390,7 @@ const TeamSSOSettings: React.FC<TeamSSOSettingsProps> = ({ accessToken }) => {
aria-label="Team Member Permissions"
/>
</ComboboxChips>
<ComboboxContent>
<ComboboxContent anchor={anchor}>
<ComboboxList>
{(permission: string) => (
<ComboboxItem key={permission} value={permission}>

View file

@ -12,6 +12,7 @@ import {
ComboboxItem,
ComboboxList,
ComboboxValue,
useComboboxAnchor,
} from "@/components/ui/combobox";
import { useInfiniteTeams } from "@/app/(dashboard)/hooks/teams/useTeams";
import { DEBOUNCE_WAIT_MS } from "@/utils/debounceConstants";
@ -36,6 +37,7 @@ const TeamMultiSelect: React.FC<TeamMultiSelectProps> = ({
pageSize = 20,
placeholder = "Search teams by alias...",
}) => {
const anchor = useComboboxAnchor();
const [search, setSearch] = useState("");
const debouncedSetSearch = useDebouncedCallback(setSearch, { wait: DEBOUNCE_WAIT_MS });
@ -75,7 +77,7 @@ const TeamMultiSelect: React.FC<TeamMultiSelectProps> = ({
onInputValueChange={debouncedSetSearch}
disabled={disabled}
>
<ComboboxChips className="w-full" aria-busy={isLoading}>
<ComboboxChips render={<div ref={anchor} />} className="w-full" aria-busy={isLoading}>
<ComboboxValue>
{(selected: string[]) =>
selected.map((teamId) => (
@ -93,7 +95,7 @@ const TeamMultiSelect: React.FC<TeamMultiSelectProps> = ({
/>
{value.length > 0 && <ComboboxClear aria-label="Clear all teams" disabled={disabled} />}
</ComboboxChips>
<ComboboxContent>
<ComboboxContent anchor={anchor}>
<ComboboxEmpty>
{isLoading ? <Loader2 className="size-4 animate-spin text-muted-foreground" /> : "No teams found"}
</ComboboxEmpty>

View file

@ -15,6 +15,7 @@ import {
ComboboxItem,
ComboboxList,
ComboboxValue,
useComboboxAnchor,
} from "@/components/ui/combobox";
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
@ -65,6 +66,7 @@ function PublicHubEmptyState({ title, body }: { title: string; body: string }) {
}
const PublicModelHub: React.FC<PublicModelHubProps> = ({ accessToken, isEmbedded = false }) => {
const anchor = useComboboxAnchor();
const [modelHubData, setModelHubData] = useState<ModelGroupInfo[] | null>(null);
const [agentHubData, setAgentHubData] = useState<AgentCard[] | null>(null);
const [mcpHubData, setMcpHubData] = useState<MCPServerData[] | null>(null);
@ -622,7 +624,7 @@ const PublicModelHub: React.FC<PublicModelHubProps> = ({ accessToken, isEmbedded
value={selectedProviders}
onValueChange={(values: string[]) => setSelectedProviders(values)}
>
<ComboboxChips className="min-h-8 w-full py-1 text-sm">
<ComboboxChips render={<div ref={anchor} />} className="min-h-8 w-full py-1 text-sm">
<ComboboxValue>
{(values: string[]) =>
values.map((provider) => (
@ -638,7 +640,7 @@ const PublicModelHub: React.FC<PublicModelHubProps> = ({ accessToken, isEmbedded
className="h-5 min-w-24 flex-1 border-0 bg-transparent py-0 text-sm"
/>
</ComboboxChips>
<ComboboxContent>
<ComboboxContent anchor={anchor}>
<ComboboxEmpty>No providers found</ComboboxEmpty>
<ComboboxList>
{(provider: string) => {

View file

@ -10,6 +10,7 @@ import {
ComboboxItem,
ComboboxList,
ComboboxValue,
useComboboxAnchor,
} from "@/components/ui/combobox";
import { cn } from "@/lib/cva.config";
import { fetchSearchTools } from "../networking";
@ -31,6 +32,7 @@ const SearchToolSelector: React.FC<SearchToolSelectorProps> = ({
placeholder = "Select search tools (optional)",
disabled = false,
}) => {
const anchor = useComboboxAnchor();
const [options, setOptions] = useState<string[]>([]);
const [loading, setLoading] = useState(false);
@ -67,7 +69,7 @@ const SearchToolSelector: React.FC<SearchToolSelectorProps> = ({
onValueChange={(selected: string[]) => onChange(selected)}
disabled={disabled}
>
<ComboboxChips className={cn("w-full", className)} aria-busy={loading}>
<ComboboxChips render={<div ref={anchor} />} className={cn("w-full", className)} aria-busy={loading}>
<ComboboxValue>
{(selected: string[]) =>
selected.map((tool) => (
@ -85,7 +87,7 @@ const SearchToolSelector: React.FC<SearchToolSelectorProps> = ({
/>
{value && value.length > 0 && <ComboboxClear aria-label="Clear all search tools" disabled={disabled} />}
</ComboboxChips>
<ComboboxContent>
<ComboboxContent anchor={anchor}>
<ComboboxEmpty>{loading ? "Loading search tools…" : "No search tools found"}</ComboboxEmpty>
<ComboboxList>
{(tool: string) => (

View file

@ -0,0 +1,95 @@
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi } from "vitest";
import { MultiSelect, type MultiSelectOption } from "./MultiSelect";
const OPTIONS: MultiSelectOption[] = [
{ value: "vs-alpha", label: "alpha-kb (vs-alpha)" },
{ value: "vs-beta", label: "beta-kb (vs-beta)", description: "second store" },
];
const renderMultiSelect = (props: Partial<React.ComponentProps<typeof MultiSelect>> = {}) => {
const onValueChange = vi.fn();
render(<MultiSelect options={OPTIONS} onValueChange={onValueChange} placeholder="Select stores" {...props} />);
return { onValueChange, input: screen.getByRole("combobox") };
};
const openPopup = async (input: HTMLElement) => {
await userEvent.click(input);
return waitFor(() => {
const popup = document.querySelector("[data-slot='combobox-content']");
expect(popup).not.toBeNull();
return popup as HTMLElement;
});
};
const stubWidth = (element: Element, width: number) =>
vi.spyOn(element, "getBoundingClientRect").mockReturnValue({
width,
height: 32,
top: 0,
left: 0,
right: width,
bottom: 32,
x: 0,
y: 0,
toJSON: () => ({}),
} as DOMRect);
const CHIPS_WIDTH = 300;
const INPUT_WIDTH = 200;
describe("MultiSelect", () => {
it("anchors the popup to the chips container rather than the inner input", async () => {
const { input } = renderMultiSelect();
const chips = input.closest("[data-slot='combobox-chips']");
expect(chips).not.toBeNull();
stubWidth(chips as Element, CHIPS_WIDTH);
stubWidth(input, INPUT_WIDTH);
const popup = await openPopup(input);
const positioner = popup.parentElement as HTMLElement;
expect(positioner.style.getPropertyValue("--anchor-width")).toBe(`${CHIPS_WIDTH}px`);
});
it("reports the selected option values", async () => {
const { onValueChange, input } = renderMultiSelect();
await openPopup(input);
await userEvent.click(screen.getByText("alpha-kb (vs-alpha)"));
expect(onValueChange).toHaveBeenCalledWith(["vs-alpha"]);
});
it("renders a chip per selected value", () => {
renderMultiSelect({ value: ["vs-alpha", "vs-beta"] });
expect(screen.getByLabelText("alpha-kb (vs-alpha)")).toBeInTheDocument();
expect(screen.getByLabelText("beta-kb (vs-beta)")).toBeInTheDocument();
});
it("labels an unknown selected value with its raw id", () => {
renderMultiSelect({ value: ["vs-deleted"] });
expect(screen.getByLabelText("vs-deleted")).toBeInTheDocument();
});
it("offers a typed value only when custom values are allowed", async () => {
const { onValueChange, input } = renderMultiSelect({ allowCustomValues: true });
await userEvent.type(input, "vs-typed");
await userEvent.click(await screen.findByText('Create "vs-typed"'));
expect(onValueChange).toHaveBeenCalledWith(["vs-typed"]);
});
it("does not offer a typed value when custom values are disallowed", async () => {
const { input } = renderMultiSelect();
await userEvent.type(input, "vs-typed");
expect(screen.queryByText('Create "vs-typed"')).not.toBeInTheDocument();
expect(await screen.findByText("No options found")).toBeInTheDocument();
});
});

View file

@ -11,6 +11,7 @@ import {
ComboboxItem,
ComboboxList,
ComboboxValue,
useComboboxAnchor,
} from "@/components/ui/combobox";
export interface MultiSelectOption {
@ -52,6 +53,7 @@ export function MultiSelect({
allowCustomValues = false,
className,
}: MultiSelectProps) {
const anchor = useComboboxAnchor();
const [query, setQuery] = useState("");
const safeOptions = options.filter(
(option): option is MultiSelectOption =>
@ -89,23 +91,25 @@ export function MultiSelect({
filter={matchesQuery}
disabled={disabled || loading}
>
<ComboboxChips className={`min-h-8 py-1 text-sm ${className ?? ""}`}>
<ComboboxChips render={<div ref={anchor} />} className={`min-h-8 py-1 text-sm ${className ?? ""}`}>
<ComboboxValue>
{(selected: MultiSelectOption[]) =>
selected.map((option) => (
<ComboboxChip key={option.value} aria-label={option.label}>
{option.label}
</ComboboxChip>
))
}
{(selected: MultiSelectOption[]) => (
<>
{selected.map((option) => (
<ComboboxChip key={option.value} aria-label={option.label}>
{option.label}
</ComboboxChip>
))}
<ComboboxChipsInput
placeholder={loading ? "Loading..." : placeholder}
className="h-5 min-w-24 flex-1 border-0 bg-transparent py-0 text-sm"
aria-label={placeholder}
/>
</>
)}
</ComboboxValue>
<ComboboxChipsInput
placeholder={loading ? "Loading..." : placeholder}
className="h-5 min-w-24 flex-1 border-0 bg-transparent py-0 text-sm"
aria-label={placeholder}
/>
</ComboboxChips>
<ComboboxContent>
<ComboboxContent anchor={anchor}>
<ComboboxEmpty>{emptyText}</ComboboxEmpty>
<ComboboxList>
{(option: MultiSelectOption) => (

View file

@ -11,6 +11,7 @@ import {
ComboboxItem,
ComboboxList,
ComboboxValue,
useComboboxAnchor,
} from "@/components/ui/combobox";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
@ -59,6 +60,7 @@ interface UserAgentActivityProps {
}
const UserAgentActivity: React.FC<UserAgentActivityProps> = ({ accessToken, userRole, dateValue, onDateChange }) => {
const anchor = useComboboxAnchor();
// Maximum number of categories to show in charts to prevent color palette overflow
const MAX_CATEGORIES = 10;
@ -385,7 +387,7 @@ const UserAgentActivity: React.FC<UserAgentActivityProps> = ({ accessToken, user
value={selectedTags}
onValueChange={(next: string[]) => setSelectedTags(next)}
>
<ComboboxChips className="w-full" aria-busy={tagsLoading}>
<ComboboxChips render={<div ref={anchor} />} className="w-full" aria-busy={tagsLoading}>
<ComboboxValue>
{(selected: string[]) =>
selected.map((tag) => (
@ -402,7 +404,7 @@ const UserAgentActivity: React.FC<UserAgentActivityProps> = ({ accessToken, user
/>
{selectedTags.length > 0 && <ComboboxClear aria-label="Clear user agent filter" />}
</ComboboxChips>
<ComboboxContent>
<ComboboxContent anchor={anchor}>
<ComboboxEmpty>No user agents found</ComboboxEmpty>
<ComboboxList>
{(tag: string) => {