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
+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 &&