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
+20 -4
View File
@@ -29,8 +29,22 @@ function firstFocusable(node: Node | null, fromEnd = false): HTMLElement | null
return null;
}
/** Where the focus goes once `lost` (removed with the nodes of `record`) has left the page. */
function slotFor(record: MutationRecord, lost: Node): HTMLElement | null {
// The element the keeper last put the focus on, while it still holds it. A Back that swaps the page
// or a sub-view removes the held control too, and the keeper's stand-in then sat where
// `restoreNavPosition` looks for an untouched <body>: the restore gave up and Back left the focus on
// the first tile instead of the one the view was opened from (measured in Chrome). A stand-in is
// not the reader's choice, so the restore may take the focus from it.
let standIn: HTMLElement | null = null;
/** Whether `el` holds the focus only because the keeper put it there. */
export function isStandInFocus(el: Element | null): boolean {
return el !== null && el === standIn;
}
/** Where the focus goes once `lost` (removed with the nodes of `record`) has left the page — never
* outside `root`: a slot with nothing focusable left (a page swapped for its loading fallback) must
* not send the focus to the first control of the whole document. */
function slotFor(record: MutationRecord, lost: Node, root: HTMLElement): HTMLElement | null {
const removed = [...record.removedNodes].find((n) => n === lost || n.contains(lost));
if (!removed) return null;
const after = firstFocusable(record.nextSibling);
@@ -38,7 +52,7 @@ function slotFor(record: MutationRecord, lost: Node): HTMLElement | null {
const before = firstFocusable(record.previousSibling, true);
if (before) return before;
let up: Node | null = record.target;
while (up && up instanceof HTMLElement) {
while (up && up instanceof HTMLElement && root.contains(up)) {
const inside = firstFocusable(up.firstChild);
if (inside) return inside;
up = up.parentElement;
@@ -53,6 +67,7 @@ export function useFocusKeeper(ref: RefObject<HTMLElement | null>): void {
let held: HTMLElement | null = null;
const onFocusIn = (e: FocusEvent) => {
const t = e.target as HTMLElement | null;
if (t !== standIn) standIn = null;
held = t && root.contains(t) && !t.closest("[data-nav-index]") ? t : null;
};
const watch = new MutationObserver((records) => {
@@ -62,8 +77,9 @@ export function useFocusKeeper(ref: RefObject<HTMLElement | null>): void {
const active = document.activeElement;
if (active && active !== document.body) return; // something already took the focus
for (const r of records) {
const target = slotFor(r, lost);
const target = slotFor(r, lost, root);
if (target) {
standIn = target;
target.focus({ preventScroll: true });
return;
}