Files
siftlode/frontend/src/components/ui/PanelGroups.tsx
T
peter 4d0abc61d5 feat(design): reorder without a drag everywhere a grip drags (WCAG 2.5.7, E28 S3e4a)
One rule, two forms: useOrderItems (Top / Up / Down / Bottom rows of a row
menu) and OrderButtons (four buttons with the place, or a compact Up / Down
pair). A move that would go nowhere is shown off.

- YouTube playlist rows: the four moves now come from useOrderItems.
- Plex playlist rows: a block among the blocks, an episode within its show,
  off while a write is in flight; drag and menu share moveBlock/moveEpisode.
- Saved views editor: the selected view's four buttons (owner: A), and the
  grip's keyboard pick-up (its instructions promised one it did not have).
- Side panel groups in edit mode: an Up / Down pair (owner: A); a group the
  stored order does not hold yet moves too; the name wraps there.
- The grip's touch-action: none is now test-held.
2026-09-26 18:14:28 +02:00

132 lines
4.8 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,
}));
// The stored order, plus any available group it does not hold yet (see above): a move — by drag
// or by a group's Up / Down — lands in it, so a group appended after the layout was saved moves too.
const fullOrder = [
...layout.order,
...orderedAvailable.filter((id) => !layout.order.includes(id)),
];
function move(id: string, overId: string) {
const from = fullOrder.indexOf(id);
const to = fullOrder.indexOf(overId);
if (from < 0 || to < 0 || from === to) return;
setLayout({ ...layout, order: arrayMove(fullOrder, from, to) });
}
function onDragEnd(e: DragEndEvent) {
const { active, over } = e;
if (over) move(String(active.id), String(over.id));
}
return (
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={onDragEnd}
accessibility={dndA11y}
>
<SortableContext items={renderedIds} strategy={verticalListSortingStrategy}>
<div className="space-y-3">
{renderedIds.map((id, i) => (
<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}
order={{
from: i,
count: renderedIds.length,
onMove: (to) => move(id, renderedIds[to]!),
}}
>
{byId.get(id)!.body}
</PanelGroup>
))}
</div>
</SortableContext>
</DndContext>
);
}