From a0e5c7e818eae710d171138d38b779ab5031ce13 Mon Sep 17 00:00:00 2001 From: Yuneng Jiang Date: Fri, 14 Aug 2026 17:51:54 -0700 Subject: [PATCH 1/2] fix(ui): anchor chips-combobox popups to the field instead of the inner input Base UI positions a combobox popup against the Combobox.Input by default. In chips mode the visible field is the ComboboxChips wrapper and the input is a smaller box nested inside it, so every chips-combobox in the dashboard opened its popup 11px right of the field and 17px past its right edge. shadcn ships the wiring for this and their combobox-multiple example uses it: useComboboxAnchor on the chips container, passed to ComboboxContent as anchor. The anchor prop also drives data-chips, which cancels the extra min-width an ordinary combobox wants. Every chips site in the dashboard omitted it. The anchor is attached through Base UI's render prop rather than a plain ref, because React 18 drops refs on function components and ComboboxChips is one. Adds MultiSelect's first test, covering the anchor wiring plus selection, chip rendering and custom values. --- .../caching/_components/cache_dashboard.tsx | 11 ++- .../custom_code/CustomCodeModal.tsx | 6 +- .../guardrails/_components/pii_components.tsx | 6 +- .../old-usage/_components/usage.tsx | 6 +- .../EntityUsageExport/UsageExportHeader.tsx | 6 +- .../components/ModelSelect/ModelSelect.tsx | 6 +- .../src/components/TeamSSOSettings.tsx | 6 +- .../common_components/team_multi_select.tsx | 6 +- .../src/components/public_model_hub.tsx | 6 +- .../search_tools/SearchToolSelector.tsx | 6 +- .../components/shared/MultiSelect.test.tsx | 74 +++++++++++++++++++ .../src/components/shared/MultiSelect.tsx | 32 ++++---- .../src/components/user_agent_activity.tsx | 6 +- 13 files changed, 139 insertions(+), 38 deletions(-) create mode 100644 ui/litellm-dashboard/src/components/shared/MultiSelect.test.tsx diff --git a/ui/litellm-dashboard/src/app/(dashboard)/caching/_components/cache_dashboard.tsx b/ui/litellm-dashboard/src/app/(dashboard)/caching/_components/cache_dashboard.tsx index e35c0103f7c..6759a9af63f 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/caching/_components/cache_dashboard.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/caching/_components/cache_dashboard.tsx @@ -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 = ({ accessToken, token, userRole, userID, premiumUser }) => { + const anchor1 = useComboboxAnchor(); + const anchor2 = useComboboxAnchor(); const [selectedApiKeys, setSelectedApiKeys] = useState([]); const [selectedModels, setSelectedModels] = useState([]); @@ -194,7 +197,7 @@ const CacheDashboard: React.FC = ({ accessToken, token, userRole value={selectedApiKeys} onValueChange={(keys: string[]) => setSelectedApiKeys(keys)} > - + }> {(keys: string[]) => keys.map((key) => ( @@ -206,7 +209,7 @@ const CacheDashboard: React.FC = ({ accessToken, token, userRole - + No virtual keys found {(key: string) => ( @@ -224,7 +227,7 @@ const CacheDashboard: React.FC = ({ accessToken, token, userRole value={selectedModels} onValueChange={(models: string[]) => setSelectedModels(models)} > - + }> {(models: string[]) => models.map((model) => ( @@ -236,7 +239,7 @@ const CacheDashboard: React.FC = ({ accessToken, token, userRole - + No models found {(model: string) => ( diff --git a/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/custom_code/CustomCodeModal.tsx b/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/custom_code/CustomCodeModal.tsx index f2d2ab724f4..1497c789cb4 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/custom_code/CustomCodeModal.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/custom_code/CustomCodeModal.tsx @@ -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 = ({ visible, onClose, onSuccess, accessToken, editData }) => { + const anchor = useComboboxAnchor(); const isEditMode = !!editData; const [guardrailName, setGuardrailName] = useState(""); const [mode, setMode] = useState(["pre_call"]); @@ -524,7 +526,7 @@ const CustomCodeModal: React.FC = ({ visible, onClose, onS onValueChange={(options: ModeOption[]) => setMode(options.map((option) => option.value))} multiple > - + } className="w-full"> {selectedModeOptions.map((option) => ( {option.label} @@ -535,7 +537,7 @@ const CustomCodeModal: React.FC = ({ visible, onClose, onS placeholder={mode.length === 0 ? "Select modes" : undefined} /> - + No matching modes {(option: ModeOption) => ( diff --git a/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/pii_components.tsx b/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/pii_components.tsx index da5e61b2f3d..6994f43772c 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/pii_components.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/pii_components.tsx @@ -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 = ({ categories, selectedCategories, onChange }) => { + const anchor = useComboboxAnchor(); const categoryNames = categories.map((cat) => cat.category); return ( @@ -50,7 +52,7 @@ export const CategoryFilter: React.FC = ({ categories, sele Filter by category - + } className="mb-4 w-full"> {selectedCategories.map((category) => ( {category} @@ -61,7 +63,7 @@ export const CategoryFilter: React.FC = ({ categories, sele placeholder={selectedCategories.length === 0 ? "Select categories to filter by" : undefined} /> - + No matching categories {(category: string) => ( diff --git a/ui/litellm-dashboard/src/app/(dashboard)/old-usage/_components/usage.tsx b/ui/litellm-dashboard/src/app/(dashboard)/old-usage/_components/usage.tsx index 5b2f8547822..7ddf2622c7a 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/old-usage/_components/usage.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/old-usage/_components/usage.tsx @@ -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 = ({ accessToken, token, userRole, userID, keys, premiumUser }) => { + const anchor = useComboboxAnchor(); const canViewGlobalSpend = hasCapability(userRole, "viewGlobalSpend"); const currentDate = new Date(); const [keySpendData, setKeySpendData] = useState([]); @@ -879,7 +881,7 @@ const UsagePage: React.FC = ({ accessToken, token, userRole, use isItemEqualToValue={(a: TagOption, b: TagOption) => a.value === b.value} itemToStringLabel={(option: TagOption) => option.label} > - + }> {(options: TagOption[]) => options.map((option) => ( @@ -891,7 +893,7 @@ const UsagePage: React.FC = ({ accessToken, token, userRole, use - + No tags found {(option: TagOption) => ( diff --git a/ui/litellm-dashboard/src/components/EntityUsageExport/UsageExportHeader.tsx b/ui/litellm-dashboard/src/components/EntityUsageExport/UsageExportHeader.tsx index adacdf16f76..772a5a766e6 100644 --- a/ui/litellm-dashboard/src/components/EntityUsageExport/UsageExportHeader.tsx +++ b/ui/litellm-dashboard/src/components/EntityUsageExport/UsageExportHeader.tsx @@ -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 = ({ 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 = ({ const labelOf = (value: string) => filterOptions.find((option) => option.value === value)?.label ?? value; const filterList = ( - + No options found {(value: string) => ( @@ -104,7 +106,7 @@ const UsageExportHeader: React.FC = ({ value={selectedFilters} onValueChange={(next: string[]) => onFiltersChange?.(next)} > - + } className="w-full"> {(selected: string[]) => selected.map((value) => ( diff --git a/ui/litellm-dashboard/src/components/ModelSelect/ModelSelect.tsx b/ui/litellm-dashboard/src/components/ModelSelect/ModelSelect.tsx index 55aa1f1ec5f..6c4386aec71 100644 --- a/ui/litellm-dashboard/src/components/ModelSelect/ModelSelect.tsx +++ b/ui/litellm-dashboard/src/components/ModelSelect/ModelSelect.tsx @@ -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} > - + } data-testid={dataTestId} style={style} className="w-full"> {(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" /> - + No models found {(group: ModelOptionGroup) => ( diff --git a/ui/litellm-dashboard/src/components/TeamSSOSettings.tsx b/ui/litellm-dashboard/src/components/TeamSSOSettings.tsx index cf141097ecf..1009e5cd7b0 100644 --- a/ui/litellm-dashboard/src/components/TeamSSOSettings.tsx +++ b/ui/litellm-dashboard/src/components/TeamSSOSettings.tsx @@ -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 = ({ accessToken }) => { + const anchor = useComboboxAnchor(); const [loading, setLoading] = useState(true); const [values, setValues] = useState(DEFAULT_VALUES); const [isEditing, setIsEditing] = useState(false); @@ -372,7 +374,7 @@ const TeamSSOSettings: React.FC = ({ accessToken }) => { value={editedValues.team_member_permissions || []} onValueChange={(permissions: string[]) => update("team_member_permissions", permissions)} > - + }> {(permissions: string[]) => permissions.map((permission) => ( @@ -388,7 +390,7 @@ const TeamSSOSettings: React.FC = ({ accessToken }) => { aria-label="Team Member Permissions" /> - + {(permission: string) => ( diff --git a/ui/litellm-dashboard/src/components/common_components/team_multi_select.tsx b/ui/litellm-dashboard/src/components/common_components/team_multi_select.tsx index 12a2031798b..da8e99a938d 100644 --- a/ui/litellm-dashboard/src/components/common_components/team_multi_select.tsx +++ b/ui/litellm-dashboard/src/components/common_components/team_multi_select.tsx @@ -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 = ({ 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 = ({ onInputValueChange={debouncedSetSearch} disabled={disabled} > - + } className="w-full" aria-busy={isLoading}> {(selected: string[]) => selected.map((teamId) => ( @@ -93,7 +95,7 @@ const TeamMultiSelect: React.FC = ({ /> {value.length > 0 && } - + {isLoading ? : "No teams found"} diff --git a/ui/litellm-dashboard/src/components/public_model_hub.tsx b/ui/litellm-dashboard/src/components/public_model_hub.tsx index ab1a10918e7..b642d28f21a 100644 --- a/ui/litellm-dashboard/src/components/public_model_hub.tsx +++ b/ui/litellm-dashboard/src/components/public_model_hub.tsx @@ -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 = ({ accessToken, isEmbedded = false }) => { + const anchor = useComboboxAnchor(); const [modelHubData, setModelHubData] = useState(null); const [agentHubData, setAgentHubData] = useState(null); const [mcpHubData, setMcpHubData] = useState(null); @@ -622,7 +624,7 @@ const PublicModelHub: React.FC = ({ accessToken, isEmbedded value={selectedProviders} onValueChange={(values: string[]) => setSelectedProviders(values)} > - + } className="min-h-8 w-full py-1 text-sm"> {(values: string[]) => values.map((provider) => ( @@ -638,7 +640,7 @@ const PublicModelHub: React.FC = ({ accessToken, isEmbedded className="h-5 min-w-24 flex-1 border-0 bg-transparent py-0 text-sm" /> - + No providers found {(provider: string) => { diff --git a/ui/litellm-dashboard/src/components/search_tools/SearchToolSelector.tsx b/ui/litellm-dashboard/src/components/search_tools/SearchToolSelector.tsx index 56f5954345d..5078950bb9e 100644 --- a/ui/litellm-dashboard/src/components/search_tools/SearchToolSelector.tsx +++ b/ui/litellm-dashboard/src/components/search_tools/SearchToolSelector.tsx @@ -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 = ({ placeholder = "Select search tools (optional)", disabled = false, }) => { + const anchor = useComboboxAnchor(); const [options, setOptions] = useState([]); const [loading, setLoading] = useState(false); @@ -67,7 +69,7 @@ const SearchToolSelector: React.FC = ({ onValueChange={(selected: string[]) => onChange(selected)} disabled={disabled} > - + } className={cn("w-full", className)} aria-busy={loading}> {(selected: string[]) => selected.map((tool) => ( @@ -85,7 +87,7 @@ const SearchToolSelector: React.FC = ({ /> {value && value.length > 0 && } - + {loading ? "Loading search tools…" : "No search tools found"} {(tool: string) => ( diff --git a/ui/litellm-dashboard/src/components/shared/MultiSelect.test.tsx b/ui/litellm-dashboard/src/components/shared/MultiSelect.test.tsx new file mode 100644 index 00000000000..e35077756a5 --- /dev/null +++ b/ui/litellm-dashboard/src/components/shared/MultiSelect.test.tsx @@ -0,0 +1,74 @@ +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> = {}) => { + const onValueChange = vi.fn(); + render(); + 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; + }); +}; + +describe("MultiSelect", () => { + it("anchors the popup to the chips container rather than the inner input", async () => { + const { input } = renderMultiSelect(); + + const popup = await openPopup(input); + + expect(popup).toHaveAttribute("data-chips", "true"); + }); + + 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(); + }); +}); diff --git a/ui/litellm-dashboard/src/components/shared/MultiSelect.tsx b/ui/litellm-dashboard/src/components/shared/MultiSelect.tsx index f084572bc0c..ef23ab2ebd4 100644 --- a/ui/litellm-dashboard/src/components/shared/MultiSelect.tsx +++ b/ui/litellm-dashboard/src/components/shared/MultiSelect.tsx @@ -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} > - + } className={`min-h-8 py-1 text-sm ${className ?? ""}`}> - {(selected: MultiSelectOption[]) => - selected.map((option) => ( - - {option.label} - - )) - } + {(selected: MultiSelectOption[]) => ( + <> + {selected.map((option) => ( + + {option.label} + + ))} + + + )} - - + {emptyText} {(option: MultiSelectOption) => ( diff --git a/ui/litellm-dashboard/src/components/user_agent_activity.tsx b/ui/litellm-dashboard/src/components/user_agent_activity.tsx index ca6c2953dfe..b7c9056764b 100644 --- a/ui/litellm-dashboard/src/components/user_agent_activity.tsx +++ b/ui/litellm-dashboard/src/components/user_agent_activity.tsx @@ -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 = ({ 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 = ({ accessToken, user value={selectedTags} onValueChange={(next: string[]) => setSelectedTags(next)} > - + } className="w-full" aria-busy={tagsLoading}> {(selected: string[]) => selected.map((tag) => ( @@ -402,7 +404,7 @@ const UserAgentActivity: React.FC = ({ accessToken, user /> {selectedTags.length > 0 && } - + No user agents found {(tag: string) => { From c7084c04c0a9e2deff680b2282f720e0bc0aad13 Mon Sep 17 00:00:00 2001 From: Yuneng Jiang Date: Fri, 14 Aug 2026 18:03:34 -0700 Subject: [PATCH 2/2] test(ui): assert which element the chips-combobox popup anchors to The previous assertion read data-chips, which is derived from the anchor prop being truthy, so it stayed true even when the ref never reached the DOM and the popup was still anchored to the inner input. Stub distinct widths on the chips container and the input, then read the width the positioner resolved. Reverting the anchor wiring now reports the input's width instead of the field's, which is the actual bug. --- .../components/shared/MultiSelect.test.tsx | 23 ++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/ui/litellm-dashboard/src/components/shared/MultiSelect.test.tsx b/ui/litellm-dashboard/src/components/shared/MultiSelect.test.tsx index e35077756a5..908c22b5d67 100644 --- a/ui/litellm-dashboard/src/components/shared/MultiSelect.test.tsx +++ b/ui/litellm-dashboard/src/components/shared/MultiSelect.test.tsx @@ -23,13 +23,34 @@ const openPopup = async (input: 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(popup).toHaveAttribute("data-chips", "true"); + expect(positioner.style.getPropertyValue("--anchor-width")).toBe(`${CHIPS_WIDTH}px`); }); it("reports the selected option values", async () => {