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,7 @@
|
||||
// @vitest-environment jsdom
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { renderHook } from "@testing-library/react";
|
||||
import type { Active, Over } from "@dnd-kit/core";
|
||||
import type { Active, DroppableContainer, Over, UniqueIdentifier } from "@dnd-kit/core";
|
||||
|
||||
vi.mock("react-i18next", () => ({
|
||||
useTranslation: () => ({
|
||||
@@ -10,7 +10,7 @@ vi.mock("react-i18next", () => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
import { useSortableA11y } from "./sortableA11y";
|
||||
import { sameListCollision, sameListKeyboardCoordinates, useSortableA11y } from "./sortableA11y";
|
||||
|
||||
const rows = ["a", "b", "c", "d"];
|
||||
const describeRow = (id: string | number) => ({
|
||||
@@ -53,3 +53,90 @@ describe("useSortableA11y", () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// dnd-kit reports the row over ITSELF in the same tick as the pick-up; announced, "moved to position
|
||||
// 1" replaced "picked up … use the arrow keys" before a screen reader read it (measured in Chrome).
|
||||
describe("the pick-up announcement", () => {
|
||||
it("is not overwritten by the row being over its own place, until it has really moved", () => {
|
||||
const { announcements } = renderHook(() => useSortableA11y(describeRow)).result.current;
|
||||
expect(announcements.onDragStart({ active: active("b") })).toBe(
|
||||
"common.dnd.pickedUp:Row b@2/4",
|
||||
);
|
||||
expect(announcements.onDragOver({ active: active("b"), over: over("b") })).toBeUndefined();
|
||||
expect(announcements.onDragOver({ active: active("b"), over: over("c") })).toBe(
|
||||
"common.dnd.movedOver:Row b@3/4",
|
||||
);
|
||||
// Back over its own place after a move IS a move, and says so.
|
||||
expect(announcements.onDragOver({ active: active("b"), over: over("b") })).toBe(
|
||||
"common.dnd.movedOver:Row b@2/4",
|
||||
);
|
||||
// A new drag starts quiet again.
|
||||
announcements.onDragStart({ active: active("a") });
|
||||
expect(announcements.onDragOver({ active: active("a"), over: over("a") })).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
// A Plex playlist nests two sortable lists in one context — its blocks, and each show's episodes.
|
||||
// Unfiltered, a movie could land "over" an episode: the announcement read a place in the other
|
||||
// list, and the drop did something else or nothing (deep review, debt burn).
|
||||
describe("a drag in nested sortable lists", () => {
|
||||
const item = (id: string, list: string, index: number, top: number) => {
|
||||
const node = document.createElement("div");
|
||||
document.body.append(node);
|
||||
return {
|
||||
entry: {
|
||||
id,
|
||||
key: id,
|
||||
disabled: false,
|
||||
node: { current: node },
|
||||
rect: { current: null },
|
||||
data: { current: { sortable: { containerId: list, index, items: [] } } },
|
||||
} as unknown as DroppableContainer,
|
||||
rect: { top, left: 0, width: 100, height: 40, bottom: top + 40, right: 100 },
|
||||
};
|
||||
};
|
||||
// Movie A (a block), then an expanded show's first episode just below it, then Movie B.
|
||||
const all = [item("A", "blocks", 0, 0), item("e1", "eps", 0, 50), item("B", "blocks", 1, 200)];
|
||||
const rects = new Map(all.map((d) => [d.entry.id, d.rect]));
|
||||
class Droppables extends Map<UniqueIdentifier, DroppableContainer> {
|
||||
getEnabled() {
|
||||
return [...this.values()].filter((c) => !c.disabled);
|
||||
}
|
||||
toArray() {
|
||||
return [...this.values()];
|
||||
}
|
||||
}
|
||||
const droppables = new Droppables(all.map((d) => [d.entry.id, d.entry]));
|
||||
const movieA = { id: "A", data: all[0]!.entry.data, rect: { current: {} } } as unknown as Active;
|
||||
|
||||
it("only ever lands over a sibling in the list the drag started in", () => {
|
||||
const collisions = sameListCollision({
|
||||
active: movieA,
|
||||
collisionRect: { ...all[0]!.rect, top: 45, bottom: 85 },
|
||||
droppableRects: rects,
|
||||
droppableContainers: all.map((d) => d.entry),
|
||||
pointerCoordinates: null,
|
||||
});
|
||||
expect(collisions.map((c) => c.id)).not.toContain("e1");
|
||||
expect(collisions[0]?.id).toBe("A");
|
||||
});
|
||||
|
||||
it("steps the keyboard to the next sibling, past the other list's items", () => {
|
||||
const next = sameListKeyboardCoordinates(
|
||||
{ code: "ArrowDown", preventDefault: () => {} } as KeyboardEvent,
|
||||
{
|
||||
active: "A",
|
||||
currentCoordinates: { x: 0, y: 0 },
|
||||
context: {
|
||||
active: movieA,
|
||||
collisionRect: all[0]!.rect,
|
||||
droppableRects: rects,
|
||||
droppableContainers: droppables,
|
||||
over: null,
|
||||
scrollableAncestors: [],
|
||||
},
|
||||
} as unknown as Parameters<typeof sameListKeyboardCoordinates>[1],
|
||||
);
|
||||
expect(next).toEqual({ x: 0, y: 200 }); // Movie B's place, not the episode's at 50
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user