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.
143 lines
5.9 KiB
TypeScript
143 lines
5.9 KiB
TypeScript
// @vitest-environment jsdom
|
|
import { describe, it, expect, vi } from "vitest";
|
|
import { renderHook } from "@testing-library/react";
|
|
import type { Active, DroppableContainer, Over, UniqueIdentifier } from "@dnd-kit/core";
|
|
|
|
vi.mock("react-i18next", () => ({
|
|
useTranslation: () => ({
|
|
t: (key: string, o?: Record<string, unknown>) =>
|
|
o ? `${key}:${o.title}@${o.pos}/${o.total}` : key,
|
|
}),
|
|
}));
|
|
|
|
import { sameListCollision, sameListKeyboardCoordinates, useSortableA11y } from "./sortableA11y";
|
|
|
|
const rows = ["a", "b", "c", "d"];
|
|
const describeRow = (id: string | number) => ({
|
|
title: `Row ${id}`,
|
|
pos: rows.indexOf(String(id)) + 1,
|
|
total: rows.length,
|
|
});
|
|
const active = (id: string) => ({ id }) as unknown as Active;
|
|
const over = (id: string) => ({ id }) as unknown as Over;
|
|
|
|
// The lists here reorder on DROP, so the dragged row's own index stays its starting place for the
|
|
// whole drag. The YouTube playlist read exactly that, and announced "moved to position 1" at every
|
|
// step and "dropped at position 1" wherever the row went. The place is the `over` row's slot.
|
|
describe("useSortableA11y", () => {
|
|
const { announcements, screenReaderInstructions } = renderHook(() => useSortableA11y(describeRow))
|
|
.result.current;
|
|
|
|
it("speaks the user's language, not dnd-kit's English", () => {
|
|
expect(screenReaderInstructions.draggable).toBe("common.dnd.instructions");
|
|
});
|
|
|
|
it("announces where the row WOULD land while it moves, and where it did land", () => {
|
|
expect(announcements.onDragStart({ active: active("a") })).toBe(
|
|
"common.dnd.pickedUp:Row a@1/4",
|
|
);
|
|
expect(announcements.onDragOver({ active: active("a"), over: over("c") })).toBe(
|
|
"common.dnd.movedOver:Row a@3/4",
|
|
);
|
|
expect(announcements.onDragEnd({ active: active("a"), over: over("d") })).toBe(
|
|
"common.dnd.dropped:Row a@4/4",
|
|
);
|
|
});
|
|
|
|
it("says a drop outside the list, or Escape, put the row back where it was", () => {
|
|
expect(announcements.onDragEnd({ active: active("b"), over: null })).toBe(
|
|
"common.dnd.cancelled:Row b@2/4",
|
|
);
|
|
expect(announcements.onDragCancel({ active: active("b"), over: null })).toBe(
|
|
"common.dnd.cancelled:Row b@2/4",
|
|
);
|
|
});
|
|
});
|
|
|
|
// 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
|
|
});
|
|
});
|