import * as React from "react" import { type Cell, type ColumnDef, type ColumnSizingState, type Header, type OnChangeFn, type Row, type RowSelectionState, flexRender, functionalUpdate, getCoreRowModel, useReactTable, } from "@tanstack/react-table" import { useVirtualizer } from "@tanstack/react-virtual" import { useDebounceCallback } from "usehooks-ts" import { Checkbox } from "@/components/ui/checkbox" import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip" import { cn } from "@/lib/utils" import { useDirection } from "./direction" /** Optional per-column layout hints for `ListView`. */ export type ListViewColumnMeta = { /** CSS grid track (`1fr`, `2fr`, `minmax(0,1fr)`). When set, used instead of TanStack pixel `size` in `grid-template-columns`. */ gridWidth?: string align?: "left" | "center" | "right" /** * Tabular figures for stable digit width. Default: on when `align` is `right` (amounts); set `false` to opt out, or `true` for dates/IDs. */ tabularNums?: boolean /** * Full text for an overflow tooltip (shown only when the cell truncates). If omitted, a string `accessorKey` value is used when available. */ getTooltipText?: (row: unknown) => string | null | undefined /** `false` disables the overflow tooltip for this column. */ truncateTooltip?: boolean /** * `false` skips single-line truncation for cells with custom layouts (e.g. action buttons). Default `true`. */ truncate?: boolean } function alignClass(meta: ListViewColumnMeta | undefined) { switch (meta?.align) { case "center": return "justify-center text-center" case "right": return "justify-end text-end" default: return "justify-start text-start" } } function tabularNumsClass(meta: ListViewColumnMeta | undefined) { if (meta?.tabularNums === false) return "" if (meta?.tabularNums === true) return "tabular-nums" if (meta?.align === "right") return "tabular-nums" return "" } function resolveTooltipLabel( row: Row, meta: ListViewColumnMeta | undefined, columnDef: ColumnDef, ): string | undefined { if (meta?.truncateTooltip === false) return undefined const fromMeta = meta?.getTooltipText?.(row.original as unknown) if (fromMeta != null && String(fromMeta).length > 0) { return String(fromMeta) } const key = "accessorKey" in columnDef ? columnDef.accessorKey : undefined if (key !== undefined && key !== null && key !== "") { try { const v = row.getValue(String(key)) if (v != null && v !== "") return String(v) } catch { /* column may not expose a value */ } } return undefined } function ListViewCellBody({ cell, row, meta, children, }: { cell: Cell row: Row meta: ListViewColumnMeta | undefined children: React.ReactNode }) { const ref = React.useRef(null) const [overflowing, setOverflowing] = React.useState(false) const direction = useDirection() const tooltipLabel = resolveTooltipLabel(row, meta, cell.column.columnDef) const tooltipAlign = meta?.align === "right" && direction === "ltr" ? "end" : "start" const measure = React.useCallback(() => { const el = ref.current if (!el) return setOverflowing(el.scrollWidth > el.clientWidth + 1) }, []) React.useLayoutEffect(() => { measure() }, [measure, children, tooltipLabel]) React.useEffect(() => { const el = ref.current if (!el || typeof ResizeObserver === "undefined") return const ro = new ResizeObserver(measure) ro.observe(el) return () => ro.disconnect() }, [measure]) if (meta?.truncate === false) { return
{children}
} const inner = (
{children}
) if (!tooltipLabel || !overflowing) { return inner } return ( {inner} {tooltipLabel} ) } function gridTemplateFromHeaders(headers: Header[]) { return headers .map((header) => { const meta = header.column.columnDef.meta as ListViewColumnMeta | undefined if (meta?.gridWidth) { return meta.gridWidth } return `${header.getSize()}px` }) .join(" ") } function defaultGetRowId(row: TData, index: number) { const r = row as Record if (r && typeof r.name === "string") return r.name if (r && typeof r.id === "string") return r.id return String(index) } export type ListViewProps = { data: TData[] columns: ColumnDef[] /** * Stable row id for selection and keys. Defaults to `name`, then `id`, then row index (index is fragile if data order changes). */ getRowId?: (originalRow: TData, index: number) => string /** Pixel height of each body row (default 40, matches frappe-ui ListView). */ rowHeight?: number className?: string /** Classes for the scrollable viewport (default includes max-height). */ scrollAreaClassName?: string /** Max height of the scroll area; number is pixels. Default `420`. */ maxHeight?: number | string emptyState?: React.ReactNode enableColumnResizing?: boolean columnSizing?: ColumnSizingState onColumnSizingChange?: OnChangeFn /** Debounced callback for persisting widths (e.g. localStorage). */ onColumnSizingCommit?: (sizing: ColumnSizingState) => void columnSizingCommitDelayMs?: number enableRowSelection?: boolean rowSelection?: RowSelectionState onRowSelectionChange?: OnChangeFn onRowClick?: (row: TData, event: React.MouseEvent) => void } function ListViewInner({ data, columns: userColumns, getRowId: getRowIdProp, rowHeight = 40, className, scrollAreaClassName, maxHeight = 420, emptyState, enableColumnResizing = true, columnSizing: controlledColumnSizing, onColumnSizingChange: controlledOnColumnSizingChange, onColumnSizingCommit, columnSizingCommitDelayMs = 250, enableRowSelection = false, rowSelection: controlledRowSelection, onRowSelectionChange: controlledOnRowSelectionChange, onRowClick, }: ListViewProps) { const parentRef = React.useRef(null) const [internalColumnSizing, setInternalColumnSizing] = React.useState({}) const columnSizing = controlledColumnSizing ?? internalColumnSizing const [internalRowSelection, setInternalRowSelection] = React.useState({}) const rowSelection = controlledRowSelection ?? internalRowSelection const setRowSelection = controlledOnRowSelectionChange ?? setInternalRowSelection const debouncedSizingCommit = useDebounceCallback( (sizing: ColumnSizingState) => { onColumnSizingCommit?.(sizing) }, columnSizingCommitDelayMs, ) const selectionColumn = React.useMemo>( () => ({ id: "__list_view_select__", size: 36, minSize: 36, maxSize: 36, enableResizing: false, meta: { truncate: false, truncateTooltip: false, } satisfies ListViewColumnMeta, header: ({ table }) => (
table.toggleAllRowsSelected(value === true)} onClick={(e) => e.stopPropagation()} />
), cell: ({ row }) => (
row.toggleSelected(value === true)} onClick={(e) => e.stopPropagation()} />
), }), [], ) const columns = React.useMemo(() => { if (!enableRowSelection) return userColumns return [selectionColumn, ...userColumns] }, [enableRowSelection, selectionColumn, userColumns]) const getRowId = React.useCallback( (originalRow: TData, index: number) => (getRowIdProp ?? defaultGetRowId)(originalRow, index), [getRowIdProp], ) const onColumnSizingChangeInternal = React.useCallback>( (updater) => { if (controlledOnColumnSizingChange) { controlledOnColumnSizingChange(updater) return } setInternalColumnSizing((old) => { const next = functionalUpdate(updater, old) debouncedSizingCommit(next) return next }) }, [controlledOnColumnSizingChange, debouncedSizingCommit], ) const direction = useDirection() const table = useReactTable({ data, columns, defaultColumn: { minSize: 50, size: 150, }, columnResizeMode: "onChange", columnResizeDirection: direction, enableColumnResizing, getCoreRowModel: getCoreRowModel(), getRowId, onColumnSizingChange: onColumnSizingChangeInternal, onRowSelectionChange: setRowSelection, state: { columnSizing, rowSelection, }, enableRowSelection, }) const headerGroup = table.getHeaderGroups()[0] const gridTemplateColumns = headerGroup ? gridTemplateFromHeaders(headerGroup.headers) : "" const { rows } = table.getRowModel() const rowVirtualizer = useVirtualizer({ count: rows.length, getScrollElement: () => parentRef.current, estimateSize: () => rowHeight, overscan: 10, }) const maxHeightStyle = typeof maxHeight === "number" ? `${maxHeight}px` : maxHeight if (data.length === 0) { return (
{emptyState ?? "No data"}
) } /** Tracks + column gaps + horizontal padding (`px-2` × 2) so header and body share one scroll width. */ const colCount = headerGroup?.headers.length ?? 0 const minTableOuterWidth = table.getCenterTotalSize() + Math.max(0, colCount - 1) * 16 + 16 return (
{headerGroup ? (
{headerGroup.headers.map((header) => { const meta = header.column.columnDef.meta as ListViewColumnMeta | undefined return (
{header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())}
{enableColumnResizing && header.column.getCanResize() ? ( <>
{ e.preventDefault() document.body.classList.add("select-none", "cursor-col-resize") const end = () => { document.body.classList.remove("select-none", "cursor-col-resize") window.removeEventListener("mouseup", end) window.removeEventListener("touchend", end) } window.addEventListener("mouseup", end) window.addEventListener("touchend", end) header.getResizeHandler()(e) }} onTouchStart={header.getResizeHandler()} className="absolute top-0 ltr:-right-2 rtl:-left-2 z-10 h-full w-2 max-w-[12px] cursor-col-resize touch-none select-none bg-transparent" /> ) : null}
) })}
) : null}
{rowVirtualizer.getVirtualItems().map((virtualRow) => { const row = rows[virtualRow.index] if (!row) return null const leadDataColumnIndex = enableRowSelection ? 1 : 0 return (
0 && "border-t border-outline-gray-1", !row.getIsSelected() && "hover:bg-surface-menu-bar", row.getIsSelected() && "bg-surface-gray-2 hover:bg-surface-gray-3", onRowClick && "cursor-pointer", )} style={{ display: "grid", gridTemplateColumns, boxSizing: "border-box", columnGap: "1rem", height: `${rowHeight}px`, transform: `translateY(${virtualRow.start}px)`, }} onClick={(e) => { if (onRowClick) onRowClick(row.original, e) }} > {virtualRow.index > 0 &&
} {row.getVisibleCells().map((cell, cellIndex) => { const meta = cell.column.columnDef.meta as ListViewColumnMeta | undefined return (
{flexRender(cell.column.columnDef.cell, cell.getContext())}
) })}
) })}
) } /** * Div-based list with CSS Grid columns, optional resize handles, row virtualization, and frappe-ui–aligned Espresso tokens. */ export function ListView(props: ListViewProps) { return } export type { ColumnSizingState, RowSelectionState }