import { describe, expect, it, vi } from 'vitest'; import { render, screen, within } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { DataTable, type DataTableColumn } from './data-table'; type Row = { id: number; name: string; seats: number }; const rows: Row[] = [ { id: 1, name: 'Charlie', seats: 2 }, { id: 2, name: 'alice', seats: 10 }, { id: 3, name: 'Bob', seats: 9 }, ]; const columns: DataTableColumn[] = [ { key: 'name', title: 'Name', dataIndex: 'name', sortable: true }, { key: 'seats', title: 'Seats', dataIndex: 'seats', sortable: true }, ]; const bodyNames = () => screen .getAllByRole('row') .slice(1) // drop the header row .map((row) => within(row).getAllByRole('cell')[0].textContent); describe('DataTable', () => { it('renders a row per record', () => { render(); expect(screen.getAllByRole('row')).toHaveLength(rows.length + 1); }); it('shows the empty text when there is no data', () => { render(); expect(screen.getByText('Nothing here')).toBeInTheDocument(); }); it('sorts case-insensitively on the first header click', async () => { render(); await userEvent.click(screen.getByRole('button', { name: /name/i })); expect(bodyNames()).toEqual(['alice', 'Bob', 'Charlie']); }); it('cycles ascending → descending → unsorted', async () => { render(); const header = screen.getByRole('button', { name: /name/i }); await userEvent.click(header); expect(bodyNames()).toEqual(['alice', 'Bob', 'Charlie']); await userEvent.click(header); expect(bodyNames()).toEqual(['Charlie', 'Bob', 'alice']); await userEvent.click(header); expect(bodyNames()).toEqual(['Charlie', 'alice', 'Bob']); }); it('compares numbers numerically, not as strings', async () => { render(); await userEvent.click(screen.getByRole('button', { name: /seats/i })); // A string sort would put "10" before "2". expect(bodyNames()).toEqual(['Charlie', 'Bob', 'alice']); }); it('reports sort intent without reordering when controlled', async () => { const onSortChange = vi.fn(); render( ); await userEvent.click(screen.getByRole('button', { name: /name/i })); expect(onSortChange).toHaveBeenCalledWith({ key: 'name', direction: 'asc' }); expect(bodyNames()).toEqual(['Charlie', 'alice', 'Bob']); }); it('uses a custom sorter when one is given', async () => { const byLength: DataTableColumn[] = [ { key: 'name', title: 'Name', dataIndex: 'name', sortable: true, sorter: (a, b) => a.name.length - b.name.length, }, ]; render(); await userEvent.click(screen.getByRole('button', { name: /name/i })); expect(bodyNames()).toEqual(['Bob', 'alice', 'Charlie']); }); it('marks the sorted header for assistive technology', async () => { render(); await userEvent.click(screen.getByRole('button', { name: /name/i })); expect(screen.getByRole('columnheader', { name: /name/i })).toHaveAttribute( 'aria-sort', 'ascending' ); }); it('leaves an unsortable column without a control', () => { render( ); expect(screen.queryByRole('button')).not.toBeInTheDocument(); expect(screen.getByRole('columnheader')).not.toHaveAttribute('aria-sort'); }); describe('cell contents', () => { it('reads a nested dataIndex path', () => { type Nested = { id: number; team: { name: string } }; render( ); expect(screen.getByText('Red')).toBeInTheDocument(); }); it('renders nothing rather than throwing when the path is missing', () => { render( ); expect(screen.getAllByRole('row')).toHaveLength(rows.length + 1); }); it('uses a custom render', () => { render( `→ ${value}` }, ]} data={rows} /> ); expect(screen.getByText('→ Charlie')).toBeInTheDocument(); }); }); describe('expandable rows', () => { const expandable = { expandedRowRender: (record: Row) =>
Detail for {record.name}
, }; it('adds a leading toggle column', () => { render(); expect(screen.getAllByRole('columnheader')).toHaveLength(columns.length + 1); }); it('reveals the detail row on click', async () => { render(); expect(screen.queryByText('Detail for Charlie')).not.toBeInTheDocument(); await userEvent.click(screen.getAllByRole('button', { name: 'Expand row' })[0]); expect(screen.getByText('Detail for Charlie')).toBeInTheDocument(); }); it('reports the toggle state to assistive technology', async () => { render(); const toggle = screen.getAllByRole('button', { name: 'Expand row' })[0]; expect(toggle).toHaveAttribute('aria-expanded', 'false'); await userEvent.click(toggle); expect(screen.getAllByRole('button', { name: 'Collapse row' })[0]).toHaveAttribute( 'aria-expanded', 'true' ); }); it('opens everything with defaultExpandAllRows', () => { render( ); expect(screen.getByText('Detail for Charlie')).toBeInTheDocument(); expect(screen.getByText('Detail for alice')).toBeInTheDocument(); }); it('omits the toggle for a row that cannot expand', () => { render( record.id !== 3 }} /> ); expect(screen.getAllByRole('button', { name: 'Expand row' })).toHaveLength(2); }); it('reports onExpand', async () => { const onExpand = vi.fn(); render( ); await userEvent.click(screen.getAllByRole('button', { name: 'Expand row' })[0]); expect(onExpand).toHaveBeenCalledWith(true, rows[0]); }); it('leaves the open set to the parent when controlled', async () => { const onExpand = vi.fn(); render( ); expect(screen.getByText('Detail for alice')).toBeInTheDocument(); await userEvent.click(screen.getAllByRole('button', { name: 'Expand row' })[0]); expect(onExpand).toHaveBeenCalledWith(true, rows[0]); expect(screen.queryByText('Detail for Charlie')).not.toBeInTheDocument(); }); }); describe('layout', () => { it('marks pinned headers so their offsets can be measured', () => { render( ); expect(screen.getAllByRole('columnheader')[0]).toHaveAttribute('data-fixed', 'left'); expect(screen.getAllByRole('columnheader')[1]).not.toHaveAttribute('data-fixed'); }); it('sticks the header once a vertical scroll is set', () => { render(); expect(screen.getAllByRole('columnheader')[0]).toHaveClass('sticky'); }); it('sets column widths through the colgroup', () => { const { container } = render( ); expect(container.querySelector('col')).toHaveStyle({ width: '200px' }); }); it('aligns a column', () => { render( ); expect(screen.getAllByRole('columnheader')[0]).toHaveClass('text-right'); }); }); describe('rows', () => { it('applies a per-row class', () => { render( (record.seats > 5 ? 'is-busy' : '')} /> ); expect(screen.getAllByRole('row')[2]).toHaveClass('is-busy'); }); it('spreads onRow props, handlers included', async () => { const onClick = vi.fn(); render( ({ onClick })} />); await userEvent.click(screen.getAllByRole('row')[1]); expect(onClick).toHaveBeenCalledTimes(1); }); it('accepts a function rowKey', () => { const rowKey = vi.fn((record: Row) => `row-${record.id}`); render(); expect(rowKey).toHaveBeenCalled(); expect(screen.getAllByRole('row')).toHaveLength(rows.length + 1); }); }); });