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