diff --git a/frontend/src/components/AccountPanel.tsx b/frontend/src/components/AccountPanel.tsx index 7f91ac25..897099cc 100644 --- a/frontend/src/components/AccountPanel.tsx +++ b/frontend/src/components/AccountPanel.tsx @@ -196,7 +196,9 @@ export default function AccountPanel({ + + + + ); + const { rerender } = render(page([0, 1, 2, 3])); + press("j"); + press("j"); + press("j"); + rerender(page([0, 1, 3])); + act(() => screen.getByTestId("undo").focus()); + rerender(page([0, 1, 2, 3])); + act(() => screen.getByTestId("undo").blur()); + press("j"); + expect(focused()).toBe("r2"); + }, + ); + }); }); diff --git a/frontend/src/lib/listNav.ts b/frontend/src/lib/listNav.ts index dda79144..330fe48e 100644 --- a/frontend/src/lib/listNav.ts +++ b/frontend/src/lib/listNav.ts @@ -44,9 +44,16 @@ const LIST = "data-nav-list"; // How long to wait for a virtualized item to mount after asking the list to reveal it. const REVEAL_FRAMES = 10; -/** Spread onto an item's ROOT element. */ -export const navItemAttrs = (index: number): { "data-nav-index": number } => ({ +/** Spread onto an item's ROOT element. `name` is what a screen reader calls the item while the keyboard + * is on it (its title) — pass it where the element Enter opens wraps the whole card (a Plex poster: + * badges, year, rating and summary run together), shows a caption that is not the item's name, or + * holds no text at all (deep-review E11 round 3). */ +export const navItemAttrs = ( + index: number, + name?: string | null, +): { "data-nav-index": number; "data-nav-name"?: string } => ({ [INDEX]: index, + ...(name ? { "data-nav-name": name } : null), }); const indexOf = (item: Element): number => Number(item.getAttribute(INDEX)); @@ -133,10 +140,11 @@ function onFocusIn(e: FocusEvent): void { if (item && list) last = { list, index: indexOf(item), el: item }; // Focus went on to page content that is not an item — the search box, a filter chip: the reader // left the list, and whatever it shows next (new results) is not "the slot they were in" (deep-review - // E11 round 2). A layer opened FROM the item — its menu, a confirm — does not count: focus comes - // back from it, and what it did (a hidden card, a deleted row) is the removal the slot is kept for. - // Removing the focused node itself fires no focusin. - else if (!target?.closest?.('[data-floating-ui-portal], [role="dialog"]')) last = null; + // E11 round 2). A layer opened FROM the item — its menu, a confirm, the Undo toast its removal put + // up — does not count: focus comes back from it, and what it did (a hidden card, a deleted row, the + // card put back) is what the slot is kept for. Removing the focused node itself fires no focusin. + else if (!target?.closest?.('[data-floating-ui-portal], [role="dialog"], [data-toast-region]')) + last = null; } /** Where `j` (`k`: the item before) lands after the last-focused item left the DOM: the item now in @@ -171,8 +179,15 @@ function focusItem(el: HTMLElement, reveal = true): void { // A card root is a plain
: focused, it has no role and no name, and a screen reader read it // as an unlabelled run of text. While it holds the focus it is a group named after what Enter // opens (deep-review E11 round 2). A row () keeps its own role. + // The item's own name when it gives one (`navItemAttrs`), else the open element's label or text: + // a title link's text is the title (deep-review E11 round 3). const open = el.querySelector("[data-nav-open]"); - const name = (open?.getAttribute("aria-label") || open?.textContent || "").trim(); + const name = ( + el.getAttribute("data-nav-name") || + open?.getAttribute("aria-label") || + open?.textContent || + "" + ).trim(); if ( el.tagName === "DIV" && name &&