From 83886494cec9033231f0673a94345bef8b67494a Mon Sep 17 00:00:00 2001 From: npeter83 Date: Thu, 8 Oct 2026 22:16:47 +0200 Subject: [PATCH] fix(ui): a membership menu re-homes focus a frame later, once its new rows are on the list Rows that just mounted join the menu's navigation list a render after the status effect ran: a row focused that early was unknown to it, and the arrows did nothing from it (seen live after a keyboard Retry). The retry test now arrows on from the row it lands on. --- frontend/src/components/PlexMenu.dom.test.tsx | 7 ++++- frontend/src/components/ui/MembershipMenu.tsx | 29 +++++++++++-------- 2 files changed, 23 insertions(+), 13 deletions(-) diff --git a/frontend/src/components/PlexMenu.dom.test.tsx b/frontend/src/components/PlexMenu.dom.test.tsx index 17e29bee..ac5b2105 100644 --- a/frontend/src/components/PlexMenu.dom.test.tsx +++ b/frontend/src/components/PlexMenu.dom.test.tsx @@ -364,7 +364,9 @@ describe("a Plex title's menu — Add to collection", () => { // A menu owns its items only: the line about the list sits beside it, not in it. expect(document.querySelector("[role=menu]")).not.toBeNull(); expect(alert.closest("[role=menu]")).toBeNull(); - list.mockResolvedValue({ collections: [coll("cB", "Beta", false)] }); + list.mockResolvedValue({ + collections: [coll("cB", "Beta", false), coll("cC", "Gamma", false)], + }); // Retry from the keyboard: the focused button goes as the list loads, and focus must not be left // on with the menu open — it lands on the first row that came back. const retry = (await screen.findByText("common.retry")).closest("button")!; @@ -372,6 +374,9 @@ describe("a Plex title's menu — Add to collection", () => { fireEvent.click(retry); const beta = await checkRow("Beta"); await vi.waitFor(() => expect(document.activeElement).toBe(beta)); + // …and the menu's own arrows carry on from there. + fireEvent.keyDown(beta, { key: "ArrowDown" }); + expect(document.activeElement).toBe(await checkRow("Gamma")); }); it("clears the name when Plex made the collection and only the read after it failed", async () => { diff --git a/frontend/src/components/ui/MembershipMenu.tsx b/frontend/src/components/ui/MembershipMenu.tsx index 2ab11c5d..5739dba6 100644 --- a/frontend/src/components/ui/MembershipMenu.tsx +++ b/frontend/src/components/ui/MembershipMenu.tsx @@ -79,18 +79,23 @@ export function MembershipMenu({ useEffect(() => { if (shownStatus.current === status) return; shownStatus.current = status; - const root = rootRef.current; - const at = document.activeElement; - // …unless a name is being typed into it meanwhile. - const parked = at === placed.current && !(at instanceof HTMLInputElement && at.value); - const lost = at == null || at === document.body || parked; - if (!root || !lost) return; - const target = - root.querySelector("[role=menuitemcheckbox]") ?? - root.querySelector("input") ?? - root.querySelector("button"); - target?.focus(); - placed.current = target; + // A frame later: rows that just mounted join the menu's list a render after this effect, and a + // row focused before that is not known to it — the arrows then did nothing from it. + const frame = requestAnimationFrame(() => { + const root = rootRef.current; + const at = document.activeElement; + // …unless a name is being typed into it meanwhile. + const parked = at === placed.current && !(at instanceof HTMLInputElement && at.value); + const lost = at == null || at === document.body || parked; + if (!root || !lost) return; + const target = + root.querySelector("[role=menuitemcheckbox]") ?? + root.querySelector("input") ?? + root.querySelector("button"); + target?.focus(); + placed.current = target; + }); + return () => cancelAnimationFrame(frame); }, [status]); async function submit() {