fix(keys): epic-end review round 3 — the part-way clip count shows, busy buttons keep focus, cards say their names

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.
This commit is contained in:
2026-09-13 20:47:13 +02:00
parent 3cd0c525e2
commit c6b552e75f
18 changed files with 312 additions and 98 deletions
+50
View File
@@ -511,4 +511,54 @@ describe("useListNav · leaving the list, and naming the item (deep-review E11 r
expect(a0.hasAttribute("aria-label")).toBe(false);
expect(screen.getByTestId("a1").getAttribute("aria-label")).toBe("title");
});
// A Plex poster's open element is the whole card — badges, title, year, rating and summary — and a
// season card's holds only the hover caption "Open show": named from that text, every season was
// "Open show" (deep-review E11 round 3). The item's own name wins.
it("names a card by the name its item gives, over the text of what it opens", () => {
function Posters() {
const ref = useRef<HTMLDivElement>(null);
useListNav(ref, { enabled: true });
return (
<div ref={ref}>
<div {...navItemAttrs(0, "Season 1")} data-testid="p0">
<div role="button" tabIndex={0} data-nav-open="">
Open show
</div>
</div>
</div>
);
}
render(<Posters />);
press("j");
expect(screen.getByTestId("p0").getAttribute("aria-label")).toBe("Season 1");
});
// Hide a card, then Undo in the toast that put up: focus on the toast's button is not the reader
// leaving the list, and `j` goes on from the card put back (deep-review E11 round 3).
it("keeps the slot when focus went to the Undo of a toast", () => {
withLayout(
(_, n) => ({ left: 0, top: n * 100 }),
() => {
const page = (ids: number[]) => (
<>
<div data-toast-region="">
<button data-testid="undo">undo</button>
</div>
<Swappable ids={ids} />
</>
);
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");
},
);
});
});
+22 -7
View File
@@ -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 <div>: 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 (<tr>) 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 &&