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:
154
src/design-system/composites/DataTable/DataTable.module.css
Normal file
154
src/design-system/composites/DataTable/DataTable.module.css
Normal 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;
|
||||
}
|
||||
101
src/design-system/composites/DataTable/DataTable.test.tsx
Normal file
101
src/design-system/composites/DataTable/DataTable.test.tsx
Normal 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(/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(<DataTable columns={columns} data={manyRows} pageSize={10} />)
|
||||
expect(screen.getByText(/1–10 of 30/)).toBeInTheDocument()
|
||||
expect(screen.queryByText('Row 10')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
203
src/design-system/composites/DataTable/DataTable.tsx
Normal file
203
src/design-system/composites/DataTable/DataTable.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
21
src/design-system/composites/DataTable/types.ts
Normal file
21
src/design-system/composites/DataTable/types.ts
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user