Pickers: anchor the desktop dropdown to its chip, never clipped

"The model selection in teach it back is kinda broken at least on desktop":
the desktop panels were position:absolute children of whatever container held
their chip. In chat that is the composer at the bottom of the column, which
works, but Teach it back keeps its chips inside a scrolling form card — so the
panel opened upward out of the card and its top rows (including the selected
model) were cut off. The probe measured top: -66px and no way to reach the
first rows by scrolling the list.

- New components/chat/PickerPopover.tsx: the desktop surface for both pickers,
  portalled to <body> and position:fixed, anchored to the opening chip
  ([data-picker-trigger][aria-expanded="true"]), opening on the side with room
  and capped to the viewport so the panel scrolls as a whole with its heading
  and note attached. It closes on an outside press, Escape, resize or a page
  scroll — but not on scrolling its own list.
- ContextPicker and ModelPicker use it on desktop; the phone sheets and their
  inner list caps are unchanged (the list max-height became a parameter: the
  sheet caps it, the desktop panel scrolls instead).
- teach-test pins it: the panel's rect is fully inside the viewport,
  elementFromPoint over it lands on the panel, it is not a descendant of the
  scrolling card, and picking a model updates the chip + freezmo.chat.model.v1
  (46 checks). Chat and the phone sheets re-verified unchanged (110 + 128).

Version 0.8.3, SW v105.
This commit is contained in:
Martin Dvořák 2026-10-18 21:55:47 +00:00
commit 705808d45a
7 changed files with 215 additions and 82 deletions

View file

@ -224,6 +224,42 @@ try {
(await page.getByRole("button", { name: "Skeptical student" }).getAttribute("aria-pressed")) === "true",
);
/* The model dropdown lives in a fixed, portalled panel: inside the
scrolling card it used to open off the top of the viewport and lose its
first rows. */
await page.locator("[data-picker-trigger]").last().click();
const modelDialog = page.getByRole("dialog", { name: "Chat model" });
await modelDialog.waitFor({ timeout: 5000 });
const panelFit = await modelDialog.evaluate((node) => {
const rect = node.getBoundingClientRect();
const centre = document.elementFromPoint(rect.left + rect.width / 2, rect.top + 12);
return {
inside:
rect.top >= 0 &&
rect.left >= 0 &&
rect.bottom <= window.innerHeight + 1 &&
rect.right <= window.innerWidth + 1,
onTop: Boolean(centre && node.contains(centre)),
portalled: node.closest(".chat-scroll") === null,
};
});
check(
"ui: the model dropdown fits the viewport and is not clipped by the card",
panelFit.inside && panelFit.onTop && panelFit.portalled,
JSON.stringify(panelFit),
);
await modelDialog.getByRole("button", { name: /stub-vision/i }).first().click();
await page.waitForTimeout(300);
check(
"ui: picking a model updates the chip and is remembered",
(await page.getByRole("button", { name: /Stub Vision/ }).count()) > 0 &&
(await page.evaluate(() => window.localStorage.getItem("freezmo.chat.model.v1"))) === "stub-vision",
);
/* Back to the first model so the session checks below stay on stub-text. */
await page.locator("[data-picker-trigger]").last().click();
await page.getByRole("dialog", { name: "Chat model" }).getByRole("button", { name: /stub-text/i }).first().click();
await page.waitForTimeout(300);
/* 6. UI: a session ----------------------------------------------- */
turns = 0;
await page.getByTestId("teach-start").click();