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
+14 -19
View File
@@ -6,17 +6,11 @@ import {
DndContext,
KeyboardSensor,
PointerSensor,
closestCenter,
useSensor,
useSensors,
type DragEndEvent,
} from "@dnd-kit/core";
import {
SortableContext,
arrayMove,
sortableKeyboardCoordinates,
verticalListSortingStrategy,
} from "@dnd-kit/sortable";
import { SortableContext, arrayMove, verticalListSortingStrategy } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import {
ArrowLeft,
@@ -40,7 +34,12 @@ import { LoadingState, StateMessage } from "./ui/QueryState";
import ViewSwitcher, { type ViewOption } from "./ViewSwitcher";
import { FocusSafeButton } from "./ui/FocusSafeButton";
import { usePlexPlaylistEdits } from "./usePlexPlaylistEdits";
import { useSortableA11y, useSortableItem } from "./ui/sortableA11y";
import {
sameListCollision,
sameListKeyboardCoordinates,
useSortableA11y,
useSortableItem,
} from "./ui/sortableA11y";
// A playlist's items, grouped for readability: movies stay standalone, while a run of episodes from the
// same show collapses into a season/show group so a whole series doesn't sprawl into an endless flat
@@ -455,7 +454,7 @@ export default function PlexPlaylistView({
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 4 } }),
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
useSensor(KeyboardSensor, { coordinateGetter: sameListKeyboardCoordinates }),
);
// A whole block (a movie, a show) sorts among the blocks; an episode only within its own show.
@@ -481,20 +480,16 @@ export default function PlexPlaylistView({
const aId = String(active.id);
const oId = String(over.id);
const blockIds = new Set(blocks.map((b) => b.id));
// `over` is always a sibling of `active` (sameListCollision): a block over a block, an episode
// over an episode of the same show. The checks below only refuse a stale id.
if (blockIds.has(aId)) {
// Moving a whole block (movie or show). Resolve the drop target to a block.
let oBlockId = oId;
if (!blockIds.has(oId)) {
const b = blocks.find((bl) => bl.kind === "show" && bl.items.some((it) => it.id === oId));
if (!b) return;
oBlockId = b.id;
}
// Moving a whole block (movie or show) among the blocks.
const from = blocks.findIndex((b) => b.id === aId);
const to = blocks.findIndex((b) => b.id === oBlockId);
const to = blocks.findIndex((b) => b.id === oId);
if (from < 0 || to < 0) return;
commit(flatten(arrayMove(blocks, from, to)));
} else {
// Moving an episode — only within its own show block.
// Moving an episode — within its own show block.
const b = blocks.find((bl) => bl.kind === "show" && bl.items.some((it) => it.id === aId));
if (!b || b.kind !== "show" || !b.items.some((it) => it.id === oId)) return;
const from = b.items.findIndex((it) => it.id === aId);
@@ -600,7 +595,7 @@ export default function PlexPlaylistView({
) : (
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
collisionDetection={sameListCollision}
onDragEnd={onDragEnd}
accessibility={dndA11y}
>