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 (
{renderedIds.map((id) => ( ))}
); }