From 7d8ff0f2df14bb41c1196eca39b43a66e3f64ba5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 11 Oct 2026 13:09:12 +0200 Subject: [PATCH] fix(day): keep Escape in the date calendar from closing the whole day On a phone the date's calendar is a sheet over the day's own sheet. In the moment after it opens, before Radix has re-rendered both layers, the day's sheet still holds the Escape listener, so an Escape aimed at the calendar closed the day as well; the day-view spec caught it as a flake on the phone viewport (4 of 10 runs locally). The day's sheet now ignores an Escape that comes from inside another dialog, and the spec waits for the layer handover instead of racing it. The coach open-at-end spec read the thread's height as soon as an element with the thread's slot was visible, but the empty state carries the same slot. It now waits for the loaded thread's frame and all of its answers. --- e2e/coach-open-at-end.spec.ts | 24 +++++++++++++++++++----- e2e/day-view.spec.ts | 19 +++++++++++++++++++ src/components/day/day-layer.tsx | 12 ++++++++++++ 3 files changed, 50 insertions(+), 5 deletions(-) diff --git a/e2e/coach-open-at-end.spec.ts b/e2e/coach-open-at-end.spec.ts index ef3ef4fd1..a86f0426c 100644 --- a/e2e/coach-open-at-end.spec.ts +++ b/e2e/coach-open-at-end.spec.ts @@ -185,12 +185,26 @@ test.describe("Coach: a saved conversation opens at its end", () => { waitUntil: "domcontentloaded", }); - const thread = page.locator('[data-slot="coach-message-thread"]'); - await expect(thread).toBeVisible({ timeout: 15_000 }); + // The empty state carries the same `coach-message-thread` slot, so the + // thread being visible says nothing about the conversation having + // arrived; the height used to be read from the empty state on a slow + // run. Wait for the frame only a loaded thread renders, and for every + // stored answer in it. + await expect( + page.locator('[data-slot="coach-message-thread-frame"]'), + ).toBeVisible({ timeout: 15_000 }); + await expect( + page.locator('[data-slot="coach-assistant-turn"]'), + ).toHaveCount(8, { timeout: 15_000 }); + const thread = page.locator( + '[data-slot="coach-message-thread-frame"] [data-slot="coach-message-thread"]', + ); // The thread scrolls at all: otherwise "at the end" proves nothing. - expect( - await thread.evaluate((el) => el.scrollHeight > el.clientHeight + 200), - ).toBe(true); + await expect + .poll(() => + thread.evaluate((el) => el.scrollHeight > el.clientHeight + 200), + ) + .toBe(true); const chart = page.locator( '[data-slot="coach-result-chart"][data-ref="r1"]', diff --git a/e2e/day-view.spec.ts b/e2e/day-view.spec.ts index 3b99bacd5..0aae88187 100644 --- a/e2e/day-view.spec.ts +++ b/e2e/day-view.spec.ts @@ -275,6 +275,25 @@ test.describe("the day view", () => { ), ) .toBe(true); + // On a phone the calendar is a sheet over the day's own sheet. Focus + // arrives before Radix has handed it the Escape key: in between, the day + // underneath still holds it. The handover shows as the day's layer + // turning inert (`pointer-events: none`) once the calendar is the top + // layer; wait for that, not for a fixed time. The docked column is no + // dialog and has no such handover. + if (!desktop) + await expect + .poll(() => + panel(page).evaluate( + (el) => + new Promise((resolve) => + requestAnimationFrame(() => + setTimeout(() => resolve(getComputedStyle(el).pointerEvents)), + ), + ), + ), + ) + .toBe("none"); await page.keyboard.press("Escape"); await expect(picker).toHaveCount(0); await expect(panel(page)).toBeVisible(); diff --git a/src/components/day/day-layer.tsx b/src/components/day/day-layer.tsx index d0cfaec05..8c75f2ea3 100644 --- a/src/components/day/day-layer.tsx +++ b/src/components/day/day-layer.tsx @@ -717,6 +717,18 @@ function DaySheet({ event.preventDefault(); returnFocus(); }} + onEscapeKeyDown={(event) => { + // A dialog opened from inside the day (the date's calendar) is + // portaled out of the panel. For a moment after it opens, until + // Radix has re-rendered both layers, the panel still holds the + // Escape listener: an Escape aimed at the calendar then closed the + // whole day. Escape from inside another dialog is never the day's. + const owner = + event.target instanceof Element + ? event.target.closest('[role="dialog"]') + : null; + if (owner && owner !== panelRef.current) event.preventDefault(); + }} className={cn( // Quicker than the primitive's 500 ms, which read as lag on a // layer opened from a tap; no slide at all under reduced motion.