1213 lines
39 KiB
TypeScript
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}
|
|
/>
|
|
);
|
|
}
|
|
);
|