refactor(e10): move the platform components into components/ui/ and enforce the boundary
The app HAS a platform layer -- AnchoredPopover (22 importers, with arrows/typeahead/Escape/anchor-flip), QueryState (21), Modal (17), PageShell, DataTable, Tabs, Tooltip and ~26 more -- but it sat flat among ~160 feature components with nothing marking it as one. Several of those files describe themselves as 'the one place' or 'canonical' and were still bypassed by hand-rolled markup, because a layer nobody can see is a layer nobody uses; three separate inventory passes of this codebase misread it the same way. Moves the 33 domain-free modules into components/ui/, rewrites the imports in 89 files, and makes the boundary a lint rule rather than a convention: inside ui/ a one-level-up import (a feature component) and any lib/api slice are errors. The rule was verified to fire on both and to leave generic lib/ helpers alone. README.md states which side of the line a component belongs on and why. No behaviour change: 3x tsc, eslint, knip, prettier and 687 vitest tests all green; no backend file touched.
This commit is contained in:
@@ -0,0 +1,107 @@
|
||||
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";
|
||||
|
||||
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] } });
|
||||
}
|
||||
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}>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user