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.
This commit is contained in:
tin-berri 2026-08-27 13:08:13 -07:00 committed by GitHub
parent fe87b187c6
commit 71449b9c55
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
18 changed files with 119 additions and 22 deletions

View file

@ -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);
});
});

View file

@ -1106,7 +1106,7 @@ export function TeamGuardrailsTab({ accessToken }: TeamGuardrailsTabProps) {
>
<SelectValue />
</SelectTrigger>
<SelectContent alignItemWithTrigger={false}>
<SelectContent>
{GUARDRAIL_MODES.map((mode) => (
<SelectItem key={mode.value} value={mode.value} title={mode.label}>
{mode.label}

View file

@ -64,7 +64,7 @@ const CategoryTable: React.FC<CategoryTableProps> = ({
<SelectTrigger size="sm" className="w-[150px]" aria-label="Severity Threshold">
<SelectValue />
</SelectTrigger>
<SelectContent alignItemWithTrigger={false}>
<SelectContent>
{SEVERITY_ITEMS.map((item) => (
<SelectItem key={item.value} value={item.value}>
{item.label}
@ -93,7 +93,7 @@ const CategoryTable: React.FC<CategoryTableProps> = ({
<SelectTrigger size="sm" className="w-[120px]" aria-label="Action">
<SelectValue />
</SelectTrigger>
<SelectContent alignItemWithTrigger={false}>
<SelectContent>
{ACTION_ITEMS.map((item) => (
<SelectItem key={item.value} value={item.value}>
{item.label}

View file

@ -194,7 +194,7 @@ const CompetitorIntentConfiguration: React.FC<CompetitorIntentConfigurationProps
<SelectTrigger id={`${fieldId}-type`} className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent alignItemWithTrigger={false}>
<SelectContent>
{INTENT_TYPES.map((type) => (
<SelectItem key={type.value} value={type.value} title={type.label}>
{type.label}
@ -268,7 +268,7 @@ const CompetitorIntentConfiguration: React.FC<CompetitorIntentConfigurationProps
<SelectTrigger id={`${fieldId}-competitor-comparison`} className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent alignItemWithTrigger={false}>
<SelectContent>
{COMPETITOR_COMPARISON_POLICIES.map((policy) => (
<SelectItem key={policy.value} value={policy.value} title={policy.label}>
{policy.label}
@ -292,7 +292,7 @@ const CompetitorIntentConfiguration: React.FC<CompetitorIntentConfigurationProps
<SelectTrigger id={`${fieldId}-possible-competitor-comparison`} className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent alignItemWithTrigger={false}>
<SelectContent>
{POSSIBLE_COMPETITOR_COMPARISON_POLICIES.map((policy) => (
<SelectItem key={policy.value} value={policy.value} title={policy.label}>
{policy.label}

View file

@ -199,7 +199,7 @@ const ContentCategoryConfiguration: React.FC<ContentCategoryConfigurationProps>
<SelectTrigger size="sm" className="w-full" aria-label="Action">
<SelectValue />
</SelectTrigger>
<SelectContent alignItemWithTrigger={false}>
<SelectContent>
{ACTION_ITEMS.map((item) => (
<SelectItem key={item.value} value={item.value}>
<Badge variant={item.value === "BLOCK" ? "destructive" : "secondary"}>{item.value}</Badge>
@ -224,7 +224,7 @@ const ContentCategoryConfiguration: React.FC<ContentCategoryConfigurationProps>
<SelectTrigger size="sm" className="w-full" aria-label="Severity Threshold">
<SelectValue />
</SelectTrigger>
<SelectContent alignItemWithTrigger={false}>
<SelectContent>
{SEVERITY_ITEMS.map((item) => (
<SelectItem key={item.value} value={item.value}>
{item.label}

View file

@ -70,7 +70,7 @@ const CustomPatternModal: React.FC<CustomPatternModalProps> = ({
<SelectTrigger className="w-full" aria-label="Action">
<SelectValue />
</SelectTrigger>
<SelectContent alignItemWithTrigger={false}>
<SelectContent>
{ACTION_ITEMS.map((item) => (
<SelectItem key={item.value} value={item.value}>
{item.label}

View file

@ -60,7 +60,7 @@ const KeywordModal: React.FC<KeywordModalProps> = ({
<SelectTrigger className="w-full" aria-label="Action">
<SelectValue />
</SelectTrigger>
<SelectContent alignItemWithTrigger={false}>
<SelectContent>
{ACTION_ITEMS.map((item) => (
<SelectItem key={item.value} value={item.value}>
{item.label}

View file

@ -38,7 +38,7 @@ const KeywordTable: React.FC<KeywordTableProps> = ({ keywords, onActionChange, o
<SelectTrigger size="sm" className="w-[120px]" aria-label="Action">
<SelectValue />
</SelectTrigger>
<SelectContent alignItemWithTrigger={false}>
<SelectContent>
{ACTION_ITEMS.map((item) => (
<SelectItem key={item.value} value={item.value}>
{item.label}

View file

@ -114,7 +114,7 @@ const PatternModal: React.FC<PatternModalProps> = ({
<SelectTrigger className="w-full" aria-label="Action">
<SelectValue />
</SelectTrigger>
<SelectContent alignItemWithTrigger={false}>
<SelectContent>
{ACTION_ITEMS.map((item) => (
<SelectItem key={item.value} value={item.value}>
{item.label}

View file

@ -58,7 +58,7 @@ const PatternTable: React.FC<PatternTableProps> = ({ patterns, onActionChange, o
<SelectTrigger size="sm" className="w-[120px]" aria-label="Action">
<SelectValue />
</SelectTrigger>
<SelectContent alignItemWithTrigger={false}>
<SelectContent>
{ACTION_ITEMS.map((item) => (
<SelectItem key={item.value} value={item.value}>
{item.label}

View file

@ -556,7 +556,7 @@ const CustomCodeModal: React.FC<CustomCodeModalProps> = ({ visible, onClose, onS
<SelectTrigger className="w-full" aria-label="Template">
<SelectValue />
</SelectTrigger>
<SelectContent alignItemWithTrigger={false}>
<SelectContent>
<SelectGroup>
<SelectLabel>STANDARD</SelectLabel>
{TEMPLATE_ITEMS.map((template) => (

View file

@ -179,7 +179,7 @@ export const PiiEntityList: React.FC<PiiEntityListProps> = ({
<SelectTrigger className={`w-[120px] ${isSelected ? "" : "opacity-50"}`} aria-label="Action">
<SelectValue />
</SelectTrigger>
<SelectContent alignItemWithTrigger={false}>
<SelectContent>
{actions.map((action) => (
<SelectItem key={action} value={action}>
<span className="flex items-center">

View file

@ -280,7 +280,7 @@ const ToolPermissionRulesEditor: React.FC<ToolPermissionRulesEditorProps> = ({ v
<SelectTrigger className="w-[200px]" aria-label="Decision">
<SelectValue />
</SelectTrigger>
<SelectContent alignItemWithTrigger={false}>
<SelectContent>
{DECISION_ITEMS.map((item) => (
<SelectItem key={item.value} value={item.value}>
{item.label}
@ -313,7 +313,7 @@ const ToolPermissionRulesEditor: React.FC<ToolPermissionRulesEditorProps> = ({ v
<SelectTrigger className="w-full" aria-label="Default action">
<SelectValue />
</SelectTrigger>
<SelectContent alignItemWithTrigger={false}>
<SelectContent>
{DECISION_ITEMS.map((item) => (
<SelectItem key={item.value} value={item.value}>
{item.label}
@ -350,7 +350,7 @@ const ToolPermissionRulesEditor: React.FC<ToolPermissionRulesEditorProps> = ({ v
<SelectTrigger className="w-full" aria-label="On disallowed action">
<SelectValue />
</SelectTrigger>
<SelectContent alignItemWithTrigger={false}>
<SelectContent>
{ON_DISALLOWED_ITEMS.map((item) => (
<SelectItem key={item.value} value={item.value}>
{item.label}

View file

@ -342,7 +342,7 @@ const CreateVectorStore: React.FC<CreateVectorStoreProps> = ({ accessToken, onSu
<SelectTrigger id="vector-store-provider" className="w-full">
<SelectValue placeholder="Select a provider" />
</SelectTrigger>
<SelectContent alignItemWithTrigger={false}>
<SelectContent>
{providerItems.map((item) => (
<SelectItem key={item.value} value={item.value}>
<Logo src={vectorStoreProviderLogoMap[item.label]} label={item.label} className="w-5 h-5" />

View file

@ -298,7 +298,7 @@ const VectorStoreForm: React.FC<VectorStoreFormProps> = ({
}}
</SelectValue>
</SelectTrigger>
<SelectContent alignItemWithTrigger={false}>
<SelectContent>
{Object.entries(VectorStoreProviders).map(([providerEnum, providerDisplayName]) => (
<SelectItem key={providerEnum} value={vectorStoreProviderMap[providerEnum]}>
<Logo

View file

@ -280,7 +280,7 @@ const VectorStoreInfoView: React.FC<VectorStoreInfoViewProps> = ({
}}
</SelectValue>
</SelectTrigger>
<SelectContent alignItemWithTrigger={false}>
<SelectContent>
{Object.entries(Providers)
.filter(([providerEnum]) => providerEnum === "Bedrock")
.map(([providerEnum, providerDisplayName]) => (

View file

@ -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<typeof SelectContent>) {
return render(
<Select value={null} items={ENVIRONMENTS}>
<SelectTrigger data-testid="trigger">
<SelectValue placeholder="Pick an environment" />
</SelectTrigger>
<SelectContent data-testid="content" {...contentProps}>
{ENVIRONMENTS.map((environment) => (
<SelectItem key={environment.value} value={environment.value}>
{environment.label}
</SelectItem>
))}
</SelectContent>
</Select>,
);
}
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");
});
});

View file

@ -49,7 +49,7 @@ function SelectContent({
sideOffset = 4,
align = "center",
alignOffset = 0,
alignItemWithTrigger = true,
alignItemWithTrigger = false,
...props
}: SelectPrimitive.Popup.Props &
Pick<SelectPrimitive.Positioner.Props, "align" | "alignOffset" | "side" | "sideOffset" | "alignItemWithTrigger">) {