fix(ui): Back finds the item again past the focus keeper; nested drags stay in their list

Deep review of the debt burn, each measured in Chrome:
- Back from a sub-view whose focused control the swap removed: the focus keeper put the focus on a
  stand-in (the returning list's first item) and `restoreNavPosition`, finding the focus "already
  somewhere", gave up — Plex show → Back landed on tile 0, not the tile it was opened from. A
  stand-in the keeper placed now counts as untouched, like <body>. The keeper's walk up also stops
  at its container: a slot left with nothing focusable no longer sends the focus to the first
  control of the whole document.
- DataTable enabled both layouts' keyboard lists with the same flag, and `useListNav` reads its ref
  only when the flag changes: after a resize across `md`, the layout that mounted was never
  registered (no j/k) and the one that left stayed registered. Each is enabled with its layout.
- A Plex playlist nests blocks and each show's episodes in one DndContext; closestCenter and the
  keyboard coordinates weighed every droppable, so a movie landed "over" an episode — announced as
  a place in the other list, dropped somewhere else or nowhere. `sameListCollision` and
  `sameListKeyboardCoordinates` keep a drag among its siblings; onDragEnd's episode → block
  resolution is gone with the case.
- dnd-kit reports the item over itself in the pick-up's own tick; announced, "moved to position 1"
  replaced "picked up … use the arrow keys" before a screen reader read it.
This commit is contained in:
2026-09-19 19:19:43 +02:00
parent 884060891e
commit fa4f823fc8
9 changed files with 306 additions and 34 deletions
@@ -0,0 +1,64 @@
// @vitest-environment jsdom
//
// Back from a sub-view the focus keeper ALSO sees: the control that held the focus in the sub-view
// is removed by the swap, the keeper puts the focus on a stand-in in its slot — the first item of
// the returning list — and `restoreNavPosition` then found the focus "already somewhere" and gave
// up. Back landed on the first tile instead of the one the view was opened from (measured in Chrome,
// deep review of the debt burn). Its own file: listNav keeps module-level state.
import { afterEach, describe, expect, it } from "vitest";
import { act, cleanup, render } from "@testing-library/react";
import { useRef, useState } from "react";
import { navItemAttrs, navPosition, restoreNavPosition, useListNav } from "./listNav";
import { useFocusKeeper } from "./useFocusKeeper";
afterEach(cleanup);
function Grid() {
const ref = useRef<HTMLDivElement>(null);
useListNav(ref, { enabled: true });
return (
<div ref={ref}>
{["a", "b", "c"].map((t, i) => (
<div key={t} {...navItemAttrs(i, t)}>
<button data-nav-open="">{t}</button>
</div>
))}
</div>
);
}
let show: (v: "grid" | "detail") => void = () => {};
function Page() {
const ref = useRef<HTMLElement>(null);
useFocusKeeper(ref);
const [view, setView] = useState<"grid" | "detail">("grid");
show = setView;
return <main ref={ref}>{view === "grid" ? <Grid /> : <button>Back to library</button>}</main>;
}
const button = (name: string) =>
[...document.querySelectorAll("button")].find((b) => b.textContent === name)!;
const wait = (ms: number) => act(() => new Promise((r) => setTimeout(r, ms)));
describe("Back from a sub-view whose held control the swap removed", () => {
it("returns the focus to the item the view was opened from, not the keeper's stand-in", async () => {
render(<Page />);
button("c").focus();
const pos = navPosition()!;
expect(pos.index).toBe(2);
act(() => show("detail"));
button("Back to library").focus(); // a control the keeper holds
act(() => show("grid")); // popstate's view swap…
const cancel = restoreNavPosition(pos); // …and its restore
await wait(120);
cancel();
expect(
document.activeElement?.closest("[data-nav-index]")?.getAttribute("data-nav-index"),
).toBe("2");
});
});