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
+4 -2
View File
@@ -1,6 +1,7 @@
import { useEffect, useRef, useState, type RefObject } from "react";
import { dispatch, useKeyScope } from "./keymap";
import { scrollParent } from "./listPaging";
import { isStandInFocus } from "./useFocusKeeper";
// ── Keyboard navigation over lists and grids (E11 S4, UX-6) ──────────────────────────────────────
// Before this, a feed card cost ~10 Tab stops (thumb, play, info, title, channel, five actions), so
@@ -378,13 +379,14 @@ function restoreTarget(list: NavList, pos: NavPosition): HTMLElement | null {
* place, and only once the page has exactly as many lists as when it was left: with more or fewer,
* list N may be another list, and <body> is the honest outcome. Gives up when the time is up, and at
* once when focus is already somewhere (the reader moved on, a dialog took it). The item is focused
* where the scroll restore put it, never scrolled to. Returns a cancel. */
* where the scroll restore put it, never scrolled to. (A stand-in the focus keeper placed when the view
* it held was swapped away counts as untouched, like <body>.) Returns a cancel. */
export function restoreNavPosition(pos: NavPosition): () => void {
const deadline = Date.now() + RESTORE_MS;
let timer: ReturnType<typeof setTimeout> | undefined;
const attempt = () => {
const active = document.activeElement;
if (active && active !== document.body) return;
if (active && active !== document.body && !isStandInFocus(active)) return;
const all = ordered();
const list = pos.label
? all.find((l) => labelOf(l) === pos.label)