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,196 @@
|
||||
import {
|
||||
Fragment,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import { useVirtualizer } from "@tanstack/react-virtual";
|
||||
import { shouldPrefetchNext } from "./readAhead";
|
||||
|
||||
// The virtualization engine, lifted out of VirtualFeed so any long client- or server-side list can
|
||||
// reuse it (the feed's video grid, the channel manager's card/row views). It owns ONLY the layout
|
||||
// mechanics — measure the container, derive the column count, chunk into rows, virtualize, and place
|
||||
// each row absolutely — and knows nothing about what an item is. The caller supplies `renderItem`.
|
||||
//
|
||||
// ⚠️ Each row is placed with `position:absolute` + `transform` (below), which makes EVERY ROW ITS OWN
|
||||
// STACKING CONTEXT. A z-indexed child therefore cannot escape its row: a menu or popover rendered
|
||||
// inside an item draws UNDER the next row, whatever z-index it carries — the channel manager's tag
|
||||
// picker hit exactly this. Anything floating that an item renders must portal out (see Overlay).
|
||||
//
|
||||
// Column sizing mirrors the CSS the feed used before virtualization
|
||||
// (grid-cols-[repeat(auto-fill,minmax(260px,1fr))] with a gap-4 / 1rem gutter).
|
||||
const GRID_GAP = 16;
|
||||
const LIST_GAP = 4;
|
||||
|
||||
/** Columns that fit `width`, or 1 for the single-column layout (`minCol == null`). */
|
||||
function columnsFor(minCol: number | null, width: number): number {
|
||||
if (minCol == null) return 1;
|
||||
return Math.max(1, Math.floor((width + GRID_GAP) / (minCol + GRID_GAP)));
|
||||
}
|
||||
|
||||
function chunk<T>(arr: T[], size: number): T[][] {
|
||||
if (size <= 1) return arr.map((x) => [x]);
|
||||
const out: T[][] = [];
|
||||
for (let i = 0; i < arr.length; i += size) out.push(arr.slice(i, i + size));
|
||||
return out;
|
||||
}
|
||||
|
||||
// The list scrolls inside an <main overflow-y-auto> (the page scroller), not the window, so the
|
||||
// virtualizer needs that element. Walk up to the nearest scrollable ancestor.
|
||||
function getScrollParent(node: HTMLElement | null): HTMLElement {
|
||||
let el = node?.parentElement ?? null;
|
||||
while (el) {
|
||||
const oy = getComputedStyle(el).overflowY;
|
||||
if (oy === "auto" || oy === "scroll") return el;
|
||||
el = el.parentElement;
|
||||
}
|
||||
return document.documentElement;
|
||||
}
|
||||
|
||||
export interface VirtualGridProps<T> {
|
||||
items: T[];
|
||||
getKey: (item: T) => string;
|
||||
/** Narrowest grid column in px, or null for ONE full-width column. The column count is derived
|
||||
* from this and the container width, so every multi-column layout reflows for free. */
|
||||
minCol: number | null;
|
||||
/** Single-column layout only: the reading-width cap in px (null = fill). */
|
||||
maxCol?: number | null;
|
||||
/** Estimated row height before measurement; real heights are measured per-row, so this only
|
||||
* affects the very first paint and the scrollbar's initial guess. Measure it, don't guess — a
|
||||
* bad estimate makes the scrollbar jump as you scroll in. */
|
||||
rowEst: number;
|
||||
/** Renders one item. `itemWidth` is the measured px width of one cell — for width-derived
|
||||
* layouts (e.g. a one-line row that drops columns as it narrows); ignore it otherwise, and to
|
||||
* protect a memo'd item from re-rendering on every resize frame, pass a stable value down. */
|
||||
renderItem: (item: T, itemWidth: number) => ReactNode;
|
||||
/** Infinite scroll — omit all three for a finite client-side list. */
|
||||
hasNextPage?: boolean;
|
||||
isFetchingNextPage?: boolean;
|
||||
fetchNextPage?: () => void;
|
||||
}
|
||||
|
||||
export default function VirtualGrid<T>({
|
||||
items,
|
||||
getKey,
|
||||
minCol,
|
||||
maxCol = null,
|
||||
rowEst,
|
||||
renderItem,
|
||||
hasNextPage = false,
|
||||
isFetchingNextPage = false,
|
||||
fetchNextPage,
|
||||
}: VirtualGridProps<T>) {
|
||||
const listRef = useRef<HTMLDivElement>(null);
|
||||
const [scrollEl, setScrollEl] = useState<HTMLElement | null>(null);
|
||||
const singleCol = minCol == null;
|
||||
const [colCount, setColCount] = useState(singleCol ? 1 : 4);
|
||||
// Distance from the scroll element's content top to where this list starts (a toolbar sits above
|
||||
// it inside the same scroll area).
|
||||
const [scrollMargin, setScrollMargin] = useState(0);
|
||||
// How wide ONE row/card actually renders. It has to be the real container width, not the
|
||||
// viewport: side rails/panels move it independently of the window, and a viewport breakpoint got
|
||||
// this wrong before. This is measured here anyway, for colCount.
|
||||
const [itemWidth, setItemWidth] = useState(0);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (listRef.current) setScrollEl(getScrollParent(listRef.current));
|
||||
}, []);
|
||||
|
||||
// Re-measure the column count and start offset on container/scroll-area resize.
|
||||
useLayoutEffect(() => {
|
||||
const node = listRef.current;
|
||||
if (!node) return;
|
||||
const measure = () => {
|
||||
if (scrollEl) {
|
||||
const m =
|
||||
node.getBoundingClientRect().top -
|
||||
scrollEl.getBoundingClientRect().top +
|
||||
scrollEl.scrollTop;
|
||||
setScrollMargin(m);
|
||||
}
|
||||
const w = node.clientWidth;
|
||||
const cols = columnsFor(minCol, w);
|
||||
setColCount(cols);
|
||||
setItemWidth(
|
||||
minCol == null ? Math.min(w, maxCol ?? w) : Math.floor((w - GRID_GAP * (cols - 1)) / cols),
|
||||
);
|
||||
};
|
||||
measure();
|
||||
const ro = new ResizeObserver(measure);
|
||||
ro.observe(node);
|
||||
if (scrollEl) ro.observe(scrollEl);
|
||||
return () => ro.disconnect();
|
||||
}, [scrollEl, minCol, maxCol]);
|
||||
|
||||
const rows = useMemo(() => chunk(items, colCount), [items, colCount]);
|
||||
|
||||
const virtualizer = useVirtualizer({
|
||||
count: rows.length,
|
||||
getScrollElement: () => scrollEl,
|
||||
estimateSize: () => rowEst,
|
||||
overscan: 4,
|
||||
gap: singleCol ? LIST_GAP : GRID_GAP,
|
||||
scrollMargin,
|
||||
});
|
||||
|
||||
const virtualRows = virtualizer.getVirtualItems();
|
||||
|
||||
// Infinite-scroll read-ahead: pull the next page once the last rendered row is within the
|
||||
// read-ahead window of the loaded end (see readAhead.ts — the window scales with the rendered row
|
||||
// count, clamped so a tall window doesn't prefetch unboundedly). A no-op for finite lists (no
|
||||
// fetchNextPage / hasNextPage false). Because this effect re-runs when a page settles
|
||||
// (isFetchingNextPage flips false) it CHAINS — refilling the buffer as the user consumes it, so a
|
||||
// fast scroll reaches the loaded end far less often.
|
||||
useEffect(() => {
|
||||
if (!fetchNextPage || !hasNextPage || isFetchingNextPage) return;
|
||||
const last = virtualRows[virtualRows.length - 1];
|
||||
if (!last) return;
|
||||
if (shouldPrefetchNext(last.index, rows.length, virtualRows.length)) {
|
||||
fetchNextPage();
|
||||
}
|
||||
}, [virtualRows, rows.length, hasNextPage, isFetchingNextPage, fetchNextPage]);
|
||||
|
||||
return (
|
||||
<div ref={listRef} style={{ height: virtualizer.getTotalSize(), position: "relative" }}>
|
||||
{virtualRows.map((vr) => {
|
||||
const row = rows[vr.index];
|
||||
if (!row) return null;
|
||||
return (
|
||||
<div
|
||||
key={vr.key}
|
||||
data-index={vr.index}
|
||||
ref={virtualizer.measureElement}
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: "100%",
|
||||
transform: `translateY(${vr.start - virtualizer.options.scrollMargin}px)`,
|
||||
}}
|
||||
>
|
||||
{/* One branch for both shapes: the single-column layout chunks to colCount 1 and gets
|
||||
its reading-width cap instead of grid columns. */}
|
||||
<div
|
||||
className={singleCol ? "mx-auto pb-1" : "grid gap-4"}
|
||||
style={
|
||||
singleCol
|
||||
? { maxWidth: maxCol ?? undefined }
|
||||
: { gridTemplateColumns: `repeat(${colCount}, minmax(0, 1fr))` }
|
||||
}
|
||||
>
|
||||
{/* A transparent Fragment carries React's key so the rendered item stays the DIRECT
|
||||
grid/column child — some cards rely on that (e.g. `h-full` stretch, actions pinned
|
||||
to the bottom edge), which a wrapper element would silently break. */}
|
||||
{row.map((item) => (
|
||||
<Fragment key={getKey(item)}>{renderItem(item, itemWidth)}</Fragment>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user