Files
siftlode/frontend/src/components/ui/sortableA11y.dom.test.tsx
T
peter fa4f823fc8 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.
2026-09-19 19:19:43 +02:00

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
});
});