feat: DataTable composite with sorting, pagination, row selection

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
hsiegeln
2026-03-18 09:57:58 +01:00
parent 8daf21428c
commit 6e90ca2ac8
4 changed files with 479 additions and 0 deletions

View File

@@ -0,0 +1,154 @@
.wrapper {
background: var(--bg-surface);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-card);
overflow: hidden;
}
.scroll {
overflow-x: auto;
}
.table {
width: 100%;
border-collapse: collapse;
}
/* Header */
.th {
padding: 9px 14px;
text-align: left;
font-size: 10px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.8px;
color: var(--text-muted);
white-space: nowrap;
user-select: none;
background: var(--bg-raised);
border-bottom: 1px solid var(--border);
transition: color 0.12s;
}
.th.sortable {
cursor: pointer;
}
.th.sortable:hover {
color: var(--text-secondary);
}
.th.sorted {
color: var(--amber);
}
.sortArrow {
font-size: 8px;
margin-left: 4px;
opacity: 0.4;
}
.sorted .sortArrow {
opacity: 1;
}
/* Rows */
.tr {
border-bottom: 1px solid var(--border-subtle);
border-left: 3px solid transparent;
cursor: pointer;
transition: background 0.08s;
height: 40px;
}
.tr:last-child {
border-bottom: none;
}
.tr:hover {
background: var(--bg-hover);
}
.tr.selected {
background: var(--amber-bg);
}
.accentError {
border-left-color: var(--error) !important;
}
.accentWarning {
border-left-color: var(--warning) !important;
}
/* Cells */
.td {
padding: 9px 14px;
font-size: 13px;
vertical-align: middle;
white-space: nowrap;
}
/* Expanded row */
.expandedRow {
border-bottom: 1px solid var(--border-subtle);
border-left: 3px solid transparent;
}
.expandedCell {
padding: 0 14px 10px 20px !important;
}
/* Empty state */
.empty {
padding: 32px 14px;
text-align: center;
color: var(--text-muted);
font-size: 13px;
}
/* Footer / Pagination */
.footer {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 16px;
border-top: 1px solid var(--border-subtle);
font-size: 12px;
color: var(--text-muted);
}
.rangeInfo {
font-family: var(--font-mono);
font-size: 11px;
}
.paginationRight {
display: flex;
align-items: center;
gap: 8px;
}
.pageSizeLabel {
font-size: 11px;
color: var(--text-muted);
}
.pageSizeSelect {
width: 70px;
}
.pagination {
display: flex;
align-items: center;
gap: 4px;
}
.pageNum {
font-family: var(--font-mono);
font-size: 11px;
color: var(--text-muted);
min-width: 40px;
text-align: center;
}

View File

@@ -0,0 +1,101 @@
import { describe, it, expect, vi } from 'vitest'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { DataTable } from './DataTable'
const columns = [
{ key: 'name', header: 'Name' },
{ key: 'status', header: 'Status' },
]
const data = [
{ id: '1', name: 'Route A', status: 'ok' },
{ id: '2', name: 'Route B', status: 'error' },
{ id: '3', name: 'Route C', status: 'ok' },
]
describe('DataTable', () => {
it('renders column headers', () => {
render(<DataTable columns={columns} data={data} />)
expect(screen.getByText('Name')).toBeInTheDocument()
expect(screen.getByText('Status')).toBeInTheDocument()
})
it('renders all data rows', () => {
render(<DataTable columns={columns} data={data} />)
expect(screen.getByText('Route A')).toBeInTheDocument()
expect(screen.getByText('Route C')).toBeInTheDocument()
})
it('calls onRowClick when a row is clicked', async () => {
const onRowClick = vi.fn()
const user = userEvent.setup()
render(<DataTable columns={columns} data={data} onRowClick={onRowClick} />)
await user.click(screen.getByText('Route B'))
expect(onRowClick).toHaveBeenCalledWith(data[1])
})
it('highlights selected row', () => {
const { container } = render(
<DataTable columns={columns} data={data} selectedId="2" />,
)
const rows = container.querySelectorAll('tbody tr')
expect(rows[1]).toHaveClass('selected')
})
it('sorts when header clicked', async () => {
const user = userEvent.setup()
render(
<DataTable
columns={[{ key: 'name', header: 'Name', sortable: true }, ...columns.slice(1)]}
data={data}
sortable
/>,
)
await user.click(screen.getByText('Name'))
expect(screen.getByText('Name').closest('th')).toHaveAttribute('aria-sort')
})
it('applies error accent class when rowAccent returns error', () => {
const { container } = render(
<DataTable
columns={columns}
data={data}
rowAccent={(row) => (row.status === 'error' ? 'error' : undefined)}
/>,
)
const rows = container.querySelectorAll('tbody tr')
expect(rows[1]).toHaveClass('accentError')
})
it('shows expanded content when row is clicked with expandedContent', async () => {
const user = userEvent.setup()
render(
<DataTable
columns={columns}
data={data}
expandedContent={(row) =>
row.status === 'error' ? <div>Error details</div> : null
}
/>,
)
await user.click(screen.getByText('Route B'))
expect(screen.getByText('Error details')).toBeInTheDocument()
})
it('shows pagination info', () => {
render(<DataTable columns={columns} data={data} pageSize={25} />)
expect(screen.getByText(/13 of 3/)).toBeInTheDocument()
})
it('paginates data when pageSize is smaller than data length', () => {
const manyRows = Array.from({ length: 30 }, (_, i) => ({
id: String(i),
name: `Row ${i}`,
status: 'ok',
}))
render(<DataTable columns={columns} data={manyRows} pageSize={10} />)
expect(screen.getByText(/110 of 30/)).toBeInTheDocument()
expect(screen.queryByText('Row 10')).not.toBeInTheDocument()
})
})

View File

@@ -0,0 +1,203 @@
import { useState, useMemo, Fragment } from 'react'
import styles from './DataTable.module.css'
import { Select } from '../../primitives/Select/Select'
import { Button } from '../../primitives/Button/Button'
import type { DataTableProps, Column } from './types'
type SortDir = 'asc' | 'desc'
function compareValues(a: unknown, b: unknown, dir: SortDir): number {
const av = a == null ? '' : String(a)
const bv = b == null ? '' : String(b)
const cmp = av.localeCompare(bv, undefined, { numeric: true })
return dir === 'asc' ? cmp : -cmp
}
export function DataTable<T extends { id: string }>({
columns,
data,
onRowClick,
selectedId,
sortable = false,
pageSize: initialPageSize = 25,
pageSizeOptions = [10, 25, 50, 100],
rowAccent,
expandedContent,
}: DataTableProps<T>) {
const [sortKey, setSortKey] = useState<string | null>(null)
const [sortDir, setSortDir] = useState<SortDir>('asc')
const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(initialPageSize)
const [expandedId, setExpandedId] = useState<string | null>(null)
const sorted = useMemo(() => {
if (!sortKey) return data
return [...data].sort((a, b) => {
const av = (a as Record<string, unknown>)[sortKey]
const bv = (b as Record<string, unknown>)[sortKey]
return compareValues(av, bv, sortDir)
})
}, [data, sortKey, sortDir])
const totalRows = sorted.length
const totalPages = Math.max(1, Math.ceil(totalRows / pageSize))
const safePage = Math.min(page, totalPages)
const start = (safePage - 1) * pageSize
const end = Math.min(start + pageSize, totalRows)
const pageRows = sorted.slice(start, end)
const rangeStart = totalRows === 0 ? 0 : start + 1
const rangeInfo = `${rangeStart.toLocaleString()}${end.toLocaleString()} of ${totalRows.toLocaleString()}`
function handleHeaderClick(col: Column<T>) {
if (!sortable && !col.sortable) return
if (sortKey === col.key) {
setSortDir((d) => (d === 'asc' ? 'desc' : 'asc'))
} else {
setSortKey(col.key)
setSortDir('asc')
}
setPage(1)
}
function handleRowClick(row: T) {
if (expandedContent) {
setExpandedId((prev) => (prev === row.id ? null : row.id))
}
onRowClick?.(row)
}
const pageSizeSelectOptions = pageSizeOptions.map((n) => ({
value: String(n),
label: String(n),
}))
return (
<div className={styles.wrapper}>
<div className={styles.scroll}>
<table className={styles.table}>
<thead>
<tr>
{columns.map((col) => {
const isSorted = sortKey === col.key
const canSort = sortable || col.sortable
return (
<th
key={col.key}
className={[
styles.th,
isSorted ? styles.sorted : '',
canSort ? styles.sortable : '',
]
.filter(Boolean)
.join(' ')}
style={col.width ? { width: col.width } : undefined}
onClick={() => handleHeaderClick(col)}
aria-sort={
isSorted ? (sortDir === 'asc' ? 'ascending' : 'descending') : undefined
}
>
{col.header}
{canSort && (
<span className={styles.sortArrow}>
{isSorted ? (sortDir === 'asc' ? '↑' : '↓') : '↕'}
</span>
)}
</th>
)
})}
</tr>
</thead>
<tbody>
{pageRows.map((row) => {
const accent = rowAccent?.(row)
const isSelected = selectedId === row.id
const isExpanded = expandedId === row.id
const expanded = expandedContent ? expandedContent(row) : null
return (
<Fragment key={row.id}>
<tr
className={[
styles.tr,
isSelected ? styles.selected : '',
accent === 'error' ? styles.accentError : '',
accent === 'warning' ? styles.accentWarning : '',
]
.filter(Boolean)
.join(' ')}
onClick={() => handleRowClick(row)}
>
{columns.map((col) => {
const raw = (row as Record<string, unknown>)[col.key]
return (
<td key={col.key} className={styles.td}>
{col.render
? col.render(raw, row)
: (raw as string | number | boolean | null | undefined)}
</td>
)
})}
</tr>
{isExpanded && expanded != null && (
<tr className={styles.expandedRow}>
<td colSpan={columns.length} className={styles.expandedCell}>
{expanded}
</td>
</tr>
)}
</Fragment>
)
})}
{pageRows.length === 0 && (
<tr>
<td colSpan={columns.length} className={styles.empty}>
No data
</td>
</tr>
)}
</tbody>
</table>
</div>
<div className={styles.footer}>
<span className={styles.rangeInfo}>{rangeInfo}</span>
<div className={styles.paginationRight}>
<span className={styles.pageSizeLabel}>Rows:</span>
<Select
options={pageSizeSelectOptions}
value={String(pageSize)}
onChange={(e) => {
setPageSize(Number(e.target.value))
setPage(1)
}}
className={styles.pageSizeSelect}
/>
<div className={styles.pagination}>
<Button
size="sm"
variant="secondary"
disabled={safePage <= 1}
onClick={() => setPage((p) => p - 1)}
aria-label="Previous page"
>
</Button>
<span className={styles.pageNum}>
{safePage} / {totalPages}
</span>
<Button
size="sm"
variant="secondary"
disabled={safePage >= totalPages}
onClick={() => setPage((p) => p + 1)}
aria-label="Next page"
>
</Button>
</div>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,21 @@
import type { ReactNode } from 'react'
export interface Column<T = unknown> {
key: string
header: string
width?: string
sortable?: boolean
render?: (value: unknown, row: T) => ReactNode
}
export interface DataTableProps<T extends { id: string }> {
columns: Column<T>[]
data: T[]
onRowClick?: (row: T) => void
selectedId?: string
sortable?: boolean
pageSize?: number
pageSizeOptions?: number[]
rowAccent?: (row: T) => 'error' | 'warning' | undefined
expandedContent?: (row: T) => ReactNode | null
}