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`.
117 lines
4.1 KiB
TypeScript
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>
|
|
);
|
|
}
|