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.
This commit is contained in:
saravanakumardb1 2026-05-28 17:32:53 -07:00
parent 6cd60e86e8
commit a55ea16370
8 changed files with 9484 additions and 2987 deletions

View File

@ -0,0 +1,41 @@
{
"name": "@bytelyst/data-table",
"version": "0.1.0",
"type": "module",
"description": "Headless-powered data table on TanStack Table + TanStack Virtual — sort, filter, paginate, select, resize, pin, reorder, virtualise. Token-themed.",
"exports": {
".": {
"import": "./dist/index.js",
"types": "./dist/index.d.ts"
}
},
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"files": [
"dist"
],
"scripts": {
"build": "tsc",
"test": "vitest run --pool forks",
"typecheck": "tsc --noEmit"
},
"peerDependencies": {
"react": ">=18.0.0",
"react-dom": ">=18.0.0"
},
"dependencies": {
"@tanstack/react-table": "^8.21.3",
"@tanstack/react-virtual": "^3.13.0",
"clsx": "^2.1.0"
},
"devDependencies": {
"@testing-library/react": "^16.3.2",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"happy-dom": "^18.0.1",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"typescript": "^5.7.3",
"vitest": "^4.0.18"
}
}

View File

@ -0,0 +1,366 @@
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>
);
}

View File

@ -0,0 +1,222 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { cleanup, fireEvent, render, screen, within } from '@testing-library/react';
import * as React from 'react';
import { DataTable } from '../DataTable.js';
import type { ColumnDef } from '@tanstack/react-table';
interface Person {
id: string;
name: string;
age: number;
}
const DATA: Person[] = [
{ id: '1', name: 'Ada', age: 36 },
{ id: '2', name: 'Bob', age: 28 },
{ id: '3', name: 'Cleo', age: 41 },
];
const COLUMNS: ColumnDef<Person, unknown>[] = [
{ id: 'name', accessorKey: 'name', header: 'Name' },
{ id: 'age', accessorKey: 'age', header: 'Age' },
];
const getRowId = (row: Person) => row.id;
beforeEach(() => {
cleanup();
});
describe('DataTable — rendering', () => {
it('renders one row per datum plus a header row', () => {
render(<DataTable columns={COLUMNS} data={DATA} getRowId={getRowId} enableFilter={false} />);
expect(screen.getByRole('table')).toBeTruthy();
expect(screen.getByText('Ada')).toBeTruthy();
expect(screen.getByText('Cleo')).toBeTruthy();
});
it('shows the empty state when there are no rows', () => {
render(<DataTable columns={COLUMNS} data={[]} getRowId={getRowId} emptyState="Nothing here" />);
expect(screen.getByText('Nothing here')).toBeTruthy();
});
it('applies compact density padding classes', () => {
render(
<DataTable
columns={COLUMNS}
data={DATA}
getRowId={getRowId}
density="compact"
enableFilter={false}
/>
);
const cell = screen.getByText('Ada');
expect(cell.closest('td')?.className).toContain('text-xs');
});
});
describe('DataTable — sorting', () => {
it('sorts rows when a header is clicked and sets aria-sort', () => {
render(<DataTable columns={COLUMNS} data={DATA} getRowId={getRowId} enableFilter={false} />);
const ageHeaderBtn = screen.getByRole('button', { name: 'Age' });
fireEvent.click(ageHeaderBtn);
const headerCell = ageHeaderBtn.closest('th')!;
// TanStack sorts numeric columns descending-first by default.
expect(headerCell.getAttribute('aria-sort')).toBe('descending');
const bodyRows = screen.getAllByRole('row').slice(1); // drop header
const firstDataCells = within(bodyRows[0]!).getAllByRole('cell');
expect(firstDataCells[0]!.textContent).toBe('Cleo'); // oldest first (age 41)
// A second click flips to ascending.
fireEvent.click(ageHeaderBtn);
expect(headerCell.getAttribute('aria-sort')).toBe('ascending');
});
});
describe('DataTable — global filter', () => {
it('narrows rows by the global filter input', () => {
render(<DataTable columns={COLUMNS} data={DATA} getRowId={getRowId} />);
fireEvent.change(screen.getByRole('searchbox'), { target: { value: 'cle' } });
expect(screen.getByText('Cleo')).toBeTruthy();
expect(screen.queryByText('Ada')).toBeNull();
});
});
describe('DataTable — pagination', () => {
const many: Person[] = Array.from({ length: 25 }, (_, i) => ({
id: String(i),
name: `P${i}`,
age: 20 + i,
}));
it('paginates and advances pages', () => {
render(
<DataTable
columns={COLUMNS}
data={many}
getRowId={getRowId}
pageSize={10}
enableFilter={false}
/>
);
expect(screen.getByText('P0')).toBeTruthy();
expect(screen.queryByText('P10')).toBeNull();
fireEvent.click(screen.getByRole('button', { name: 'Next' }));
expect(screen.getByText('P10')).toBeTruthy();
expect(screen.queryByText('P0')).toBeNull();
});
});
describe('DataTable — row selection + bulk actions', () => {
it('selects rows and renders a bulk-action bar', () => {
const onBulk = vi.fn();
render(
<DataTable
columns={COLUMNS}
data={DATA}
getRowId={getRowId}
enableRowSelection
enableFilter={false}
renderBulkActions={(selected, clear) => (
<button
type="button"
onClick={() => {
onBulk(selected.length);
clear();
}}
>
Delete
</button>
)}
/>
);
expect(screen.queryByTestId('bl-bulk-actions')).toBeNull();
fireEvent.click(screen.getByLabelText('Select row 1'));
const bar = screen.getByTestId('bl-bulk-actions');
expect(within(bar).getByText('1 selected')).toBeTruthy();
fireEvent.click(within(bar).getByText('Delete'));
expect(onBulk).toHaveBeenCalledWith(1);
expect(screen.queryByTestId('bl-bulk-actions')).toBeNull(); // cleared
});
it('select-all toggles every row', () => {
render(
<DataTable
columns={COLUMNS}
data={DATA}
getRowId={getRowId}
enableRowSelection
enableFilter={false}
/>
);
fireEvent.click(screen.getByLabelText('Select all rows'));
expect(within(screen.getByTestId('bl-bulk-actions')).getByText('3 selected')).toBeTruthy();
});
});
describe('DataTable — column pinning', () => {
it('pins a column to the left via the header toggle', () => {
render(
<DataTable
columns={COLUMNS}
data={DATA}
getRowId={getRowId}
enableColumnPinning
enableFilter={false}
/>
);
const pinBtn = screen.getByRole('button', { name: 'Pin name left' });
fireEvent.click(pinBtn);
expect(screen.getByRole('button', { name: 'Unpin name' })).toBeTruthy();
});
});
describe('DataTable — virtualised', () => {
it('renders far fewer than 10k DOM rows', () => {
// happy-dom performs no layout, so give the scroll container a measured
// size + a ResizeObserver so TanStack Virtual can compute visible rows.
class RO {
observe() {}
unobserve() {}
disconnect() {}
}
vi.stubGlobal('ResizeObserver', RO);
vi.spyOn(Element.prototype, 'getBoundingClientRect').mockReturnValue({
width: 600,
height: 400,
top: 0,
left: 0,
right: 600,
bottom: 400,
x: 0,
y: 0,
toJSON() {},
} as DOMRect);
const big: Person[] = Array.from({ length: 10000 }, (_, i) => ({
id: String(i),
name: `Row ${i}`,
age: i,
}));
render(
<DataTable
columns={COLUMNS}
data={big}
getRowId={getRowId}
virtualized
enableFilter={false}
maxBodyHeight={400}
rowHeight={40}
/>
);
// Virtualisation engaged: the body reserves a full-height spacer
// (10000 × 40px) instead of laying out every row.
const tbody = document.querySelector('tbody') as HTMLElement;
expect(tbody.style.height).toBe(`${10000 * 40}px`);
// And the DOM holds far fewer than 10k rows.
const bodyRows = document.querySelectorAll('tbody tr[data-index]');
expect(bodyRows.length).toBeLessThan(200);
});
});

View File

@ -0,0 +1,14 @@
/**
* @bytelyst/data-table headless-powered table on TanStack Table v8 +
* TanStack Virtual.
*
* Exports (0.1.0 Wave 9.C):
* <DataTable> sort · filter · paginate · select · resize · pin · reorder ·
* density · virtualise (10k rows)
*
* Re-exports the TanStack column helpers consumers need so they don't have to
* add a direct dependency for the common case.
*/
export { DataTable } from './DataTable.js';
export type { DataTableProps, DataTableDensity } from './types.js';
export { createColumnHelper, type ColumnDef } from '@tanstack/react-table';

View File

@ -0,0 +1,53 @@
import type { ColumnDef } from '@tanstack/react-table';
import type * as React from 'react';
/** Row density — drives vertical padding + font size. */
export type DataTableDensity = 'compact' | 'comfortable';
export interface DataTableProps<T> {
/** TanStack column definitions. Use an `id` on each for reorder/pin to work. */
columns: ColumnDef<T, unknown>[];
/** Row data. */
data: T[];
// ── Behaviour toggles ──
/** Click a header to sort (default true). */
enableSorting?: boolean;
/** Show a global filter input above the table (default true). */
enableFilter?: boolean;
/** Placeholder for the global filter input. */
filterPlaceholder?: string;
/** Paginate the rows (default true). Ignored when `virtualized`. */
enablePagination?: boolean;
/** Rows per page (default 10). */
pageSize?: number;
/** Add a selection checkbox column + expose selection (default false). */
enableRowSelection?: boolean;
/** Render a bulk-action bar when ≥1 row is selected. */
renderBulkActions?: (selectedRows: T[], clearSelection: () => void) => React.ReactNode;
/** Allow drag-resizing of column widths (default false). */
enableColumnResizing?: boolean;
/** Allow drag-reordering of columns by their header (default false). */
enableColumnReorder?: boolean;
/** Show a pin toggle in each header to pin a column to the left (default false). */
enableColumnPinning?: boolean;
// ── Presentation ──
/** Row density (default 'comfortable'). */
density?: DataTableDensity;
/** Stable row id resolver — required for predictable selection. */
getRowId?: (row: T, index: number) => string;
/** Rendered when `data` is empty. */
emptyState?: React.ReactNode;
/** Accessible label for the table element. */
ariaLabel?: string;
className?: string;
// ── Virtualisation (9.C.5/9.C.8) ──
/** Virtualise rows for large datasets. Disables built-in pagination. */
virtualized?: boolean;
/** Fixed row height in px when virtualising (default 40). */
rowHeight?: number;
/** Scroll container height in px when virtualising (default 480). */
maxBodyHeight?: number;
}

View File

@ -0,0 +1,11 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src",
"lib": ["ES2022", "DOM"],
"jsx": "react-jsx"
},
"include": ["src"],
"exclude": ["src/**/*.test.ts", "src/**/*.test.tsx"]
}

View File

@ -0,0 +1,2 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({ test: { environment: 'happy-dom', pool: 'forks' } });

11762
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff