"use client"
/**
* DataRowList — generic vertical-stack list view used by every hub's "list"
* tab (placements, team, compliance, sites, library, …). Replaces the
* hand-rolled `
{rows.map(- …)}`
* shell that was duplicated across `*-list-view.tsx` files.
*
* Composition over inheritance: callers provide a `renderRow(row)` that
* returns whatever ListPageBoardCard / link / chip-stack they need — this
* component owns the chrome (spacing, empty state, virtualization), not the
* row body.
*
* Auto-virtualises with `@tanstack/react-virtual` when the row count meets
* `virtualizeThreshold` (default 100). Disable by passing `0`.
*/
import * as React from "react"
import { useWindowVirtualizer } from "@tanstack/react-virtual"
import { cn } from "../../lib/utils"
const DEFAULT_VIRTUALIZE_THRESHOLD = 100
const DEFAULT_ESTIMATED_ROW_HEIGHT = 96
const DEFAULT_OVERSCAN = 8
export interface DataRowListProps {
/** The filtered/sorted rows from `tableState.rows` (or wherever). */
rows: readonly TRow[]
/** Stable id used as the React `key` and (for virtualizer) the v-key. */
getRowId: (row: TRow, index: number) => string | number
/** Render the body of one row. Wrap with `` etc. */
renderRow: (row: TRow, index: number) => React.ReactNode
/**
* Shown when `rows.length === 0`. Strings render as muted body copy; pass
* a `ReactNode` for richer empty states (illustration, CTA, etc.).
*/
emptyState?: React.ReactNode
/**
* Auto-virtualise when `rows.length >= virtualizeThreshold`. Default 100.
* Pass `0` to never virtualise (preserves predictable layout for short
* lists like dashboards / pinned tabs).
*/
virtualizeThreshold?: number
/** Hint for the virtualizer; clamps to measured size after first paint. */
estimatedRowHeight?: number
/** Override the default container padding / gap if needed. */
className?: string
/** Override the per-row `
- ` className (e.g. tighter spacing). */
rowClassName?: string
/** `aria-label` for the `
` (screen-reader name for the list). */
ariaLabel?: string
/**
* Id of the row a detail rail is currently showing, so the user can tell
* which card the rail beside them is about. The list marks it with a ring
* (a card's own rounded edge carries a ring better than the leading bar the
* table view draws) plus `aria-current`.
*
* The row body comes from `renderRow`, so the ring is applied to whatever
* that returns rather than to the `- ` — a square outline around a rounded
* card would miss its corners.
*/
openRowId?: string | number | null
}
const DEFAULT_OUTER_CLASS = "flex list-none flex-col gap-2 px-4 pb-8 pt-2 lg:px-6"
/**
* Open-row ring, applied to the direct children of the `
- ` — the card, link,
* or chip stack `renderRow` returned — so it follows their radius instead of
* boxing them in. `forced-colors` drops ring colours, hence the outline there.
*/
const OPEN_ROW_CLASS = cn(
"*:ring-2 *:ring-dt-row-open-marker *:ring-offset-0",
"forced-colors:*:outline forced-colors:*:outline-2 forced-colors:*:outline-[Highlight]",
)
function DataRowListItem({
row,
index,
renderRow,
}: {
row: TRow
index: number
renderRow: (row: TRow, index: number) => React.ReactNode
}) {
return <>{renderRow(row, index)}>
}
export function DataRowList(props: DataRowListProps) {
const {
rows,
getRowId,
renderRow,
emptyState,
virtualizeThreshold = DEFAULT_VIRTUALIZE_THRESHOLD,
estimatedRowHeight = DEFAULT_ESTIMATED_ROW_HEIGHT,
className,
rowClassName,
ariaLabel,
openRowId,
} = props
if (rows.length === 0) {
if (emptyState == null) return null
if (typeof emptyState === "string") {
return (
)
}
return
{emptyState}
}
if (virtualizeThreshold > 0 && rows.length >= virtualizeThreshold) {
return (
)
}
return (
{rows.map((row, i) => {
const isOpen = openRowId != null && getRowId(row, i) === openRowId
return (
-
)
})}
)
}
// ─────────────────────────────────────────────────────────────────────────────
// Virtualised variant — keeps the DOM short on long lists (e.g. 1000+ rows).
// Uses `useWindowVirtualizer` so the page scroll drives row recycling; this
// is the right tool for hub-level lists (not nested-scroll containers).
// ─────────────────────────────────────────────────────────────────────────────
function DataRowListVirtualized({
rows,
getRowId,
renderRow,
estimatedRowHeight,
className,
rowClassName,
ariaLabel,
openRowId,
}: {
rows: readonly TRow[]
getRowId: (row: TRow, index: number) => string | number
renderRow: (row: TRow, index: number) => React.ReactNode
estimatedRowHeight: number
className?: string
rowClassName?: string
ariaLabel?: string
openRowId?: string | number | null
}) {
const anchorRef = React.useRef(null)
// `scrollMargin` is read by the virtualizer during render, so it has to
// be state (not a ref). We measure with `useLayoutEffect` after the first
// paint and on resize so window-scroll math stays accurate when the page
// layout shifts (sidebar collapse, banner, etc.).
const [scrollMargin, setScrollMargin] = React.useState(0)
const updateScrollMargin = React.useCallback(() => {
const el = anchorRef.current
if (!el) return
setScrollMargin(el.getBoundingClientRect().top + window.scrollY)
}, [])
const updateScrollMarginRef = React.useRef(updateScrollMargin)
// Layout, not passive: the effect below reads this ref during the same
// commit, and it only sees the current callback if the write lands first.
React.useLayoutEffect(() => {
updateScrollMarginRef.current = updateScrollMargin
})
React.useLayoutEffect(() => {
const onResize = () => updateScrollMarginRef.current()
onResize()
window.addEventListener("resize", onResize)
return () => window.removeEventListener("resize", onResize)
}, [rows.length])
const virtualizer = useWindowVirtualizer({
count: rows.length,
estimateSize: () => estimatedRowHeight,
overscan: DEFAULT_OVERSCAN,
scrollMargin,
getItemKey: i => String(getRowId(rows[i], i)),
})
const totalSize = virtualizer.getTotalSize()
return (
{virtualizer.getVirtualItems().map(vr => {
const row = rows[vr.index]
if (!row) return null
const isOpen = openRowId != null && getRowId(row, vr.index) === openRowId
return (
-
)
})}
)
}