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:
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user