fix(studio): Renders dropdown options take a mouse click again (#4890)

* fix(studio): Renders dropdown options take a mouse click again

* test(studio): name a Select whose list never opens and keep both Studio layout checks running
This commit is contained in:
Miguel Ángel
2026-10-01 23:22:06 -07:00
committed by GitHub
parent 0559be0b13
commit 6dcc300fd2
6 changed files with 92 additions and 10 deletions
+7 -2
View File
@@ -958,7 +958,7 @@ jobs:
node packages/studio/tests/e2e/perf-ratchet.mjs check \
packages/studio/tests/e2e/perf-ceilings.json studio-open /tmp/studio-open-counts.json \
/tmp/base-perf-ceilings.json
- name: Measure the fade handle tabs on a narrow clip
- name: Measure the fade handle tabs and click every Renders select
if: success() || failure()
run: |
set -euo pipefail
@@ -973,8 +973,13 @@ jobs:
echo "FAIL: studio dev server did not start on port 5316"
exit 1
fi
# Both checks run even when the first fails.
rc=0
STUDIO_URL="http://localhost:5316/#project/fade-handles" \
node packages/studio/tests/e2e/fade-handles.mjs
node packages/studio/tests/e2e/fade-handles.mjs || rc=1
STUDIO_URL="http://localhost:5316/#project/fade-handles?tab=renders" \
node packages/studio/tests/e2e/render-selects.mjs || rc=1
exit "$rc"
- name: Measure the caret of a text edited in place
if: success() || failure()
run: |
+2 -2
View File
@@ -52,8 +52,8 @@ export const popupSurface = cn(
"data-[preview-state=open]:opacity-100 data-[preview-state=open]:scale-100",
);
/** The popup's own layer: menus sit above panel chrome and below a modal. */
const POPUP_LAYER = "z-200";
/** Every floating list, menu, popover and tooltip sits on this layer, above panel chrome and modals. */
export const POPUP_LAYER = "z-200";
const menuPopup = cn(popupSurface, "min-w-36 p-1 shadow-menu");
@@ -6,7 +6,7 @@
import { Popover as BasePopover } from "@base-ui/react/popover";
import type { ComponentPropsWithoutRef, ReactElement, ReactNode } from "react";
import { cn } from "./cn";
import { popupSurface, type PopupPreviewState } from "./Menu";
import { POPUP_LAYER, popupSurface, type PopupPreviewState } from "./Menu";
type PortalContainer = ComponentPropsWithoutRef<typeof BasePopover.Portal>["container"];
@@ -70,7 +70,7 @@ export function Popover({
sideOffset={sideOffset}
collisionPadding={VIEWPORT_MARGIN}
arrowPadding={ARROW_CORNER_CLEARANCE}
className="z-200"
className={POPUP_LAYER}
>
<BasePopover.Popup
aria-label={ariaLabel}
+2 -2
View File
@@ -6,7 +6,7 @@
import { Select as BaseSelect } from "@base-ui/react/select";
import { cn } from "./cn";
import { fieldBase } from "./Input";
import { floatingMotion } from "./Menu";
import { floatingMotion, POPUP_LAYER } from "./Menu";
import type { PreviewState } from "./Button";
export interface SelectOption {
@@ -77,7 +77,7 @@ export function Select({
</BaseSelect.Trigger>
<BaseSelect.Portal>
<BaseSelect.Positioner sideOffset={4} alignItemWithTrigger={false}>
<BaseSelect.Positioner sideOffset={4} alignItemWithTrigger={false} className={POPUP_LAYER}>
<BaseSelect.Popup
className={cn(
"min-w-[var(--anchor-width)] rounded-md border border-border bg-surface py-1 shadow-menu",
@@ -6,7 +6,7 @@
import { Tooltip as BaseTooltip } from "@base-ui/react/tooltip";
import { cloneElement, useId, useRef, useState, type ReactElement } from "react";
import { cn } from "./cn";
import { floatingMotion } from "./Menu";
import { floatingMotion, POPUP_LAYER } from "./Menu";
interface TooltipProps {
label: string;
@@ -39,7 +39,7 @@ export function Tooltip({ label, children, delay = 400, side = "top" }: TooltipP
side={side}
sideOffset={SIDE_OFFSET}
collisionPadding={VIEWPORT_MARGIN}
className="z-200"
className={POPUP_LAYER}
>
<BaseTooltip.Popup
id={tooltipId}
@@ -0,0 +1,77 @@
#!/usr/bin/env node
// Each Renders Select must take a real mouse click on its last option: the open list has to sit above
// the sibling fields and the Export button. Hit-testing only happens in real layout, so this runs in Chrome.
import { launchStudioChrome } from "./chrome-executable.mjs";
const STUDIO_URL = process.env.STUDIO_URL;
const LABELS = ["Format", "Resolution", "Frame rate", "Quality"];
const SHOT_DIR = process.env.RENDER_SELECTS_SHOT_DIR;
if (!STUDIO_URL) {
console.error("STUDIO_URL is required and must point at a Studio project with ?tab=renders");
process.exit(2);
}
const trigger = (label) => `[role="combobox"][aria-label="${label}"]`;
const { browser } = await launchStudioChrome();
const failures = [];
const evidence = [];
try {
const page = await browser.newPage();
await page.setViewport({ width: 1440, height: 900 });
await page.goto(STUDIO_URL, { waitUntil: "domcontentloaded" });
await page.waitForSelector(trigger("Format"), { visible: true, timeout: 60_000 });
// Format's last option is WebM, which keeps Quality on screen for the last pass.
for (const label of LABELS) {
if (!(await page.$(trigger(label)))) {
failures.push(`${label}: no Select on the Renders panel`);
continue;
}
// A click before Studio settles is dropped, so press until the list says it is open.
const open = `${trigger(label)}[aria-expanded="true"]`;
for (let tries = 0; tries < 10 && !(await page.$(open)); tries += 1) {
await page.click(trigger(label));
await page.waitForSelector(open, { timeout: 1_000 }).catch(() => null);
}
if (!(await page.$(open))) {
failures.push(`${label}: the list never opened`);
continue;
}
await page.waitForFunction(() =>
[...document.querySelectorAll('[role="option"]')].some((el) => el.checkVisibility()),
);
// Let the open motion finish so the hit test sees the resting layout.
await new Promise((resolve) => setTimeout(resolve, 300));
// A closing list can linger in the DOM, so only options that are drawn count.
const { text: want, ...point } = await page.evaluate(() => {
const drawn = [...document.querySelectorAll('[role="option"]')].filter(
(el) => el.checkVisibility() && !el.hasAttribute("data-disabled"),
);
const el = drawn.at(-1);
const r = el.getBoundingClientRect();
return { text: el.textContent.trim(), x: r.x + r.width / 2, y: r.y + r.height / 2 };
});
const hit = await page.evaluate(({ x, y }) => {
const top = document.elementFromPoint(x, y);
return {
onOption: Boolean(top?.closest('[role="option"]')),
top: top?.outerHTML.slice(0, 120),
};
}, point);
if (SHOT_DIR) await page.screenshot({ path: `${SHOT_DIR}/${label.replace(" ", "-")}.png` });
if (!hit.onOption) {
// Clicking through would press whatever covers the option, Export included.
failures.push(`${label}: "${want}" is covered by ${hit.top}`);
await page.keyboard.press("Escape");
continue;
}
await page.mouse.click(point.x, point.y);
await page.waitForSelector(`${trigger(label)}[aria-expanded="false"]`, { timeout: 5_000 });
const got = await page.$eval(trigger(label), (el) => el.textContent.trim());
evidence.push({ label, want, got });
if (got !== want) failures.push(`${label}: clicked "${want}", the field shows "${got}"`);
}
} finally {
await browser.close();
}
console.log(JSON.stringify({ evidence, failures }, null, 2));
if (failures.length > 0) process.exit(1);