learning_ai_common_plat/packages/data-table/src/DataTable.tsx
saravanakumardb1 a55ea16370 feat(data-table): @bytelyst/data-table@0.1.0 on TanStack Table v8 + Virtual
DataTable wrapper: sort, global filter, pagination, row selection + bulk
action bar, column resize/pin/reorder, compact/comfortable density, and a
virtualised mode for 10k+ rows (seeded initialRect for SSR/headless).

Note: roadmap 9.C says 'TanStack Table v9' but no stable v9 exists yet; built
on the current stable v8.21.3 + react-virtual 3.13.

Verified: vitest 10/10; tsc --noEmit clean; tsc build emits dist; published
@bytelyst/data-table@0.1.0 to local Gitea registry.
2026-05-28 17:32:53 -07:00

367 lines
13 KiB
TypeScript

import * as React from 'react';
import { clsx } from 'clsx';
import {
flexRender,
getCoreRowModel,
getFilteredRowModel,
getPaginationRowModel,
getSortedRowModel,
useReactTable,
type Column,
type ColumnDef,
type ColumnOrderState,
type ColumnPinningState,
type Row,
type SortingState,
} from '@tanstack/react-table';
import { useVirtualizer } from '@tanstack/react-virtual';
import type { DataTableProps } from './types.js';
const SELECT_COL_ID = '__select__';
const densityRowCls: Record<NonNullable<DataTableProps<unknown>['density']>, string> = {
compact: 'px-3 py-1 text-xs',
comfortable: 'px-4 py-2.5 text-sm',
};
/** Sticky-left offsets for pinned columns. */
function pinnedStyle<T>(column: Column<T, unknown>): React.CSSProperties {
const isPinned = column.getIsPinned();
if (isPinned !== 'left') return {};
return {
position: 'sticky',
left: column.getStart('left'),
zIndex: 1,
background: 'var(--bl-surface-card, #fff)',
};
}
/**
* `<DataTable>` — a token-themed table powered by TanStack Table v8 (the
* current stable; the roadmap's "v9" predates a stable v9 release) and
* TanStack Virtual. Opt into features via props; everything is keyboard- and
* screen-reader-friendly.
*/
export function DataTable<T>({
columns,
data,
enableSorting = true,
enableFilter = true,
filterPlaceholder = 'Filter…',
enablePagination = true,
pageSize = 10,
enableRowSelection = false,
renderBulkActions,
enableColumnResizing = false,
enableColumnReorder = false,
enableColumnPinning = false,
density = 'comfortable',
getRowId,
emptyState,
ariaLabel = 'Data table',
className,
virtualized = false,
rowHeight = 40,
maxBodyHeight = 480,
}: DataTableProps<T>) {
const [sorting, setSorting] = React.useState<SortingState>([]);
const [globalFilter, setGlobalFilter] = React.useState('');
const [columnOrder, setColumnOrder] = React.useState<ColumnOrderState>([]);
const [columnPinning, setColumnPinning] = React.useState<ColumnPinningState>({});
const dragColRef = React.useRef<string | null>(null);
const resolvedColumns = React.useMemo<ColumnDef<T, unknown>[]>(() => {
if (!enableRowSelection) return columns;
const selectionColumn: ColumnDef<T, unknown> = {
id: SELECT_COL_ID,
size: 44,
enableSorting: false,
enableResizing: false,
header: ({ table }) => (
<input
type="checkbox"
aria-label="Select all rows"
checked={table.getIsAllRowsSelected()}
ref={el => {
if (el) el.indeterminate = table.getIsSomeRowsSelected();
}}
onChange={table.getToggleAllRowsSelectedHandler()}
/>
),
cell: ({ row }) => (
<input
type="checkbox"
aria-label={`Select row ${row.index + 1}`}
checked={row.getIsSelected()}
disabled={!row.getCanSelect()}
onChange={row.getToggleSelectedHandler()}
/>
),
};
return [selectionColumn, ...columns];
}, [columns, enableRowSelection]);
const table = useReactTable<T>({
data,
columns: resolvedColumns,
state: { sorting, globalFilter, columnOrder, columnPinning },
onSortingChange: setSorting,
onGlobalFilterChange: setGlobalFilter,
onColumnOrderChange: setColumnOrder,
onColumnPinningChange: setColumnPinning,
getRowId,
enableSorting,
enableRowSelection,
enableColumnResizing,
columnResizeMode: 'onChange',
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: enableSorting ? getSortedRowModel() : undefined,
getFilteredRowModel: enableFilter ? getFilteredRowModel() : undefined,
getPaginationRowModel: enablePagination && !virtualized ? getPaginationRowModel() : undefined,
initialState: { pagination: { pageSize } },
});
const rows = table.getRowModel().rows;
const selectedRows = table.getSelectedRowModel().rows.map((r: Row<T>) => r.original);
const rowCellCls = densityRowCls[density];
// ── Virtualiser (only when virtualized) ──
const scrollRef = React.useRef<HTMLDivElement>(null);
const virtualizer = useVirtualizer({
count: rows.length,
getScrollElement: () => scrollRef.current,
estimateSize: () => rowHeight,
overscan: 8,
enabled: virtualized,
// Seed the viewport so rows render before the scroll element is measured
// (SSR + headless environments report a zero-size rect otherwise).
initialRect: { width: 0, height: maxBodyHeight },
});
const onHeaderDragStart = (colId: string) => {
dragColRef.current = colId;
};
const onHeaderDrop = (targetId: string) => {
const source = dragColRef.current;
dragColRef.current = null;
if (!source || source === targetId) return;
const order = table.getAllLeafColumns().map(c => c.id);
const current = columnOrder.length ? columnOrder : order;
const from = current.indexOf(source);
const to = current.indexOf(targetId);
if (from === -1 || to === -1) return;
const next = [...current];
next.splice(to, 0, next.splice(from, 1)[0]!);
setColumnOrder(next);
};
function renderHeaderCell(
header: ReturnType<typeof table.getHeaderGroups>[number]['headers'][number]
) {
const column = header.column;
const canSort = enableSorting && column.getCanSort();
const sortDir = column.getIsSorted();
const isSelectCol = column.id === SELECT_COL_ID;
const reorderable = enableColumnReorder && !isSelectCol;
return (
<th
key={header.id}
scope="col"
aria-sort={sortDir === 'asc' ? 'ascending' : sortDir === 'desc' ? 'descending' : 'none'}
draggable={reorderable}
onDragStart={reorderable ? () => onHeaderDragStart(column.id) : undefined}
onDragOver={reorderable ? e => e.preventDefault() : undefined}
onDrop={reorderable ? () => onHeaderDrop(column.id) : undefined}
style={{
width: enableColumnResizing ? header.getSize() : undefined,
position: 'relative',
...pinnedStyle(column),
}}
className={clsx(
rowCellCls,
'border-b border-[var(--bl-border,rgba(0,0,0,0.12))] text-left font-semibold text-[var(--bl-text-secondary,#444)]'
)}
>
<span className="flex items-center gap-1">
{header.isPlaceholder ? null : (
<button
type="button"
disabled={!canSort}
onClick={canSort ? column.getToggleSortingHandler() : undefined}
className={clsx('inline-flex items-center gap-1', canSort && 'cursor-pointer')}
>
{flexRender(column.columnDef.header, header.getContext())}
{sortDir === 'asc' && <span aria-hidden="true"></span>}
{sortDir === 'desc' && <span aria-hidden="true"></span>}
</button>
)}
{enableColumnPinning && !isSelectCol && (
<button
type="button"
aria-label={
column.getIsPinned() === 'left' ? `Unpin ${column.id}` : `Pin ${column.id} left`
}
aria-pressed={column.getIsPinned() === 'left'}
onClick={() => column.pin(column.getIsPinned() === 'left' ? false : 'left')}
className="text-[var(--bl-text-tertiary,#999)]"
>
{column.getIsPinned() === 'left' ? '📌' : '📍'}
</button>
)}
</span>
{enableColumnResizing && column.getCanResize() && (
<span
role="separator"
aria-orientation="vertical"
aria-label={`Resize ${column.id}`}
onMouseDown={header.getResizeHandler()}
onTouchStart={header.getResizeHandler()}
className="absolute right-0 top-0 h-full w-1 cursor-col-resize select-none touch-none bg-[var(--bl-border,rgba(0,0,0,0.12))] opacity-0 hover:opacity-100"
/>
)}
</th>
);
}
const colCount = table.getAllLeafColumns().length;
return (
<div className={clsx('flex flex-col gap-2', className)} data-testid="bl-data-table">
{enableFilter && (
<input
type="search"
role="searchbox"
aria-label={filterPlaceholder}
value={globalFilter}
onChange={e => setGlobalFilter(e.target.value)}
placeholder={filterPlaceholder}
className="h-9 w-full max-w-xs rounded-lg border border-[var(--bl-border,rgba(0,0,0,0.12))] bg-[var(--bl-surface-card,#fff)] px-3 text-sm outline-none focus-visible:border-[var(--bl-accent,#6366f1)]"
/>
)}
{enableRowSelection && selectedRows.length > 0 && (
<div
data-testid="bl-bulk-actions"
className="flex items-center gap-3 rounded-lg bg-[var(--bl-accent-muted,rgba(99,102,241,0.12))] px-3 py-2 text-sm"
>
<span>{selectedRows.length} selected</span>
{renderBulkActions?.(selectedRows, () => table.resetRowSelection())}
</div>
)}
<div
ref={scrollRef}
style={virtualized ? { maxHeight: maxBodyHeight, overflow: 'auto' } : undefined}
className="rounded-xl border border-[var(--bl-border-subtle,rgba(0,0,0,0.06))]"
>
<table
aria-label={ariaLabel}
role="table"
className="w-full border-collapse"
style={{ width: enableColumnResizing ? table.getTotalSize() : '100%' }}
>
<thead>
{table.getHeaderGroups().map(hg => (
<tr key={hg.id}>{hg.headers.map(h => renderHeaderCell(h))}</tr>
))}
</thead>
<tbody
style={
virtualized
? { display: 'block', height: virtualizer.getTotalSize(), position: 'relative' }
: undefined
}
>
{rows.length === 0 ? (
<tr>
<td
colSpan={colCount}
className={clsx(rowCellCls, 'text-center text-[var(--bl-text-tertiary,#999)]')}
>
{emptyState ?? 'No rows.'}
</td>
</tr>
) : virtualized ? (
virtualizer.getVirtualItems().map(vi => {
const row = rows[vi.index]!;
return (
<tr
key={row.id}
data-index={vi.index}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: rowHeight,
transform: `translateY(${vi.start}px)`,
display: 'flex',
}}
>
{row.getVisibleCells().map(cell => (
<td key={cell.id} className={clsx(rowCellCls, 'flex-1')}>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</td>
))}
</tr>
);
})
) : (
rows.map(row => (
<tr
key={row.id}
data-state={row.getIsSelected() ? 'selected' : undefined}
className={clsx(
'border-b border-[var(--bl-border-subtle,rgba(0,0,0,0.06))] last:border-0',
row.getIsSelected() && 'bg-[var(--bl-accent-muted,rgba(99,102,241,0.10))]'
)}
>
{row.getVisibleCells().map(cell => (
<td
key={cell.id}
style={{
width: enableColumnResizing ? cell.column.getSize() : undefined,
...pinnedStyle(cell.column),
}}
className={rowCellCls}
>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</td>
))}
</tr>
))
)}
</tbody>
</table>
</div>
{enablePagination && !virtualized && rows.length > 0 && (
<div className="flex items-center justify-between text-sm text-[var(--bl-text-secondary,#444)]">
<span>
Page {table.getState().pagination.pageIndex + 1} of {Math.max(1, table.getPageCount())}
</span>
<span className="flex gap-2">
<button
type="button"
onClick={() => table.previousPage()}
disabled={!table.getCanPreviousPage()}
className="rounded-md border border-[var(--bl-border,rgba(0,0,0,0.12))] px-2 py-1 disabled:opacity-40"
>
Previous
</button>
<button
type="button"
onClick={() => table.nextPage()}
disabled={!table.getCanNextPage()}
className="rounded-md border border-[var(--bl-border,rgba(0,0,0,0.12))] px-2 py-1 disabled:opacity-40"
>
Next
</button>
</span>
</div>
)}
</div>
);
}