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