fix(a11y): every sortable list speaks the user's language to a screen reader

dnd-kit's screen-reader text is hard-coded English and names an item by its id ("Draggable item
10357 was dropped", a role of "sortable"). Only the YouTube playlist replaced it; the Plex playlist,
the saved-views list and the side-panel groups read it out under a Hungarian UI — found while
checking the Plex playlist's keyboard reorder on localdev.

`ui/sortableA11y` is now the one place: `useSortableA11y` gives a DndContext its instructions and
announcements (HU/EN, `common.dnd.*`), `useSortableItem` announces the role. The YouTube playlist's
own announcements move onto it, which also fixes them: its lists reorder on drop, so reading the
dragged row's own index said "moved to position 1" at every step and "dropped at position 1"
wherever the row went. The place announced is now the slot the row would land in.

A `local/sortable-a11y` lint rule (with its contract test) refuses a DndContext without
`accessibility` and a direct `useSortable`.
This commit is contained in:
2026-09-19 17:37:21 +02:00
parent 946c8f26db
commit 17a26894b0
13 changed files with 273 additions and 57 deletions
+12 -29
View File
@@ -15,7 +15,6 @@ import {
SortableContext,
arrayMove,
sortableKeyboardCoordinates,
useSortable,
verticalListSortingStrategy,
} from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
@@ -69,6 +68,7 @@ import { alignClass } from "./ui/tableAlign";
import { filterRows, isActive, type Column, type FilterMap } from "../lib/tableColumns";
import { GRIP_KEY, playlistColumns } from "./playlistColumns";
import { FocusSafeButton } from "./ui/FocusSafeButton";
import { useSortableA11y, useSortableItem } from "./ui/sortableA11y";
// The playlist detail is a HYBRID table (E4 S4): real sortable/filterable column headers over a
// body that is still a drag-reorderable @dnd-kit list.
@@ -115,7 +115,7 @@ function Row({
menuProps: Record<string, unknown>;
}) {
const { t } = useTranslation();
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortableItem({
id: video.id,
disabled: dragDisabled,
});
@@ -415,32 +415,15 @@ export default function Playlists() {
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
);
const posOf = (id: string | number) => shownRef.current.findIndex((v) => v.id === id) + 1;
const titleOf = (id: string | number) => shownRef.current.find((v) => v.id === id)?.title ?? "";
const total = () => shownRef.current.length;
const screenReaderInstructions = { draggable: t("playlists.dndInstructions") };
const announcements = {
onDragStart: ({ active }: { active: { id: string | number } }) =>
t("playlists.dndPickedUp", {
title: titleOf(active.id),
pos: posOf(active.id),
total: total(),
}),
onDragOver: ({ active }: { active: { id: string | number } }) =>
t("playlists.dndMovedOver", {
title: titleOf(active.id),
pos: posOf(active.id),
total: total(),
}),
onDragEnd: ({ active }: { active: { id: string | number } }) =>
t("playlists.dndDropped", {
title: titleOf(active.id),
pos: posOf(active.id),
total: total(),
}),
onDragCancel: ({ active }: { active: { id: string | number } }) =>
t("playlists.dndCancelled", { title: titleOf(active.id), pos: posOf(active.id) }),
};
// Named and placed from `shownRef`, read at announcement time (see ui/sortableA11y).
const dndA11y = useSortableA11y((id) => {
const rows = shownRef.current;
return {
title: rows.find((v) => v.id === id)?.title ?? "",
pos: rows.findIndex((v) => v.id === id) + 1,
total: rows.length,
};
});
const plName = (p: { kind: string; name: string }) => playlistName(p, t);
@@ -1072,7 +1055,7 @@ export default function Playlists() {
onDragEnd={onDragEnd}
// dnd-kit's defaults are hardcoded English. They were harmless while only a pointer could
// drag; now that the keyboard can, they ARE the instructions for the feature.
accessibility={{ screenReaderInstructions, announcements }}
accessibility={dndA11y}
>
{/* Not clipped, so the header's filter popovers can overflow the table box. */}
<div className="max-w-5xl mx-auto">