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
+57 -5
View File
@@ -1,7 +1,16 @@
import { useMemo, useRef } from "react";
import { useTranslation } from "react-i18next";
import type { Announcements, ScreenReaderInstructions, UniqueIdentifier } from "@dnd-kit/core";
import { useSortable } from "@dnd-kit/sortable";
import { closestCenter } from "@dnd-kit/core";
import type {
Active,
Announcements,
CollisionDetection,
DroppableContainer,
KeyboardCoordinateGetter,
ScreenReaderInstructions,
UniqueIdentifier,
} from "@dnd-kit/core";
import { sortableKeyboardCoordinates, useSortable } from "@dnd-kit/sortable";
// What a screen reader hears while a keyboard user reorders a list. dnd-kit's defaults are
// hard-coded English ("To pick up a draggable item, press the space bar…", "Draggable item 10357
@@ -39,12 +48,22 @@ export function useSortableA11y(describe: (id: UniqueIdentifier) => SortableSubj
...at(active),
pos: at(over).pos,
});
// dnd-kit reports the item over ITSELF right after the pick-up, in the same tick — announced,
// "moved to position 1" replaced "picked up … use the arrow keys" in the live region before a
// screen reader read it (measured in Chrome). Only a real move is announced as one.
let moved = false;
return {
screenReaderInstructions: { draggable: t("common.dnd.instructions") },
announcements: {
onDragStart: ({ active }) => t("common.dnd.pickedUp", at(active.id)),
onDragOver: ({ active, over }) =>
over ? t("common.dnd.movedOver", landing(active.id, over.id)) : undefined,
onDragStart: ({ active }) => {
moved = false;
return t("common.dnd.pickedUp", at(active.id));
},
onDragOver: ({ active, over }) => {
if (!over || (!moved && over.id === active.id)) return undefined;
moved = true;
return t("common.dnd.movedOver", landing(active.id, over.id));
},
onDragEnd: ({ active, over }) =>
over
? t("common.dnd.dropped", landing(active.id, over.id))
@@ -55,6 +74,39 @@ export function useSortableA11y(describe: (id: UniqueIdentifier) => SortableSubj
}, [t]);
}
// A context that nests sortable lists (a Plex playlist: its blocks, and each show's episodes) must
// keep a drag inside the list it started in. dnd-kit's collision detection and keyboard coordinates
// weigh every droppable in the context, so a movie could land "over" an episode and an episode over
// a movie: the announcement read a place in the other list, and the drop then did something else or
// nothing. Filtered here, `over` is always a sibling — what is announced is what the drop does.
function ownList(active: Active | null, all: DroppableContainer[]): DroppableContainer[] {
const list = active?.data.current?.sortable?.containerId;
return list === undefined
? all
: all.filter((c) => c.data.current?.sortable?.containerId === list);
}
/** `collisionDetection` for nested sortable lists: `closestCenter` among the active item's siblings. */
export const sameListCollision: CollisionDetection = (args) =>
closestCenter({ ...args, droppableContainers: ownList(args.active, args.droppableContainers) });
/** The keyboard sensor's `coordinateGetter` for nested sortable lists: an arrow key steps to the next
* sibling, never into a neighbouring list. */
export const sameListKeyboardCoordinates: KeyboardCoordinateGetter = (event, args) => {
const all = args.context.droppableContainers;
// The map's own class (dnd-kit does not export it), holding only the siblings.
const Siblings = all.constructor as new (
entries: Iterable<[UniqueIdentifier, DroppableContainer]>,
) => typeof all;
const siblings = new Siblings(
ownList(args.context.active, all.toArray()).map((c) => [c.id, c] as const),
);
return sortableKeyboardCoordinates(event, {
...args,
context: { ...args.context, droppableContainers: siblings },
});
};
/** `useSortable`, with its role announced in the user's language instead of dnd-kit's "sortable". */
export function useSortableItem(args: Parameters<typeof useSortable>[0]) {
const { t } = useTranslation();