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