From 1706f951bb30e62e836324bfac54002be5854ef2 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Sat, 31 Oct 2026 02:00:15 +0100 Subject: [PATCH] fix: open menus in place with reduced motion instead of flashing in the corner first --- app/globals.css | 13 +++++---- scripts/a11y-guard/a11y-guard.spec.ts | 40 +++++++++++++++++++++++++++ 2 files changed, 48 insertions(+), 5 deletions(-) diff --git a/app/globals.css b/app/globals.css index cbba30934..0297c65ce 100644 --- a/app/globals.css +++ b/app/globals.css @@ -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; } diff --git a/scripts/a11y-guard/a11y-guard.spec.ts b/scripts/a11y-guard/a11y-guard.spec.ts index 08558abab..89a248a11 100644 --- a/scripts/a11y-guard/a11y-guard.spec.ts +++ b/scripts/a11y-guard/a11y-guard.spec.ts @@ -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(); + (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 }).__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);