Files
cadence-ui/packages/ui/src/components/data-table.tsx
T

1213 lines
39 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 {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuLabel,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuTrigger
} from "./dropdown-menu";
import {
EmptyState,
EmptyStateActions,
EmptyStateDescription,
EmptyStateHeader,
EmptyStateTitle
} from "./empty-state";
import { Input, type InputProps } from "./input";
import { InputGroup, InputGroupPrefix } from "./input-group";
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle
} from "./sheet";
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 DataTableDensity = "comfortable" | "compact";
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);
hideable?: boolean;
id: string;
searchValue?: (row: TData) => string;
searchable?: boolean;
sortable?: boolean;
width?: number | string;
};
export type DataTableProps<TData> = Omit<ComponentPropsWithoutRef<"div">, "children"> & {
columns: DataTableColumn<TData>[];
defaultDensity?: DataTableDensity;
defaultPageIndex?: number;
defaultPageSize?: number;
defaultSearchValue?: string;
defaultSelection?: Record<string, boolean>;
defaultSorting?: DataTableSort[];
defaultVisibleColumns?: Record<string, boolean>;
density?: DataTableDensity;
empty?: ReactNode;
enableSelection?: boolean;
getRowId?: (row: TData, index: number) => string;
loading?: boolean;
loadingRowCount?: number;
onDensityChange?: (density: DataTableDensity) => void;
onPageIndexChange?: (pageIndex: number) => void;
onPageSizeChange?: (pageSize: number) => void;
onSearchValueChange?: (searchValue: string) => void;
onSelectionChange?: (selection: Record<string, boolean>) => void;
onSortingChange?: (sorting: DataTableSort[]) => void;
onVisibleColumnsChange?: (visibility: Record<string, boolean>) => void;
pageIndex?: number;
pageSize?: number;
pageSizeOptions?: number[];
renderRowDetails?: (row: TData) => ReactNode;
renderRowActions?: (row: TData) => ReactNode;
rowDetailsDescription?: ReactNode | ((row: TData) => ReactNode);
rowDetailsLabel?: string;
rowDetailsTitle?: ReactNode | ((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;
visibleColumns?: Record<string, boolean>;
};
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,
defaultDensity = "comfortable",
defaultPageIndex = 0,
defaultPageSize = 5,
defaultSearchValue = "",
defaultSelection = {},
defaultSorting = [],
defaultVisibleColumns = {},
density,
empty,
enableSelection = false,
getRowId,
loading = false,
loadingRowCount = 5,
onDensityChange,
onPageIndexChange,
onPageSizeChange,
onSearchValueChange,
onSelectionChange,
onSortingChange,
onVisibleColumnsChange,
pageIndex,
pageSize,
pageSizeOptions = [5, 10, 20],
renderRowDetails,
renderRowActions,
rowDetailsDescription,
rowDetailsLabel = "Open details",
rowDetailsTitle,
rows,
searchLabel = "Search rows",
searchPlaceholder = "Search rows",
searchValue,
selection,
selectionLabel,
selectionActions,
sorting,
tableLabel = "Data table",
toolbarActions,
visibleColumns,
...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 [currentDensity, setCurrentDensity] = useControllableState<DataTableDensity>({
controlledValue: density,
defaultValue: defaultDensity,
onChange: onDensityChange
});
const [currentVisibleColumns, setCurrentVisibleColumns] = useControllableState<
Record<string, boolean>
>({
controlledValue: visibleColumns,
defaultValue: defaultVisibleColumns,
onChange: onVisibleColumnsChange
});
const [detailRowId, setDetailRowId] = useState<string | null>(null);
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>
]
: []),
...(renderRowDetails
? [
{
cell: ({ row }) => (
<div {...createSlot("actions")} className="flex items-center justify-end">
<Button
size="sm"
variant="ghost"
onClick={() => {
setDetailRowId(row.id);
}}
>
{rowDetailsLabel}
</Button>
</div>
),
enableGlobalFilter: false,
enableHiding: false,
enableSorting: false,
header: () => <span className="sr-only">Row details</span>,
id: "__details",
meta: {
align: "end",
width: 108
} 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),
enableHiding: column.hideable ?? true,
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 })));
},
onColumnVisibilityChange: (updater) => {
const nextValue =
typeof updater === "function"
? updater(currentVisibleColumns)
: updater;
setCurrentVisibleColumns(nextValue);
},
state: {
globalFilter: currentSearchValue,
pagination: {
pageIndex: currentPageIndex,
pageSize: currentPageSize
} satisfies PaginationState,
columnVisibility: currentVisibleColumns,
rowSelection: currentSelection,
sorting: currentSorting as SortingState
}
});
const totalColumns = table.getAllLeafColumns().length;
const filteredRowCount = table.getFilteredRowModel().rows.length;
const hideableColumns = table
.getAllLeafColumns()
.filter((column) => !column.id.startsWith("__") && column.getCanHide());
const selectedRows = table.getSelectedRowModel().rows.map((row) => row.original);
const pageCount = table.getPageCount();
const isEmpty = !loading && filteredRowCount === 0;
const shouldRenderSearch = searchableColumns.length > 0;
const shouldRenderViewOptions = hideableColumns.length > 0;
const shouldRenderToolbar =
shouldRenderSearch || toolbarActions !== undefined || shouldRenderViewOptions;
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]);
const detailRow = renderRowDetails
? table.getPrePaginationRowModel().rows.find((row) => row.id === detailRowId)
: undefined;
const detailRowOriginal = detailRow?.original;
const resolvedDetailTitle =
detailRowOriginal && rowDetailsTitle
? typeof rowDetailsTitle === "function"
? rowDetailsTitle(detailRowOriginal)
: rowDetailsTitle
: detailRowOriginal
? "Row details"
: null;
const resolvedDetailDescription =
detailRowOriginal && rowDetailsDescription
? typeof rowDetailsDescription === "function"
? rowDetailsDescription(detailRowOriginal)
: rowDetailsDescription
: null;
return (
<div
{...props}
{...createSlot("root")}
{...createDataAttributes({
empty: isEmpty,
loading,
selected: selectedRows.length > 0
})}
className={cn(dataTableRootVariants(), className)}
data-density={currentDensity}
ref={ref}
>
{shouldRenderToolbar ? (
<DataTableToolbar>
<div className="flex flex-1 flex-wrap items-center gap-3">
{shouldRenderSearch ? (
<div className={cn(dataTableSearchContainerVariants())}>
<DataTableSearch
aria-label={searchLabel}
onChange={(event) => {
setCurrentSearchValue(event.currentTarget.value);
setCurrentPageIndex(0);
}}
placeholder={searchPlaceholder}
value={currentSearchValue}
/>
</div>
) : null}
</div>
<DataTableFilters>
{toolbarActions}
{shouldRenderViewOptions ? (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button size="sm" variant="subtle">
View
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuLabel>Density</DropdownMenuLabel>
<DropdownMenuRadioGroup
value={currentDensity}
onValueChange={(value) => {
setCurrentDensity(value as DataTableDensity);
}}
>
<DropdownMenuRadioItem value="comfortable">
Comfortable
</DropdownMenuRadioItem>
<DropdownMenuRadioItem value="compact">
Compact
</DropdownMenuRadioItem>
</DropdownMenuRadioGroup>
<DropdownMenuSeparator />
<DropdownMenuLabel>Columns</DropdownMenuLabel>
{hideableColumns.map((column) => {
const meta = column.columnDef.meta as InternalColumnMeta<TData> | undefined;
const label =
meta?.sourceColumn ? getHeaderLabel(meta.sourceColumn) : column.id;
return (
<DropdownMenuCheckboxItem
checked={column.getIsVisible()}
key={column.id}
onCheckedChange={(checked) => {
column.toggleVisibility(Boolean(checked));
}}
>
{label}
</DropdownMenuCheckboxItem>
);
})}
</DropdownMenuContent>
</DropdownMenu>
) : null}
</DataTableFilters>
</DataTableToolbar>
) : null}
{selectionEnabled && selectedRows.length > 0 ? (
<DataTableSelectionBar>
<div className="grid gap-1">
<p className="text-[0.72rem] font-medium uppercase tracking-[var(--tracking-caps)] text-[color-mix(in_oklch,var(--color-primary)_68%,var(--color-foreground))]">
Selection ready
</p>
<div className="text-sm font-medium text-[var(--color-foreground)]">
{typeof selectionLabel === "function"
? selectionLabel(selectedRows)
: selectionLabel ?? `${selectedRows.length} selected`}
</div>
</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"
density={currentDensity}
sortable={header.column.getCanSort()}
sort={sortState}
style={getColumnWidthStyle(meta?.width)}
>
{header.isPlaceholder ? null : header.column.getCanSort() ? (
<button
className={cn(
"group inline-flex w-full items-center gap-2 rounded-[calc(var(--ui-control-radius)-0.15rem)] px-2.5 py-2",
"outline-none transition-[background-color,box-shadow,color,transform] duration-[var(--dur-fast)] ease-[var(--ease-standard)]",
"focus-visible:ring-2 focus-visible:ring-[var(--color-ring)] focus-visible:ring-offset-2",
"focus-visible:ring-offset-[color-mix(in_oklch,var(--ui-card-default-bg)_82%,white_18%)]",
sortState
? "-translate-y-px bg-[color-mix(in_oklch,var(--color-surface-bright)_52%,var(--color-primary-container))] text-[var(--color-foreground)] shadow-[inset_0_1px_0_color-mix(in_oklch,white_42%,transparent)]"
: "hover:-translate-y-px hover:bg-[color-mix(in_oklch,var(--ui-card-subtle-bg)_74%,white_26%)] hover:text-[var(--color-foreground)]",
align === "end"
? "justify-end"
: align === "center"
? "justify-center"
: "justify-start"
)}
onClick={header.column.getToggleSortingHandler()}
type="button"
>
<span>{flexRender(header.column.columnDef.header, header.getContext())}</span>
<span
aria-hidden="true"
className={cn(
"inline-flex items-center justify-center transition-[color,transform] duration-[var(--dur-fast)] ease-[var(--ease-standard)]",
sortState
? "translate-x-px text-[var(--color-primary)]"
: "text-[var(--color-muted-foreground)] group-hover:translate-x-px group-hover:text-[var(--color-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}
density={currentDensity}
key={cell.id}
style={getColumnWidthStyle(meta?.width)}
>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</DataTableCell>
);
})}
</DataTableRow>
))}
</DataTableBody>
</DataTableTable>
</div>
<DataTablePagination>
<div className="grid gap-0.5">
<span className="text-[0.72rem] font-medium uppercase tracking-[var(--tracking-caps)] text-[var(--color-muted-foreground)]">
Visible rows
</span>
<span className="text-sm font-medium text-[var(--color-foreground)]">
{pageStart}-{pageEnd} of {filteredRowCount}
</span>
</div>
<div className="flex flex-wrap items-center gap-3">
{resolvedPageSizeOptions.length > 1 ? (
<div className="flex items-center gap-2 rounded-[var(--radius-full)] bg-[color-mix(in_oklch,var(--ui-card-subtle-bg)_76%,white_24%)] px-2.5 py-1.5 shadow-[var(--ui-control-shadow)]">
<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="h-9 w-[5.5rem] border-0 bg-transparent px-2 shadow-none">
<SelectValue placeholder={String(currentPageSize)} />
</SelectTrigger>
<SelectContent>
{resolvedPageSizeOptions.map((option) => (
<SelectItem key={option} value={String(option)}>
{option}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
) : null}
<div className="rounded-[var(--radius-full)] bg-[color-mix(in_oklch,var(--ui-card-subtle-bg)_72%,white_28%)] px-3 py-1.5 text-sm font-medium text-[var(--color-foreground)] shadow-[var(--ui-control-shadow)]">
Page {Math.min(currentPageIndex + 1, Math.max(pageCount, 1))} of {Math.max(pageCount, 1)}
</div>
<div className="flex items-center gap-2 rounded-[var(--radius-full)] bg-[color-mix(in_oklch,var(--ui-card-subtle-bg)_76%,white_24%)] p-1 shadow-[var(--ui-control-shadow)]">
<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>
{renderRowDetails && detailRowOriginal ? (
<Sheet
open={detailRowId !== null}
onOpenChange={(nextOpen) => {
if (!nextOpen) {
setDetailRowId(null);
}
}}
>
<SheetContent size="lg">
<SheetHeader>
<SheetTitle>{resolvedDetailTitle}</SheetTitle>
{resolvedDetailDescription ? (
<SheetDescription>{resolvedDetailDescription}</SheetDescription>
) : null}
</SheetHeader>
<div className="mt-6">{renderRowDetails(detailRowOriginal)}</div>
</SheetContent>
</Sheet>
) : null}
</div>
);
}
type DataTableComponent = <TData>(
props: DataTableProps<TData> & { ref?: ForwardedRef<HTMLDivElement> }
) => JSX.Element;
export const DataTable = forwardRef(DataTableInner) as DataTableComponent;
function DataTableSearchIcon() {
return (
<svg aria-hidden="true" className="size-4" viewBox="0 0 16 16">
<path
d="M7.25 12.5a5.25 5.25 0 1 1 0-10.5a5.25 5.25 0 0 1 0 10.5Zm3.75-1.5 3 3"
fill="none"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="1.5"
/>
</svg>
);
}
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 justify-end gap-2", className)}
ref={ref}
/>
);
}
);
export type DataTableSearchProps = Omit<InputProps, "size"> & {
wrapperClassName?: string;
};
export const DataTableSearch = forwardRef<HTMLInputElement, DataTableSearchProps>(
function DataTableSearch({ className, type = "search", wrapperClassName, ...props }, ref) {
return (
<InputGroup className={cn(wrapperClassName)}>
<InputGroupPrefix>
<DataTableSearchIcon />
</InputGroupPrefix>
<Input {...props} className={className} ref={ref} type={type} />
</InputGroup>
);
}
);
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> & {
density?: DataTableDensity;
sort?: "asc" | "desc" | false;
};
export const DataTableHeaderCell = forwardRef<HTMLTableCellElement, DataTableHeaderCellProps>(
function DataTableHeaderCell(
{
align = "start",
className,
density = "comfortable",
sort = false,
sortable = false,
...props
},
ref
) {
return (
<th
{...props}
{...createSlot("header")}
{...createDataAttributes({
density,
sort: sort || undefined
})}
className={cn(dataTableHeaderCellVariants({ align, density, 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> & {
density?: DataTableDensity;
};
export const DataTableCell = forwardRef<HTMLTableCellElement, DataTableCellProps>(
function DataTableCell(
{ align = "start", className, density = "comfortable", ...props },
ref
) {
return (
<td
{...props}
{...createSlot("cell")}
className={cn(dataTableCellVariants({ align, density }), 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}
/>
);
}
);