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:
parent
6cd60e86e8
commit
a55ea16370
41
packages/data-table/package.json
Normal file
41
packages/data-table/package.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
366
packages/data-table/src/DataTable.tsx
Normal file
366
packages/data-table/src/DataTable.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
222
packages/data-table/src/__tests__/data-table.test.tsx
Normal file
222
packages/data-table/src/__tests__/data-table.test.tsx
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
14
packages/data-table/src/index.ts
Normal file
14
packages/data-table/src/index.ts
Normal 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';
|
||||||
53
packages/data-table/src/types.ts
Normal file
53
packages/data-table/src/types.ts
Normal 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;
|
||||||
|
}
|
||||||
11
packages/data-table/tsconfig.json
Normal file
11
packages/data-table/tsconfig.json
Normal 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"]
|
||||||
|
}
|
||||||
2
packages/data-table/vitest.config.ts
Normal file
2
packages/data-table/vitest.config.ts
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
import { defineConfig } from 'vitest/config';
|
||||||
|
export default defineConfig({ test: { environment: 'happy-dom', pool: 'forks' } });
|
||||||
11762
pnpm-lock.yaml
generated
11762
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
Loading…
Reference in New Issue
Block a user