Files
siftlode/frontend/src/components/ui/PanelGroups.tsx
T
peter 17a26894b0 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`.
2026-09-19 17:37:21 +02:00

119 lines
4.2 KiB
TypeScript

import { type ReactNode } from "react";
import {
closestCenter,
DndContext,
KeyboardSensor,
PointerSensor,
useSensor,
useSensors,
type DragEndEvent,
} from "@dnd-kit/core";
import {
arrayMove,
SortableContext,
sortableKeyboardCoordinates,
verticalListSortingStrategy,
} from "@dnd-kit/sortable";
import type { PanelLayout } from "../../lib/panelLayout";
import PanelGroup from "./PanelGroup";
import { useSortableA11y } from "./sortableA11y";
export type PanelItem = {
id: string;
title: string;
body: ReactNode;
/** When set (and the panel is non-collapsible), the group's title becomes a button firing this —
* e.g. the feed's "Your tags" opens the tag manager. */
onTitleClick?: () => void;
/** Accessible name / tooltip for the title action (what onTitleClick does). */
titleActionLabel?: string;
};
// Renders a panel's islands in the user's saved order, with drag-to-reorder + hide + per-group
// collapse driven by `layout` (see lib/panelLayout). `items` holds only the currently AVAILABLE
// groups (a caller drops facet-gated / empty ones); the layout order is filtered to them so a
// stored order that references a now-absent group is tolerated. Reordering/hiding is active only
// in `editing` mode. Shared by all three side panels.
export default function PanelGroups({
items,
layout,
setLayout,
editing,
collapsible = true,
}: {
items: PanelItem[];
layout: PanelLayout;
setLayout: (l: PanelLayout) => void;
editing: boolean;
/** When false, groups render as plain (always-open) label + control rows — no collapse chevron.
* Reorder + hide (in edit mode) stay. Default true (the collapsible-island look). */
collapsible?: boolean;
}) {
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 4 } }),
// The group grips are focusable and call themselves reorderable; without this only a pointer
// could actually move them (keyboard drag matched the other sortable lists).
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
);
const byId = new Map(items.map((it) => [it.id, it]));
const orderedAvailable = layout.order.filter((id) => byId.has(id));
// Append any available id missing from the stored order (e.g. a group that became available
// after the layout was saved) so it never silently disappears.
for (const it of items) if (!orderedAvailable.includes(it.id)) orderedAvailable.push(it.id);
const renderedIds = editing
? orderedAvailable
: orderedAvailable.filter((id) => !layout.hidden[id]);
function toggleCollapse(id: string) {
setLayout({ ...layout, collapsed: { ...layout.collapsed, [id]: !layout.collapsed[id] } });
}
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;
const from = layout.order.indexOf(active.id as string);
const to = layout.order.indexOf(over.id as string);
if (from < 0 || to < 0) return;
setLayout({ ...layout, order: arrayMove(layout.order, from, to) });
}
return (
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={onDragEnd}
accessibility={dndA11y}
>
<SortableContext items={renderedIds} strategy={verticalListSortingStrategy}>
<div className="space-y-3">
{renderedIds.map((id) => (
<PanelGroup
key={id}
id={id}
title={byId.get(id)!.title}
editing={editing}
collapsible={collapsible}
collapsed={!!layout.collapsed[id]}
hidden={!!layout.hidden[id]}
onToggleCollapse={() => toggleCollapse(id)}
onToggleHidden={() => toggleHidden(id)}
onTitleClick={byId.get(id)!.onTitleClick}
titleActionLabel={byId.get(id)!.titleActionLabel}
>
{byId.get(id)!.body}
</PanelGroup>
))}
</div>
</SortableContext>
</DndContext>
);
}