import { useMemo, useRef } from "react"; import { useTranslation } from "react-i18next"; 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 // was dropped", a role of "sortable"), and only the YouTube playlist had replaced them — the Plex // playlist, the saved-views list and the side-panel groups spoke English under a Hungarian UI and // named an item by its internal id. Every sortable list goes through here instead // (`local` lint: a `` needs `accessibility`, `useSortable` comes from this file). // // A position is where the item WOULD land — the `over` item's slot — not where it started: the // lists here reorder on drop, so reading the dragged item's own index announced its old place at // every step and at the drop (the YouTube playlist did exactly that). /** One sortable item as the announcements name it: its title, and its 1-based place in the list it * sorts within (a Plex episode sorts within its show, not the whole playlist). */ export interface SortableSubject { title: string; pos: number; total: number; } /** The `accessibility` prop for a ``. `describe` is read at announcement time, so it * may close over the latest render without making the announcements a render dependency. */ export function useSortableA11y(describe: (id: UniqueIdentifier) => SortableSubject): { screenReaderInstructions: ScreenReaderInstructions; announcements: Announcements; } { const { t } = useTranslation(); const latest = useRef(describe); latest.current = describe; return useMemo(() => { // Spread into a fresh object: i18next's options type is an index signature, which a named // interface does not satisfy. const at = (id: UniqueIdentifier) => ({ ...latest.current(id) }); const landing = (active: UniqueIdentifier, over: UniqueIdentifier) => ({ ...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 }) => { moved = false; return t("common.dnd.pickedUp", at(active.id)); }, onDragOver: ({ active, over }) => { if (!over || (!moved && over.id === active.id)) return undefined; // `moved` is per-DRAG state, and the announcement closures capturing it is the point: // dnd-kit reports the item over ITSELF in the pick-up tick (see above), and this flag is // the only thing separating that from a real move. A ref would be the wrong lifetime — // one drag, not the component. // eslint-disable-next-line react-hooks/immutability 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)) : t("common.dnd.cancelled", at(active.id)), onDragCancel: ({ active }) => t("common.dnd.cancelled", at(active.id)), }, }; }, [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[0]) { const { t } = useTranslation(); return useSortable({ ...args, attributes: { ...args.attributes, roleDescription: t("common.dnd.roleDescription") }, }); }