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
@@ -0,0 +1,60 @@
// @vitest-environment jsdom
//
// Which surfaces give the focus back to a card focused as a whole (deep-review E11 rounds 2 and 3). A
// menu does, by hand — floating-ui's own return would land on the card's first link (see
// ContextMenu.dom.test). A hover card never returned focus, and one closing after the reader clicked a
// link in it must not pull the focus back to the card under it.
import { describe, it, expect, beforeAll, afterEach } from "vitest";
import { render, screen, cleanup, act } from "@testing-library/react";
import { FloatingTree } from "@floating-ui/react";
import { useAnchoredPopover, PopoverSurface } from "./AnchoredPopover";
beforeAll(() => {
class Stub {
observe() {}
unobserve() {}
disconnect() {}
takeRecords() {
return [];
}
}
globalThis.ResizeObserver ??= Stub as unknown as typeof ResizeObserver;
globalThis.IntersectionObserver ??= Stub as unknown as typeof IntersectionObserver;
});
afterEach(cleanup);
function Card({ open }: { open: boolean }) {
const pop = useAnchoredPopover({ open, onOpenChange: () => {}, mode: "hover" });
return (
<>
<div tabIndex={-1} data-testid="card" ref={pop.refs.setReference}>
card
</div>
<PopoverSurface popover={pop}>
<a href="#more" data-testid="link">
more
</a>
</PopoverSurface>
</>
);
}
const page = (open: boolean) => (
<FloatingTree>
<Card open={open} />
</FloatingTree>
);
describe("AnchoredPopover · a hover card closing", () => {
it("does not hand the focus back to the card it was opened over", async () => {
const { rerender } = render(page(false));
const card = screen.getByTestId("card");
act(() => card.focus());
rerender(page(true));
act(() => screen.getByTestId("link").focus());
rerender(page(false));
await act(async () => {}); // the hand-back runs in a microtask
expect(document.activeElement).not.toBe(card);
});
});
+18 -14
View File
@@ -601,6 +601,11 @@ function PopoverSurfaceInner({
// in the accessible-name computation — so an explicit ariaLabel is dead unless we drop the ref.
if (ariaLabel) delete floatingProps["aria-labelledby"];
// A menu/listbox/dialog traps focus into the surface (and returns it on close). A hover card is
// passive, and a COMBOBOX must leave focus in the input (its list is navigated virtually) — neither
// is focus-managed.
const managed = focusManaged ?? (mode !== "hover" && !isCombobox);
// Where focus goes back on close. floating-ui returns it to the element focused before the open —
// but when that element is not a Tab stop itself, to its first TABBABLE descendant instead, even
// when handed the element as a ref (its return runs through `getFirstTabbableElement` either way).
@@ -611,36 +616,35 @@ function PopoverSurfaceInner({
// panel, and only when the focus was still in a floating layer (or nowhere) as the panel closed:
// focus the reader moved on to stays where they put it. A menu row's action runs a task later
// (`closeThenAct`), so a dialog it opens still captures the card as the element to return to.
//
// Only for a focus-managed surface, whose return this replaces: a hover card or a combobox never
// returned focus, and a hover card closing after the reader clicked a link in it must not pull the
// focus back to the card under it (deep-review E11 round 3).
const [returnTo] = useState(() => {
const active = document.activeElement;
return active instanceof HTMLElement && active !== document.body && active.tabIndex < 0
return managed &&
active instanceof HTMLElement &&
active !== document.body &&
active.tabIndex < 0
? active
: null;
});
const floatingRef = refs.floating;
useLayoutEffect(() => {
if (!returnTo) return;
// Read once here: the panel's ref is attached before layout effects run, and cleared by the time
// this cleanup does.
const floating = floatingRef.current;
return () => {
// "Still in the layer" is read off the portal marker, not the panel: FloatingPortal renders the
// panel a commit after this surface mounts, so the panel's ref is still empty when this effect
// runs (deep-review E11 round 3).
const active = document.activeElement;
const stillOurs =
!active ||
active === document.body ||
!!floating?.contains(active) ||
!!active.closest("[data-floating-ui-portal]");
!active || active === document.body || !!active.closest("[data-floating-ui-portal]");
queueMicrotask(() => {
if (stillOurs && returnTo.isConnected) returnTo.focus({ preventScroll: true });
});
};
}, [returnTo, floatingRef]);
}, [returnTo]);
const zClass = mode === "hover" ? "z-tooltip" : "z-popover";
// A menu/listbox/dialog traps focus into the surface (and returns it on close). A hover card is
// passive, and a COMBOBOX must leave focus in the input (its list is navigated virtually) — neither
// is focus-managed.
const managed = focusManaged ?? (mode !== "hover" && !isCombobox);
const surface = (
<div
+1 -1
View File
@@ -49,7 +49,7 @@ export default function Pager({
// Turning the page — or resizing it, which re-slices from a different first item — swaps the whole
// list, so put the reader at its top; landing mid-list in a page you haven't seen is disorienting.
// The page scroller is the app's <main> (PageScroller documents itself as the ONE scroller; the
// same implicit contract BackToTop and VirtualGrid's getScrollParent already rely on).
// same implicit contract BackToTop and lib/listPaging's scrollParent (VirtualGrid, listNav) already rely on).
function scrollListToTop() {
document.querySelector("main")?.scrollTo({ top: 0 });
}
+2
View File
@@ -125,6 +125,8 @@ export default function Toaster({
// as a toast was up (the connection notice: until the server answered). The same rung UpNextCard
// uses for the same reason. Clicks between the cards go through to what is underneath.
<div
// lib/listNav: focus on a toast's Undo is not the reader leaving the list the toast is about.
data-toast-region=""
aria-live={isTop && armed ? "polite" : "off"}
className={`absolute left-4 z-overlay flex flex-col gap-2 w-80 max-w-[calc(100vw-2rem)] ${
overlay ? "bottom-24 pointer-events-none" : "bottom-4"