Files
siftlode/frontend/src/components/ui/PanelGroup.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

117 lines
4.1 KiB
TypeScript

import { type ReactNode, type CSSProperties } from "react";
import { useTranslation } from "react-i18next";
import { ChevronDown, Eye, EyeOff, GripVertical, Pencil } from "lucide-react";
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
// panels share the same island look and behavior. `collapsible` groups (the default) get a per-group
// collapse chevron; non-collapsible groups render as a plain always-open label + control row, and
// their title can instead act as a trigger via `onTitleClick` (e.g. feed "Your tags" -> tag manager).
export default function PanelGroup({
id,
title,
editing,
collapsible = true,
collapsed,
hidden,
onToggleCollapse,
onToggleHidden,
onTitleClick,
titleActionLabel,
children,
}: {
id: string;
title: string;
editing: boolean;
collapsible?: boolean;
collapsed: boolean;
hidden: boolean;
onToggleCollapse: () => void;
onToggleHidden: () => void;
onTitleClick?: () => void;
titleActionLabel?: string;
children: ReactNode;
}) {
const { t } = useTranslation();
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortableItem({
id,
disabled: !editing,
});
const style: CSSProperties = { transform: CSS.Transform.toString(transform), transition };
// Non-collapsible groups are always open (outside edit mode); collapsible ones honour `collapsed`.
const showBody = !editing && (!collapsible || !collapsed);
// A non-collapsible group's title acts as a trigger when one is provided.
const titleIsTrigger = !editing && !collapsible && !!onTitleClick;
const titleCls =
"flex-1 min-w-0 flex items-center gap-1 text-left select-none text-sm uppercase tracking-wide";
return (
<div
ref={setNodeRef}
style={style}
className={`glass-card rounded-xl ${
isDragging ? "relative z-base shadow-2xl ring-1 ring-accent" : ""
} ${hidden && editing ? "opacity-50" : ""}`}
>
<div className="flex items-center gap-1.5 px-3 py-2">
{editing && (
<button
{...attributes}
{...listeners}
title={t("sidebar.dragToReorder")}
className="-ml-1 cursor-grab active:cursor-grabbing text-muted hover:text-fg touch-none"
>
<GripVertical className="w-4 h-4" />
</button>
)}
{collapsible && !editing ? (
<button
onClick={onToggleCollapse}
className={`${titleCls} text-muted hover:text-fg transition`}
>
<span className="truncate">{title}</span>
</button>
) : titleIsTrigger ? (
<button
onClick={onTitleClick}
title={titleActionLabel}
className={`${titleCls} group text-muted hover:text-accent transition`}
>
<span className="truncate">{title}</span>
<Pencil className="w-3 h-3 shrink-0 text-muted/60 group-hover:text-accent" />
</button>
) : (
<span className={`${titleCls} text-muted`}>
<span className="truncate">{title}</span>
</span>
)}
{editing ? (
<button
onClick={onToggleHidden}
title={hidden ? t("sidebar.showWidget") : t("sidebar.hideWidget")}
className="text-muted hover:text-fg"
>
{hidden ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
</button>
) : (
collapsible && (
<button
onClick={onToggleCollapse}
title={collapsed ? t("sidebar.expand") : t("sidebar.collapse")}
className="text-muted hover:text-fg"
>
<ChevronDown
className={`w-4 h-4 transition-transform ${collapsed ? "-rotate-90" : ""}`}
/>
</button>
)
)}
</div>
{showBody && <div className="px-3 pb-3">{children}</div>}
</div>
);
}