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:
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user