From c6b552e75f70b07ebfa5ac59b992c0ddf9c164a7 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Sun, 13 Sep 2026 20:47:13 +0200 Subject: [PATCH] =?UTF-8?q?fix(keys):=20epic-end=20review=20round=203=20?= =?UTF-8?q?=E2=80=94=20the=20part-way=20clip=20count=20shows,=20busy=20but?= =?UTF-8?q?tons=20keep=20focus,=20cards=20say=20their=20names?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Targeted round over 3cd0c525's fix surfaces: 14 raised, 14 fixed. - VideoEditor: the "only X of Y clips" toast went silent exactly on the job-cap/quota refusal it was written for (req had already reported the error); it now always shows. The cut list holds still while Create queues, and a retry continues the "(i/N)" series. - ChannelPage: Subscribe/Unsubscribe use Button `loading`, "Load more" and ChannelDiscovery's Subscribe block the click with aria-disabled instead of native disabled; busy Block/Hide no longer light up on hover. - listNav: an item can name itself (`navItemAttrs(i, name)`) — Plex posters, seasons, episodes, message rows and download rows pass their title; focus on a toast's Undo keeps the removed card's slot. - AnchoredPopover: the hand-back only for focus-managed surfaces (a hover card no longer pulls focus back); the dead panel-ref check is gone. - AccountPanel: no confirmation mail offered to the demo account; send_failed copy no longer blames the message for an unreachable server. Pager comment points at scrollParent. --- frontend/src/components/AccountPanel.tsx | 4 +- frontend/src/components/ChannelDiscovery.tsx | 10 ++- .../ChannelPage.busyFocus.dom.test.tsx | 84 ++++++++++--------- frontend/src/components/ChannelPage.tsx | 19 +++-- frontend/src/components/Messages.tsx | 2 +- .../VideoEditor.partial.dom.test.tsx | 44 +++++++--- frontend/src/components/VideoEditor.tsx | 41 +++++++-- .../src/components/accountPanel.dom.test.tsx | 16 +++- frontend/src/components/downloadRow.tsx | 2 +- frontend/src/components/plexCards.tsx | 9 +- .../AnchoredPopover.returnFocus.dom.test.tsx | 60 +++++++++++++ .../src/components/ui/AnchoredPopover.tsx | 32 +++---- frontend/src/components/ui/Pager.tsx | 2 +- frontend/src/components/ui/Toaster.tsx | 2 + frontend/src/i18n/locales/en/errors.json | 2 +- frontend/src/i18n/locales/hu/errors.json | 2 +- frontend/src/lib/listNav.dom.test.tsx | 50 +++++++++++ frontend/src/lib/listNav.ts | 29 +++++-- 18 files changed, 312 insertions(+), 98 deletions(-) create mode 100644 frontend/src/components/ui/AnchoredPopover.returnFocus.dom.test.tsx 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 &&