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
+2 -2
View File
@@ -1,8 +1,8 @@
import { type ReactNode, type CSSProperties } from "react";
import { useTranslation } from "react-i18next";
import { ChevronDown, Eye, EyeOff, GripVertical, Pencil } from "lucide-react";
import { useSortable } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import { useSortableItem } from "./sortableA11y";
// One "island" card inside a SidePanel: a titled glass-card, and — in the panel's edit mode — a drag
// handle (reorder) and a hide toggle. Generalized from the feed sidebar's WidgetCard so all three
@@ -35,7 +35,7 @@ export default function PanelGroup({
children: ReactNode;
}) {
const { t } = useTranslation();
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortableItem({
id,
disabled: !editing,
});
+12 -1
View File
@@ -16,6 +16,7 @@ import {
} from "@dnd-kit/sortable";
import type { PanelLayout } from "../../lib/panelLayout";
import PanelGroup from "./PanelGroup";
import { useSortableA11y } from "./sortableA11y";
export type PanelItem = {
id: string;
@@ -70,6 +71,11 @@ export default function PanelGroups({
function toggleHidden(id: string) {
setLayout({ ...layout, hidden: { ...layout.hidden, [id]: !layout.hidden[id] } });
}
const dndA11y = useSortableA11y((id) => ({
title: byId.get(String(id))?.title ?? "",
pos: renderedIds.indexOf(String(id)) + 1,
total: renderedIds.length,
}));
function onDragEnd(e: DragEndEvent) {
const { active, over } = e;
if (!over || active.id === over.id) return;
@@ -80,7 +86,12 @@ export default function PanelGroups({
}
return (
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={onDragEnd}>
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={onDragEnd}
accessibility={dndA11y}
>
<SortableContext items={renderedIds} strategy={verticalListSortingStrategy}>
<div className="space-y-3">
{renderedIds.map((id) => (
@@ -0,0 +1,55 @@
// @vitest-environment jsdom
import { describe, it, expect, vi } from "vitest";
import { renderHook } from "@testing-library/react";
import type { Active, Over } 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 { 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",
);
});
});
@@ -0,0 +1,65 @@
import { useMemo, useRef } from "react";
import { useTranslation } from "react-i18next";
import type { Announcements, ScreenReaderInstructions, UniqueIdentifier } from "@dnd-kit/core";
import { 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 `<DndContext>` 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 `<DndContext>`. `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,
});
return {
screenReaderInstructions: { draggable: t("common.dnd.instructions") },
announcements: {
onDragStart: ({ active }) => t("common.dnd.pickedUp", at(active.id)),
onDragOver: ({ active, over }) =>
over ? t("common.dnd.movedOver", landing(active.id, over.id)) : undefined,
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]);
}
/** `useSortable`, with its role announced in the user's language instead of dnd-kit's "sortable". */
export function useSortableItem(args: Parameters<typeof useSortable>[0]) {
const { t } = useTranslation();
return useSortable({
...args,
attributes: { ...args.attributes, roleDescription: t("common.dnd.roleDescription") },
});
}