import { Column, type ColumnDef, ColumnFiltersState, flexRender, getCoreRowModel, getFilteredRowModel, getPaginationRowModel, getSortedRowModel, PaginationState, Row, RowSelectionState, type SortingState, Updater, useReactTable, } from "@tanstack/react-table"; import { useVirtualizer } from "@tanstack/react-virtual"; import React, { ReactNode, useEffect, useRef, useState } from "react"; import { Avatar, Button, Checkbox, DropdownMenu, DropdownMenuContent, DropdownMenuGroup, DropdownMenuItem, DropdownMenuItemProps, DropdownMenuPortal, DropdownMenuSub, DropdownMenuSubContent, DropdownMenuSubTrigger, DropdownMenuTrigger, Icon, IconButton, Pagination, ScrollArea, ScrollBar, Spinner, Tooltip, } from "@sparkle/components"; import { radioIndicatorStyles, radioStyles, } from "@sparkle/components/RadioGroup"; import { useCopyToClipboard } from "@sparkle/hooks"; import { ArrowDownIcon, ArrowUpIcon, ClipboardCheckIcon, ClipboardIcon, MoreIcon, } from "@sparkle/icons/app"; import { cn } from "@sparkle/lib/utils"; import { breakpoints, useWindowSize } from "./WindowUtility"; const cellHeight = "s-h-12"; declare module "@tanstack/react-table" { // eslint-disable-next-line @typescript-eslint/no-unused-vars interface ColumnMeta { className?: string; tooltip?: string; sizeRatio?: number; } } interface TBaseData { onClick?: () => void; onDoubleClick?: () => void; dropdownMenuProps?: React.ComponentPropsWithoutRef; menuItems?: MenuItem[]; } interface ColumnBreakpoint { [columnId: string]: "xs" | "sm" | "md" | "lg" | "xl"; } function shouldRenderColumn( windowWidth: number, breakpoint?: keyof typeof breakpoints ): boolean { if (!breakpoint) { return true; } return windowWidth >= breakpoints[breakpoint]; } interface DataTableProps { data: TData[]; totalRowCount?: number; rowCountIsCapped?: boolean; columns: ColumnDef[]; // eslint-disable-line @typescript-eslint/no-explicit-any className?: string; widthClassName?: string; filter?: string; filterColumn?: string; pagination?: PaginationState; setPagination?: (pagination: PaginationState) => void; columnsBreakpoints?: ColumnBreakpoint; sorting?: SortingState; setSorting?: (sorting: SortingState) => void; isServerSideSorting?: boolean; disablePaginationNumbers?: boolean; getRowId?: ( originalRow: TData, index: number, parent?: Row | undefined ) => string; // row selection props rowSelection?: RowSelectionState; setRowSelection?: (rowSelection: RowSelectionState) => void; enableRowSelection?: boolean | ((row: Row) => boolean); enableMultiRowSelection?: boolean; enableSortingRemoval?: boolean; } export function DataTable({ data, totalRowCount, rowCountIsCapped = false, columns, className, widthClassName = "s-w-full", filter, filterColumn, columnsBreakpoints = {}, pagination, setPagination, sorting, setSorting, isServerSideSorting = false, disablePaginationNumbers = false, rowSelection, setRowSelection, enableRowSelection = false, enableMultiRowSelection = true, getRowId, enableSortingRemoval = true, }: DataTableProps) { const windowSize = useWindowSize(); const [columnFilters, setColumnFilters] = useState([]); const isServerSidePagination = !!totalRowCount && totalRowCount > data.length; const isClientSideSortingEnabled = !isServerSideSorting && !isServerSidePagination; const onPaginationChange = pagination && setPagination ? (updater: Updater) => { const newValue = typeof updater === "function" ? updater(pagination) : updater; setPagination(newValue); } : undefined; const onSortingChange = sorting && setSorting ? (updater: Updater) => { const newValue = typeof updater === "function" ? updater(sorting) : updater; setSorting(newValue); } : undefined; const onRowSelectionChange = rowSelection && setRowSelection ? (updater: Updater) => { const newValue = typeof updater === "function" ? updater(rowSelection) : updater; setRowSelection(newValue); } : undefined; const table = useReactTable({ data, columns, rowCount: totalRowCount, manualPagination: isServerSidePagination, manualSorting: isServerSideSorting, ...(isServerSideSorting && { onSortingChange: onSortingChange, }), enableSortingRemoval, getCoreRowModel: getCoreRowModel(), ...(!isServerSideSorting && { getSortedRowModel: getSortedRowModel(), enableSorting: isClientSideSortingEnabled, }), getFilteredRowModel: getFilteredRowModel(), getPaginationRowModel: pagination ? getPaginationRowModel() : undefined, onColumnFiltersChange: setColumnFilters, ...(enableRowSelection && { onRowSelectionChange, }), state: { columnFilters, ...(isServerSideSorting && { sorting, }), pagination, ...(enableRowSelection && { rowSelection }), }, initialState: { sorting, }, onPaginationChange, enableRowSelection, enableMultiRowSelection, getRowId, }); useEffect(() => { if (filterColumn) { table.getColumn(filterColumn)?.setFilterValue(filter); } }, [filter, filterColumn]); return (
{table.getHeaderGroups().map((headerGroup) => ( {headerGroup.headers.map((header) => { const breakpoint = columnsBreakpoints[header.id]; if ( !windowSize.width || !shouldRenderColumn(windowSize.width, breakpoint) ) { return null; } return (
{flexRender( header.column.columnDef.header, header.getContext() )} {header.column.getCanSort() && ( )}
); })}
))}
{table.getRowModel().rows.map((row) => { const handleRowClick = () => { if (enableRowSelection && row.getCanSelect()) { row.toggleSelected(!enableMultiRowSelection ? true : undefined); } row.original.onClick?.(); }; return ( {row.getVisibleCells().map((cell) => { const breakpoint = columnsBreakpoints[cell.column.id]; if ( !windowSize.width || !shouldRenderColumn(windowSize.width, breakpoint) ) { return null; } return ( {flexRender( cell.column.columnDef.cell, cell.getContext() )} ); })} ); })}
{pagination && (
)}
); } export interface ScrollableDataTableProps< TData extends TBaseData, > extends DataTableProps { maxHeight?: string | boolean; onLoadMore?: () => void; isLoading?: boolean; containerRef?: React.Ref; } // cellHeight in pixels const COLUMN_HEIGHT = 48; const MIN_COLUMN_WIDTH = 40; export function ScrollableDataTable({ data, totalRowCount, columns, className, widthClassName = "s-w-full", columnsBreakpoints = {}, maxHeight, onLoadMore, sorting, setSorting, isLoading = false, rowSelection, setRowSelection, enableRowSelection, enableMultiRowSelection = true, getRowId, containerRef, }: ScrollableDataTableProps) { const windowSize = useWindowSize(); const loadMoreRef = useRef(null); const [tableWidth, setTableWidth] = useState(0); const tableContainerRef = useRef(null); const isSorting = !!setSorting; // Handle container ref const setRef = (element: HTMLDivElement | null) => { tableContainerRef.current = element; if (containerRef) { if (typeof containerRef === "function") { containerRef(element); } else if ("current" in containerRef) { ( containerRef as React.MutableRefObject ).current = element; } } }; // Monitor table width changes useEffect(() => { if (!tableContainerRef.current) { return; } const resizeObserver = new ResizeObserver((entries) => { for (const entry of entries) { setTableWidth(entry.contentRect.width); } }); resizeObserver.observe(tableContainerRef.current); return () => { resizeObserver.disconnect(); }; }, []); const onSortingChange = sorting && setSorting ? (updater: Updater) => { const newValue = typeof updater === "function" ? updater(sorting) : updater; setSorting(newValue); } : undefined; const onRowSelectionChange = rowSelection && setRowSelection ? (updater: Updater) => { const newValue = typeof updater === "function" ? updater(rowSelection) : updater; setRowSelection(newValue); } : undefined; const table = useReactTable({ data, columns, rowCount: totalRowCount, getCoreRowModel: getCoreRowModel(), enableColumnResizing: true, ...(enableRowSelection && { onRowSelectionChange, }), state: { ...(enableRowSelection && { rowSelection }), ...(isSorting && { sorting, }), }, manualSorting: isSorting, ...(isSorting && { onSortingChange: onSortingChange, }), enableSortingRemoval: true, enableRowSelection, enableMultiRowSelection, getRowId, }); useEffect(() => { if (!tableContainerRef.current || !table || !tableWidth) { return; } const columns = table.getAllColumns(); // Calculate ideal widths and handle minimums const idealSizing = columns.reduce( (acc, column) => { const ratio = column.columnDef.meta?.sizeRatio || 0; const calculated = Math.max( Math.floor((ratio / 100) * tableWidth), MIN_COLUMN_WIDTH ); return { ...acc, [column.id]: calculated }; }, {} as Record ); // Ensure total width matches tableWidth const totalIdealWidth = Object.values(idealSizing).reduce( (a, b) => a + b, 0 ); const widthDifference = tableWidth - totalIdealWidth; // adjust the largest column with leftover size if (widthDifference !== 0) { const adjustColumnId = Object.entries(idealSizing).sort( (a, b) => b[1] - a[1] )[0][0]; idealSizing[adjustColumnId] += widthDifference; } table.setColumnSizing(idealSizing); }, [table, tableWidth]); // Get the current column sizing from the table for rendering const columnSizing = table.getState().columnSizing; const { rows } = table.getRowModel(); const rowVirtualizer = useVirtualizer({ count: rows.length, getScrollElement: () => tableContainerRef.current, estimateSize: () => COLUMN_HEIGHT, }); // Intersection observer for infinite loading useEffect(() => { if (!onLoadMore || !loadMoreRef.current) { return; } const observer = new IntersectionObserver( (entries) => { // retrieving the sentinel div if (entries[0].isIntersecting && !isLoading) { onLoadMore(); } }, { root: tableContainerRef.current, rootMargin: "200% 0% 0% 0%", threshold: 0.1, } ); observer.observe(loadMoreRef.current); return () => { if (loadMoreRef.current) { observer.unobserve(loadMoreRef.current); } observer.disconnect(); }; }, [onLoadMore, isLoading]); return (
{table.getHeaderGroups().map((headerGroup) => ( {headerGroup.headers.map((header) => { const breakpoint = columnsBreakpoints[header.id]; if ( !windowSize.width || !shouldRenderColumn(windowSize.width, breakpoint) ) { return null; } return (
{flexRender( header.column.columnDef.header, header.getContext() )} {isSorting && header.column.getCanSort() && ( )}
); })}
))}
{rowVirtualizer.getVirtualItems().map((virtualRow) => { const row = rows[virtualRow.index]; const handleRowClick = () => { if (enableRowSelection && row.getCanSelect()) { row.toggleSelected( !enableMultiRowSelection ? true : undefined ); } row.original.onClick?.(); }; return ( {row.getVisibleCells().map((cell) => { const breakpoint = columnsBreakpoints[cell.column.id]; if ( !windowSize.width || !shouldRenderColumn(windowSize.width, breakpoint) ) { return null; } return (
{flexRender( cell.column.columnDef.cell, cell.getContext() )}
); })}
); })}
{/*sentinel div used for the intersection observer*/}
{isLoading && (
Loading more data...
)}
); } interface DataTableRootProps extends React.HTMLAttributes { children: ReactNode; containerClassName?: string; containerProps?: React.HTMLAttributes; } DataTable.Root = function DataTableRoot({ children, className, containerClassName, containerProps, ...props }: DataTableRootProps) { return (
{children}
); }; interface HeaderProps extends React.HTMLAttributes { children: ReactNode; } DataTable.Header = function Header({ children, className, ...props }: HeaderProps) { return ( {children} ); }; interface HeadProps extends React.ThHTMLAttributes { children?: ReactNode; column: Column; // eslint-disable-line @typescript-eslint/no-explicit-any } DataTable.Head = function Head({ children, className, column, ...props }: HeadProps) { return ( {column.columnDef.meta?.tooltip ? ( ) : ( children )} ); }; DataTable.Body = function Body({ children, className, ...props }: React.HTMLAttributes) { return ( {children} ); }; interface RowProps extends React.HTMLAttributes { children: ReactNode; onClick?: () => void; onDoubleClick?: () => void; widthClassName: string; "data-selected"?: boolean; rowData?: TBaseData; } DataTable.Row = function Row({ children, className, onClick, onDoubleClick, widthClassName, rowData, ...props }: RowProps) { const [contextMenuPosition, setContextMenuPosition] = useState<{ x: number; y: number; } | null>(null); const handleContextMenu = (event: React.MouseEvent) => { if (!rowData?.menuItems?.length) { return; } event.preventDefault(); setContextMenuPosition({ x: event.clientX, y: event.clientY }); }; return ( <> {children} {contextMenuPosition && rowData?.menuItems?.length && ( !open && setContextMenuPosition(null)} modal > {rowData?.menuItems?.map((item, index) => renderMenuItem(item, index, () => setContextMenuPosition(null) ) )} )} ); }; interface BaseMenuItem { kind: "item" | "submenu"; label: string; disabled?: boolean; } interface RegularMenuItem extends BaseMenuItem, Omit { kind: "item"; } type SubmenuEntry = { id: string; name: string; }; interface SubmenuMenuItem extends BaseMenuItem { kind: "submenu"; items: SubmenuEntry[]; onSelect: (itemId: string) => void; } export type MenuItem = RegularMenuItem | SubmenuMenuItem; // Shared menu rendering functions const renderSubmenuItem = ( item: SubmenuMenuItem, index: number, onItemClick?: () => void ) => ( {item.items.map((subItem) => ( { event.stopPropagation(); item.onSelect(subItem.id); onItemClick?.(); }} /> ))} ); const renderRegularItem = ( item: RegularMenuItem, index: number, onItemClick?: () => void ) => { // eslint-disable-next-line @typescript-eslint/no-unused-vars const { kind, ...itemProps } = item; return ( { event.stopPropagation(); itemProps.onClick?.(event); onItemClick?.(); }} /> ); }; const renderMenuItem = ( item: MenuItem, index: number, onItemClick?: () => void ) => { switch (item.kind) { case "submenu": return renderSubmenuItem(item, index, onItemClick); case "item": return renderRegularItem(item, index, onItemClick); } }; export interface DataTableMoreButtonProps { className?: string; menuItems?: MenuItem[]; dropdownMenuProps?: Omit< React.ComponentPropsWithoutRef, "modal" >; disabled?: boolean; } DataTable.MoreButton = function MoreButton({ className, menuItems, dropdownMenuProps, disabled, }: DataTableMoreButtonProps) { if (!menuItems?.length) { return null; } return ( { event.stopPropagation(); }} asChild >
} label={tooltip} /> ) : (
{label} {textToCopy && (
)} ); }; interface CellContentWithCopyProps { children: React.ReactNode; textToCopy?: string; className?: string; } DataTable.CellContentWithCopy = function CellContentWithCopy({ children, textToCopy, className, }: CellContentWithCopyProps) { const [isCopied, copyToClipboard] = useCopyToClipboard(); const handleCopy = async () => { void copyToClipboard( new ClipboardItem({ "text/plain": new Blob([textToCopy ?? String(children)], { type: "text/plain", }), }) ); }; return (
{children} { e.stopPropagation(); await handleCopy(); }} size="xs" />
); }; DataTable.Caption = function Caption({ children, className, ...props }: React.HTMLAttributes) { return ( {children} ); }; export function createSelectionColumn(): ColumnDef { return { id: "select", enableSorting: false, enableHiding: false, header: ({ table }) => ( { if (state === "indeterminate") { return; } table.toggleAllRowsSelected(state); }} /> ), cell: ({ row }) => (
{ if (state === "indeterminate") { return; } row.toggleSelected(state); }} />
), meta: { className: "s-w-10", }, }; } export function createRadioSelectionColumn(): ColumnDef { return { id: "radio-select", enableSorting: false, enableHiding: false, header: () => null, cell: ({ row }) => (
{row.getIsSelected() && (
)}
), meta: { className: "s-w-10", }, }; }