964 lines
30 KiB
TypeScript
964 lines
30 KiB
TypeScript
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<TData> = {
|
|
column: DataTableColumn<TData>;
|
|
sorted: "asc" | "desc" | false;
|
|
};
|
|
|
|
export type DataTableColumn<TData> = {
|
|
accessor?: keyof TData | ((row: TData) => unknown);
|
|
align?: DataTableAlignment;
|
|
cell?: (row: TData) => ReactNode;
|
|
header: ReactNode | ((column: DataTableColumnContext<TData>) => ReactNode);
|
|
id: string;
|
|
searchValue?: (row: TData) => string;
|
|
searchable?: boolean;
|
|
sortable?: boolean;
|
|
width?: number | string;
|
|
};
|
|
|
|
export type DataTableProps<TData> = Omit<ComponentPropsWithoutRef<"div">, "children"> & {
|
|
columns: DataTableColumn<TData>[];
|
|
defaultPageIndex?: number;
|
|
defaultPageSize?: number;
|
|
defaultSearchValue?: string;
|
|
defaultSelection?: Record<string, boolean>;
|
|
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<string, boolean>) => 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<string, boolean>;
|
|
selectionLabel?: ReactNode | ((rows: TData[]) => ReactNode);
|
|
selectionActions?: (rows: TData[]) => ReactNode;
|
|
sorting?: DataTableSort[];
|
|
tableLabel?: string;
|
|
toolbarActions?: ReactNode;
|
|
};
|
|
|
|
type InternalColumnMeta<TData> = {
|
|
align: DataTableAlignment;
|
|
sourceColumn?: DataTableColumn<TData>;
|
|
width?: number | string;
|
|
};
|
|
|
|
function useControllableState<T>({
|
|
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<string, unknown>)
|
|
.map((item) => stringifySearchValue(item))
|
|
.join(" ");
|
|
}
|
|
|
|
return String(value);
|
|
}
|
|
|
|
function getColumnAccessorValue<TData>(row: TData, column: DataTableColumn<TData>) {
|
|
if (typeof column.accessor === "function") {
|
|
return column.accessor(row);
|
|
}
|
|
|
|
if (typeof column.accessor === "string") {
|
|
return row[column.accessor];
|
|
}
|
|
|
|
return undefined;
|
|
}
|
|
|
|
function getColumnSearchText<TData>(row: TData, column: DataTableColumn<TData>) {
|
|
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<TData>(column: DataTableColumn<TData>) {
|
|
return typeof column.header === "string" ? column.header : column.id;
|
|
}
|
|
|
|
function DataTableInner<TData>(
|
|
{
|
|
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<TData>,
|
|
ref: ForwardedRef<HTMLDivElement>
|
|
) {
|
|
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<DataTableSort[]>({
|
|
controlledValue: sorting,
|
|
defaultValue: defaultSorting,
|
|
onChange: onSortingChange
|
|
});
|
|
const [currentSelection, setCurrentSelection] = useControllableState<Record<string, boolean>>({
|
|
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<TData> = (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<TData>[] = [
|
|
...(selectionEnabled
|
|
? [
|
|
{
|
|
cell: ({ row }) => (
|
|
<Checkbox
|
|
aria-label={`Select row ${row.index + 1}`}
|
|
checked={row.getIsSelected()}
|
|
onCheckedChange={(checked) => {
|
|
row.toggleSelected(Boolean(checked));
|
|
}}
|
|
/>
|
|
),
|
|
enableGlobalFilter: false,
|
|
enableHiding: false,
|
|
enableSorting: false,
|
|
header: ({ table }) => (
|
|
<Checkbox
|
|
aria-label="Select all rows"
|
|
checked={table.getIsAllPageRowsSelected()}
|
|
onCheckedChange={(checked) => {
|
|
table.toggleAllPageRowsSelected(Boolean(checked));
|
|
}}
|
|
/>
|
|
),
|
|
id: "__select",
|
|
meta: {
|
|
align: "center",
|
|
width: 56
|
|
} satisfies InternalColumnMeta<TData>
|
|
} satisfies ColumnDef<TData>
|
|
]
|
|
: []),
|
|
...columns.map((column) => ({
|
|
accessorFn: column.accessor
|
|
? (row: TData) => getColumnAccessorValue(row, column)
|
|
: undefined,
|
|
cell: ({ row }: CellContext<TData, unknown>) =>
|
|
column.cell
|
|
? column.cell(row.original)
|
|
: stringifySearchValue(getColumnAccessorValue(row.original, column)),
|
|
enableGlobalFilter: searchableColumns.includes(column),
|
|
enableSorting: column.sortable ?? false,
|
|
header: ({ column: tanstackColumn }: HeaderContext<TData, unknown>) =>
|
|
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<TData>,
|
|
sortDescFirst: false
|
|
})),
|
|
...(renderRowActions
|
|
? [
|
|
{
|
|
cell: ({ row }) => (
|
|
<div {...createSlot("actions")} className="flex items-center justify-end">
|
|
{renderRowActions(row.original)}
|
|
</div>
|
|
),
|
|
enableGlobalFilter: false,
|
|
enableHiding: false,
|
|
enableSorting: false,
|
|
header: () => <span className="sr-only">Row actions</span>,
|
|
id: "__actions",
|
|
meta: {
|
|
align: "end",
|
|
width: 88
|
|
} satisfies InternalColumnMeta<TData>
|
|
} satisfies ColumnDef<TData>
|
|
]
|
|
: [])
|
|
];
|
|
|
|
// 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 (
|
|
<div
|
|
{...props}
|
|
{...createSlot("root")}
|
|
{...createDataAttributes({
|
|
empty: isEmpty,
|
|
loading,
|
|
selected: selectedRows.length > 0
|
|
})}
|
|
className={cn(dataTableRootVariants(), className)}
|
|
ref={ref}
|
|
>
|
|
{shouldRenderToolbar ? (
|
|
<DataTableToolbar>
|
|
<div className="flex flex-1 flex-wrap items-center gap-3">
|
|
{shouldRenderSearch ? (
|
|
<div className={dataTableSearchContainerVariants()}>
|
|
<DataTableSearch
|
|
aria-label={searchLabel}
|
|
onChange={(event) => {
|
|
setCurrentSearchValue(event.currentTarget.value);
|
|
setCurrentPageIndex(0);
|
|
}}
|
|
placeholder={searchPlaceholder}
|
|
value={currentSearchValue}
|
|
/>
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
{toolbarActions ? <DataTableFilters>{toolbarActions}</DataTableFilters> : null}
|
|
</DataTableToolbar>
|
|
) : null}
|
|
|
|
{selectionEnabled && selectedRows.length > 0 ? (
|
|
<DataTableSelectionBar>
|
|
<div className="text-sm font-medium text-[var(--color-foreground)]">
|
|
{typeof selectionLabel === "function"
|
|
? selectionLabel(selectedRows)
|
|
: selectionLabel ?? `${selectedRows.length} selected`}
|
|
</div>
|
|
<div {...createSlot("actions")} className="flex flex-wrap items-center gap-2">
|
|
{selectionActions?.(selectedRows)}
|
|
<Button
|
|
size="sm"
|
|
variant="ghost"
|
|
onClick={() => {
|
|
table.resetRowSelection();
|
|
}}
|
|
>
|
|
Clear selection
|
|
</Button>
|
|
</div>
|
|
</DataTableSelectionBar>
|
|
) : null}
|
|
|
|
<DataTableContent loading={loading}>
|
|
{loading ? <DataTableLoading className="sr-only min-h-0 px-0 py-0" /> : null}
|
|
<div className="overflow-x-auto">
|
|
<DataTableTable aria-label={tableLabel}>
|
|
<DataTableHeader>
|
|
{table.getHeaderGroups().map((headerGroup) => (
|
|
<tr key={headerGroup.id}>
|
|
{headerGroup.headers.map((header) => {
|
|
const meta = header.column.columnDef.meta as InternalColumnMeta<TData> | undefined;
|
|
const sortState = header.column.getIsSorted();
|
|
const align: DataTableAlignment = meta?.align ?? "start";
|
|
|
|
return (
|
|
<DataTableHeaderCell
|
|
key={header.id}
|
|
align={align}
|
|
aria-sort={
|
|
sortState === "asc"
|
|
? "ascending"
|
|
: sortState === "desc"
|
|
? "descending"
|
|
: undefined
|
|
}
|
|
scope="col"
|
|
sortable={header.column.getCanSort()}
|
|
sort={sortState}
|
|
style={getColumnWidthStyle(meta?.width)}
|
|
>
|
|
{header.isPlaceholder ? null : header.column.getCanSort() ? (
|
|
<button
|
|
className={[
|
|
"inline-flex w-full items-center gap-2 rounded-[var(--radius-sm)] px-2 py-1.5",
|
|
"outline-none transition-colors duration-200 hover:bg-[var(--color-surface)]",
|
|
"focus-visible:ring-2 focus-visible:ring-[var(--color-ring)]",
|
|
align === "end" ? "justify-end" : align === "center" ? "justify-center" : "justify-start"
|
|
].join(" ")}
|
|
onClick={header.column.getToggleSortingHandler()}
|
|
type="button"
|
|
>
|
|
<span>{flexRender(header.column.columnDef.header, header.getContext())}</span>
|
|
<span
|
|
aria-hidden="true"
|
|
className="inline-flex items-center justify-center text-[var(--color-muted-foreground)]"
|
|
>
|
|
{sortState === "asc" ? (
|
|
<SortAscendingIcon className="size-3.5" />
|
|
) : sortState === "desc" ? (
|
|
<SortDescendingIcon className="size-3.5" />
|
|
) : (
|
|
<SortUnsortedIcon className="size-3.5" />
|
|
)}
|
|
</span>
|
|
<span className="sr-only">
|
|
Sort by{" "}
|
|
{meta?.sourceColumn ? getHeaderLabel(meta.sourceColumn) : header.column.id}
|
|
</span>
|
|
</button>
|
|
) : (
|
|
flexRender(header.column.columnDef.header, header.getContext())
|
|
)}
|
|
</DataTableHeaderCell>
|
|
);
|
|
})}
|
|
</tr>
|
|
))}
|
|
</DataTableHeader>
|
|
|
|
<DataTableBody>
|
|
{loading
|
|
? Array.from({ length: loadingRowCount }, (_, index) => (
|
|
<DataTableRow
|
|
interactive={false}
|
|
key={`loading-row-${index}`}
|
|
>
|
|
{Array.from({ length: totalColumns }, (_, cellIndex) => (
|
|
<DataTableCell key={`loading-cell-${cellIndex}`}>
|
|
<Skeleton
|
|
className={cellIndex === totalColumns - 1 ? "ml-auto w-12" : undefined}
|
|
/>
|
|
</DataTableCell>
|
|
))}
|
|
</DataTableRow>
|
|
))
|
|
: isEmpty
|
|
? (
|
|
<tr>
|
|
<td colSpan={totalColumns}>
|
|
{empty ?? (
|
|
<DataTableEmpty
|
|
description="Try another search, clear filters, or create a new record to repopulate this view."
|
|
title="No matching rows"
|
|
/>
|
|
)}
|
|
</td>
|
|
</tr>
|
|
)
|
|
: table.getRowModel().rows.map((row) => (
|
|
<DataTableRow
|
|
data-selected={row.getIsSelected() ? "" : undefined}
|
|
key={row.id}
|
|
selected={row.getIsSelected()}
|
|
>
|
|
{row.getVisibleCells().map((cell) => {
|
|
const meta = cell.column.columnDef.meta as InternalColumnMeta<TData> | undefined;
|
|
const align: DataTableAlignment = meta?.align ?? "start";
|
|
|
|
return (
|
|
<DataTableCell
|
|
align={align}
|
|
key={cell.id}
|
|
style={getColumnWidthStyle(meta?.width)}
|
|
>
|
|
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
|
</DataTableCell>
|
|
);
|
|
})}
|
|
</DataTableRow>
|
|
))}
|
|
</DataTableBody>
|
|
</DataTableTable>
|
|
</div>
|
|
|
|
<DataTablePagination>
|
|
<div className="text-sm text-[var(--color-muted-foreground)]">
|
|
{pageStart}-{pageEnd} of {filteredRowCount}
|
|
</div>
|
|
|
|
<div className="flex flex-wrap items-center gap-3">
|
|
{resolvedPageSizeOptions.length > 1 ? (
|
|
<div className="flex items-center gap-2">
|
|
<span className="text-sm text-[var(--color-muted-foreground)]">Rows</span>
|
|
<Select
|
|
value={String(currentPageSize)}
|
|
onValueChange={(value) => {
|
|
setCurrentPageSize(Number(value));
|
|
setCurrentPageIndex(0);
|
|
}}
|
|
>
|
|
<SelectTrigger aria-label="Rows per page" className="w-[5.5rem]">
|
|
<SelectValue placeholder={String(currentPageSize)} />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{resolvedPageSizeOptions.map((option) => (
|
|
<SelectItem key={option} value={String(option)}>
|
|
{option}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
) : null}
|
|
|
|
<div className="text-sm text-[var(--color-muted-foreground)]">
|
|
Page {Math.min(currentPageIndex + 1, Math.max(pageCount, 1))} of {Math.max(pageCount, 1)}
|
|
</div>
|
|
|
|
<div className="flex items-center gap-2">
|
|
<Button
|
|
disabled={!table.getCanPreviousPage()}
|
|
size="sm"
|
|
variant="ghost"
|
|
onClick={() => {
|
|
table.previousPage();
|
|
}}
|
|
>
|
|
Previous
|
|
</Button>
|
|
<Button
|
|
disabled={!table.getCanNextPage()}
|
|
size="sm"
|
|
variant="ghost"
|
|
onClick={() => {
|
|
table.nextPage();
|
|
}}
|
|
>
|
|
Next
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</DataTablePagination>
|
|
</DataTableContent>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
type DataTableComponent = <TData>(
|
|
props: DataTableProps<TData> & { ref?: ForwardedRef<HTMLDivElement> }
|
|
) => JSX.Element;
|
|
|
|
export const DataTable = forwardRef(DataTableInner) as DataTableComponent;
|
|
|
|
export type DataTableToolbarProps = ComponentPropsWithoutRef<"div">;
|
|
|
|
export const DataTableToolbar = forwardRef<HTMLDivElement, DataTableToolbarProps>(
|
|
function DataTableToolbar({ className, ...props }, ref) {
|
|
return (
|
|
<div
|
|
{...props}
|
|
{...createSlot("toolbar")}
|
|
className={cn(dataTableToolbarVariants(), className)}
|
|
ref={ref}
|
|
/>
|
|
);
|
|
}
|
|
);
|
|
|
|
export type DataTableFiltersProps = ComponentPropsWithoutRef<"div">;
|
|
|
|
export const DataTableFilters = forwardRef<HTMLDivElement, DataTableFiltersProps>(
|
|
function DataTableFilters({ className, ...props }, ref) {
|
|
return (
|
|
<div
|
|
{...props}
|
|
{...createSlot("actions")}
|
|
className={cn("flex flex-wrap items-center gap-2", className)}
|
|
ref={ref}
|
|
/>
|
|
);
|
|
}
|
|
);
|
|
|
|
export type DataTableSearchProps = Omit<InputProps, "size">;
|
|
|
|
export const DataTableSearch = forwardRef<HTMLInputElement, DataTableSearchProps>(
|
|
function DataTableSearch({ className, type = "search", ...props }, ref) {
|
|
return <Input {...props} className={className} ref={ref} type={type} />;
|
|
}
|
|
);
|
|
|
|
export type DataTableContentProps = ComponentPropsWithoutRef<"div"> &
|
|
VariantProps<typeof dataTableContentVariants>;
|
|
|
|
export const DataTableContent = forwardRef<HTMLDivElement, DataTableContentProps>(
|
|
function DataTableContent({ className, loading, ...props }, ref) {
|
|
return (
|
|
<div
|
|
{...props}
|
|
{...createSlot("content")}
|
|
{...createDataAttributes({ loading })}
|
|
className={cn(dataTableContentVariants({ loading }), className)}
|
|
ref={ref}
|
|
/>
|
|
);
|
|
}
|
|
);
|
|
|
|
export type DataTableTableProps = ComponentPropsWithoutRef<"table">;
|
|
|
|
export const DataTableTable = forwardRef<HTMLTableElement, DataTableTableProps>(
|
|
function DataTableTable({ className, ...props }, ref) {
|
|
return (
|
|
<table
|
|
{...props}
|
|
{...createSlot("table")}
|
|
className={cn(dataTableTableVariants(), className)}
|
|
ref={ref}
|
|
/>
|
|
);
|
|
}
|
|
);
|
|
|
|
export type DataTableHeaderProps = ComponentPropsWithoutRef<"thead">;
|
|
|
|
export const DataTableHeader = forwardRef<HTMLTableSectionElement, DataTableHeaderProps>(
|
|
function DataTableHeader({ className, ...props }, ref) {
|
|
return (
|
|
<thead
|
|
{...props}
|
|
{...createSlot("header")}
|
|
className={cn(dataTableHeaderVariants(), className)}
|
|
ref={ref}
|
|
/>
|
|
);
|
|
}
|
|
);
|
|
|
|
export type DataTableHeaderCellProps = Omit<ComponentPropsWithoutRef<"th">, "align"> &
|
|
VariantProps<typeof dataTableHeaderCellVariants> & {
|
|
sort?: "asc" | "desc" | false;
|
|
};
|
|
|
|
export const DataTableHeaderCell = forwardRef<HTMLTableCellElement, DataTableHeaderCellProps>(
|
|
function DataTableHeaderCell(
|
|
{ align = "start", className, sort = false, sortable = false, ...props },
|
|
ref
|
|
) {
|
|
return (
|
|
<th
|
|
{...props}
|
|
{...createSlot("header")}
|
|
{...createDataAttributes({
|
|
sort: sort || undefined
|
|
})}
|
|
className={cn(dataTableHeaderCellVariants({ align, sortable }), className)}
|
|
ref={ref}
|
|
/>
|
|
);
|
|
}
|
|
);
|
|
|
|
export type DataTableBodyProps = ComponentPropsWithoutRef<"tbody">;
|
|
|
|
export const DataTableBody = forwardRef<HTMLTableSectionElement, DataTableBodyProps>(
|
|
function DataTableBody({ className, ...props }, ref) {
|
|
return (
|
|
<tbody
|
|
{...props}
|
|
className={cn(dataTableBodyVariants(), className)}
|
|
ref={ref}
|
|
/>
|
|
);
|
|
}
|
|
);
|
|
|
|
export type DataTableRowProps = ComponentPropsWithoutRef<"tr"> &
|
|
VariantProps<typeof dataTableRowVariants>;
|
|
|
|
export const DataTableRow = forwardRef<HTMLTableRowElement, DataTableRowProps>(
|
|
function DataTableRow(
|
|
{ className, interactive = true, selected = false, ...props },
|
|
ref
|
|
) {
|
|
return (
|
|
<tr
|
|
{...props}
|
|
{...createSlot("row")}
|
|
{...createDataAttributes({
|
|
selected
|
|
})}
|
|
className={cn(dataTableRowVariants({ interactive, selected }), className)}
|
|
ref={ref}
|
|
/>
|
|
);
|
|
}
|
|
);
|
|
|
|
export type DataTableCellProps = Omit<ComponentPropsWithoutRef<"td">, "align"> &
|
|
VariantProps<typeof dataTableCellVariants>;
|
|
|
|
export const DataTableCell = forwardRef<HTMLTableCellElement, DataTableCellProps>(
|
|
function DataTableCell({ align = "start", className, ...props }, ref) {
|
|
return (
|
|
<td
|
|
{...props}
|
|
{...createSlot("cell")}
|
|
className={cn(dataTableCellVariants({ align }), className)}
|
|
ref={ref}
|
|
/>
|
|
);
|
|
}
|
|
);
|
|
|
|
export type DataTableEmptyProps = ComponentPropsWithoutRef<"div"> & {
|
|
actions?: ReactNode;
|
|
description?: ReactNode;
|
|
title?: ReactNode;
|
|
};
|
|
|
|
export const DataTableEmpty = forwardRef<HTMLDivElement, DataTableEmptyProps>(
|
|
function DataTableEmpty(
|
|
{
|
|
actions,
|
|
className,
|
|
description = "No rows match the current search or filter state.",
|
|
title = "Nothing to show",
|
|
...props
|
|
},
|
|
ref
|
|
) {
|
|
return (
|
|
<div
|
|
{...props}
|
|
{...createSlot("empty")}
|
|
className={cn(dataTableStatusVariants(), className)}
|
|
ref={ref}
|
|
>
|
|
<EmptyState className="w-full border-none bg-transparent p-0 shadow-none">
|
|
<EmptyStateHeader>
|
|
<EmptyStateTitle>{title}</EmptyStateTitle>
|
|
<EmptyStateDescription>{description}</EmptyStateDescription>
|
|
</EmptyStateHeader>
|
|
{actions ? <EmptyStateActions>{actions}</EmptyStateActions> : null}
|
|
</EmptyState>
|
|
</div>
|
|
);
|
|
}
|
|
);
|
|
|
|
export type DataTableLoadingProps = ComponentPropsWithoutRef<"div"> & {
|
|
description?: ReactNode;
|
|
label?: ReactNode;
|
|
};
|
|
|
|
export const DataTableLoading = forwardRef<HTMLDivElement, DataTableLoadingProps>(
|
|
function DataTableLoading(
|
|
{
|
|
className,
|
|
description = "Preparing the next set of rows and controls.",
|
|
label = "Loading rows",
|
|
...props
|
|
},
|
|
ref
|
|
) {
|
|
return (
|
|
<div
|
|
{...props}
|
|
className={cn(dataTableStatusVariants(), className)}
|
|
ref={ref}
|
|
role="status"
|
|
>
|
|
<div className="grid justify-items-center gap-3 text-center">
|
|
<Spinner aria-label={String(label)} size="lg" tone="primary" />
|
|
<div className="space-y-1">
|
|
<p className="text-sm font-medium text-[var(--color-foreground)]">{label}</p>
|
|
<p className="text-sm leading-6 text-[var(--color-muted-foreground)]">
|
|
{description}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
);
|
|
|
|
export type DataTablePaginationProps = ComponentPropsWithoutRef<"div">;
|
|
|
|
export const DataTablePagination = forwardRef<HTMLDivElement, DataTablePaginationProps>(
|
|
function DataTablePagination({ className, ...props }, ref) {
|
|
return (
|
|
<div
|
|
{...props}
|
|
{...createSlot("pagination")}
|
|
className={cn(dataTablePaginationVariants(), className)}
|
|
ref={ref}
|
|
/>
|
|
);
|
|
}
|
|
);
|
|
|
|
export type DataTableSelectionBarProps = ComponentPropsWithoutRef<"div">;
|
|
|
|
export const DataTableSelectionBar = forwardRef<HTMLDivElement, DataTableSelectionBarProps>(
|
|
function DataTableSelectionBar({ className, ...props }, ref) {
|
|
return (
|
|
<div
|
|
{...props}
|
|
className={cn(dataTableSelectionBarVariants(), className)}
|
|
ref={ref}
|
|
/>
|
|
);
|
|
}
|
|
);
|