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
@@ -99,6 +99,36 @@ describe("DataTable · keyboardNav", () => {
expect(narrow.container.querySelector("table")).toBeNull();
});
// `useListNav` reads its container when its `enabled` changes. With the flag left on across a
// breakpoint, the layout mounted by a resize was never registered — `j` did nothing — while the
// unmounted one stayed registered (deep review, debt burn).
it("keeps the keys working on whichever layout a resize mounts", () => {
let wide = true;
const listeners = new Set<() => void>();
vi.stubGlobal("matchMedia", (query: string) => ({
get matches() {
return query === "(min-width: 48rem)" ? wide : false;
},
media: query,
addEventListener: (_: string, l: () => void) => listeners.add(l),
removeEventListener: (_: string, l: () => void) => listeners.delete(l),
}));
const resize = (next: boolean) => {
wide = next;
act(() => listeners.forEach((l) => l()));
};
table(() => {});
resize(false);
expect(document.querySelectorAll("[data-nav-list]")).toHaveLength(1);
press("j");
expect(focused()).toBe("DIV a");
resize(true);
expect(document.querySelectorAll("[data-nav-list]")).toHaveLength(1);
(document.activeElement as HTMLElement).blur();
press("j");
expect(focused()).toBe("TR a");
});
it("leaves the rows out of the keyboard without keyboardNav", () => {
const { container } = table(() => {}, false);
expect(container.querySelector("[data-nav-index]")).toBeNull();