fix: open menus in place with reduced motion instead of flashing in the corner first

This commit is contained in:
Linus Rath
2026-10-31 02:00:15 +01:00
parent 6ccd384f35
commit 1706f951bb
2 changed files with 48 additions and 5 deletions
+8 -5
View File
@@ -755,9 +755,12 @@ optgroup {
animation-iteration-count: 1 !important;
/* Every element transitions `all` by default once it has a duration.
Visibility is left out: a transition from hidden would keep a newly
shown menu or dialog hidden for a frame, and focus() would miss it. */
transition-property: all, visibility !important;
transition-duration: 0.01ms, 0s !important;
shown menu or dialog hidden for a frame, and focus() would miss it.
So is the position: Chrome paints a transition's first frame at its
start value, and a menu placed from script flashed where it was
measured (a submenu in the window's top-left corner). */
transition-property: all, visibility, top, right, bottom, left !important;
transition-duration: 0.01ms, 0s, 0s, 0s, 0s, 0s !important;
scroll-behavior: auto !important;
}
}
@@ -768,8 +771,8 @@ optgroup {
[data-motion="reduce"] *::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-property: all, visibility !important;
transition-duration: 0.01ms, 0s !important;
transition-property: all, visibility, top, right, bottom, left !important;
transition-duration: 0.01ms, 0s, 0s, 0s, 0s, 0s !important;
scroll-behavior: auto !important;
}
+40
View File
@@ -118,6 +118,46 @@ test("keyboard focus stays visible in Windows contrast themes", async ({ page })
expect(outlines.filter((o) => o !== "ok" && o !== "skip")).toEqual([]);
});
test("menus open in place under reduced motion", async ({ page }) => {
// Reduce motion shortens transitions to 0.01ms, but Chrome paints a
// transition's first frame at its start value: a menu moved from where it
// was measured showed there for a frame (the Sort submenu in the corner).
await page.emulateMedia({ reducedMotion: "reduce" });
await signIn(page);
// Each frame, where every visible menu is painted.
await page.evaluate(() => {
const frames = new Map<Element, string[]>();
(window as unknown as { __menuFrames: typeof frames }).__menuFrames = frames;
const tick = () => {
for (const menu of document.querySelectorAll('[role="menu"]')) {
if (getComputedStyle(menu).visibility !== "visible") continue;
const r = menu.getBoundingClientRect();
frames.set(menu, [...(frames.get(menu) ?? []), `${Math.round(r.left)},${Math.round(r.top)}`]);
}
requestAnimationFrame(tick);
};
requestAnimationFrame(tick);
});
await page.getByTestId("mail-folder-menu").first().click();
await page.getByTestId("mail-sort").click();
await page.getByTestId("mail-sort-oldest").waitFor();
await page.waitForTimeout(500);
const result = await page.evaluate(() => {
const frames = (window as unknown as { __menuFrames: Map<Element, string[]> }).__menuFrames;
const jumps: string[] = [];
for (const [menu, seen] of frames) {
const r = menu.getBoundingClientRect();
const final = `${Math.round(r.left)},${Math.round(r.top)}`;
const elsewhere = seen.filter((at) => at !== final);
if (elsewhere.length > 0) jumps.push(`${menu.getAttribute("aria-label") ?? "menu"} at ${elsewhere.join(" ")} before ${final}`);
}
return { jumps, menus: frames.size };
});
// The ⋮ menu and its Sort submenu.
expect(result.menus).toBe(2);
expect(result.jumps).toEqual([]);
});
test("single-key shortcuts don't act behind an open dialog", async ({ page }) => {
await signIn(page);
const rows = page.locator(ROWS);