From 6e90ca2ac8ae88d7d441b3a6af716bf3360f8234 Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Wed, 18 Mar 2026 09:57:58 +0100 Subject: [PATCH] feat: DataTable composite with sorting, pagination, row selection Co-Authored-By: Claude Opus 4.6 (1M context) --- .../composites/DataTable/DataTable.module.css | 154 +++++++++++++ .../composites/DataTable/DataTable.test.tsx | 101 +++++++++ .../composites/DataTable/DataTable.tsx | 203 ++++++++++++++++++ .../composites/DataTable/types.ts | 21 ++ 4 files changed, 479 insertions(+) create mode 100644 src/design-system/composites/DataTable/DataTable.module.css create mode 100644 src/design-system/composites/DataTable/DataTable.test.tsx create mode 100644 src/design-system/composites/DataTable/DataTable.tsx create mode 100644 src/design-system/composites/DataTable/types.ts diff --git a/src/design-system/composites/DataTable/DataTable.module.css b/src/design-system/composites/DataTable/DataTable.module.css new file mode 100644 index 0000000..d9ecae3 --- /dev/null +++ b/src/design-system/composites/DataTable/DataTable.module.css @@ -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; +} diff --git a/src/design-system/composites/DataTable/DataTable.test.tsx b/src/design-system/composites/DataTable/DataTable.test.tsx new file mode 100644 index 0000000..08bafb3 --- /dev/null +++ b/src/design-system/composites/DataTable/DataTable.test.tsx @@ -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() + expect(screen.getByText('Name')).toBeInTheDocument() + expect(screen.getByText('Status')).toBeInTheDocument() + }) + + it('renders all data rows', () => { + render() + 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() + await user.click(screen.getByText('Route B')) + expect(onRowClick).toHaveBeenCalledWith(data[1]) + }) + + it('highlights selected row', () => { + const { container } = render( + , + ) + const rows = container.querySelectorAll('tbody tr') + expect(rows[1]).toHaveClass('selected') + }) + + it('sorts when header clicked', async () => { + const user = userEvent.setup() + render( + , + ) + 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( + (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( + + row.status === 'error' ?
Error details
: null + } + />, + ) + await user.click(screen.getByText('Route B')) + expect(screen.getByText('Error details')).toBeInTheDocument() + }) + + it('shows pagination info', () => { + render() + expect(screen.getByText(/1–3 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() + expect(screen.getByText(/1–10 of 30/)).toBeInTheDocument() + expect(screen.queryByText('Row 10')).not.toBeInTheDocument() + }) +}) diff --git a/src/design-system/composites/DataTable/DataTable.tsx b/src/design-system/composites/DataTable/DataTable.tsx new file mode 100644 index 0000000..fcc2c22 --- /dev/null +++ b/src/design-system/composites/DataTable/DataTable.tsx @@ -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({ + columns, + data, + onRowClick, + selectedId, + sortable = false, + pageSize: initialPageSize = 25, + pageSizeOptions = [10, 25, 50, 100], + rowAccent, + expandedContent, +}: DataTableProps) { + const [sortKey, setSortKey] = useState(null) + const [sortDir, setSortDir] = useState('asc') + const [page, setPage] = useState(1) + const [pageSize, setPageSize] = useState(initialPageSize) + const [expandedId, setExpandedId] = useState(null) + + const sorted = useMemo(() => { + if (!sortKey) return data + return [...data].sort((a, b) => { + const av = (a as Record)[sortKey] + const bv = (b as Record)[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) { + 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 ( +
+
+ + + + {columns.map((col) => { + const isSorted = sortKey === col.key + const canSort = sortable || col.sortable + return ( + + ) + })} + + + + {pageRows.map((row) => { + const accent = rowAccent?.(row) + const isSelected = selectedId === row.id + const isExpanded = expandedId === row.id + const expanded = expandedContent ? expandedContent(row) : null + + return ( + + handleRowClick(row)} + > + {columns.map((col) => { + const raw = (row as Record)[col.key] + return ( + + ) + })} + + {isExpanded && expanded != null && ( + + + + )} + + ) + })} + {pageRows.length === 0 && ( + + + + )} + +
handleHeaderClick(col)} + aria-sort={ + isSorted ? (sortDir === 'asc' ? 'ascending' : 'descending') : undefined + } + > + {col.header} + {canSort && ( + + {isSorted ? (sortDir === 'asc' ? '↑' : '↓') : '↕'} + + )} +
+ {col.render + ? col.render(raw, row) + : (raw as string | number | boolean | null | undefined)} +
+ {expanded} +
+ No data +
+
+ +
+ {rangeInfo} +
+ Rows: +