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:
@@ -15,7 +15,6 @@ import {
|
||||
SortableContext,
|
||||
arrayMove,
|
||||
sortableKeyboardCoordinates,
|
||||
useSortable,
|
||||
verticalListSortingStrategy,
|
||||
} from "@dnd-kit/sortable";
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
@@ -41,6 +40,7 @@ import { LoadingState, StateMessage } from "./ui/QueryState";
|
||||
import ViewSwitcher, { type ViewOption } from "./ViewSwitcher";
|
||||
import { FocusSafeButton } from "./ui/FocusSafeButton";
|
||||
import { usePlexPlaylistEdits } from "./usePlexPlaylistEdits";
|
||||
import { useSortableA11y, useSortableItem } from "./ui/sortableA11y";
|
||||
|
||||
// A playlist's items, grouped for readability: movies stay standalone, while a run of episodes from the
|
||||
// same show collapses into a season/show group so a whole series doesn't sprawl into an endless flat
|
||||
@@ -146,7 +146,7 @@ function DragHandle({ title, ...props }: { title: string } & Record<string, unkn
|
||||
|
||||
function EpisodeRow({ ep, ctx, dense }: { ep: PlexCard; ctx: Ctx; dense: boolean }) {
|
||||
const { t } = useTranslation();
|
||||
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
|
||||
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortableItem({
|
||||
id: ep.id,
|
||||
disabled: ctx.busy,
|
||||
});
|
||||
@@ -200,7 +200,7 @@ function EpisodeRow({ ep, ctx, dense }: { ep: PlexCard; ctx: Ctx; dense: boolean
|
||||
|
||||
function MovieRow({ item, ctx }: { item: PlexCard; ctx: Ctx }) {
|
||||
const { t } = useTranslation();
|
||||
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
|
||||
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortableItem({
|
||||
id: item.id,
|
||||
disabled: ctx.busy,
|
||||
});
|
||||
@@ -244,7 +244,7 @@ function MovieRow({ item, ctx }: { item: PlexCard; ctx: Ctx }) {
|
||||
|
||||
function ShowGroup({ block, ctx }: { block: Extract<Block, { kind: "show" }>; ctx: Ctx }) {
|
||||
const { t } = useTranslation();
|
||||
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
|
||||
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortableItem({
|
||||
id: block.id,
|
||||
disabled: ctx.busy,
|
||||
});
|
||||
@@ -458,6 +458,23 @@ export default function PlexPlaylistView({
|
||||
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
|
||||
);
|
||||
|
||||
// A whole block (a movie, a show) sorts among the blocks; an episode only within its own show.
|
||||
const dndA11y = useSortableA11y((id) => {
|
||||
const at = blocks.findIndex((b) => b.id === id);
|
||||
if (at >= 0) {
|
||||
const b = blocks[at]!;
|
||||
return {
|
||||
title: b.kind === "show" ? b.title : b.item.title,
|
||||
pos: at + 1,
|
||||
total: blocks.length,
|
||||
};
|
||||
}
|
||||
const show = blocks.find((b) => b.kind === "show" && b.items.some((it) => it.id === id));
|
||||
const eps = show?.kind === "show" ? show.items : [];
|
||||
const ep = eps.findIndex((it) => it.id === id);
|
||||
return { title: eps[ep]?.title ?? "", pos: ep + 1, total: eps.length };
|
||||
});
|
||||
|
||||
function onDragEnd(e: DragEndEvent) {
|
||||
const { active, over } = e;
|
||||
if (!over || active.id === over.id) return;
|
||||
@@ -581,7 +598,12 @@ export default function PlexPlaylistView({
|
||||
) : items.length === 0 ? (
|
||||
<p className="py-6 text-center text-sm text-muted">{t("plex.playlist.empty")}</p>
|
||||
) : (
|
||||
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={onDragEnd}>
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
onDragEnd={onDragEnd}
|
||||
accessibility={dndA11y}
|
||||
>
|
||||
<SortableContext items={blocks.map((b) => b.id)} strategy={verticalListSortingStrategy}>
|
||||
<div className="space-y-1.5">
|
||||
{blocks.map((b) =>
|
||||
|
||||
Reference in New Issue
Block a user