From 71449b9c550c488f24f748d6f66428b2091b6b48 Mon Sep 17 00:00:00 2001 From: tin-berri Date: Thu, 27 Aug 2026 13:08:13 -0700 Subject: [PATCH] fix(ui): open select popups below the trigger instead of over it (#38554) The shared SelectContent wrapper defaulted alignItemWithTrigger to true, which puts Base UI's positioner into item-aligned mode and places the popup so the active item sits on top of the trigger. In that mode the side and sideOffset the wrapper passes two lines above are ignored, and the popup reports data-side="none". The overlap only becomes visible once the items are tall enough to matter, which is why the autorouter Template picker shows it clearly: its options are three-line cards, so the popup covers both the select box and its own label. No call site in the dashboard asked for item-aligned mode. 21 of them across 15 files already passed alignItemWithTrigger={false} by hand to undo the default, and the remaining 127 inherited the bug. Flipping the default makes side and sideOffset live, so collision handling works and a select with no room below now flips above the trigger rather than covering it. The 21 hand-written opt-outs are deleted as redundant. --- .../autoRouterTemplateSelect.spec.ts | 59 +++++++++++++++++++ .../_components/TeamGuardrailsTab.tsx | 2 +- .../content_filter/CategoryTable.tsx | 4 +- .../CompetitorIntentConfiguration.tsx | 6 +- .../ContentCategoryConfiguration.tsx | 4 +- .../content_filter/CustomPatternModal.tsx | 2 +- .../content_filter/KeywordModal.tsx | 2 +- .../content_filter/KeywordTable.tsx | 2 +- .../content_filter/PatternModal.tsx | 2 +- .../content_filter/PatternTable.tsx | 2 +- .../custom_code/CustomCodeModal.tsx | 2 +- .../guardrails/_components/pii_components.tsx | 2 +- .../ToolPermissionRulesEditor.tsx | 6 +- .../_components/CreateVectorStore.tsx | 2 +- .../_components/VectorStoreForm.tsx | 2 +- .../_components/vector_store_info.tsx | 2 +- .../src/components/ui/select.test.tsx | 38 ++++++++++++ .../src/components/ui/select.tsx | 2 +- 18 files changed, 119 insertions(+), 22 deletions(-) create mode 100644 tests/e2e/ui/tests/modelsPage/autoRouterTemplateSelect.spec.ts diff --git a/tests/e2e/ui/tests/modelsPage/autoRouterTemplateSelect.spec.ts b/tests/e2e/ui/tests/modelsPage/autoRouterTemplateSelect.spec.ts new file mode 100644 index 00000000000..98bd1b84f11 --- /dev/null +++ b/tests/e2e/ui/tests/modelsPage/autoRouterTemplateSelect.spec.ts @@ -0,0 +1,59 @@ +import { expect, test, type Page as PlaywrightPage } from "@playwright/test"; +import { ADMIN_STORAGE_PATH } from "../../constants"; +import { navigateToPage } from "../../helpers/navigation"; +import { Page } from "../../fixtures/pages"; + +/** + * Opens Add Auto Router and returns the Template select's trigger, which is the + * shallowest real page that renders SelectContent with tall multi-line options. + */ +async function openTemplateSelect(page: PlaywrightPage) { + await navigateToPage(page, Page.Models); + await page.getByRole("tab", { name: "Auto-Routers" }).click(); + await page.getByRole("button", { name: "Add Auto Router" }).click(); + + const trigger = page.getByTestId("template-selector"); + await expect(trigger).toBeVisible(); + return trigger; +} + +test.describe("Auto Router template select anchoring", () => { + test.use({ storageState: ADMIN_STORAGE_PATH }); + + test("opens the options below the trigger rather than over it", async ({ page }) => { + await page.setViewportSize({ width: 1280, height: 900 }); + const trigger = await openTemplateSelect(page); + const triggerBox = await trigger.boundingBox(); + + await trigger.click(); + const popup = page.locator('[data-slot="select-content"]'); + await expect(popup).toBeVisible(); + const popupBox = await popup.boundingBox(); + + expect(triggerBox).not.toBeNull(); + expect(popupBox).not.toBeNull(); + + // Item-aligned mode reports "none" and puts the active item over the trigger. + await expect(popup).toHaveAttribute("data-side", "bottom"); + expect(popupBox!.y).toBeGreaterThanOrEqual(triggerBox!.y + triggerBox!.height); + }); + + test("flips above the trigger instead of covering it when there is no room below", async ({ page }) => { + await page.setViewportSize({ width: 1280, height: 560 }); + const trigger = await openTemplateSelect(page); + await trigger.scrollIntoViewIfNeeded(); + const triggerBox = await trigger.boundingBox(); + + await trigger.click(); + const popup = page.locator('[data-slot="select-content"]'); + await expect(popup).toBeVisible(); + const popupBox = await popup.boundingBox(); + + expect(triggerBox).not.toBeNull(); + expect(popupBox).not.toBeNull(); + + const overlaps = + popupBox!.y < triggerBox!.y + triggerBox!.height && popupBox!.y + popupBox!.height > triggerBox!.y; + expect(overlaps).toBe(false); + }); +}); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/TeamGuardrailsTab.tsx b/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/TeamGuardrailsTab.tsx index dc1223264bb..1de4e697f64 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/TeamGuardrailsTab.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/TeamGuardrailsTab.tsx @@ -1106,7 +1106,7 @@ export function TeamGuardrailsTab({ accessToken }: TeamGuardrailsTabProps) { > - + {GUARDRAIL_MODES.map((mode) => ( {mode.label} diff --git a/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/content_filter/CategoryTable.tsx b/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/content_filter/CategoryTable.tsx index fa40ce6ab54..f012923d32f 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/content_filter/CategoryTable.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/content_filter/CategoryTable.tsx @@ -64,7 +64,7 @@ const CategoryTable: React.FC = ({ - + {SEVERITY_ITEMS.map((item) => ( {item.label} @@ -93,7 +93,7 @@ const CategoryTable: React.FC = ({ - + {ACTION_ITEMS.map((item) => ( {item.label} diff --git a/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/content_filter/CompetitorIntentConfiguration.tsx b/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/content_filter/CompetitorIntentConfiguration.tsx index 8445495246d..0632ec87f34 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/content_filter/CompetitorIntentConfiguration.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/content_filter/CompetitorIntentConfiguration.tsx @@ -194,7 +194,7 @@ const CompetitorIntentConfiguration: React.FC - + {INTENT_TYPES.map((type) => ( {type.label} @@ -268,7 +268,7 @@ const CompetitorIntentConfiguration: React.FC - + {COMPETITOR_COMPARISON_POLICIES.map((policy) => ( {policy.label} @@ -292,7 +292,7 @@ const CompetitorIntentConfiguration: React.FC - + {POSSIBLE_COMPETITOR_COMPARISON_POLICIES.map((policy) => ( {policy.label} diff --git a/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/content_filter/ContentCategoryConfiguration.tsx b/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/content_filter/ContentCategoryConfiguration.tsx index 1924133a6cc..f2226a3bc6e 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/content_filter/ContentCategoryConfiguration.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/content_filter/ContentCategoryConfiguration.tsx @@ -199,7 +199,7 @@ const ContentCategoryConfiguration: React.FC - + {ACTION_ITEMS.map((item) => ( {item.value} @@ -224,7 +224,7 @@ const ContentCategoryConfiguration: React.FC - + {SEVERITY_ITEMS.map((item) => ( {item.label} diff --git a/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/content_filter/CustomPatternModal.tsx b/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/content_filter/CustomPatternModal.tsx index 2ead171d5e4..68eb7e138ab 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/content_filter/CustomPatternModal.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/content_filter/CustomPatternModal.tsx @@ -70,7 +70,7 @@ const CustomPatternModal: React.FC = ({ - + {ACTION_ITEMS.map((item) => ( {item.label} diff --git a/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/content_filter/KeywordModal.tsx b/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/content_filter/KeywordModal.tsx index 504f35973fd..bf1b49dabd0 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/content_filter/KeywordModal.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/content_filter/KeywordModal.tsx @@ -60,7 +60,7 @@ const KeywordModal: React.FC = ({ - + {ACTION_ITEMS.map((item) => ( {item.label} diff --git a/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/content_filter/KeywordTable.tsx b/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/content_filter/KeywordTable.tsx index 5c7e3ef3ab8..5b69b04955f 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/content_filter/KeywordTable.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/content_filter/KeywordTable.tsx @@ -38,7 +38,7 @@ const KeywordTable: React.FC = ({ keywords, onActionChange, o - + {ACTION_ITEMS.map((item) => ( {item.label} diff --git a/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/content_filter/PatternModal.tsx b/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/content_filter/PatternModal.tsx index 4caa47217fe..aeeadedfbf1 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/content_filter/PatternModal.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/content_filter/PatternModal.tsx @@ -114,7 +114,7 @@ const PatternModal: React.FC = ({ - + {ACTION_ITEMS.map((item) => ( {item.label} diff --git a/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/content_filter/PatternTable.tsx b/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/content_filter/PatternTable.tsx index 6dd266f07a0..f4e87119d7b 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/content_filter/PatternTable.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/content_filter/PatternTable.tsx @@ -58,7 +58,7 @@ const PatternTable: React.FC = ({ patterns, onActionChange, o - + {ACTION_ITEMS.map((item) => ( {item.label} 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 77bac8bb0aa..a69824f32d3 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 @@ -556,7 +556,7 @@ const CustomCodeModal: React.FC = ({ visible, onClose, onS - + STANDARD {TEMPLATE_ITEMS.map((template) => ( 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 5f8e833af8d..0de7eb1c9ce 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 @@ -179,7 +179,7 @@ export const PiiEntityList: React.FC = ({ - + {actions.map((action) => ( diff --git a/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/tool_permission/ToolPermissionRulesEditor.tsx b/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/tool_permission/ToolPermissionRulesEditor.tsx index 8fe2bf5bf21..c154d102314 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/tool_permission/ToolPermissionRulesEditor.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/tool_permission/ToolPermissionRulesEditor.tsx @@ -280,7 +280,7 @@ const ToolPermissionRulesEditor: React.FC = ({ v - + {DECISION_ITEMS.map((item) => ( {item.label} @@ -313,7 +313,7 @@ const ToolPermissionRulesEditor: React.FC = ({ v - + {DECISION_ITEMS.map((item) => ( {item.label} @@ -350,7 +350,7 @@ const ToolPermissionRulesEditor: React.FC = ({ v - + {ON_DISALLOWED_ITEMS.map((item) => ( {item.label} diff --git a/ui/litellm-dashboard/src/app/(dashboard)/vector-stores/_components/CreateVectorStore.tsx b/ui/litellm-dashboard/src/app/(dashboard)/vector-stores/_components/CreateVectorStore.tsx index 9d447090381..8f24e47340c 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/vector-stores/_components/CreateVectorStore.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/vector-stores/_components/CreateVectorStore.tsx @@ -342,7 +342,7 @@ const CreateVectorStore: React.FC = ({ accessToken, onSu - + {providerItems.map((item) => ( diff --git a/ui/litellm-dashboard/src/app/(dashboard)/vector-stores/_components/VectorStoreForm.tsx b/ui/litellm-dashboard/src/app/(dashboard)/vector-stores/_components/VectorStoreForm.tsx index be0954a7d24..9d78b727768 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/vector-stores/_components/VectorStoreForm.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/vector-stores/_components/VectorStoreForm.tsx @@ -298,7 +298,7 @@ const VectorStoreForm: React.FC = ({ }} - + {Object.entries(VectorStoreProviders).map(([providerEnum, providerDisplayName]) => ( = ({ }} - + {Object.entries(Providers) .filter(([providerEnum]) => providerEnum === "Bedrock") .map(([providerEnum, providerDisplayName]) => ( diff --git a/ui/litellm-dashboard/src/components/ui/select.test.tsx b/ui/litellm-dashboard/src/components/ui/select.test.tsx index 15dd266d38c..308b5ecec3b 100644 --- a/ui/litellm-dashboard/src/components/ui/select.test.tsx +++ b/ui/litellm-dashboard/src/components/ui/select.test.tsx @@ -1,5 +1,6 @@ import { describe, expect, it } from "vitest"; import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; const ENVIRONMENTS = [ @@ -63,3 +64,40 @@ describe("SelectValue label resolution", () => { expect(screen.getByTestId("trigger")).toHaveTextContent("Any environment"); }); }); + +function renderOpenableSelect(contentProps?: React.ComponentProps) { + return render( + , + ); +} + +describe("SelectContent anchoring", () => { + it("anchors to the edge of the trigger rather than over it by default", async () => { + const user = userEvent.setup(); + renderOpenableSelect(); + + await user.click(screen.getByTestId("trigger")); + + expect(await screen.findByTestId("content")).toHaveAttribute("data-align-trigger", "false"); + }); + + it("still lets a caller opt into item-aligned anchoring", async () => { + const user = userEvent.setup(); + renderOpenableSelect({ alignItemWithTrigger: true }); + + await user.click(screen.getByTestId("trigger")); + + expect(await screen.findByTestId("content")).toHaveAttribute("data-align-trigger", "true"); + }); +}); diff --git a/ui/litellm-dashboard/src/components/ui/select.tsx b/ui/litellm-dashboard/src/components/ui/select.tsx index 3c3f8c81c66..814695d3418 100644 --- a/ui/litellm-dashboard/src/components/ui/select.tsx +++ b/ui/litellm-dashboard/src/components/ui/select.tsx @@ -49,7 +49,7 @@ function SelectContent({ sideOffset = 4, align = "center", alignOffset = 0, - alignItemWithTrigger = true, + alignItemWithTrigger = false, ...props }: SelectPrimitive.Popup.Props & Pick) {