import React from "react"; import { FlatList, PanResponder, Pressable, View, type LayoutChangeEvent, type ListRenderItem, type ViewStyle, } from "react-native"; import { cn } from "../utils/cn"; import { MutedText, Text } from "./text"; import { Skeleton } from "./skeleton"; export interface DataTableColumn { /** Stable identifier for the column; also the default cell value source. */ key: string; /** Header label. */ header: string; /** Fixed width (px). Mutually exclusive with `flex`; `width` wins if both set. */ width?: number; /** Flex grow factor when no fixed `width` is given. Defaults to 1. */ flex?: number; /** Custom cell renderer. Receives the whole row. */ render?: (row: T) => React.ReactNode; /** Cell + header horizontal alignment. Defaults to "left". */ align?: "left" | "center" | "right"; } export interface DataTableProps { columns: DataTableColumn[]; data: T[]; /** Stable key for each row — required by the underlying list. */ keyExtractor: (row: T, index: number) => string; /** Row press handler; when set, rows become pressable. */ onRowPress?: (row: T) => void; /** Replaces the body with skeleton rows while true. */ loading?: boolean; /** Number of skeleton rows to show while loading. Defaults to 5. */ loadingRowCount?: number; /** Rendered when `data` is empty and not loading. */ emptyState?: React.ReactNode; /** Fallback empty message when `emptyState` is not provided. */ emptyMessage?: string; /** * Let the operator drag the boundary between two columns. * * Off by default, because it costs a measured container and per-column * width state — a table that never needs it should not pay for it. On, the * columns start at their `flex` share of the measured width (so the table * still fills its space) and a drag moves width between the pair either * side of the handle rather than growing the table. * * Web-oriented: the handles are 8px targets suited to a pointer. They * render on native too and work, but a phone is not where a column gets * dragged. */ resizable?: boolean; className?: string; } const alignClass = { left: "text-left", center: "text-center", right: "text-right", } as const; const alignItems: Record< NonNullable["align"]>, ViewStyle["alignItems"] > = { left: "flex-start", center: "center", right: "flex-end", }; /** * Resolve a column's layout style. `width` (fixed px) takes precedence over * `flex` (grow factor, default 1). Kept pure so layout rules are unit-testable * without a render host. */ export function resolveColumnStyle(column: DataTableColumn): ViewStyle { const align = column.align ?? "left"; const base: ViewStyle = { paddingHorizontal: 12, paddingVertical: 12, justifyContent: "center", alignItems: alignItems[align], }; if (typeof column.width === "number") { return { ...base, width: column.width }; } return { ...base, flex: column.flex ?? 1 }; } /** Below this a cell shows nothing but its own padding. */ export const MIN_COLUMN_WIDTH = 64; /** * The web pointer for a draggable boundary. * * `col-resize` is a CSS cursor react-native-web passes through, and RN's own * `ViewStyle` types only the few cursors native supports — so this is typed as * a plain style object rather than cast, which keeps the assertion off the * component and out of every future reader's way. Native ignores the property. */ const WEB_COL_RESIZE_CURSOR = { cursor: "col-resize" } as unknown as ViewStyle; /** * Starting widths for a resizable table: the container divided by flex, with * fixed-width columns taken out first. * * Pure so the layout rule is testable without a render host, and so the answer * is the same on the first frame as after a resize — a table that measures * itself and then computes differently is a table that jumps. */ export function distributeColumnWidths( columns: DataTableColumn[], totalWidth: number, minWidth = MIN_COLUMN_WIDTH ): number[] { const fixed = columns.reduce( (sum, c) => sum + (typeof c.width === "number" ? c.width : 0), 0 ); const flexColumns = columns.filter((c) => typeof c.width !== "number"); const flexTotal = flexColumns.reduce((sum, c) => sum + (c.flex ?? 1), 0) || 1; // Never negative: a container narrower than its fixed columns would // otherwise hand out negative widths and collapse every flexible cell. const spare = Math.max(0, totalWidth - fixed); return columns.map((c) => typeof c.width === "number" ? c.width : Math.max(minWidth, (spare * (c.flex ?? 1)) / flexTotal) ); } /** * Apply one drag to a pair of adjacent columns. * * Resizing takes width from the NEIGHBOUR rather than from the table, so the * total stays put and the columns to the right do not crawl sideways as the * handle moves. Both sides clamp at `minWidth`, so a drag past the limit stops * instead of inverting the pair. */ export function resizeColumnPair( widths: number[], index: number, delta: number, minWidth = MIN_COLUMN_WIDTH ): number[] { const next = [...widths]; const left = next[index]; const right = next[index + 1]; if (left === undefined || right === undefined) { return next; } const clamped = Math.max(minWidth - left, Math.min(delta, right - minWidth)); next[index] = left + clamped; next[index + 1] = right - clamped; return next; } /** * A cell's style, with an explicit width when the table is resizable. * * Wraps {@link resolveColumnStyle} rather than replacing it: the flex path is * still what a non-resizable table uses, and one function deciding padding and * alignment keeps the two from drifting apart by a pixel. */ function cellStyle(column: DataTableColumn, width?: number): ViewStyle { const base = resolveColumnStyle(column); if (width === undefined) { return base; } // `flex` has to go, not just be overridden: a box with both grows past the // width it was told to take. const { flex: _flex, ...rest } = base; return { ...rest, width }; } /** Which body the table renders: skeleton rows, an empty placeholder, or data. */ export type DataTableBodyMode = "loading" | "empty" | "data"; /** * Decide the body mode from the loading flag + row count. Loading wins over * everything; an empty data set (when not loading) shows the empty state. * Pure so the branch logic is unit-testable without a render host. */ export function dataTableBodyMode(loading: boolean, rowCount: number): DataTableBodyMode { if (loading) { return "loading"; } return rowCount === 0 ? "empty" : "data"; } function HeaderRowInner({ columns, widths, onResize, }: { columns: DataTableColumn[]; /** Resolved pixel widths, when the table is resizable. */ widths?: number[]; onResize?: (index: number, delta: number) => void; }) { return ( {columns.map((column, i) => ( {column.header} {onResize && i < columns.length - 1 ? ( onResize(i, delta)} /> ) : null} ))} ); } /** * The draggable boundary between two header cells. * * A `PanResponder` rather than a gesture-handler recogniser: this package does * not depend on `react-native-gesture-handler`, and a header handle needs no * more than "how far has the pointer moved since it went down". * * `dx` is reported cumulatively from the gesture's start, so each move is fed * the DIFFERENCE from the last one — passing `dx` straight through would apply * the whole travel again on every frame and the column would run away. */ function ColumnResizeHandle({ onResize }: { onResize: (delta: number) => void }) { const last = React.useRef(0); const responder = React.useMemo( () => PanResponder.create({ onStartShouldSetPanResponder: () => true, onMoveShouldSetPanResponder: () => true, onPanResponderGrant: () => { last.current = 0; }, onPanResponderMove: (_event, gesture) => { onResize(gesture.dx - last.current); last.current = gesture.dx; }, onPanResponderRelease: () => { last.current = 0; }, }), [onResize] ); return ( ); } // Memoized so an unchanged row (stable `columns` + `onPress` identity) skips // re-rendering when the FlatList recycles. `React.memo` on a generic function // erases the type parameter, so we cast back to a generic-preserving signature. const HeaderRow = React.memo(HeaderRowInner) as typeof HeaderRowInner; function DataRowInner({ row, columns, widths, onPress, }: { row: T; columns: DataTableColumn[]; widths?: number[]; onPress?: (row: T) => void; }) { const cells = ( <> {columns.map((column, i) => ( {column.render ? ( column.render(row) ) : ( {String((row as Record)[column.key] ?? "")} )} ))} ); if (onPress) { return ( onPress(row)} accessibilityRole="button" > {cells} ); } return {cells}; } const DataRow = React.memo(DataRowInner) as typeof DataRowInner; /** * Generic, typed data table for admin dashboards. Renders a sticky-ish header * row above a virtualised `FlatList` body (react-native's built-in list — the * package does not depend on `@shopify/flash-list`, and adding a runtime dep is * out of scope). Works on RN web (the primary admin target) and native. * * Loading shows skeleton rows; an empty data set shows `emptyState` (or * `emptyMessage`). Columns size by fixed `width` or `flex`, support custom * `render`, and alignment. */ export function DataTable({ columns, data, keyExtractor, onRowPress, loading = false, loadingRowCount = 5, emptyState, emptyMessage = "No data", resizable = false, className, }: DataTableProps) { const mode = dataTableBodyMode(loading, data.length); // ── Resizable columns ────────────────────────────────────────────────── // // Widths are held here rather than on each column so a drag moves ONE pair // and the table's total width is unchanged. They are seeded from the // measured container, so a resizable table still fills its space instead of // needing pixel widths written by hand at every call site. // // A manual drag wins over a re-seed: `touched` stops a window resize from // discarding the operator's own column widths, which is what made the first // cut feel broken — every layout pass reset them. const [tableWidth, setTableWidth] = React.useState(0); const [widths, setWidths] = React.useState(null); const touched = React.useRef(false); const onLayout = React.useCallback((e: LayoutChangeEvent) => { const next = e.nativeEvent.layout.width; setTableWidth((prev) => (Math.abs(prev - next) < 1 ? prev : next)); }, []); React.useEffect(() => { if (!resizable || tableWidth <= 0 || touched.current) { return; } setWidths(distributeColumnWidths(columns, tableWidth)); // `columns` is in the deps because adding or removing one has to // redistribute; a caller that rebuilds the array every render should // memoize it, which every manager in this estate already does for the // row-recycling reason below. }, [resizable, tableWidth, columns]); const handleResize = React.useCallback((index: number, delta: number) => { touched.current = true; setWidths((prev) => (prev ? resizeColumnPair(prev, index, delta) : prev)); }, []); const resolved = resizable ? (widths ?? undefined) : undefined; // Stable `renderItem` identity (only re-created when `columns`/`onRowPress` // change) so the memoized `DataRow` can recycle. `extraData` tells FlatList // to re-render rows when either of those closed-over deps change. const renderItem = React.useCallback>( ({ item }) => ( ), [columns, onRowPress, resolved] ); return ( {mode === "loading" ? ( {Array.from({ length: loadingRowCount }).map((_, i) => ( ))} ) : mode === "empty" ? ( {emptyState ?? {emptyMessage}} ) : ( )} ); }