feat(ui): add shared composable DataTable component

Phase 0 of the dashboard table-standardization effort: one composable
DataTable built on TanStack react-table and the shadcn-style primitives in
components/ui/table.tsx (Base UI, Tailwind v4), plus its behavioral test
suite. No existing tables are migrated in this change.

The component owns the TanStack instance and a shadcn shell, and exposes
composable slots (toolbar, pagination, footer) plus DataTableToolbar,
DataTablePagination, DataTableViewOptions, and DataTableSortHeader. Sorting
and pagination each use a single mode enum (none/client/server) so server
modes only surface state via callbacks and never reorder or slice locally.
columnMeta.ts defines the canonical ColumnMeta augmentation. The rendering
shell imports only components/ui/table primitives; no tremor or antd.
This commit is contained in:
Yuneng Jiang 2026-07-09 15:03:44 -07:00
parent 8519d7fc24
commit f4e1e8eb68
No known key found for this signature in database
12 changed files with 1551 additions and 0 deletions

View file

@ -0,0 +1,431 @@
import type { ColumnDef, ExpandedState } from "@tanstack/react-table";
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { useState } from "react";
import { describe, expect, it, vi } from "vitest";
import { DataTable } from "./DataTable";
import { DataTableSortHeader } from "./DataTableSortHeader";
import { DataTableViewOptions } from "./DataTableViewOptions";
interface Person {
id: string;
name: string;
email: string;
flagged?: boolean;
}
function person(id: string, name: string, flagged = false): Person {
return { id, name, email: `${name.toLowerCase()}@x.io`, flagged };
}
const names = (): (string | null)[] => screen.getAllByTestId("name-cell").map((el) => el.textContent);
const nameCellColumns: ColumnDef<Person, unknown>[] = [
{
accessorKey: "name",
header: "Name",
cell: ({ row }) => <span data-testid="name-cell">{row.original.name}</span>,
},
];
const headerCycleColumns: ColumnDef<Person, unknown>[] = [
{
accessorKey: "name",
header: ({ column }) => <DataTableSortHeader column={column} title="Name" variant="header-cycle" />,
cell: ({ row }) => <span data-testid="name-cell">{row.original.name}</span>,
},
];
const dropdownSortColumns: ColumnDef<Person, unknown>[] = [
{
accessorKey: "name",
header: ({ column }) => <DataTableSortHeader column={column} title="Name" variant="dropdown-tristate" />,
cell: ({ row }) => <span data-testid="name-cell">{row.original.name}</span>,
},
];
const nameEmailColumns: ColumnDef<Person, unknown>[] = [
{
accessorKey: "name",
header: "Name",
cell: ({ row }) => <span data-testid="name-cell">{row.original.name}</span>,
},
{
accessorKey: "email",
header: "Email",
cell: ({ row }) => <span>{row.original.email}</span>,
},
];
const pinnedColumns: ColumnDef<Person, unknown>[] = [
{
accessorKey: "name",
header: "Name",
cell: ({ row }) => <span data-testid="name-cell">{row.original.name}</span>,
meta: { pinned: "left" },
},
{
accessorKey: "email",
header: "Email",
cell: ({ row }) => <span>{row.original.email}</span>,
},
];
const rowClickColumns: ColumnDef<Person, unknown>[] = [
{
accessorKey: "name",
header: "Name",
cell: ({ row }) => <span data-testid="name-cell">{row.original.name}</span>,
},
{
id: "actions",
header: "Actions",
cell: () => (
<div>
<button data-testid="row-button">Act</button>
<input data-testid="row-input" aria-label="row input" />
</div>
),
},
];
const expansionColumns: ColumnDef<Person, unknown>[] = [
{
id: "expander",
header: "",
cell: ({ row }) => (
<button data-testid={`expand-${row.id}`} onClick={() => row.toggleExpanded()}>
toggle
</button>
),
},
{
accessorKey: "name",
header: "Name",
cell: ({ row }) => <span data-testid="name-cell">{row.original.name}</span>,
},
];
const CHARLIE_ALICE_BOB: Person[] = [person("c", "Charlie"), person("a", "Alice"), person("b", "Bob")];
describe("DataTable sorting", () => {
it("client mode reorders rows when the sort header is clicked", async () => {
const user = userEvent.setup();
render(<DataTable data={CHARLIE_ALICE_BOB} columns={headerCycleColumns} sortingMode="client" />);
expect(names()).toEqual(["Charlie", "Alice", "Bob"]);
await user.click(screen.getByTestId("sort-header-name"));
expect(names()).toEqual(["Alice", "Bob", "Charlie"]);
});
it("server mode fires the callback but never reorders locally", async () => {
const user = userEvent.setup();
const onSortingChange = vi.fn();
render(
<DataTable
data={CHARLIE_ALICE_BOB}
columns={headerCycleColumns}
sortingMode="server"
sorting={[{ id: "name", desc: false }]}
onSortingChange={onSortingChange}
/>,
);
// sorting state says ascending, but server mode must render data as given
expect(names()).toEqual(["Charlie", "Alice", "Bob"]);
await user.click(screen.getByTestId("sort-header-name"));
expect(onSortingChange).toHaveBeenCalledTimes(1);
expect(names()).toEqual(["Charlie", "Alice", "Bob"]);
});
it("dropdown-tristate variant sorts ascending, descending, then resets", async () => {
const user = userEvent.setup();
render(<DataTable data={CHARLIE_ALICE_BOB} columns={dropdownSortColumns} sortingMode="client" />);
await user.click(screen.getByTestId("sort-trigger-name"));
await user.click(await screen.findByText("Ascending"));
expect(names()).toEqual(["Alice", "Bob", "Charlie"]);
await user.click(screen.getByTestId("sort-trigger-name"));
await user.click(await screen.findByText("Descending"));
expect(names()).toEqual(["Charlie", "Bob", "Alice"]);
await user.click(screen.getByTestId("sort-trigger-name"));
await user.click(await screen.findByText("Reset"));
expect(names()).toEqual(["Charlie", "Alice", "Bob"]);
});
});
describe("DataTable pagination", () => {
const fivePeople: Person[] = Array.from({ length: 5 }, (_, i) => person(String(i), `P${i}`));
it("client mode slices rows and advances pages", async () => {
const user = userEvent.setup();
render(<DataTable data={fivePeople} columns={nameCellColumns} paginationMode="client" pageSizeOptions={[2]} />);
expect(names()).toEqual(["P0", "P1"]);
expect(screen.getByTestId("pagination-range")).toHaveTextContent("Showing 1-2 of 5");
await user.click(screen.getByTestId("pagination-next"));
expect(names()).toEqual(["P2", "P3"]);
expect(screen.getByTestId("pagination-range")).toHaveTextContent("Showing 3-4 of 5");
});
it("server mode shows X-Y of Z from rowCount and does NOT slice the given rows", async () => {
const user = userEvent.setup();
const onPaginationChange = vi.fn();
const pageSlice: Person[] = [person("10", "P10"), person("11", "P11"), person("12", "P12")];
render(
<DataTable
data={pageSlice}
columns={nameCellColumns}
paginationMode="server"
pagination={{ pageIndex: 1, pageSize: 10 }}
rowCount={25}
onPaginationChange={onPaginationChange}
/>,
);
expect(names()).toEqual(["P10", "P11", "P12"]);
expect(screen.getByTestId("pagination-range")).toHaveTextContent("Showing 11-20 of 25");
await user.click(screen.getByTestId("pagination-next"));
expect(onPaginationChange).toHaveBeenCalledTimes(1);
});
});
describe("DataTable column visibility", () => {
it("hides a column when toggled off in the view-options menu", async () => {
const user = userEvent.setup();
render(
<DataTable
data={CHARLIE_ALICE_BOB}
columns={nameEmailColumns}
toolbar={(table) => <DataTableViewOptions table={table} />}
/>,
);
expect(screen.getByText("Email")).toBeInTheDocument();
await user.click(screen.getByTestId("view-options-trigger"));
await user.click(await screen.findByTestId("view-option-email"));
await waitFor(() => expect(screen.queryByText("Email")).not.toBeInTheDocument());
await user.click(screen.getByTestId("view-option-email"));
await waitFor(() => expect(screen.getByText("Email")).toBeInTheDocument());
});
it("omits columns that opt out of hiding from the menu", async () => {
const user = userEvent.setup();
const columns: ColumnDef<Person, unknown>[] = [
{
accessorKey: "name",
header: "Name",
enableHiding: false,
cell: ({ row }) => <span data-testid="name-cell">{row.original.name}</span>,
},
{
accessorKey: "email",
header: "Email",
cell: ({ row }) => <span>{row.original.email}</span>,
},
];
render(
<DataTable
data={CHARLIE_ALICE_BOB}
columns={columns}
toolbar={(table) => <DataTableViewOptions table={table} />}
/>,
);
await user.click(screen.getByTestId("view-options-trigger"));
expect(await screen.findByTestId("view-option-email")).toBeInTheDocument();
expect(screen.queryByTestId("view-option-name")).toBeNull();
});
});
describe("DataTable pinned columns", () => {
it("applies sticky positioning to a pinned column only", () => {
const { container } = render(<DataTable data={CHARLIE_ALICE_BOB} columns={pinnedColumns} />);
const pinnedHead = container.querySelector<HTMLElement>('th[data-header-id="name"]');
const normalHead = container.querySelector<HTMLElement>('th[data-header-id="email"]');
expect(pinnedHead?.style.position).toBe("sticky");
expect(pinnedHead?.style.left).toBe("0px");
expect(normalHead?.style.position).toBe("");
});
});
describe("DataTable row click guard", () => {
it("fires onRowClick from a plain cell but not from interactive elements", async () => {
const user = userEvent.setup();
const onRowClick = vi.fn();
render(<DataTable data={[person("a", "Alice")]} columns={rowClickColumns} onRowClick={onRowClick} />);
await user.click(screen.getByTestId("name-cell"));
expect(onRowClick).toHaveBeenCalledTimes(1);
expect(onRowClick).toHaveBeenCalledWith(expect.objectContaining({ id: "a" }));
await user.click(screen.getByTestId("row-button"));
expect(onRowClick).toHaveBeenCalledTimes(1);
await user.click(screen.getByTestId("row-input"));
expect(onRowClick).toHaveBeenCalledTimes(1);
});
});
describe("DataTable expansion", () => {
const subComponent = ({ row }: { row: { original: Person } }) => (
<div data-testid="sub-row">details for {row.original.name}</div>
);
it("toggles the sub-row in uncontrolled mode", async () => {
const user = userEvent.setup();
render(
<DataTable
data={[person("a", "Alice")]}
columns={expansionColumns}
getRowId={(row) => row.id}
getRowCanExpand={() => true}
renderSubComponent={subComponent}
/>,
);
expect(screen.queryByTestId("sub-row")).not.toBeInTheDocument();
await user.click(screen.getByTestId("expand-a"));
expect(screen.getByTestId("sub-row")).toBeInTheDocument();
await user.click(screen.getByTestId("expand-a"));
expect(screen.queryByTestId("sub-row")).not.toBeInTheDocument();
});
it("toggles the sub-row in controlled mode driven by parent state", async () => {
const user = userEvent.setup();
const Harness = () => {
const [expanded, setExpanded] = useState<ExpandedState>({});
return (
<DataTable
data={[person("a", "Alice")]}
columns={expansionColumns}
getRowId={(row) => row.id}
expanded={expanded}
onExpandedChange={setExpanded}
getRowCanExpand={() => true}
renderSubComponent={subComponent}
/>
);
};
render(<Harness />);
expect(screen.queryByTestId("sub-row")).not.toBeInTheDocument();
await user.click(screen.getByTestId("expand-a"));
expect(screen.getByTestId("sub-row")).toBeInTheDocument();
await user.click(screen.getByTestId("expand-a"));
expect(screen.queryByTestId("sub-row")).not.toBeInTheDocument();
});
it("stays collapsed in controlled mode when the parent ignores the change", async () => {
const user = userEvent.setup();
const onExpandedChange = vi.fn();
render(
<DataTable
data={[person("a", "Alice")]}
columns={expansionColumns}
getRowId={(row) => row.id}
expanded={{}}
onExpandedChange={onExpandedChange}
getRowCanExpand={() => true}
renderSubComponent={subComponent}
/>,
);
await user.click(screen.getByTestId("expand-a"));
expect(onExpandedChange).toHaveBeenCalledTimes(1);
expect(screen.queryByTestId("sub-row")).not.toBeInTheDocument();
});
});
describe("DataTable row styling and footer", () => {
it("applies rowClassName to the matching row only", () => {
const data = [person("a", "Alice", true), person("b", "Bob", false)];
const { container } = render(
<DataTable
data={data}
columns={nameCellColumns}
getRowId={(row) => row.id}
rowClassName={(row) => (row.original.flagged ? "flagged-row" : "")}
/>,
);
expect(container.querySelector('tr[data-row-id="a"]')?.className).toContain("flagged-row");
expect(container.querySelector('tr[data-row-id="b"]')?.className).not.toContain("flagged-row");
});
it("renders the footer slot inside a tfoot element", () => {
render(
<DataTable
data={CHARLIE_ALICE_BOB}
columns={nameCellColumns}
footer={() => (
<tr data-testid="footer-row">
<td>Total: 3</td>
</tr>
)}
/>,
);
expect(screen.getByTestId("footer-row").closest("tfoot")).not.toBeNull();
});
});
describe("DataTable layout", () => {
it("exposes resize handles with stable selectors only when resizing is enabled", () => {
const { container, rerender } = render(
<DataTable data={CHARLIE_ALICE_BOB} columns={nameEmailColumns} enableColumnResizing />,
);
expect(container.querySelectorAll("[data-resizer][data-header-id]").length).toBe(2);
rerender(<DataTable data={CHARLIE_ALICE_BOB} columns={nameEmailColumns} />);
expect(container.querySelectorAll("[data-resizer]").length).toBe(0);
});
it("makes the header sticky and constrains body height when maxBodyHeight is set", () => {
const { container } = render(<DataTable data={CHARLIE_ALICE_BOB} columns={nameEmailColumns} maxBodyHeight={240} />);
expect(container.querySelector("thead")?.className).toContain("sticky");
const scroller = container.querySelector('[data-slot="table-container"]')?.parentElement as HTMLElement;
expect(scroller.style.maxHeight).toBe("240px");
});
});
describe("DataTable misconfiguration guards", () => {
it("throws when server sorting is missing required props", () => {
const spy = vi.spyOn(console, "error").mockImplementation(() => {});
expect(() => render(<DataTable data={[]} columns={nameCellColumns} sortingMode="server" />)).toThrow(
/sortingMode='server'/,
);
spy.mockRestore();
});
it("throws when server pagination is missing required props", () => {
const spy = vi.spyOn(console, "error").mockImplementation(() => {});
expect(() => render(<DataTable data={[]} columns={nameCellColumns} paginationMode="server" />)).toThrow(
/paginationMode='server'/,
);
spy.mockRestore();
});
it("throws when both defaultSorting and sorting are provided", () => {
const spy = vi.spyOn(console, "error").mockImplementation(() => {});
expect(() =>
render(
<DataTable
data={[]}
columns={nameCellColumns}
defaultSorting={[{ id: "name", desc: false }]}
sorting={[{ id: "name", desc: false }]}
/>,
),
).toThrow(/defaultSorting/);
spy.mockRestore();
});
});

View file

@ -0,0 +1,497 @@
"use client";
import {
type Cell,
type Column,
type ColumnDef,
type ColumnPinningState,
type ColumnSizingState,
type ExpandedState,
flexRender,
getCoreRowModel,
getExpandedRowModel,
getPaginationRowModel,
getSortedRowModel,
type Header,
type OnChangeFn,
type Row,
type RowData,
type Table,
type TableOptions,
useReactTable,
type VisibilityState,
} from "@tanstack/react-table";
import * as React from "react";
import { Fragment, useState } from "react";
import {
Table as TableRoot,
TableBody,
TableCell,
TableFooter,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { cn } from "@/lib/cva.config";
import "./columnMeta";
import { DataTablePagination } from "./DataTablePagination";
import type { ColumnPinnedSide, DataTableProps, DataTableSize, PaginationMode, SortingMode } from "./types";
const DEFAULT_PAGE_SIZE_OPTIONS = [25, 50, 100];
const INTERACTIVE_SELECTOR = "button, a, input, select, textarea, [role=checkbox], [data-row-click-exempt]";
const noop = () => {};
export class DataTableConfigError extends Error {
constructor(messages: readonly string[]) {
super(`DataTable misconfiguration:\n- ${messages.join("\n- ")}`);
this.name = "DataTableConfigError";
}
}
export function validateDataTableConfig<TData extends RowData, TValue>(
props: DataTableProps<TData, TValue>,
): readonly string[] {
const serverSortingIncomplete =
props.sortingMode === "server" && (props.sorting === undefined || props.onSortingChange === undefined);
const serverPaginationPropsMissing =
props.pagination === undefined || props.onPaginationChange === undefined || props.rowCount === undefined;
const serverPaginationIncomplete = props.paginationMode === "server" && serverPaginationPropsMissing;
const bothSortingSources = props.defaultSorting !== undefined && props.sorting !== undefined;
return [
serverSortingIncomplete ? "sortingMode='server' requires both `sorting` and `onSortingChange`." : null,
serverPaginationIncomplete
? "paginationMode='server' requires `pagination`, `onPaginationChange`, and `rowCount`."
: null,
bothSortingSources ? "Provide either `defaultSorting` (uncontrolled) or `sorting` (controlled), not both." : null,
].filter((message): message is string => message !== null);
}
function columnDefId<TData, TValue>(column: ColumnDef<TData, TValue>): string | undefined {
if ("id" in column && typeof column.id === "string") {
return column.id;
}
if ("accessorKey" in column && column.accessorKey != null) {
return String(column.accessorKey);
}
return undefined;
}
function derivePinning<TData, TValue>(columns: ColumnDef<TData, TValue>[]): ColumnPinningState {
const collect = (side: ColumnPinnedSide): string[] =>
columns
.filter((column) => column.meta?.pinned === side)
.map(columnDefId)
.filter((id): id is string => id !== undefined);
return { left: collect("left"), right: collect("right") };
}
function buildRowModels<TData>(
sortingMode: SortingMode,
paginationMode: PaginationMode,
getRowCanExpand: ((row: Row<TData>) => boolean) | undefined,
): Partial<TableOptions<TData>> {
return {
...(sortingMode === "client" ? { getSortedRowModel: getSortedRowModel() } : {}),
...(paginationMode === "client" ? { getPaginationRowModel: getPaginationRowModel() } : {}),
...(getRowCanExpand !== undefined ? { getRowCanExpand, getExpandedRowModel: getExpandedRowModel() } : {}),
};
}
function stickyZIndex(isPinned: boolean, isHeader: boolean): number {
if (isPinned && isHeader) {
return 30;
}
if (isHeader) {
return 20;
}
return 10;
}
function pinnedShadow(pinned: false | ColumnPinnedSide): string {
if (pinned === "left") {
return "shadow-[inset_-1px_0_0_var(--color-border)]";
}
if (pinned === "right") {
return "shadow-[inset_1px_0_0_var(--color-border)]";
}
return "";
}
function computeStickyStyle<TData, TValue>(
column: Column<TData, TValue>,
isHeader: boolean,
stickyHeader: boolean,
): { style: React.CSSProperties; className: string } {
const pinned = column.getIsPinned();
const stickyTop = isHeader && stickyHeader;
if (!pinned && !stickyTop) {
return { style: {}, className: "" };
}
const left = pinned === "left" ? column.getStart("left") : undefined;
const right = pinned === "right" ? column.getAfter("right") : undefined;
const style: React.CSSProperties = {
position: "sticky",
zIndex: stickyZIndex(pinned !== false, isHeader),
...(stickyTop ? { top: 0 } : {}),
...(left !== undefined ? { left } : {}),
...(right !== undefined ? { right } : {}),
};
return { style, className: cn(pinned ? "bg-background" : "", pinnedShadow(pinned)) };
}
function widthStyle<TData, TValue>(
column: Column<TData, TValue>,
enableColumnResizing: boolean,
): React.CSSProperties | undefined {
if (enableColumnResizing || column.columnDef.size !== undefined) {
return { width: column.getSize() };
}
return undefined;
}
interface HeadCellProps<TData> {
header: Header<TData, unknown>;
size: DataTableSize;
stickyHeader: boolean;
enableColumnResizing: boolean;
}
function DataTableHeadCell<TData>({ header, size, stickyHeader, enableColumnResizing }: HeadCellProps<TData>) {
const { column } = header;
const meta = column.columnDef.meta;
const sticky = computeStickyStyle(column, true, stickyHeader);
const canResize = enableColumnResizing && column.getCanResize();
return (
<TableHead
data-header-id={header.id}
className={cn(
"relative text-muted-foreground",
size === "compact" ? "h-8 px-2 py-1 text-xs" : "",
meta?.numeric ? "text-right" : "",
meta?.className,
meta?.headerClassName,
sticky.className,
)}
style={{ ...sticky.style, ...widthStyle(column, enableColumnResizing) }}
>
{header.isPlaceholder ? null : (
<div className={cn("flex items-center gap-1", meta?.numeric ? "justify-end" : "")}>
{flexRender(column.columnDef.header, header.getContext())}
</div>
)}
{canResize && (
<div
data-resizer
data-header-id={header.id}
onMouseDown={header.getResizeHandler()}
onTouchStart={header.getResizeHandler()}
onDoubleClick={() => column.resetSize()}
className={cn(
"absolute top-0 right-0 h-full w-1 cursor-col-resize touch-none select-none hover:bg-border",
column.getIsResizing() ? "bg-primary" : "",
)}
/>
)}
</TableHead>
);
}
interface BodyCellProps<TData> {
cell: Cell<TData, unknown>;
size: DataTableSize;
stickyHeader: boolean;
enableColumnResizing: boolean;
}
function DataTableBodyCell<TData>({ cell, size, stickyHeader, enableColumnResizing }: BodyCellProps<TData>) {
const { column } = cell;
const meta = column.columnDef.meta;
const sticky = computeStickyStyle(column, false, stickyHeader);
return (
<TableCell
className={cn(
"overflow-hidden text-ellipsis",
size === "compact" ? "px-2 py-1 text-xs" : "",
meta?.numeric ? "text-right tabular-nums" : "",
meta?.className,
sticky.className,
)}
style={{ ...sticky.style, ...widthStyle(column, enableColumnResizing) }}
>
{flexRender(column.columnDef.cell, cell.getContext())}
</TableCell>
);
}
interface BodyRowProps<TData> {
row: Row<TData>;
size: DataTableSize;
stickyHeader: boolean;
enableColumnResizing: boolean;
onRowClick?: (row: TData) => void;
rowClassName?: (row: Row<TData>) => string;
renderSubComponent?: (props: { row: Row<TData> }) => React.ReactElement;
}
function DataTableBodyRow<TData>({
row,
size,
stickyHeader,
enableColumnResizing,
onRowClick,
rowClassName,
renderSubComponent,
}: BodyRowProps<TData>) {
const clickable = onRowClick !== undefined;
const cells = row.getVisibleCells();
const handleClick = (event: React.MouseEvent<HTMLTableRowElement>) => {
if (onRowClick === undefined) {
return;
}
const target = event.target as HTMLElement | null;
if (target === null || !event.currentTarget.contains(target)) {
return;
}
if (target.closest(INTERACTIVE_SELECTOR) !== null) {
return;
}
onRowClick(row.original);
};
return (
<Fragment>
<TableRow
data-row-id={row.id}
className={cn(clickable ? "cursor-pointer" : "", size === "compact" ? "h-8" : "", rowClassName?.(row))}
onClick={clickable ? handleClick : undefined}
>
{cells.map((cell) => (
<DataTableBodyCell
key={cell.id}
cell={cell}
size={size}
stickyHeader={stickyHeader}
enableColumnResizing={enableColumnResizing}
/>
))}
</TableRow>
{renderSubComponent !== undefined && row.getIsExpanded() && (
<TableRow className="hover:bg-transparent">
<TableCell colSpan={cells.length} className="p-0">
{renderSubComponent({ row })}
</TableCell>
</TableRow>
)}
</Fragment>
);
}
function MessageRow({ colSpan, children }: { colSpan: number; children: React.ReactNode }) {
return (
<TableRow className="hover:bg-transparent">
<TableCell colSpan={colSpan} className="h-24 text-center align-middle text-sm text-muted-foreground">
{children}
</TableCell>
</TableRow>
);
}
function useControllable<T>(
controlled: T | undefined,
controlledOnChange: OnChangeFn<T> | undefined,
initial: T,
): { value: T; onChange: OnChangeFn<T> } {
const [internal, setInternal] = useState<T>(initial);
if (controlled !== undefined) {
return { value: controlled, onChange: controlledOnChange ?? noop };
}
return { value: internal, onChange: setInternal };
}
function useDataTableInstance<TData extends RowData, TValue>(props: DataTableProps<TData, TValue>): Table<TData> {
const {
data,
columns,
getRowId,
sortingMode = "none",
sorting,
onSortingChange,
defaultSorting,
enableSortingRemoval = false,
paginationMode = "none",
pagination,
onPaginationChange,
rowCount,
pageSizeOptions = DEFAULT_PAGE_SIZE_OPTIONS,
enableColumnResizing = false,
columnResizeMode = "onEnd",
defaultColumnVisibility,
getRowCanExpand,
renderSubComponent,
expanded,
onExpandedChange,
} = props;
const sortingState = useControllable(sorting, onSortingChange, defaultSorting ?? []);
const paginationState = useControllable(pagination, onPaginationChange, {
pageIndex: 0,
pageSize: pageSizeOptions[0] ?? 25,
});
const expandedState = useControllable<ExpandedState>(expanded, onExpandedChange, {});
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>(defaultColumnVisibility ?? {});
const [columnSizing, setColumnSizing] = useState<ColumnSizingState>({});
const columnPinning = React.useMemo(() => derivePinning(columns), [columns]);
const expansionGuard = renderSubComponent !== undefined ? getRowCanExpand : undefined;
const tableOptions: TableOptions<TData> = {
data,
columns,
state: {
sorting: sortingState.value,
pagination: paginationState.value,
expanded: expandedState.value,
columnVisibility,
columnSizing,
},
initialState: { columnPinning },
manualSorting: sortingMode === "server",
manualPagination: paginationMode === "server",
enableSortingRemoval,
enableColumnResizing,
columnResizeMode,
onSortingChange: sortingState.onChange,
onPaginationChange: paginationState.onChange,
onExpandedChange: expandedState.onChange,
onColumnVisibilityChange: setColumnVisibility,
onColumnSizingChange: setColumnSizing,
getCoreRowModel: getCoreRowModel(),
...buildRowModels(sortingMode, paginationMode, expansionGuard),
...(getRowId !== undefined ? { getRowId } : {}),
...(paginationMode === "server" && rowCount !== undefined ? { rowCount } : {}),
};
return useReactTable(tableOptions);
}
export function DataTable<TData extends RowData, TValue>(props: DataTableProps<TData, TValue>) {
// Validate once at construction so a misconfig surfaces immediately instead of on every render.
useState<null>(() => {
const errors = validateDataTableConfig(props);
if (errors.length > 0) {
throw new DataTableConfigError(errors);
}
return null;
});
const {
isLoading = false,
loadingMessage = "Loading…",
noDataMessage = "No results",
paginationMode = "none",
rowCount,
pageSizeOptions = DEFAULT_PAGE_SIZE_OPTIONS,
enableColumnResizing = false,
onRowClick,
rowClassName,
renderSubComponent,
maxBodyHeight,
size = "default",
toolbar,
paginationSlot,
footer,
} = props;
const table = useDataTableInstance(props);
const rows = table.getRowModel().rows;
const visibleColumnCount = table.getVisibleLeafColumns().length;
const stickyHeader = maxBodyHeight !== undefined;
const tableStyle = enableColumnResizing ? { width: table.getTotalSize() } : undefined;
const renderPagination = (): React.ReactNode => {
if (paginationSlot !== undefined) {
return paginationSlot(table);
}
if (paginationMode === "none") {
return null;
}
const current = table.getState().pagination;
const total = paginationMode === "server" ? rowCount ?? 0 : table.getPrePaginationRowModel().rows.length;
return (
<DataTablePagination
page={current.pageIndex}
pageSize={current.pageSize}
rowCount={total}
onPageChange={(next) => table.setPageIndex(next)}
onPageSizeChange={(next) => table.setPageSize(next)}
pageSizeOptions={pageSizeOptions}
isLoading={isLoading}
/>
);
};
const renderBody = (): React.ReactNode => {
if (isLoading) {
return <MessageRow colSpan={visibleColumnCount}>{loadingMessage}</MessageRow>;
}
if (rows.length === 0) {
return <MessageRow colSpan={visibleColumnCount}>{noDataMessage}</MessageRow>;
}
return rows.map((row) => (
<DataTableBodyRow
key={row.id}
row={row}
size={size}
stickyHeader={stickyHeader}
enableColumnResizing={enableColumnResizing}
onRowClick={onRowClick}
rowClassName={rowClassName}
renderSubComponent={renderSubComponent}
/>
));
};
return (
<div className="w-full">
{toolbar !== undefined && <div className="w-full">{toolbar(table)}</div>}
<div
className={cn("rounded-lg border border-border", stickyHeader ? "overflow-auto" : "overflow-x-auto")}
style={stickyHeader ? { maxHeight: maxBodyHeight } : undefined}
>
<TableRoot className={enableColumnResizing ? "table-fixed" : ""} style={tableStyle}>
<TableHeader className={stickyHeader ? "sticky top-0 z-20" : ""}>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id} className="bg-muted/50 hover:bg-muted/50">
{headerGroup.headers.map((header) => (
<DataTableHeadCell
key={header.id}
header={header}
size={size}
stickyHeader={stickyHeader}
enableColumnResizing={enableColumnResizing}
/>
))}
</TableRow>
))}
</TableHeader>
<TableBody>{renderBody()}</TableBody>
{footer !== undefined && <TableFooter>{footer(table)}</TableFooter>}
</TableRoot>
</div>
{renderPagination()}
</div>
);
}

View file

@ -0,0 +1,68 @@
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi } from "vitest";
import { DataTablePagination } from "./DataTablePagination";
const baseProps = {
page: 0,
pageSize: 25,
rowCount: 100,
onPageChange: () => {},
onPageSizeChange: () => {},
};
describe("DataTablePagination", () => {
it("renders the current range from plain props", () => {
render(<DataTablePagination {...baseProps} />);
expect(screen.getByTestId("pagination-range")).toHaveTextContent("Showing 1-25 of 100");
});
it("computes the range for a middle page and clamps the end to rowCount", () => {
render(<DataTablePagination {...baseProps} page={3} pageSize={30} rowCount={100} />);
expect(screen.getByTestId("pagination-range")).toHaveTextContent("Showing 91-100 of 100");
});
it("disables the previous controls on the first page", () => {
render(<DataTablePagination {...baseProps} page={0} />);
expect(screen.getByTestId("pagination-first")).toBeDisabled();
expect(screen.getByTestId("pagination-prev")).toBeDisabled();
expect(screen.getByTestId("pagination-next")).toBeEnabled();
});
it("disables the next controls on the last page", () => {
render(<DataTablePagination {...baseProps} page={3} pageSize={25} rowCount={100} />);
expect(screen.getByTestId("pagination-next")).toBeDisabled();
expect(screen.getByTestId("pagination-last")).toBeDisabled();
expect(screen.getByTestId("pagination-prev")).toBeEnabled();
});
it("advances by one page when next is clicked", async () => {
const user = userEvent.setup();
const onPageChange = vi.fn();
render(<DataTablePagination {...baseProps} page={1} onPageChange={onPageChange} />);
await user.click(screen.getByTestId("pagination-next"));
expect(onPageChange).toHaveBeenCalledWith(2);
});
it("jumps to the last page index when last is clicked", async () => {
const user = userEvent.setup();
const onPageChange = vi.fn();
render(<DataTablePagination {...baseProps} page={0} pageSize={25} rowCount={100} onPageChange={onPageChange} />);
await user.click(screen.getByTestId("pagination-last"));
expect(onPageChange).toHaveBeenCalledWith(3);
});
it("shows an empty state and disables all navigation when there are no rows", () => {
render(<DataTablePagination {...baseProps} rowCount={0} />);
expect(screen.getByTestId("pagination-range")).toHaveTextContent("No results");
expect(screen.getByTestId("pagination-next")).toBeDisabled();
expect(screen.getByTestId("pagination-prev")).toBeDisabled();
});
it("disables navigation while loading", () => {
render(<DataTablePagination {...baseProps} page={1} isLoading />);
expect(screen.getByTestId("pagination-next")).toBeDisabled();
expect(screen.getByTestId("pagination-prev")).toBeDisabled();
});
});

View file

@ -0,0 +1,113 @@
"use client";
import { ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { cn } from "@/lib/cva.config";
export const DEFAULT_PAGE_SIZE_OPTIONS = [25, 50, 100];
export interface DataTablePaginationProps {
page: number;
pageSize: number;
rowCount: number;
onPageChange: (page: number) => void;
onPageSizeChange: (pageSize: number) => void;
pageSizeOptions?: number[];
isLoading?: boolean;
className?: string;
}
export function DataTablePagination({
page,
pageSize,
rowCount,
onPageChange,
onPageSizeChange,
pageSizeOptions = DEFAULT_PAGE_SIZE_OPTIONS,
isLoading = false,
className,
}: DataTablePaginationProps) {
const pageCount = pageSize > 0 ? Math.ceil(rowCount / pageSize) : 0;
const start = rowCount === 0 ? 0 : page * pageSize + 1;
const end = Math.min((page + 1) * pageSize, rowCount);
const canPrev = page > 0 && !isLoading;
const canNext = page < pageCount - 1 && !isLoading;
const lastPage = Math.max(pageCount - 1, 0);
return (
<div className={cn("flex flex-wrap items-center justify-between gap-4 px-2 py-2", className)}>
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<span>Rows per page</span>
<Select
value={String(pageSize)}
onValueChange={(value) => {
if (typeof value === "string") {
onPageSizeChange(Number(value));
}
}}
>
<SelectTrigger size="sm" data-testid="pagination-page-size" className="w-[4.5rem]">
<SelectValue />
</SelectTrigger>
<SelectContent>
{pageSizeOptions.map((option) => (
<SelectItem key={option} value={String(option)}>
{option}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex items-center gap-4">
<span data-testid="pagination-range" className="text-sm text-muted-foreground tabular-nums">
{rowCount === 0 ? "No results" : `Showing ${start}-${end} of ${rowCount}`}
</span>
<div className="flex items-center gap-1">
<Button
variant="outline"
size="icon-sm"
data-testid="pagination-first"
aria-label="Go to first page"
disabled={!canPrev}
onClick={() => onPageChange(0)}
>
<ChevronsLeft />
</Button>
<Button
variant="outline"
size="icon-sm"
data-testid="pagination-prev"
aria-label="Go to previous page"
disabled={!canPrev}
onClick={() => onPageChange(page - 1)}
>
<ChevronLeft />
</Button>
<Button
variant="outline"
size="icon-sm"
data-testid="pagination-next"
aria-label="Go to next page"
disabled={!canNext}
onClick={() => onPageChange(page + 1)}
>
<ChevronRight />
</Button>
<Button
variant="outline"
size="icon-sm"
data-testid="pagination-last"
aria-label="Go to last page"
disabled={!canNext}
onClick={() => onPageChange(lastPage)}
>
<ChevronsRight />
</Button>
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,112 @@
import {
type ColumnDef,
flexRender,
getCoreRowModel,
getSortedRowModel,
type OnChangeFn,
type SortingState,
useReactTable,
} from "@tanstack/react-table";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { useState } from "react";
import { describe, expect, it, vi } from "vitest";
import { DataTableSortHeader, type DataTableSortVariant } from "./DataTableSortHeader";
interface Item {
name: string;
}
interface HarnessProps {
variant: DataTableSortVariant;
canSort?: boolean;
onSortingChange?: OnChangeFn<SortingState>;
}
function SortHeaderHarness({ variant, canSort = true, onSortingChange }: HarnessProps) {
const [sorting, setSorting] = useState<SortingState>([]);
const columns: ColumnDef<Item, unknown>[] = [
{
accessorKey: "name",
enableSorting: canSort,
header: ({ column }) => <DataTableSortHeader column={column} title="Name" variant={variant} />,
},
];
const options = {
data: [{ name: "x" }],
columns,
state: { sorting },
onSortingChange: (updater: SortingState | ((prev: SortingState) => SortingState)) => {
setSorting(updater);
onSortingChange?.(updater);
},
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
};
const table = useReactTable(options);
return (
<table>
<thead>
{table.getHeaderGroups().map((headerGroup) => (
<tr key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<th key={header.id}>{flexRender(header.column.columnDef.header, header.getContext())}</th>
))}
</tr>
))}
</thead>
</table>
);
}
describe("DataTableSortHeader", () => {
it("renders a plain label and no button when the column cannot sort", () => {
render(<SortHeaderHarness variant="header-cycle" canSort={false} />);
expect(screen.queryByTestId("sort-header-name")).toBeNull();
expect(screen.getByText("Name")).toBeInTheDocument();
});
it("header-cycle indicator advances none -> asc -> desc on click", async () => {
const user = userEvent.setup();
render(<SortHeaderHarness variant="header-cycle" />);
const indicator = () => screen.getByTestId("sort-header-name").querySelector("[data-sort-indicator]");
expect(indicator()).toHaveAttribute("data-sort-indicator", "none");
await user.click(screen.getByTestId("sort-header-name"));
expect(indicator()).toHaveAttribute("data-sort-indicator", "asc");
await user.click(screen.getByTestId("sort-header-name"));
expect(indicator()).toHaveAttribute("data-sort-indicator", "desc");
});
it("dropdown-tristate sets ascending, descending, and reset from the menu", async () => {
const user = userEvent.setup();
render(<SortHeaderHarness variant="dropdown-tristate" />);
await user.click(screen.getByTestId("sort-trigger-name"));
await user.click(await screen.findByText("Descending"));
expect(screen.getByTestId("sort-trigger-name").querySelector('[data-sort-indicator="desc"]')).not.toBeNull();
await user.click(screen.getByTestId("sort-trigger-name"));
await user.click(await screen.findByText("Ascending"));
expect(screen.getByTestId("sort-trigger-name").querySelector('[data-sort-indicator="asc"]')).not.toBeNull();
await user.click(screen.getByTestId("sort-trigger-name"));
await user.click(await screen.findByText("Reset"));
expect(screen.getByTestId("sort-trigger-name").querySelector('[data-sort-indicator="none"]')).not.toBeNull();
});
it("dropdown-tristate trigger stops the click from reaching an outer handler", async () => {
const user = userEvent.setup();
const onOuterClick = vi.fn();
render(
<div onClick={onOuterClick}>
<SortHeaderHarness variant="dropdown-tristate" />
</div>,
);
await user.click(screen.getByTestId("sort-trigger-name"));
expect(onOuterClick).not.toHaveBeenCalled();
});
});

View file

@ -0,0 +1,96 @@
"use client";
import { Menu } from "@base-ui/react/menu";
import type { Column, SortDirection } from "@tanstack/react-table";
import { ChevronDown, ChevronsUpDown, ChevronUp, X } from "lucide-react";
import type * as React from "react";
import { cn } from "@/lib/cva.config";
export type DataTableSortVariant = "header-cycle" | "dropdown-tristate";
interface DataTableSortHeaderProps<TData, TValue> {
column: Column<TData, TValue>;
title: React.ReactNode;
variant?: DataTableSortVariant;
className?: string;
}
function SortIndicator({ sorted }: { sorted: false | SortDirection }) {
if (sorted === "asc") {
return <ChevronUp className="size-3.5" data-sort-indicator="asc" />;
}
if (sorted === "desc") {
return <ChevronDown className="size-3.5" data-sort-indicator="desc" />;
}
return <ChevronsUpDown className="size-3.5 text-muted-foreground" data-sort-indicator="none" />;
}
const MENU_ITEM_CLASS =
"flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 outline-hidden select-none data-highlighted:bg-accent data-highlighted:text-accent-foreground";
export function DataTableSortHeader<TData, TValue>({
column,
title,
variant = "header-cycle",
className,
}: DataTableSortHeaderProps<TData, TValue>) {
const sorted = column.getIsSorted();
if (!column.getCanSort()) {
return <span className={cn("font-medium", className)}>{title}</span>;
}
if (variant === "dropdown-tristate") {
return (
<div className={cn("flex items-center gap-1", className)}>
<span className="font-medium">{title}</span>
<Menu.Root>
<Menu.Trigger
render={
<button
type="button"
data-testid={`sort-trigger-${column.id}`}
aria-label={`Sort options for ${column.id}`}
onClick={(event) => event.stopPropagation()}
className={cn(
"inline-flex size-6 items-center justify-center rounded-md hover:bg-muted",
sorted ? "text-primary" : "text-muted-foreground",
)}
>
<SortIndicator sorted={sorted} />
</button>
}
/>
<Menu.Portal>
<Menu.Positioner side="bottom" align="start" sideOffset={4} className="isolate z-50">
<Menu.Popup className="min-w-[9rem] rounded-md bg-popover p-1 text-sm text-popover-foreground shadow-md ring-1 ring-foreground/10 outline-hidden">
<Menu.Item className={MENU_ITEM_CLASS} onClick={() => column.toggleSorting(false)}>
<ChevronUp className="size-3.5" /> Ascending
</Menu.Item>
<Menu.Item className={MENU_ITEM_CLASS} onClick={() => column.toggleSorting(true)}>
<ChevronDown className="size-3.5" /> Descending
</Menu.Item>
<Menu.Item className={MENU_ITEM_CLASS} onClick={() => column.clearSorting()}>
<X className="size-3.5" /> Reset
</Menu.Item>
</Menu.Popup>
</Menu.Positioner>
</Menu.Portal>
</Menu.Root>
</div>
);
}
return (
<button
type="button"
data-testid={`sort-header-${column.id}`}
onClick={column.getToggleSortingHandler()}
className={cn("flex items-center gap-1 font-medium select-none hover:text-foreground", className)}
>
<span>{title}</span>
<SortIndicator sorted={sorted} />
</button>
);
}

View file

@ -0,0 +1,35 @@
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi } from "vitest";
import { DataTableToolbar } from "./DataTableToolbar";
describe("DataTableToolbar", () => {
it("renders slotted action children", () => {
render(
<DataTableToolbar>
<button data-testid="toolbar-action">Action</button>
</DataTableToolbar>,
);
expect(screen.getByTestId("toolbar-action")).toBeInTheDocument();
});
it("shows the reset button only when there are active filters", async () => {
const user = userEvent.setup();
const onResetFilters = vi.fn();
const { rerender } = render(<DataTableToolbar onResetFilters={onResetFilters} hasActiveFilters={false} />);
expect(screen.queryByText("Reset Filters")).toBeNull();
rerender(<DataTableToolbar onResetFilters={onResetFilters} hasActiveFilters />);
await user.click(screen.getByText("Reset Filters"));
expect(onResetFilters).toHaveBeenCalledTimes(1);
});
it("wires the filters toggle button", async () => {
const user = userEvent.setup();
const onToggleFilters = vi.fn();
render(<DataTableToolbar onToggleFilters={onToggleFilters} />);
await user.click(screen.getByText("Filters"));
expect(onToggleFilters).toHaveBeenCalledTimes(1);
});
});

View file

@ -0,0 +1,55 @@
"use client";
import { Search } from "lucide-react";
import type * as React from "react";
import { FilterInput } from "@/components/common_components/Filters/FilterInput";
import { FiltersButton } from "@/components/common_components/Filters/FiltersButton";
import { ResetFiltersButton } from "@/components/common_components/Filters/ResetFiltersButton";
import { cn } from "@/lib/cva.config";
interface DataTableToolbarProps {
searchValue?: string;
onSearchChange?: (value: string) => void;
searchPlaceholder?: string;
filtersActive?: boolean;
hasActiveFilters?: boolean;
onToggleFilters?: () => void;
onResetFilters?: () => void;
children?: React.ReactNode;
className?: string;
}
export function DataTableToolbar({
searchValue,
onSearchChange,
searchPlaceholder = "Search",
filtersActive = false,
hasActiveFilters = false,
onToggleFilters,
onResetFilters,
children,
className,
}: DataTableToolbarProps) {
const showReset = onResetFilters !== undefined && hasActiveFilters;
return (
<div className={cn("flex flex-wrap items-center justify-between gap-2 pb-3", className)}>
<div className="flex flex-wrap items-center gap-2">
{onSearchChange !== undefined && (
<FilterInput
value={searchValue ?? ""}
onChange={onSearchChange}
placeholder={searchPlaceholder}
icon={Search}
/>
)}
{onToggleFilters !== undefined && (
<FiltersButton onClick={onToggleFilters} active={filtersActive} hasActiveFilters={hasActiveFilters} />
)}
{showReset && <ResetFiltersButton onClick={onResetFilters} />}
</div>
{children !== undefined && <div className="flex flex-wrap items-center gap-2">{children}</div>}
</div>
);
}

View file

@ -0,0 +1,55 @@
"use client";
import { Menu } from "@base-ui/react/menu";
import type { Table } from "@tanstack/react-table";
import { Check, SlidersHorizontal } from "lucide-react";
import { Button } from "@/components/ui/button";
interface DataTableViewOptionsProps<TData> {
table: Table<TData>;
label?: string;
className?: string;
}
export function DataTableViewOptions<TData>({ table, label = "View", className }: DataTableViewOptionsProps<TData>) {
const hideableColumns = table.getAllLeafColumns().filter((column) => column.getCanHide());
if (hideableColumns.length === 0) {
return null;
}
return (
<Menu.Root>
<Menu.Trigger
render={
<Button variant="outline" size="sm" className={className} data-testid="view-options-trigger">
<SlidersHorizontal />
{label}
</Button>
}
/>
<Menu.Portal>
<Menu.Positioner side="bottom" align="end" sideOffset={4} className="isolate z-50">
<Menu.Popup className="min-w-[12rem] rounded-md bg-popover p-1 text-sm text-popover-foreground shadow-md ring-1 ring-foreground/10 outline-hidden">
{hideableColumns.map((column) => (
<Menu.CheckboxItem
key={column.id}
checked={column.getIsVisible()}
onCheckedChange={(checked) => column.toggleVisibility(checked)}
closeOnClick={false}
data-testid={`view-option-${column.id}`}
className="relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-7 capitalize outline-hidden select-none data-highlighted:bg-accent data-highlighted:text-accent-foreground"
>
<Menu.CheckboxItemIndicator className="absolute left-2 flex size-4 items-center justify-center">
<Check className="size-3.5" />
</Menu.CheckboxItemIndicator>
{column.columnDef.meta?.title ?? column.id}
</Menu.CheckboxItem>
))}
</Menu.Popup>
</Menu.Positioner>
</Menu.Portal>
</Menu.Root>
);
}

View file

@ -0,0 +1,13 @@
import type { RowData } from "@tanstack/react-table";
import type { ColumnPinnedSide } from "./types";
declare module "@tanstack/react-table" {
interface ColumnMeta<TData extends RowData, TValue> {
numeric?: boolean;
className?: string;
headerClassName?: string;
title?: string;
pinned?: ColumnPinnedSide;
}
}

View file

@ -0,0 +1,16 @@
import "./columnMeta";
export { DataTable, DataTableConfigError, validateDataTableConfig } from "./DataTable";
export { DataTablePagination, DEFAULT_PAGE_SIZE_OPTIONS } from "./DataTablePagination";
export { DataTableToolbar } from "./DataTableToolbar";
export { DataTableViewOptions } from "./DataTableViewOptions";
export { DataTableSortHeader, type DataTableSortVariant } from "./DataTableSortHeader";
export type { DataTablePaginationProps } from "./DataTablePagination";
export type {
ColumnPinnedSide,
ColumnResizeMode,
DataTableProps,
DataTableSize,
PaginationMode,
SortingMode,
} from "./types";

View file

@ -0,0 +1,60 @@
import type {
ColumnDef,
ExpandedState,
OnChangeFn,
PaginationState,
Row,
RowData,
SortingState,
Table,
VisibilityState,
} from "@tanstack/react-table";
import type * as React from "react";
export type SortingMode = "none" | "client" | "server";
export type PaginationMode = "none" | "client" | "server";
export type ColumnResizeMode = "onEnd" | "onChange";
export type DataTableSize = "compact" | "default";
export type ColumnPinnedSide = "left" | "right";
export interface DataTableProps<TData extends RowData, TValue> {
data: TData[];
columns: ColumnDef<TData, TValue>[];
getRowId?: (row: TData, index: number, parent?: Row<TData>) => string;
isLoading?: boolean;
loadingMessage?: string;
noDataMessage?: React.ReactNode;
sortingMode?: SortingMode;
sorting?: SortingState;
onSortingChange?: OnChangeFn<SortingState>;
defaultSorting?: SortingState;
enableSortingRemoval?: boolean;
paginationMode?: PaginationMode;
pagination?: PaginationState;
onPaginationChange?: OnChangeFn<PaginationState>;
rowCount?: number;
pageSizeOptions?: number[];
enableColumnResizing?: boolean;
columnResizeMode?: ColumnResizeMode;
defaultColumnVisibility?: VisibilityState;
getRowCanExpand?: (row: Row<TData>) => boolean;
renderSubComponent?: (props: { row: Row<TData> }) => React.ReactElement;
expanded?: ExpandedState;
onExpandedChange?: OnChangeFn<ExpandedState>;
onRowClick?: (row: TData) => void;
rowClassName?: (row: Row<TData>) => string;
maxBodyHeight?: number | string;
size?: DataTableSize;
toolbar?: (table: Table<TData>) => React.ReactNode;
paginationSlot?: (table: Table<TData>) => React.ReactNode;
footer?: (table: Table<TData>) => React.ReactNode;
}