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
@@ -46,6 +46,29 @@ describe("useFocusKeeper", () => {
expect(document.activeElement).toBe(btn("after the list"));
});
// A page swapped for its loading fallback leaves nothing focusable in the slot. The walk up used to
// run past the container and hand the focus to the first control of the whole document.
it("never puts the focus outside its own container", async () => {
function Swapped() {
const ref = useRef<HTMLDivElement>(null);
useFocusKeeper(ref);
const [loaded, setLoaded] = useState(true);
return (
<>
<button>rail</button>
<div ref={ref}>
{loaded ? <button onClick={() => setLoaded(false)}>go</button> : <p>Loading…</p>}
</div>
</>
);
}
render(<Swapped />);
btn("go").focus();
act(() => btn("go").click());
await flush();
expect(document.activeElement).toBe(document.body);
});
it("leaves a keyboard list's rows to listNav", async () => {
render(<Rows nav />);
btn("approve b").focus();