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.
This commit is contained in:
2026-10-08 22:16:47 +02:00
parent 4eaa55385f
commit 83886494ce
2 changed files with 23 additions and 13 deletions
@@ -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. // 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(document.querySelector("[role=menu]")).not.toBeNull();
expect(alert.closest("[role=menu]")).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 // Retry from the keyboard: the focused button goes as the list loads, and focus must not be left
// on <body> with the menu open — it lands on the first row that came back. // on <body> with the menu open — it lands on the first row that came back.
const retry = (await screen.findByText("common.retry")).closest("button")!; 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); fireEvent.click(retry);
const beta = await checkRow("Beta"); const beta = await checkRow("Beta");
await vi.waitFor(() => expect(document.activeElement).toBe(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 () => { it("clears the name when Plex made the collection and only the read after it failed", async () => {
+17 -12
View File
@@ -79,18 +79,23 @@ export function MembershipMenu({
useEffect(() => { useEffect(() => {
if (shownStatus.current === status) return; if (shownStatus.current === status) return;
shownStatus.current = status; shownStatus.current = status;
const root = rootRef.current; // A frame later: rows that just mounted join the menu's list a render after this effect, and a
const at = document.activeElement; // row focused before that is not known to it — the arrows then did nothing from it.
// …unless a name is being typed into it meanwhile. const frame = requestAnimationFrame(() => {
const parked = at === placed.current && !(at instanceof HTMLInputElement && at.value); const root = rootRef.current;
const lost = at == null || at === document.body || parked; const at = document.activeElement;
if (!root || !lost) return; // …unless a name is being typed into it meanwhile.
const target = const parked = at === placed.current && !(at instanceof HTMLInputElement && at.value);
root.querySelector<HTMLElement>("[role=menuitemcheckbox]") ?? const lost = at == null || at === document.body || parked;
root.querySelector<HTMLElement>("input") ?? if (!root || !lost) return;
root.querySelector<HTMLElement>("button"); const target =
target?.focus(); root.querySelector<HTMLElement>("[role=menuitemcheckbox]") ??
placed.current = target; root.querySelector<HTMLElement>("input") ??
root.querySelector<HTMLElement>("button");
target?.focus();
placed.current = target;
});
return () => cancelAnimationFrame(frame);
}, [status]); }, [status]);
async function submit() { async function submit() {