Skip to content

Commit fdcf797

Browse files
committed
fix: combobox works inside the health-check editor sheet
The operation/identity comboboxes portal their popup to <body>, outside the sheet's dialog subtree, so a modal Radix sheet froze them: body pointer-events were disabled (no click/select) and react-remove-scroll locked wheel scroll. - The editor sheet is non-modal, dropping both locks while keeping the overlay. - The popup gets pointer-events:auto (defensive for any modal-dialog combobox). - The sheet keeps itself open when an interaction originates inside a combobox popup, so clicking an option doesn't dismiss it. Adds e2e guards that click and wheel-scroll the popup inside the sheet.
1 parent 62e30c7 commit fdcf797

4 files changed

Lines changed: 166 additions & 2 deletions

File tree

e2e/scenarios/health-checks-ui.test.ts

Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -819,3 +819,144 @@ scenario(
819819
}),
820820
),
821821
);
822+
823+
// ===========================================================================
824+
// 6. Edit sheet, click interaction: the combobox popup is portaled outside the
825+
// Radix sheet, so clicking an option must not be treated as an outside
826+
// interaction that dismisses the sheet (keyboard scenarios above miss this).
827+
// ===========================================================================
828+
829+
scenario(
830+
"Health checks (UI) · clicking a combobox option in the sheet selects it without closing the sheet",
831+
{},
832+
Effect.scoped(
833+
Effect.gen(function* () {
834+
const target = yield* Target;
835+
const browser = yield* Browser;
836+
const { client: makeClient } = yield* Api;
837+
const identity = yield* target.newIdentity();
838+
const client = yield* makeClient(api, identity);
839+
const slug = newSlug("hc-ui-click");
840+
841+
yield* Effect.ensuring(
842+
Effect.gen(function* () {
843+
yield* registerIdentityIntegration(client, slug, "https://identity.example.com");
844+
const operation = yield* getMeOperation(client, slug);
845+
846+
yield* browser.session(identity, async ({ page, step }) => {
847+
const sheet = page.getByRole("dialog");
848+
const operationInput = page.locator("#health-check-operation");
849+
const identityInput = page.locator("#health-check-identity");
850+
851+
await step("Open the health-check editor", async () => {
852+
await page.goto(`/integrations/${slug}`, { waitUntil: "networkidle" });
853+
await page.getByRole("heading", { level: 3, name: "Health check" }).waitFor();
854+
await page.getByRole("button", { name: "Set up" }).click();
855+
await operationInput.waitFor();
856+
});
857+
858+
await step(
859+
"Click the operation option: it selects and the sheet stays open",
860+
async () => {
861+
await operationInput.click();
862+
await page.getByRole("option").filter({ hasText: "getMe" }).first().click();
863+
// Clicking the portaled popup option must NOT dismiss the sheet.
864+
await sheet.waitFor();
865+
expect(await operationInput.inputValue()).toContain("getMe");
866+
},
867+
);
868+
869+
await step("Click the identity option by mouse, then save", async () => {
870+
await identityInput.click();
871+
await page.getByRole("option").filter({ hasText: "email" }).first().click();
872+
await sheet.waitFor();
873+
expect(await identityInput.inputValue()).toBe("email");
874+
await page.getByRole("button", { name: "Save", exact: true }).click();
875+
await operationInput.waitFor({ state: "hidden" });
876+
});
877+
});
878+
879+
// The mouse-driven selections persisted: only possible if the option
880+
// clicks selected without dismissing the sheet first.
881+
const stored = yield* client.integrations.healthCheckGet({ params: { slug } });
882+
expect(stored).toEqual({ operation, identityField: "email" });
883+
}),
884+
client.openapi.removeSpec({ params: { slug } }).pipe(Effect.ignore),
885+
);
886+
}),
887+
),
888+
);
889+
890+
// ===========================================================================
891+
// 7. Edit sheet, scroll: a modal dialog locks body scroll, which freezes the
892+
// portaled combobox list. The editor sheet is non-modal so the list scrolls.
893+
// ===========================================================================
894+
895+
scenario(
896+
"Health checks (UI) · the combobox list scrolls inside the edit sheet",
897+
{},
898+
Effect.scoped(
899+
Effect.gen(function* () {
900+
const target = yield* Target;
901+
const browser = yield* Browser;
902+
const { client: makeClient } = yield* Api;
903+
const identity = yield* target.newIdentity();
904+
const client = yield* makeClient(api, identity);
905+
const slug = newSlug("hc-ui-scroll");
906+
907+
yield* Effect.ensuring(
908+
Effect.gen(function* () {
909+
// Many operations so the popup list overflows and must scroll.
910+
yield* client.openapi.addSpec({
911+
payload: {
912+
spec: { kind: "blob", value: largeSpec("https://big.example.com") },
913+
slug,
914+
baseUrl: "https://big.example.com",
915+
authenticationTemplate: [
916+
{
917+
slug: "apiKey",
918+
type: "apiKey",
919+
headers: { authorization: ["Bearer ", { type: "variable", name: "token" }] },
920+
},
921+
],
922+
},
923+
});
924+
925+
yield* browser.session(identity, async ({ page, step }) => {
926+
const operationInput = page.locator("#health-check-operation");
927+
const list = page.locator("[data-slot='combobox-list']").first();
928+
929+
await step("Open the operation combobox in the sheet", async () => {
930+
await page.goto(`/integrations/${slug}`, { waitUntil: "networkidle" });
931+
await page.getByRole("heading", { level: 3, name: "Health check" }).waitFor();
932+
await page.getByRole("button", { name: "Set up" }).click();
933+
await operationInput.waitFor();
934+
await operationInput.click();
935+
await list.waitFor();
936+
});
937+
938+
await step("Wheel-scroll the list: it actually moves (not scroll-locked)", async () => {
939+
const before = await list.evaluate((el) => el.scrollTop);
940+
await list.hover();
941+
await page.mouse.wheel(0, 600);
942+
// Poll: the wheel scroll must move the list (blocked → stays 0).
943+
await page.waitForFunction(
944+
(start) => {
945+
const el = document.querySelector("[data-slot='combobox-list']");
946+
return el != null && el.scrollTop > start + 20;
947+
},
948+
before,
949+
{ timeout: 5000 },
950+
);
951+
const after = await list.evaluate((el) => el.scrollTop);
952+
expect(after, "the list scrolled past its starting offset").toBeGreaterThan(before);
953+
// The sheet stayed open through the scroll interaction.
954+
await page.getByRole("dialog").waitFor();
955+
});
956+
});
957+
}),
958+
client.openapi.removeSpec({ params: { slug } }).pipe(Effect.ignore),
959+
);
960+
}),
961+
),
962+
);

packages/react/src/components/combobox.tsx

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -103,7 +103,11 @@ function ComboboxContent({
103103
align={align}
104104
alignOffset={alignOffset}
105105
anchor={anchor}
106-
className="isolate z-50"
106+
// `pointer-events-auto` re-enables interaction when the popup is portaled
107+
// out of a modal dialog (Radix sets `pointer-events: none` on the body
108+
// for modal dialogs/sheets; without this the portaled list can't be
109+
// scrolled or clicked). Harmless outside a dialog (auto is the default).
110+
className="isolate z-50 pointer-events-auto"
107111
>
108112
<ComboboxPrimitive.Popup
109113
data-slot="combobox-content"

packages/react/src/components/health-check-editor.tsx

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -483,7 +483,12 @@ function HealthCheckEditorSheet(props: {
483483
const canPreview = livePreview !== undefined && livePreview.templates.length > 0;
484484

485485
return (
486-
<Sheet open={open} onOpenChange={onOpenChange}>
486+
// Non-modal: a modal Radix dialog locks body scroll (react-remove-scroll)
487+
// and disables outside pointer events, which kills the combobox popup
488+
// (portaled to <body>, outside the dialog subtree) - it can't be scrolled or
489+
// clicked. The overlay still renders and the dismissal guard still keeps the
490+
// sheet open while interacting with the popup.
491+
<Sheet open={open} onOpenChange={onOpenChange} modal={false}>
487492
<SheetContent side="right">
488493
<SheetHeader>
489494
<SheetTitle>Health check</SheetTitle>

packages/react/src/components/sheet.tsx

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,11 +38,18 @@ function SheetOverlay({
3838
);
3939
}
4040

41+
// base-ui popups (combobox/select) portal their list OUTSIDE the dialog content,
42+
// so clicking an option reads as an interaction outside the sheet and would
43+
// dismiss it before the selection lands. Keep the sheet open for interactions
44+
// that originate inside such a popup.
45+
const PORTALED_POPUP_SELECTOR = "[data-slot='combobox-content'],[data-slot='select-content']";
46+
4147
function SheetContent({
4248
className,
4349
children,
4450
side = "right",
4551
showCloseButton = true,
52+
onInteractOutside,
4653
...props
4754
}: React.ComponentProps<typeof SheetPrimitive.Content> & {
4855
side?: "top" | "right" | "bottom" | "left";
@@ -53,6 +60,13 @@ function SheetContent({
5360
<SheetOverlay />
5461
<SheetPrimitive.Content
5562
data-slot="sheet-content"
63+
onInteractOutside={(event) => {
64+
const target = event.detail.originalEvent.target;
65+
if (target instanceof Element && target.closest(PORTALED_POPUP_SELECTOR)) {
66+
event.preventDefault();
67+
}
68+
onInteractOutside?.(event);
69+
}}
5670
className={cn(
5771
"fixed z-50 flex flex-col gap-4 bg-background shadow-lg transition ease-in-out data-[state=closed]:animate-out data-[state=closed]:duration-300 data-[state=open]:animate-in data-[state=open]:duration-500",
5872
side === "right" &&

0 commit comments

Comments
 (0)