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.
367 lines
13 KiB
TypeScript
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>
|
|
);
|
|
}
|