nekode
uidatatablesource: ok · hit
DataTable
Advanced table component with sorting, selection, pagination, filtering, row expansion, and more.
npx shadcn add "https://ui.nekode.id/r/datatable.json"free
Where it goes
data-table
Interaction
dropdown
Use cases
dashboard
Tags auto-generated by rules on 9/24/2026 — empty means no evidence, not lack of support.
Dependencies
Bundle weight · lightui-primitives@base-ui/react
iconslucide-react
Registry dependencies
checkboxlabelbuttonpopovercontext-menuseparatorselectdropdown-menutable
Files11 · source cached 10/8/2026, 1:40:57 PM
src/registry/ui/datatable/DataTable.tsx
"use client";
import * as React from "react";
import { Table, TableBody } from "@/components/ui/table";
import { cn } from "@/lib/utils";
import { DataTableEmptyState } from "./DataTableEmptyState";
import { DataTableFooter } from "./DataTableFooter";
import { DataTableHeader } from "./DataTableHeader";
import { DataTableLoader } from "./DataTableLoader";
import { DataTablePagination } from "./DataTablePagination";
import { DataTableRow } from "./DataTableRow";
import type {
BaseDataTableProps,
DataTableColumn,
RequiredSortProps,
SortDirection,
SortProps,
} from "./types";
import {
computeOrderedColumns,
computePinOffsets,
getColumnKey,
getRecordId,
useMergedRef,
} from "./utils";
export type {
DataTableColumn,
DataTablePaginationProps,
RowContextMenuItem,
SortDirection,
SortStatus,
} from "./types";
const SELECTION_COL_WIDTH = 44;
const EXPAND_COL_WIDTH = 40;
const DEFAULT_PINNED_WIDTH = 150;
export function DataTable<
T extends Record<string, any>,
Cols extends readonly DataTableColumn<T>[] = DataTableColumn<T>[],
>(
props: BaseDataTableProps<T> & {
columns: Cols;
} & (Cols[number]["sortable"] extends true
? RequiredSortProps<T>
: SortProps<T>),
) {
const {
data,
fetching = false,
columns,
idAccessor,
selectedRecords,
onSelectedRecordsChange,
height,
rowContextMenu,
pagination,
noRecordsText = "No records to show",
highlightOnHover = true,
onRowClick,
className,
variant = "bordered",
rowClassName,
rowStyle,
headerClassName,
headerStyle,
renderRowSubContent,
rowExpandable,
expandOnRowClick = true,
singleExpand = false,
expandedRecordIds,
onExpandedRecordIdsChange,
headerRef,
bodyRef,
footerRef,
tableRef,
scrollViewportRef,
rowRef,
sortStatus,
onSortStatusChange,
} = props as BaseDataTableProps<T> & SortProps<T>;
const selectable = !!onSelectedRecordsChange;
const expandable = !!renderRowSubContent;
const sticky = !!height;
/* ------------------------------- Selection ------------------------------ */
const selectedIds = React.useMemo(
() =>
new Set((selectedRecords ?? []).map((r) => getRecordId(r, idAccessor))),
[selectedRecords, idAccessor],
);
const pageSelectedCount = React.useMemo(
() =>
data.filter((r) => selectedIds.has(getRecordId(r, idAccessor))).length,
[data, selectedIds, idAccessor],
);
const allChecked = data.length > 0 && pageSelectedCount === data.length;
const someChecked = pageSelectedCount > 0 && !allChecked;
// Anchor for shift-click ranges, tracked by id (not index) so it can't
// point at the wrong row after paging, sorting or refetching.
const [lastSelectionId, setLastSelectionId] =
React.useState<React.Key | null>(null);
const handleToggleSelect = React.useCallback(
(record: T, rowIndex: number, shiftKey: boolean) => {
if (!onSelectedRecordsChange) return;
const id = getRecordId(record, idAccessor);
const lastSelectionIndex =
lastSelectionId === null
? -1
: data.findIndex(
(r) => getRecordId(r, idAccessor) === lastSelectionId,
);
if (shiftKey && lastSelectionIndex !== -1) {
const [start, end] =
lastSelectionIndex < rowIndex
? [lastSelectionIndex, rowIndex]
: [rowIndex, lastSelectionIndex];
const rangeRecords = data.slice(start, end + 1);
const willSelect = !selectedIds.has(id);
if (willSelect) {
const merged = [...(selectedRecords ?? [])];
const existing = new Set(
merged.map((r) => getRecordId(r, idAccessor)),
);
for (const r of rangeRecords) {
const rid = getRecordId(r, idAccessor);
if (!existing.has(rid)) {
merged.push(r);
existing.add(rid);
}
}
onSelectedRecordsChange(merged);
} else {
const rangeIds = new Set(
rangeRecords.map((r) => getRecordId(r, idAccessor)),
);
onSelectedRecordsChange(
(selectedRecords ?? []).filter(
(r) => !rangeIds.has(getRecordId(r, idAccessor)),
),
);
}
} else if (selectedIds.has(id)) {
onSelectedRecordsChange(
(selectedRecords ?? []).filter(
(r) => getRecordId(r, idAccessor) !== id,
),
);
} else {
onSelectedRecordsChange([...(selectedRecords ?? []), record]);
}
setLastSelectionId(id);
},
[
onSelectedRecordsChange,
selectedRecords,
selectedIds,
idAccessor,
data,
lastSelectionId,
],
);
const handleToggleAll = React.useCallback(() => {
if (!onSelectedRecordsChange) return;
if (allChecked) {
const idsOnPage = new Set(data.map((r) => getRecordId(r, idAccessor)));
onSelectedRecordsChange(
(selectedRecords ?? []).filter(
(r) => !idsOnPage.has(getRecordId(r, idAccessor)),
),
);
} else {
const merged = [...(selectedRecords ?? [])];
const existing = new Set(merged.map((r) => getRecordId(r, idAccessor)));
for (const r of data) {
const id = getRecordId(r, idAccessor);
if (!existing.has(id)) {
merged.push(r);
existing.add(id);
}
}
onSelectedRecordsChange(merged);
}
}, [onSelectedRecordsChange, allChecked, data, selectedRecords, idAccessor]);
/* ------------------------------- Expansion ------------------------------ */
const [internalExpanded, setInternalExpanded] = React.useState<
Set<React.Key>
>(new Set());
const isExpandControlled = expandedRecordIds !== undefined;
const expandedSet = React.useMemo(
() =>
new Set(
isExpandControlled ? expandedRecordIds : Array.from(internalExpanded),
),
[isExpandControlled, expandedRecordIds, internalExpanded],
);
const toggleExpand = React.useCallback(
(record: T) => {
const id = getRecordId(record, idAccessor);
let next: Set<React.Key>;
if (singleExpand) {
next = expandedSet.has(id) ? new Set() : new Set([id]);
} else {
next = new Set(expandedSet);
if (next.has(id)) next.delete(id);
else next.add(id);
}
if (isExpandControlled) onExpandedRecordIdsChange?.(Array.from(next));
else setInternalExpanded(next);
},
[
expandedSet,
singleExpand,
isExpandControlled,
onExpandedRecordIdsChange,
idAccessor,
],
);
/* --------------------------------- Sort ---------------------------------- */
const handleSort = React.useCallback(
(col: DataTableColumn<T>) => {
if (!col.sortable || !onSortStatusChange) return;
const isCurrent = sortStatus?.columnAccessor === col.accessor;
const nextDirection: SortDirection =
isCurrent && sortStatus?.direction === "asc" ? "desc" : "asc";
onSortStatusChange({
columnAccessor: col.accessor,
direction: nextDirection,
});
},
[onSortStatusChange, sortStatus],
);
/* -------------------------------- Pinning -------------------------------- */
const orderedColumns = React.useMemo(
() => computeOrderedColumns(columns),
[columns],
);
const leadingGutter =
(selectable ? SELECTION_COL_WIDTH : 0) +
(expandable ? EXPAND_COL_WIDTH : 0);
const { leftOffsets, rightOffsets, leftPinnedCols, rightPinnedCols } =
React.useMemo(
() =>
computePinOffsets(orderedColumns, leadingGutter, DEFAULT_PINNED_WIDTH),
[orderedColumns, leadingGutter],
);
const pinnedCellStyle = React.useCallback(
(col: DataTableColumn<T>): React.CSSProperties => {
const key = getColumnKey(col);
if (col.pinned === "left")
return { position: "sticky", left: leftOffsets[key] };
if (col.pinned === "right")
return { position: "sticky", right: rightOffsets[key] };
return {};
},
[leftOffsets, rightOffsets],
);
const pinnedCellClassName = React.useCallback(
(
col: DataTableColumn<T>,
zIndexClass: string,
bgClassName: string = "bg-background",
) => {
const isLastLeft =
leftPinnedCols.length > 0 &&
leftPinnedCols[leftPinnedCols.length - 1] === col;
const isFirstRight =
rightPinnedCols.length > 0 && rightPinnedCols[0] === col;
return cn(
col.pinned && "sticky",
col.pinned && bgClassName,
col.pinned && zIndexClass,
col.pinned === "left" &&
isLastLeft &&
"shadow-[2px_0_4px_-2px_rgba(0,0,0,0.15)]",
col.pinned === "right" &&
isFirstRight &&
"shadow-[-2px_0_4px_-2px_rgba(0,0,0,0.15)]",
);
},
[leftPinnedCols, rightPinnedCols],
);
/* ------------------------------ Scroll shadow ----------------------------- */
const scrollRef = React.useRef<HTMLDivElement>(null);
const mergedScrollRef = useMergedRef(scrollRef, scrollViewportRef);
const [scrollShadow, setScrollShadow] = React.useState({
left: false,
right: false,
});
const updateScrollShadow = React.useCallback(() => {
const el = scrollRef.current;
if (!el) return;
setScrollShadow({
left: el.scrollLeft > 1,
right: el.scrollLeft + el.clientWidth < el.scrollWidth - 1,
});
}, []);
React.useEffect(() => {
updateScrollShadow();
window.addEventListener("resize", updateScrollShadow);
return () => window.removeEventListener("resize", updateScrollShadow);
}, [updateScrollShadow, data, orderedColumns]);
/* ------------------------------ Pagination -------------------------------- */
const totalCols =
orderedColumns.length + (selectable ? 1 : 0) + (expandable ? 1 : 0);
/* --------------------------------- Render --------------------------------- */
return (
<div
className={cn(
"rounded-md",
variant === "bordered" && "border",
className,
)}
>
<div className="relative">
<DataTableLoader fetching={fetching} />
<div
aria-hidden
className={cn(
"pointer-events-none absolute inset-y-0 left-0 z-25 w-6 bg-gradient-to-r from-black/10 to-transparent opacity-0 transition-opacity dark:from-white/10",
scrollShadow.left && "opacity-100",
)}
/>
<div
aria-hidden
className={cn(
"pointer-events-none absolute inset-y-0 right-0 z-25 w-6 bg-gradient-to-l from-black/10 to-transparent opacity-0 transition-opacity dark:from-white/10",
scrollShadow.right && "opacity-100",
)}
/>
<div
ref={mergedScrollRef}
onScroll={updateScrollShadow}
style={height ? { height, overflow: "auto" } : { overflow: "auto" }}
>
<Table
ref={tableRef}
className="min-w-full"
style={{
tableLayout: columns.some((c) => c.width) ? "fixed" : "auto",
}}
>
<DataTableHeader<T>
ref={headerRef}
columns={orderedColumns}
selectable={selectable}
expandable={expandable}
selectionColWidth={SELECTION_COL_WIDTH}
expandColWidth={EXPAND_COL_WIDTH}
selectionChecked={allChecked}
selectionIndeterminate={someChecked}
onSelectionChange={handleToggleAll}
sortStatus={sortStatus}
onSort={handleSort}
getColumnKey={getColumnKey}
pinnedCellStyle={pinnedCellStyle}
pinnedCellClassName={pinnedCellClassName}
headerClassName={headerClassName}
headerStyle={headerStyle}
sticky={sticky}
variant={variant}
/>
<TableBody ref={bodyRef}>
{data.length === 0 ? (
<DataTableEmptyState
text={fetching ? "" : noRecordsText}
colSpan={totalCols}
/>
) : (
data.map((record, rowIndex) => {
const rowId = getRecordId(record, idAccessor);
const isSelected = selectedIds.has(rowId);
const isExpanded = expandedSet.has(rowId);
const canExpand =
expandable &&
(rowExpandable ? rowExpandable(record, rowIndex) : true);
const contextMenuItems = rowContextMenu?.(record, rowIndex);
const resolvedRowClassName =
typeof rowClassName === "function"
? rowClassName(record, rowIndex)
: rowClassName;
const resolvedRowStyle =
typeof rowStyle === "function"
? rowStyle(record, rowIndex)
: rowStyle;
return (
<DataTableRow<T>
key={rowId}
record={record}
rowIndex={rowIndex}
columns={orderedColumns}
selectable={selectable}
expandable={expandable}
selectionColWidth={SELECTION_COL_WIDTH}
expandColWidth={EXPAND_COL_WIDTH}
isSelected={isSelected}
isExpanded={isExpanded}
canExpand={canExpand}
renderSubContent={renderRowSubContent}
onToggleSelect={handleToggleSelect}
onToggleExpand={toggleExpand}
onRowClick={onRowClick}
expandOnRowClick={expandOnRowClick}
highlightOnHover={highlightOnHover}
rowClassName={resolvedRowClassName}
rowStyle={resolvedRowStyle}
contextMenuItems={contextMenuItems}
getColumnKey={getColumnKey}
pinnedCellStyle={pinnedCellStyle}
pinnedCellClassName={pinnedCellClassName}
leadingGutter={leadingGutter}
totalCols={totalCols}
variant={variant}
rowRef={rowRef}
/>
);
})
)}
</TableBody>
<DataTableFooter
columns={orderedColumns}
data={data}
selectable={selectable}
expandable={expandable}
selectionColWidth={SELECTION_COL_WIDTH}
expandColWidth={EXPAND_COL_WIDTH}
getColumnKey={getColumnKey}
pinnedCellStyle={pinnedCellStyle}
pinnedCellClassName={pinnedCellClassName}
sticky={sticky}
variant={variant}
/>
</Table>
</div>
</div>
{pagination && (
<DataTablePagination
pagination={pagination}
variant={variant}
sticky={sticky}
footerRef={footerRef}
fetching={fetching}
/>
)}
</div>
);
}
export default DataTable;
src/registry/ui/datatable/DataTableAction.tsx
"use client";
import { MoreVertical } from "lucide-react";
import type * as React from "react";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { cn } from "@/lib/utils";
import type { RowContextMenuItem } from "./types";
export interface DataTableActionProps {
/** Menu items — same shape as `rowContextMenu`'s per-row return value, so
* you can reuse the exact same list for both the right-click context menu
* and this button. */
items: RowContextMenuItem[];
/** Accessible label for the trigger button. */
label?: string;
/** Icon shown on the trigger button. Defaults to a vertical 3-dot icon. */
icon?: React.ReactNode;
className?: string;
align?: "start" | "center" | "end";
side?: "top" | "bottom" | "left" | "right";
}
/**
* A "kebab" (3-dot) button that opens a menu — meant for a table's actions
* column. Typically used together with (or instead of) `rowContextMenu`:
*
* {
* accessor: "actions",
* title: "",
* width: 60,
* render: (record) => (
* <DataTableAction
* items={[
* { key: "edit", label: "Edit", onClick: () => editUser(record) },
* { key: "delete", label: "Delete", danger: true, onClick: () => deleteUser(record) },
* ]}
* />
* ),
* }
*
* Stops click propagation so it's safe to use inside a row that also has
* `onRowClick` / expandable rows without accidentally triggering those.
*/
export function DataTableAction({
items,
label = "Open menu",
icon,
className,
align = "end",
side = "bottom",
}: DataTableActionProps) {
if (!items.length) return null;
return (
<DropdownMenu>
<DropdownMenuTrigger
className={cn(
"flex h-7 w-7 items-center justify-center rounded-md text-muted-foreground hover:bg-muted hover:text-foreground",
className,
)}
aria-label={label}
onClick={(e: React.MouseEvent) => e.stopPropagation()}
>
{icon ?? <MoreVertical className="h-4 w-4" />}
</DropdownMenuTrigger>
<DropdownMenuContent
align={align}
side={side}
onClick={(e: React.MouseEvent) => e.stopPropagation()}
>
{items.map((item) => (
<DropdownMenuItem
key={item.key}
disabled={item.disabled}
onClick={item.onClick}
className={cn(
item.danger && "text-destructive focus:text-destructive",
)}
>
{item.icon && (
<span className="mr-2 flex items-center">{item.icon}</span>
)}
{item.label}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
);
}
src/registry/ui/datatable/DataTableEmptyState.tsx
"use client";
import { TableCell, TableRow } from "@/components/ui/table";
export function DataTableEmptyState({
text,
colSpan,
}: {
text: string;
colSpan: number;
}) {
return (
<TableRow className="hover:bg-transparent">
<TableCell
colSpan={colSpan}
className="h-24 text-center text-muted-foreground"
>
{text}
</TableCell>
</TableRow>
);
}
src/registry/ui/datatable/DataTableFooter.tsx
"use client";
import type * as React from "react";
import { TableCell, TableRow } from "@/components/ui/table";
import { cn } from "@/lib/utils";
import type { DataTableColumn } from "./types";
interface DataTableFooterProps<T> {
columns: DataTableColumn<T>[];
data: T[];
selectable: boolean;
expandable: boolean;
selectionColWidth: number;
expandColWidth: number;
getColumnKey: (col: DataTableColumn<T>) => string;
pinnedCellStyle: (col: DataTableColumn<T>) => React.CSSProperties;
pinnedCellClassName: (
col: DataTableColumn<T>,
zIndexClass: string,
bgClassName?: string,
) => string;
sticky: boolean;
variant: "bordered" | "borderless";
}
export function DataTableFooter<T>({
columns,
data,
selectable,
expandable,
selectionColWidth,
expandColWidth,
getColumnKey,
pinnedCellStyle,
pinnedCellClassName,
sticky,
variant,
}: DataTableFooterProps<T>) {
const hasFooter = columns.some((c) => c.footer != null);
if (!hasFooter) return null;
return (
<tfoot
className={cn(
sticky && "sticky bottom-0 z-20",
variant === "borderless" && "[&_tr]:border-0",
)}
>
<TableRow
className={cn(
"bg-muted font-medium hover:bg-muted",
variant === "borderless" && "border-0",
)}
>
{selectable && (
<TableCell
className="sticky left-0 z-10 bg-muted"
style={{ width: selectionColWidth }}
/>
)}
{expandable && (
<TableCell
className="sticky z-10 bg-muted"
style={{
left: selectable ? selectionColWidth : 0,
width: expandColWidth,
}}
/>
)}
{columns.map((col) => {
const key = getColumnKey(col);
return (
<TableCell
key={key}
className={cn(
pinnedCellClassName(col, "z-10", "bg-muted"),
col.textAlign === "center" && "text-center",
col.textAlign === "right" && "text-right",
col.className,
)}
style={{ ...pinnedCellStyle(col), ...col.style }}
>
{typeof col.footer === "function" ? col.footer(data) : col.footer}
</TableCell>
);
})}
</TableRow>
</tfoot>
);
}
src/registry/ui/datatable/DataTableHeader.tsx
"use client";
import { ArrowDown, ArrowUp, ChevronsUpDown } from "lucide-react";
import * as React from "react";
import { Checkbox } from "@/components/ui/checkbox";
import { TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { cn } from "@/lib/utils";
import type { DataTableColumn, SortStatus } from "./types";
interface DataTableHeaderProps<T> {
columns: DataTableColumn<T>[];
selectable: boolean;
expandable: boolean;
selectionColWidth: number;
expandColWidth: number;
selectionChecked: boolean;
selectionIndeterminate: boolean;
onSelectionChange: () => void;
sortStatus?: SortStatus<T>;
onSort: (col: DataTableColumn<T>) => void;
getColumnKey: (col: DataTableColumn<T>) => string;
pinnedCellStyle: (col: DataTableColumn<T>) => React.CSSProperties;
pinnedCellClassName: (
col: DataTableColumn<T>,
zIndexClass: string,
bgClassName?: string,
) => string;
headerClassName?: string;
headerStyle?: React.CSSProperties;
sticky: boolean;
variant: "bordered" | "borderless";
}
function DataTableHeaderInner<T>(
{
columns,
selectable,
expandable,
selectionColWidth,
expandColWidth,
selectionChecked,
selectionIndeterminate,
onSelectionChange,
sortStatus,
onSort,
getColumnKey,
pinnedCellStyle,
pinnedCellClassName,
headerClassName,
headerStyle,
sticky,
variant,
}: DataTableHeaderProps<T>,
ref: React.Ref<HTMLTableSectionElement>,
) {
function renderSortIcon(col: DataTableColumn<T>) {
if (!col.sortable) return null;
if (sortStatus?.columnAccessor !== col.accessor) {
return (
<ChevronsUpDown className="h-3.5 w-3.5 text-muted-foreground/60" />
);
}
return sortStatus.direction === "asc" ? (
<ArrowUp className="h-3.5 w-3.5" />
) : (
<ArrowDown className="h-3.5 w-3.5" />
);
}
return (
<TableHeader
ref={ref}
className={cn(
sticky && "sticky top-0 z-20 bg-background",
variant === "borderless" && "[&_tr]:border-0",
)}
>
<TableRow
className={cn(variant === "borderless" && "border-0", headerClassName)}
style={headerStyle}
>
{selectable && (
<TableHead
className="sticky left-0 z-20 bg-background"
style={{ width: selectionColWidth }}
>
<Checkbox
checked={selectionChecked}
indeterminate={selectionIndeterminate}
onCheckedChange={() => onSelectionChange()}
aria-label="Select all records"
/>
</TableHead>
)}
{expandable && (
<TableHead
className="sticky z-20 bg-background"
style={{
left: selectable ? selectionColWidth : 0,
width: expandColWidth,
}}
/>
)}
{columns.map((col) => {
const key = getColumnKey(col);
return (
<TableHead
key={key}
className={cn(
"bg-background",
pinnedCellClassName(col, "z-20"),
col.textAlign === "center" && "text-center",
col.textAlign === "right" && "text-right",
col.className,
)}
style={{
width: col.width,
...pinnedCellStyle(col),
...col.style,
}}
>
<div
className={cn(
"flex items-center gap-1.5",
col.textAlign === "center" && "justify-center",
col.textAlign === "right" && "justify-end",
)}
>
{col.sortable ? (
<button
type="button"
className="flex items-center gap-1.5 font-medium hover:text-foreground"
onClick={() => onSort(col)}
>
<span>{col.title ?? col.accessor}</span>
{renderSortIcon(col)}
</button>
) : (
<span className="font-medium">
{col.title ?? col.accessor}
</span>
)}
</div>
</TableHead>
);
})}
</TableRow>
</TableHeader>
);
}
export const DataTableHeader = React.forwardRef(DataTableHeaderInner) as <T>(
props: DataTableHeaderProps<T> & { ref?: React.Ref<HTMLTableSectionElement> },
) => React.ReactElement;
src/registry/ui/datatable/DataTableLoader.tsx
"use client";
import { Loader2 } from "lucide-react";
export function DataTableLoader({ fetching }: { fetching?: boolean }) {
if (!fetching) return null;
return (
<div className="pointer-events-none absolute inset-0 z-30 flex items-start justify-center bg-background/40 backdrop-blur-[1px]">
<div className="mt-4 flex items-center gap-2 rounded-md border bg-background px-3 py-1.5 text-sm shadow-sm">
<Loader2 className="h-4 w-4 animate-spin" />
Loading...
</div>
</div>
);
}
src/registry/ui/datatable/DataTablePagination.tsx
"use client";
import {
ChevronLeft,
ChevronRight,
ChevronsLeft,
ChevronsRight,
MoreHorizontal,
} from "lucide-react";
import * as React from "react";
import { Button } from "@/components/ui/button";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { cn } from "@/lib/utils";
import type { DataTablePaginationProps } from "./types";
import { getPaginationRange } from "./utils";
interface Props {
pagination: DataTablePaginationProps;
variant: "bordered" | "borderless";
sticky: boolean;
footerRef?: React.Ref<HTMLDivElement>;
fetching?: boolean;
}
export function DataTablePagination({
pagination,
variant,
sticky,
footerRef,
fetching,
}: Props) {
const totalPages = Math.max(
1,
Math.ceil(pagination.totalRecords / pagination.recordsPerPage),
);
const rangeStart =
pagination.totalRecords === 0
? 0
: (pagination.page - 1) * pagination.recordsPerPage + 1;
const rangeEnd = Math.min(
pagination.page * pagination.recordsPerPage,
pagination.totalRecords,
);
const pageNumbers = React.useMemo(
() =>
getPaginationRange(
pagination.page,
totalPages,
pagination.siblingCount ?? 1,
),
[pagination.page, totalPages, pagination.siblingCount],
);
return (
<div
ref={footerRef}
className={cn(
"flex flex-col gap-3 px-4 py-3 sm:flex-row sm:items-center sm:justify-between",
variant === "bordered" && "border-t",
sticky && "shrink-0",
)}
>
<div className="text-sm text-muted-foreground">
{pagination.renderInfo
? pagination.renderInfo({
from: rangeStart,
to: rangeEnd,
totalRecords: pagination.totalRecords,
page: pagination.page,
totalPages,
})
: pagination.totalRecords === 0
? "No records"
: `Showing ${rangeStart}\u2013${rangeEnd} of ${pagination.totalRecords}`}
</div>
<div className="flex items-center gap-4">
{pagination.onRecordsPerPageChange && (
<div className="flex items-center gap-2 text-sm">
<span className="text-muted-foreground">Rows per page</span>
<Select
value={String(pagination.recordsPerPage)}
onValueChange={(v) =>
pagination.onRecordsPerPageChange?.(Number(v))
}
disabled={fetching}
>
<SelectTrigger className="h-8 w-[70px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
{(pagination.recordsPerPageOptions ?? [10, 25, 50, 100]).map(
(n) => (
<SelectItem key={n} value={String(n)}>
{n}
</SelectItem>
),
)}
</SelectContent>
</Select>
</div>
)}
<div className="flex items-center gap-1">
<Button
variant="outline"
size="icon"
className="h-8 w-8"
disabled={fetching || pagination.page <= 1}
onClick={() => pagination.onPageChange(1)}
>
<ChevronsLeft className="h-4 w-4" />
</Button>
<Button
variant="outline"
size="icon"
className="h-8 w-8"
disabled={fetching || pagination.page <= 1}
onClick={() => pagination.onPageChange(pagination.page - 1)}
>
<ChevronLeft className="h-4 w-4" />
</Button>
{pageNumbers.map((p, i) =>
p === "ellipsis" ? (
<span
// left and right ellipsis can both appear; key by the page before it
key={`ellipsis-after-${pageNumbers[i - 1]}`}
className="flex h-8 w-8 items-center justify-center text-muted-foreground"
>
<MoreHorizontal className="h-4 w-4" />
</span>
) : (
<Button
key={p}
disabled={fetching}
variant={p === pagination.page ? "default" : "outline"}
size="icon"
className="h-8 w-8"
onClick={() => pagination.onPageChange(p)}
>
{p}
</Button>
),
)}
<Button
variant="outline"
size="icon"
className="h-8 w-8"
disabled={fetching || pagination.page >= totalPages}
onClick={() => pagination.onPageChange(pagination.page + 1)}
>
<ChevronRight className="h-4 w-4" />
</Button>
<Button
variant="outline"
size="icon"
className="h-8 w-8"
disabled={fetching || pagination.page >= totalPages}
onClick={() => pagination.onPageChange(totalPages)}
>
<ChevronsRight className="h-4 w-4" />
</Button>
</div>
</div>
</div>
);
}
src/registry/ui/datatable/DataTableRow.tsx
"use client";
import { ChevronRight } from "lucide-react";
import * as React from "react";
import { Checkbox } from "@/components/ui/checkbox";
import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuTrigger,
} from "@/components/ui/context-menu";
import { TableCell, TableRow } from "@/components/ui/table";
import { cn } from "@/lib/utils";
import type { DataTableColumn, RowContextMenuItem } from "./types";
interface DataTableRowProps<T> {
record: T;
rowIndex: number;
columns: DataTableColumn<T>[];
selectable: boolean;
expandable: boolean;
selectionColWidth: number;
expandColWidth: number;
isSelected: boolean;
isExpanded: boolean;
canExpand: boolean;
renderSubContent?: (record: T, rowIndex: number) => React.ReactNode;
onToggleSelect: (record: T, rowIndex: number, shiftKey: boolean) => void;
onToggleExpand: (record: T) => void;
onRowClick?: (record: T, rowIndex: number) => void;
expandOnRowClick: boolean;
highlightOnHover: boolean;
rowClassName?: string;
rowStyle?: React.CSSProperties;
contextMenuItems?: RowContextMenuItem[];
getColumnKey: (col: DataTableColumn<T>) => string;
pinnedCellStyle: (col: DataTableColumn<T>) => React.CSSProperties;
pinnedCellClassName: (
col: DataTableColumn<T>,
zIndexClass: string,
bgClassName?: string,
) => string;
leadingGutter: number;
totalCols: number;
variant: "bordered" | "borderless";
rowRef?: (
element: HTMLTableRowElement | null,
record: T,
rowIndex: number,
) => void;
}
function DataTableRowInner<T>({
record,
rowIndex,
columns,
selectable,
expandable,
selectionColWidth,
expandColWidth,
isSelected,
isExpanded,
canExpand,
renderSubContent,
onToggleSelect,
onToggleExpand,
onRowClick,
expandOnRowClick,
highlightOnHover,
rowClassName,
rowStyle,
contextMenuItems,
getColumnKey,
pinnedCellStyle,
pinnedCellClassName,
leadingGutter,
totalCols,
variant,
rowRef,
}: DataTableRowProps<T>) {
const shiftKeyRef = React.useRef(false);
function handleRowClick() {
if (expandable && canExpand && expandOnRowClick) onToggleExpand(record);
onRowClick?.(record, rowIndex);
}
const rowShellClassName = cn(
highlightOnHover && "hover:bg-muted/50",
(onRowClick || (canExpand && expandOnRowClick)) && "cursor-pointer",
variant === "borderless" && "border-0",
rowClassName,
);
const cells = (
<React.Fragment>
{selectable && (
<TableCell
className="sticky left-0 z-10 bg-background"
style={{ width: selectionColWidth }}
onClick={(e) => e.stopPropagation()}
>
<Checkbox
checked={isSelected}
onCheckedChange={() => {
onToggleSelect(record, rowIndex, shiftKeyRef.current);
// Reset so a later keyboard toggle isn't treated as shift-click.
shiftKeyRef.current = false;
}}
onClick={(e: React.MouseEvent) => {
shiftKeyRef.current = e.shiftKey;
}}
aria-label={`Select record ${rowIndex + 1}`}
/>
</TableCell>
)}
{expandable && (
<TableCell
className="sticky z-10 bg-background"
style={{
left: selectable ? selectionColWidth : 0,
width: expandColWidth,
}}
onClick={(e) => e.stopPropagation()}
>
{canExpand && (
<button
type="button"
className="flex h-6 w-6 items-center justify-center rounded hover:bg-muted"
onClick={() => onToggleExpand(record)}
aria-label={isExpanded ? "Collapse row" : "Expand row"}
>
<ChevronRight
className={cn(
"h-4 w-4 transition-transform",
isExpanded && "rotate-90",
)}
/>
</button>
)}
</TableCell>
)}
{columns.map((col) => {
const key = getColumnKey(col);
return (
<TableCell
key={key}
className={cn(
pinnedCellClassName(col, "z-10"),
col.textAlign === "center" && "text-center",
col.textAlign === "right" && "text-right",
col.className,
)}
style={{ ...pinnedCellStyle(col), ...col.style }}
>
{col.render
? col.render(record, rowIndex)
: String((record as Record<string, unknown>)[col.accessor] ?? "")}
</TableCell>
);
})}
</React.Fragment>
);
const mainRow =
contextMenuItems && contextMenuItems.length > 0 ? (
<ContextMenu>
<ContextMenuTrigger
render={
<TableRow
ref={(el: HTMLTableRowElement | null) =>
rowRef?.(el, record, rowIndex)
}
data-state={isSelected ? "selected" : undefined}
className={rowShellClassName}
style={rowStyle}
onClick={handleRowClick}
/>
}
>
{cells}
</ContextMenuTrigger>
<ContextMenuContent>
{contextMenuItems.map((item) => (
<ContextMenuItem
key={item.key}
disabled={item.disabled}
onClick={item.onClick}
className={cn(
item.danger && "text-destructive focus:text-destructive",
)}
>
{item.icon && (
<span className="mr-2 flex items-center">{item.icon}</span>
)}
{item.label}
</ContextMenuItem>
))}
</ContextMenuContent>
</ContextMenu>
) : (
<TableRow
ref={(el) => rowRef?.(el, record, rowIndex)}
data-state={isSelected ? "selected" : undefined}
className={rowShellClassName}
style={rowStyle}
onClick={handleRowClick}
>
{cells}
</TableRow>
);
if (!canExpand || !isExpanded) return mainRow;
return (
<React.Fragment>
{mainRow}
<TableRow
className={cn(
"hover:bg-transparent",
variant === "borderless" && "border-0",
)}
>
<TableCell colSpan={totalCols} className="bg-muted/30 p-0">
<div style={{ paddingLeft: leadingGutter }} className="px-4 py-3">
{renderSubContent?.(record, rowIndex)}
</div>
</TableCell>
</TableRow>
</React.Fragment>
);
}
function propsAreEqual<T>(
prev: Readonly<DataTableRowProps<T>>,
next: Readonly<DataTableRowProps<T>>,
) {
return (
prev.record === next.record &&
prev.rowIndex === next.rowIndex &&
prev.isSelected === next.isSelected &&
prev.isExpanded === next.isExpanded &&
prev.canExpand === next.canExpand &&
prev.columns === next.columns &&
prev.rowClassName === next.rowClassName &&
prev.rowStyle === next.rowStyle &&
prev.contextMenuItems === next.contextMenuItems &&
prev.pinnedCellStyle === next.pinnedCellStyle &&
prev.pinnedCellClassName === next.pinnedCellClassName &&
prev.getColumnKey === next.getColumnKey &&
prev.onToggleSelect === next.onToggleSelect &&
prev.onToggleExpand === next.onToggleExpand &&
prev.onRowClick === next.onRowClick &&
prev.renderSubContent === next.renderSubContent &&
prev.selectable === next.selectable &&
prev.expandable === next.expandable &&
prev.expandOnRowClick === next.expandOnRowClick &&
prev.highlightOnHover === next.highlightOnHover &&
prev.leadingGutter === next.leadingGutter &&
prev.totalCols === next.totalCols &&
prev.variant === next.variant &&
prev.rowRef === next.rowRef
);
}
export const DataTableRow = React.memo(
DataTableRowInner,
propsAreEqual,
) as typeof DataTableRowInner;
src/registry/ui/datatable/index.ts
export { DataTable, default } from "./DataTable";
export type { DataTableActionProps } from "./DataTableAction";
export { DataTableAction } from "./DataTableAction";
export type {
DataTableColumn,
DataTablePaginationProps,
RowContextMenuItem,
SortDirection,
SortStatus,
} from "./types";
export {
createSequenceColumn,
getColumnKey,
getRecordId,
getSequenceNumber,
} from "./utils";
src/registry/ui/datatable/types.ts
import type * as React from "react";
export type SortDirection = "asc" | "desc";
export interface SortStatus<T> {
columnAccessor: Extract<keyof T, string> | (string & {});
direction: SortDirection;
}
export interface DataTableColumn<T> {
accessor: Extract<keyof T, string> | (string & {});
id?: string;
title?: React.ReactNode;
className?: string;
style?: React.CSSProperties;
sortable?: boolean;
width?: number | string;
render?: (record: T, rowIndex: number) => React.ReactNode;
textAlign?: "left" | "center" | "right";
/** Pinned columns should use a numeric `width` (px); string widths fall back to 150px for sticky offsets. */
pinned?: "left" | "right";
footer?: React.ReactNode | ((data: T[]) => React.ReactNode);
}
export interface RowContextMenuItem {
key: string;
label: React.ReactNode;
icon?: React.ReactNode;
onClick: () => void;
danger?: boolean;
disabled?: boolean;
}
export interface DataTablePaginationProps {
page: number;
totalRecords: number;
recordsPerPage: number;
onPageChange: (page: number) => void;
recordsPerPageOptions?: number[];
onRecordsPerPageChange?: (recordsPerPage: number) => void;
renderInfo?: (info: {
from: number;
to: number;
totalRecords: number;
page: number;
totalPages: number;
}) => React.ReactNode;
siblingCount?: number;
}
export interface BaseDataTableProps<T> {
data: T[];
fetching?: boolean;
columns: DataTableColumn<T>[];
idAccessor?: keyof T | ((record: T) => React.Key);
selectedRecords?: T[];
onSelectedRecordsChange?: (records: T[]) => void;
height?: number;
rowContextMenu?: (
record: T,
rowIndex: number,
) => RowContextMenuItem[] | undefined;
pagination?: DataTablePaginationProps;
noRecordsText?: string;
highlightOnHover?: boolean;
onRowClick?: (record: T, rowIndex: number) => void;
className?: string;
variant?: "bordered" | "borderless";
rowClassName?: string | ((record: T, rowIndex: number) => string | undefined);
rowStyle?:
| React.CSSProperties
| ((record: T, rowIndex: number) => React.CSSProperties | undefined);
headerClassName?: string;
headerStyle?: React.CSSProperties;
renderRowSubContent?: (record: T, rowIndex: number) => React.ReactNode;
rowExpandable?: (record: T, rowIndex: number) => boolean;
expandOnRowClick?: boolean;
singleExpand?: boolean;
expandedRecordIds?: React.Key[];
onExpandedRecordIdsChange?: (ids: React.Key[]) => void;
headerRef?: React.Ref<HTMLTableSectionElement>;
bodyRef?: React.Ref<HTMLTableSectionElement>;
footerRef?: React.Ref<HTMLDivElement>;
tableRef?: React.Ref<HTMLTableElement>;
scrollViewportRef?: React.Ref<HTMLDivElement>;
rowRef?: (
element: HTMLTableRowElement | null,
record: T,
rowIndex: number,
) => void;
}
export type SortProps<T> = {
sortStatus?: SortStatus<T>;
onSortStatusChange?: (status: SortStatus<T>) => void;
};
export type RequiredSortProps<T> = {
sortStatus: SortStatus<T>;
onSortStatusChange: (status: SortStatus<T>) => void;
};
src/registry/ui/datatable/utils.ts
import type * as React from "react";
import { useCallback } from "react";
import type { DataTableColumn } from "./types";
export function getRecordId<T>(
record: T,
idAccessor?: keyof T | ((record: T) => React.Key),
): React.Key {
if (typeof idAccessor === "function") return idAccessor(record);
if (idAccessor) return record[idAccessor] as unknown as React.Key;
// Prefer a conventional `id` field; stringifying is a slow last resort
// and treats identical records as the same row.
const id = (record as { id?: unknown } | null)?.id;
if (typeof id === "string" || typeof id === "number") return id;
return JSON.stringify(record);
}
/** Stable, module-level (never recreated) — safe to pass straight into memoized children. */
export function getColumnKey<T>(col: DataTableColumn<T>): string {
return col.id ?? col.accessor;
}
export function computeOrderedColumns<T>(
columns: DataTableColumn<T>[],
): DataTableColumn<T>[] {
const left = columns.filter((c) => c.pinned === "left");
const right = columns.filter((c) => c.pinned === "right");
const middle = columns.filter(
(c) => c.pinned !== "left" && c.pinned !== "right",
);
return [...left, ...middle, ...right];
}
export function computePinOffsets<T>(
orderedColumns: DataTableColumn<T>[],
leadingGutter: number,
defaultWidth: number,
) {
const leftPinnedCols = orderedColumns.filter((c) => c.pinned === "left");
const rightPinnedCols = orderedColumns.filter((c) => c.pinned === "right");
const leftOffsets: Record<string, number> = {};
let lo = leadingGutter;
for (const col of leftPinnedCols) {
const key = getColumnKey(col);
leftOffsets[key] = lo;
lo += typeof col.width === "number" ? col.width : defaultWidth;
}
const rightOffsets: Record<string, number> = {};
let ro = 0;
for (let i = rightPinnedCols.length - 1; i >= 0; i--) {
const col = rightPinnedCols[i];
const key = getColumnKey(col);
rightOffsets[key] = ro;
ro += typeof col.width === "number" ? col.width : defaultWidth;
}
return { leftOffsets, rightOffsets, leftPinnedCols, rightPinnedCols };
}
export function getPaginationRange(
current: number,
total: number,
siblingCount = 1,
): (number | "ellipsis")[] {
const totalVisible = siblingCount * 2 + 5;
if (totalVisible >= total) {
return Array.from({ length: Math.max(total, 0) }, (_, i) => i + 1);
}
const leftSibling = Math.max(current - siblingCount, 1);
const rightSibling = Math.min(current + siblingCount, total);
const showLeftEllipsis = leftSibling > 2;
const showRightEllipsis = rightSibling < total - 1;
const pages: (number | "ellipsis")[] = [1];
if (showLeftEllipsis) pages.push("ellipsis");
for (let i = leftSibling; i <= rightSibling; i++) {
if (i !== 1 && i !== total) pages.push(i);
}
if (showRightEllipsis) pages.push("ellipsis");
if (total > 1) pages.push(total);
return pages;
}
/** Merge multiple refs (object or callback) into a single stable callback ref. */
export function useMergedRef<T>(
...refs: Array<React.Ref<T> | undefined>
): React.RefCallback<T> {
return useCallback((node: T | null) => {
for (const ref of refs) {
if (!ref) continue;
if (typeof ref === "function") ref(node);
else (ref as React.MutableRefObject<T | null>).current = node;
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, refs);
}
/**
* Row number for a "No." column, accounting for pagination — e.g. row 0 on
* page 2 with 10 records/page is #11, not #1.
*/
export function getSequenceNumber(
rowIndex: number,
page?: number,
recordsPerPage?: number,
): number {
if (page && recordsPerPage) return (page - 1) * recordsPerPage + rowIndex + 1;
return rowIndex + 1;
}
/**
* Ready-made column definition for a "No." / sequence-number column.
* Pass `page`/`recordsPerPage` when paginating so numbering continues
* across pages instead of resetting to 1 on every page.
*
* columns={[createSequenceColumn({ page, recordsPerPage }), ...yourColumns]}
*/
export function createSequenceColumn<T>(options?: {
title?: React.ReactNode;
width?: number | string;
page?: number;
recordsPerPage?: number;
/** Override the (otherwise unused) synthetic accessor/id, e.g. if "no" collides with a real field. */
accessor?: string;
pinned?: "left" | "right";
}): DataTableColumn<T> {
const {
title = "No.",
width = 56,
page,
recordsPerPage,
accessor = "__sequence",
pinned,
} = options ?? {};
return {
accessor,
title,
width,
pinned,
textAlign: "center",
render: (_record, rowIndex) =>
getSequenceNumber(rowIndex, page, recordsPerPage),
};
}