import { type CellContext, flexRender, getCoreRowModel, getFilteredRowModel, getPaginationRowModel, getSortedRowModel, type HeaderContext, useReactTable, type ColumnDef, type FilterFn, type PaginationState, type RowSelectionState, type SortingState } from "@tanstack/react-table"; import { forwardRef, useEffect, useState, type ComponentPropsWithoutRef, type CSSProperties, type ForwardedRef, type JSX, type ReactNode } from "react"; import { Button } from "./button"; import { Checkbox } from "./checkbox"; import { EmptyState, EmptyStateActions, EmptyStateDescription, EmptyStateHeader, EmptyStateTitle } from "./empty-state"; import { Input, type InputProps } from "./input"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./select"; import { Skeleton } from "./skeleton"; import { Spinner } from "./spinner"; import { cn } from "../lib/cn"; import type { VariantProps } from "../lib/cva"; import { createDataAttributes, createSlot } from "../lib/contracts"; import { SortAscendingIcon, SortDescendingIcon, SortUnsortedIcon } from "../lib/icons"; import { dataTableBodyVariants, dataTableCellVariants, dataTableContentVariants, dataTableHeaderCellVariants, dataTableHeaderVariants, dataTablePaginationVariants, dataTableRootVariants, dataTableRowVariants, dataTableSearchContainerVariants, dataTableSelectionBarVariants, dataTableStatusVariants, dataTableTableVariants, dataTableToolbarVariants } from "./data-table.variants"; export type DataTableAlignment = "start" | "center" | "end"; export type DataTableSort = { desc?: boolean; id: string; }; export type DataTableColumnContext = { column: DataTableColumn; sorted: "asc" | "desc" | false; }; export type DataTableColumn = { accessor?: keyof TData | ((row: TData) => unknown); align?: DataTableAlignment; cell?: (row: TData) => ReactNode; header: ReactNode | ((column: DataTableColumnContext) => ReactNode); id: string; searchValue?: (row: TData) => string; searchable?: boolean; sortable?: boolean; width?: number | string; }; export type DataTableProps = Omit, "children"> & { columns: DataTableColumn[]; defaultPageIndex?: number; defaultPageSize?: number; defaultSearchValue?: string; defaultSelection?: Record; defaultSorting?: DataTableSort[]; empty?: ReactNode; enableSelection?: boolean; getRowId?: (row: TData, index: number) => string; loading?: boolean; loadingRowCount?: number; onPageIndexChange?: (pageIndex: number) => void; onPageSizeChange?: (pageSize: number) => void; onSearchValueChange?: (searchValue: string) => void; onSelectionChange?: (selection: Record) => void; onSortingChange?: (sorting: DataTableSort[]) => void; pageIndex?: number; pageSize?: number; pageSizeOptions?: number[]; renderRowActions?: (row: TData) => ReactNode; rows: TData[]; searchLabel?: string; searchPlaceholder?: string; searchValue?: string; selection?: Record; selectionLabel?: ReactNode | ((rows: TData[]) => ReactNode); selectionActions?: (rows: TData[]) => ReactNode; sorting?: DataTableSort[]; tableLabel?: string; toolbarActions?: ReactNode; }; type InternalColumnMeta = { align: DataTableAlignment; sourceColumn?: DataTableColumn; width?: number | string; }; function useControllableState({ controlledValue, defaultValue, onChange }: { controlledValue: T | undefined; defaultValue: T; onChange?: (value: T) => void; }) { const [uncontrolledValue, setUncontrolledValue] = useState(defaultValue); const value = controlledValue ?? uncontrolledValue; const setValue = (nextValue: T | ((currentValue: T) => T)) => { const resolvedValue = typeof nextValue === "function" ? (nextValue as (currentValue: T) => T)(value) : nextValue; if (controlledValue === undefined) { setUncontrolledValue(resolvedValue); } onChange?.(resolvedValue); }; return [value, setValue] as const; } function stringifySearchValue(value: unknown): string { if (value === null || value === undefined) { return ""; } if (Array.isArray(value)) { return value.map((item) => stringifySearchValue(item)).join(" "); } if (value instanceof Date) { return value.toISOString(); } if (typeof value === "object") { return Object.values(value as Record) .map((item) => stringifySearchValue(item)) .join(" "); } return String(value); } function getColumnAccessorValue(row: TData, column: DataTableColumn) { if (typeof column.accessor === "function") { return column.accessor(row); } if (typeof column.accessor === "string") { return row[column.accessor]; } return undefined; } function getColumnSearchText(row: TData, column: DataTableColumn) { if (column.searchValue) { return column.searchValue(row); } return stringifySearchValue(getColumnAccessorValue(row, column)); } function getColumnWidthStyle(width?: number | string): CSSProperties | undefined { if (width === undefined) { return undefined; } return { width: typeof width === "number" ? `${width}px` : width }; } function getHeaderLabel(column: DataTableColumn) { return typeof column.header === "string" ? column.header : column.id; } function DataTableInner( { className, columns, defaultPageIndex = 0, defaultPageSize = 5, defaultSearchValue = "", defaultSelection = {}, defaultSorting = [], empty, enableSelection = false, getRowId, loading = false, loadingRowCount = 5, onPageIndexChange, onPageSizeChange, onSearchValueChange, onSelectionChange, onSortingChange, pageIndex, pageSize, pageSizeOptions = [5, 10, 20], renderRowActions, rows, searchLabel = "Search rows", searchPlaceholder = "Search rows", searchValue, selection, selectionLabel, selectionActions, sorting, tableLabel = "Data table", toolbarActions, ...props }: DataTableProps, ref: ForwardedRef ) { const selectionEnabled = enableSelection || selection !== undefined || onSelectionChange !== undefined; const searchableColumns = columns.filter( (column) => (column.searchable ?? false) || column.searchValue || column.accessor ); const [currentSearchValue, setCurrentSearchValue] = useControllableState({ controlledValue: searchValue, defaultValue: defaultSearchValue, onChange: onSearchValueChange }); const [currentSorting, setCurrentSorting] = useControllableState({ controlledValue: sorting, defaultValue: defaultSorting, onChange: onSortingChange }); const [currentSelection, setCurrentSelection] = useControllableState>({ controlledValue: selection, defaultValue: defaultSelection, onChange: onSelectionChange }); const [currentPageIndex, setCurrentPageIndex] = useControllableState({ controlledValue: pageIndex, defaultValue: defaultPageIndex, onChange: onPageIndexChange }); const [currentPageSize, setCurrentPageSize] = useControllableState({ controlledValue: pageSize, defaultValue: defaultPageSize, onChange: onPageSizeChange }); const resolvedPageSizeOptions = [...new Set([...pageSizeOptions, currentPageSize])].sort( (left, right) => left - right ); const globalFilterFn: FilterFn = (row, _columnId, filterValue) => { const query = String(filterValue ?? "").trim().toLowerCase(); if (query.length === 0) { return true; } return searchableColumns.some((column) => getColumnSearchText(row.original, column).toLowerCase().includes(query) ); }; const tableColumns: ColumnDef[] = [ ...(selectionEnabled ? [ { cell: ({ row }) => ( { row.toggleSelected(Boolean(checked)); }} /> ), enableGlobalFilter: false, enableHiding: false, enableSorting: false, header: ({ table }) => ( { table.toggleAllPageRowsSelected(Boolean(checked)); }} /> ), id: "__select", meta: { align: "center", width: 56 } satisfies InternalColumnMeta } satisfies ColumnDef ] : []), ...columns.map((column) => ({ accessorFn: column.accessor ? (row: TData) => getColumnAccessorValue(row, column) : undefined, cell: ({ row }: CellContext) => column.cell ? column.cell(row.original) : stringifySearchValue(getColumnAccessorValue(row.original, column)), enableGlobalFilter: searchableColumns.includes(column), enableSorting: column.sortable ?? false, header: ({ column: tanstackColumn }: HeaderContext) => typeof column.header === "function" ? column.header({ column, sorted: tanstackColumn.getIsSorted() }) : column.header, id: column.id, meta: { align: column.align ?? "start", sourceColumn: column, width: column.width } satisfies InternalColumnMeta, sortDescFirst: false })), ...(renderRowActions ? [ { cell: ({ row }) => (
{renderRowActions(row.original)}
), enableGlobalFilter: false, enableHiding: false, enableSorting: false, header: () => Row actions, id: "__actions", meta: { align: "end", width: 88 } satisfies InternalColumnMeta } satisfies ColumnDef ] : []) ]; // eslint-disable-next-line react-hooks/incompatible-library -- TanStack Table is the intentional row-model engine behind this source-owned component. const table = useReactTable({ autoResetPageIndex: true, columns: tableColumns, data: rows, enableRowSelection: selectionEnabled, getCoreRowModel: getCoreRowModel(), getFilteredRowModel: getFilteredRowModel(), getPaginationRowModel: getPaginationRowModel(), getRowId: (row, index) => { if (getRowId) { return getRowId(row, index); } const candidateId = (row as { id?: unknown }).id; if (typeof candidateId === "string" || typeof candidateId === "number") { return String(candidateId); } return String(index); }, getSortedRowModel: getSortedRowModel(), globalFilterFn, onPaginationChange: (updater) => { const nextValue = typeof updater === "function" ? updater({ pageIndex: currentPageIndex, pageSize: currentPageSize }) : updater; setCurrentPageIndex(nextValue.pageIndex); setCurrentPageSize(nextValue.pageSize); }, onRowSelectionChange: (updater) => { const nextValue = typeof updater === "function" ? updater(currentSelection as RowSelectionState) : updater; setCurrentSelection(nextValue); }, onSortingChange: (updater) => { const nextValue = typeof updater === "function" ? updater(currentSorting as SortingState) : updater; setCurrentSorting(nextValue.map((item) => ({ desc: item.desc, id: item.id }))); }, state: { globalFilter: currentSearchValue, pagination: { pageIndex: currentPageIndex, pageSize: currentPageSize } satisfies PaginationState, rowSelection: currentSelection, sorting: currentSorting as SortingState } }); const totalColumns = table.getAllLeafColumns().length; const filteredRowCount = table.getFilteredRowModel().rows.length; const selectedRows = table.getSelectedRowModel().rows.map((row) => row.original); const pageCount = table.getPageCount(); const isEmpty = !loading && filteredRowCount === 0; const shouldRenderSearch = searchableColumns.length > 0; const shouldRenderToolbar = shouldRenderSearch || toolbarActions !== undefined; const pageStart = filteredRowCount === 0 ? 0 : currentPageIndex * currentPageSize + 1; const pageEnd = Math.min((currentPageIndex + 1) * currentPageSize, filteredRowCount); useEffect(() => { const maxPageIndex = Math.max(pageCount - 1, 0); if (currentPageIndex > maxPageIndex) { setCurrentPageIndex(maxPageIndex); } }, [currentPageIndex, pageCount, setCurrentPageIndex]); return (
0 })} className={cn(dataTableRootVariants(), className)} ref={ref} > {shouldRenderToolbar ? (
{shouldRenderSearch ? (
{ setCurrentSearchValue(event.currentTarget.value); setCurrentPageIndex(0); }} placeholder={searchPlaceholder} value={currentSearchValue} />
) : null}
{toolbarActions ? {toolbarActions} : null}
) : null} {selectionEnabled && selectedRows.length > 0 ? (
{typeof selectionLabel === "function" ? selectionLabel(selectedRows) : selectionLabel ?? `${selectedRows.length} selected`}
{selectionActions?.(selectedRows)}
) : null} {loading ? : null}
{table.getHeaderGroups().map((headerGroup) => ( {headerGroup.headers.map((header) => { const meta = header.column.columnDef.meta as InternalColumnMeta | undefined; const sortState = header.column.getIsSorted(); const align: DataTableAlignment = meta?.align ?? "start"; return ( {header.isPlaceholder ? null : header.column.getCanSort() ? ( ) : ( flexRender(header.column.columnDef.header, header.getContext()) )} ); })} ))} {loading ? Array.from({ length: loadingRowCount }, (_, index) => ( {Array.from({ length: totalColumns }, (_, cellIndex) => ( ))} )) : isEmpty ? ( {empty ?? ( )} ) : table.getRowModel().rows.map((row) => ( {row.getVisibleCells().map((cell) => { const meta = cell.column.columnDef.meta as InternalColumnMeta | undefined; const align: DataTableAlignment = meta?.align ?? "start"; return ( {flexRender(cell.column.columnDef.cell, cell.getContext())} ); })} ))}
{pageStart}-{pageEnd} of {filteredRowCount}
{resolvedPageSizeOptions.length > 1 ? (
Rows
) : null}
Page {Math.min(currentPageIndex + 1, Math.max(pageCount, 1))} of {Math.max(pageCount, 1)}
); } type DataTableComponent = ( props: DataTableProps & { ref?: ForwardedRef } ) => JSX.Element; export const DataTable = forwardRef(DataTableInner) as DataTableComponent; export type DataTableToolbarProps = ComponentPropsWithoutRef<"div">; export const DataTableToolbar = forwardRef( function DataTableToolbar({ className, ...props }, ref) { return (
); } ); export type DataTableFiltersProps = ComponentPropsWithoutRef<"div">; export const DataTableFilters = forwardRef( function DataTableFilters({ className, ...props }, ref) { return (
); } ); export type DataTableSearchProps = Omit; export const DataTableSearch = forwardRef( function DataTableSearch({ className, type = "search", ...props }, ref) { return ; } ); export type DataTableContentProps = ComponentPropsWithoutRef<"div"> & VariantProps; export const DataTableContent = forwardRef( function DataTableContent({ className, loading, ...props }, ref) { return (
); } ); export type DataTableTableProps = ComponentPropsWithoutRef<"table">; export const DataTableTable = forwardRef( function DataTableTable({ className, ...props }, ref) { return ( ); } ); export type DataTableHeaderProps = ComponentPropsWithoutRef<"thead">; export const DataTableHeader = forwardRef( function DataTableHeader({ className, ...props }, ref) { return ( ); } ); export type DataTableHeaderCellProps = Omit, "align"> & VariantProps & { sort?: "asc" | "desc" | false; }; export const DataTableHeaderCell = forwardRef( function DataTableHeaderCell( { align = "start", className, sort = false, sortable = false, ...props }, ref ) { return ( ); } ); export type DataTableRowProps = ComponentPropsWithoutRef<"tr"> & VariantProps; export const DataTableRow = forwardRef( function DataTableRow( { className, interactive = true, selected = false, ...props }, ref ) { return ( ); } ); export type DataTableCellProps = Omit, "align"> & VariantProps; export const DataTableCell = forwardRef( function DataTableCell({ align = "start", className, ...props }, ref) { return (
); } ); export type DataTableBodyProps = ComponentPropsWithoutRef<"tbody">; export const DataTableBody = forwardRef( function DataTableBody({ className, ...props }, ref) { return (
); } ); export type DataTableEmptyProps = ComponentPropsWithoutRef<"div"> & { actions?: ReactNode; description?: ReactNode; title?: ReactNode; }; export const DataTableEmpty = forwardRef( function DataTableEmpty( { actions, className, description = "No rows match the current search or filter state.", title = "Nothing to show", ...props }, ref ) { return (
{title} {description} {actions ? {actions} : null}
); } ); export type DataTableLoadingProps = ComponentPropsWithoutRef<"div"> & { description?: ReactNode; label?: ReactNode; }; export const DataTableLoading = forwardRef( function DataTableLoading( { className, description = "Preparing the next set of rows and controls.", label = "Loading rows", ...props }, ref ) { return (

{label}

{description}

); } ); export type DataTablePaginationProps = ComponentPropsWithoutRef<"div">; export const DataTablePagination = forwardRef( function DataTablePagination({ className, ...props }, ref) { return (
); } ); export type DataTableSelectionBarProps = ComponentPropsWithoutRef<"div">; export const DataTableSelectionBar = forwardRef( function DataTableSelectionBar({ className, ...props }, ref) { return (
); } );