// @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) => 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 { 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[1], ); expect(next).toEqual({ x: 0, y: 200 }); // Movie B's place, not the episode's at 50 }); });