Skip to content

Commit 3b2ecda

Browse files
committed
Keep the sidebar machine menu's focused row visible
Retain the focused row when Base UI resizes the sidebar Default machine menu's scrollport. Its existing upward placement, viewport height cap, portal and anchor tracking stay in place. Cover populated sidebars at 1862x1039, 1920x1080, 1280x800 and 1280x600, with one and three projects, plus the 390x844 touch navigation path. Exercise project creation without reload and a long menu across resize. Assistant-model: GPT-6.1 Sol ultrafast Assistant-workflow: inline (fast subagents) Assistant-verification: red-before-fix: the focused Manage machines row became hidden after resizing an open 30-machine sidebar menu from 1862x1039 to 1280x600; the regression passed after the fix Assistant-verification: GIT_CONFIG_GLOBAL=/dev/null bun test apps/web (809 pass, 2 skip, 0 fail) Assistant-verification: bun run types and bun run ci passed; no design-contract hashes changed Assistant-verification: 31 relevant Chromium e2e tests passed with retries disabled, including the viewport matrix, project creation, keyboard focus, scrolling and default selection Assistant-verification: the reported one-machine downward clipping did not reproduce on eb5edfe; the independently reproduced resize/focus defect is fixed Co-authored-by: Alex Lavaee <alexlavaee@microsoft.com>
1 parent eb5edfe commit 3b2ecda

2 files changed

Lines changed: 182 additions & 2 deletions

File tree

‎apps/web/src/machines-entry.tsx‎

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { Menu } from "@base-ui/react/menu";
22
import { Count } from "@chopin/editor/count";
33
import { MachineIcon, PlusIcon, ServerIcon, WarningIcon } from "@chopin/icons";
4+
import { useLayoutEffect, useRef } from "react";
45

56
import { ago } from "./build-model";
67
import { setFavorite } from "./machine-choice";
@@ -14,6 +15,7 @@ import {
1415
import "./machines-entry.css";
1516

1617
import type { Machine } from "./machine-summary";
18+
import type { ReactNode } from "react";
1719

1820
/** Where the Machines page's Add machine flow starts: Add your computer, opened at once. */
1921
const ADD_MACHINE_PATH = "/machines?add";
@@ -95,7 +97,7 @@ export function MachinesEntry(
9597
side="top"
9698
sideOffset={4}
9799
>
98-
<Menu.Popup className="machines-menu">
100+
<MachinesMenuPopup>
99101
<Menu.RadioGroup
100102
onValueChange={value => void choose(value as string)}
101103
value={favorite?.id ?? ANY}
@@ -136,13 +138,31 @@ export function MachinesEntry(
136138
</span>
137139
Manage machines
138140
</Menu.LinkItem>
139-
</Menu.Popup>
141+
</MachinesMenuPopup>
140142
</Menu.Positioner>
141143
</Menu.Portal>
142144
</Menu.Root>
143145
);
144146
}
145147

148+
function MachinesMenuPopup({ children }: { children: ReactNode }) {
149+
let popup = useRef<HTMLDivElement>(null);
150+
useLayoutEffect(() => {
151+
let panel = popup.current;
152+
if (!panel) return;
153+
// Placement remeasures on resize, but a smaller scrollport can hide the focused row.
154+
let observer = new ResizeObserver(() => {
155+
let focused = document.activeElement;
156+
if (focused instanceof HTMLElement && panel.contains(focused)) {
157+
focused.scrollIntoView({ block: "nearest" });
158+
}
159+
});
160+
observer.observe(panel);
161+
return () => observer.disconnect();
162+
}, []);
163+
return <Menu.Popup className="machines-menu" ref={popup}>{children}</Menu.Popup>;
164+
}
165+
146166
/** A machine's icon with its state as a dot on the corner, as the Machines page draws it. */
147167
function MachineMark({ machine }: { machine: Machine }) {
148168
let Icon = machine.service ? ServerIcon : MachineIcon;

‎e2e/machine-choice.e2e.ts‎

Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { CONNECTOR_PROTOCOL } from "../apps/connector/src/version";
99
import { authenticate, expect, ready, test } from "./room";
1010
import { createChannel, seedChannel, titleChannel } from "./database";
1111
import { RELEASE } from "./release";
12+
import { failedBuild } from "./build-seed";
1213

1314
import type { Locator, Page } from "@playwright/test";
1415

@@ -911,6 +912,18 @@ for (let height of [1080, 969]) {
911912
await reachable(page, menu, labels);
912913
await shoot(page, `creation-${height}-${repository}-build`);
913914
await page.keyboard.press("Escape");
915+
let entry = sidebar.getByRole("button", { name: /^Machines/ });
916+
await entry.click();
917+
let defaults = page.getByRole("menu").filter({ hasText: "Default machine" });
918+
await defaults.getByRole("menuitemradio").first().click({ trial: true });
919+
await settledMenu(defaults);
920+
expect((await defaults.boundingBox())!.y + (await defaults.boundingBox())!.height)
921+
.toBeLessThanOrEqual((await entry.boundingBox())!.y);
922+
for (let row of await defaults.locator('[role^="menuitem"]').all()) {
923+
expect(await shownIn(row, defaults)).toBe(true);
924+
await row.click({ trial: true });
925+
}
926+
await page.keyboard.press("Escape");
914927
}
915928
},
916929
);
@@ -1034,6 +1047,153 @@ test("twelve machines on a phone: the machine sheet scrolls to its last row and
10341047
.toHaveAccessibleName(`Machine: ${LONG_NAME}, connected`);
10351048
});
10361049

1050+
/** Wait for actual placement and entrance motion, not an arbitrary delay. */
1051+
async function settledMenu(menu: Locator) {
1052+
await menu.evaluate(async element => {
1053+
await Promise.all(
1054+
element.getAnimations({ subtree: true }).map(animation => animation.finished),
1055+
);
1056+
await new Promise<void>(resolve => {
1057+
let previous = "";
1058+
let stable = 0;
1059+
let measure = () => {
1060+
let box = element.getBoundingClientRect();
1061+
let current = `${box.x},${box.y},${box.width},${box.height}`;
1062+
stable = current === previous ? stable + 1 : 0;
1063+
previous = current;
1064+
if (stable === 3) resolve();
1065+
else requestAnimationFrame(measure);
1066+
};
1067+
requestAnimationFrame(measure);
1068+
});
1069+
});
1070+
}
1071+
1072+
for (
1073+
let viewport of [
1074+
{ width: 1862, height: 1039 },
1075+
{ width: 1920, height: 1080 },
1076+
{ width: 1280, height: 800 },
1077+
{ width: 1280, height: 600 },
1078+
PHONE.viewport,
1079+
]
1080+
) {
1081+
for (let projects of [1, 3]) {
1082+
test(`populated sidebar Default machine fits ${viewport.width}x${viewport.height} with ${projects} projects`, async ({ seed, join: enter }) => {
1083+
let title = `Sidebar ${viewport.width}x${viewport.height} ${projects}`;
1084+
await seed(`# ${title}\n\nA document open beside Chat.\n`, {
1085+
graph: GRAPH,
1086+
builds: [failedBuild(GRAPH)],
1087+
});
1088+
let phone = viewport.width === 390;
1089+
let page = await enter(person(), phone ? PHONE : { viewport });
1090+
await connected(page, "devbox");
1091+
for (let index = 0; index < projects; index++) {
1092+
let name = index === 0 ? "score" : `archive-${index}`;
1093+
if (index > 0) {
1094+
for (let document of [1, 2]) {
1095+
let id = crypto.randomUUID();
1096+
await createChannel(Number(new URL(page.url()).port), id, {
1097+
id: `R_archive_${index}`,
1098+
owner: "octo-org",
1099+
name,
1100+
});
1101+
await seedChannel(
1102+
Number(new URL(page.url()).port),
1103+
id,
1104+
`# ${name} ${document}\n\nContent.\n`,
1105+
);
1106+
}
1107+
}
1108+
let added = await page.request.post("/api/navigation/projects", {
1109+
headers: { origin: new URL(page.url()).origin },
1110+
data: { owner: "octo-org", repository: name },
1111+
});
1112+
expect(added.ok(), await added.text()).toBe(true);
1113+
}
1114+
await page.reload();
1115+
await page.getByRole("button", { name: "Build", exact: true }).click();
1116+
if (phone) {
1117+
// Phones intentionally link to Machines from the documents sheet instead of a menu.
1118+
await page.getByRole("button", { name: "Documents", exact: true }).tap();
1119+
let link = page.getByRole("link", { name: /^Machines/ });
1120+
await link.scrollIntoViewIfNeeded();
1121+
await expect(link).toBeInViewport({ ratio: 1 });
1122+
await link.tap();
1123+
await expect(page).toHaveURL(/\/machines$/);
1124+
await expect(page.getByRole("button", { name: "Favorite devbox", exact: true }))
1125+
.toBeVisible();
1126+
return;
1127+
}
1128+
let sidebar = page.getByRole("complementary", { name: "Projects" });
1129+
await expect(sidebar.getByRole("link", { name: title, exact: true })).toBeVisible();
1130+
let entry = sidebar.getByRole("button", { name: /^Machines/ });
1131+
await expect(entry).toBeInViewport({ ratio: 1 });
1132+
await entry.click();
1133+
let menu = page.getByRole("menu").filter({ hasText: "Default machine" });
1134+
await menu.getByRole("menuitemradio").first().click({ trial: true });
1135+
await settledMenu(menu);
1136+
if (SHOTS) {
1137+
await mkdir(SHOTS, { recursive: true });
1138+
await page.screenshot({
1139+
path: `${SHOTS}/sidebar-${viewport.width}x${viewport.height}-${projects}.png`,
1140+
});
1141+
}
1142+
let panel = (await menu.boundingBox())!;
1143+
let trigger = (await entry.boundingBox())!;
1144+
expect(panel.y).toBeGreaterThanOrEqual(0);
1145+
expect(panel.y + panel.height).toBeLessThanOrEqual(trigger.y);
1146+
for (let row of await menu.locator('[role^="menuitem"]').all()) {
1147+
expect(await shownIn(row, menu)).toBe(true);
1148+
await row.click({ trial: true });
1149+
}
1150+
});
1151+
}
1152+
}
1153+
1154+
test("the populated sidebar menu scrolls and refits while the viewport shrinks", async ({ seed, join: enter }) => {
1155+
test.setTimeout(90_000);
1156+
await seed("# Sidebar menu\n\n" + "A long document.\n\n".repeat(80), {
1157+
graph: GRAPH,
1158+
builds: [failedBuild(GRAPH)],
1159+
});
1160+
let page = await enter(person(), { viewport: { width: 1862, height: 1039 } });
1161+
await machines(page, 30);
1162+
let added = await page.request.post("/api/navigation/projects", {
1163+
headers: { origin: new URL(page.url()).origin },
1164+
data: { owner: "octo-org", repository: "score" },
1165+
});
1166+
expect(added.ok(), await added.text()).toBe(true);
1167+
await page.reload();
1168+
await page.getByRole("button", { name: "Build", exact: true }).click();
1169+
let sidebar = page.getByRole("complementary", { name: "Projects" });
1170+
let entry = sidebar.getByRole("button", { name: /^Machines/ });
1171+
await entry.click();
1172+
let menu = page.getByRole("menu").filter({ hasText: "Default machine" });
1173+
await menu.getByRole("menuitemradio").first().click({ trial: true });
1174+
await page.keyboard.press("End");
1175+
let last = menu.getByRole("menuitem", { name: "Manage machines", exact: true });
1176+
await expect(last).toBeFocused();
1177+
expect(await shownIn(last, menu)).toBe(true);
1178+
await page.setViewportSize({ width: 1280, height: 600 });
1179+
await settledMenu(menu);
1180+
if (SHOTS) {
1181+
await mkdir(SHOTS, { recursive: true });
1182+
await page.screenshot({ path: `${SHOTS}/sidebar-default-machine-resize.png` });
1183+
}
1184+
expect(await shownIn(last, menu)).toBe(true);
1185+
let panel = (await menu.boundingBox())!;
1186+
let trigger = (await entry.boundingBox())!;
1187+
expect(trigger.y + trigger.height).toBeLessThanOrEqual(600);
1188+
expect(panel.y).toBeGreaterThanOrEqual(0);
1189+
expect(panel.y + panel.height).toBeLessThanOrEqual(trigger.y);
1190+
for (let row of await menu.locator('[role^="menuitem"]').all()) {
1191+
await row.scrollIntoViewIfNeeded();
1192+
await row.click({ trial: true });
1193+
expect(await shownIn(row, menu)).toBe(true);
1194+
}
1195+
});
1196+
10371197
for (let width of [1280, 600, 390]) {
10381198
test(`the sidebar's default machine control is never clipped at ${width}px`, async ({ join: enter }) => {
10391199
let phone = width === 390;

0 commit comments

Comments
 (0)