Align design system with ADS 3.0 and add new components
Token foundation: fix 16 palette colours to match official ADS_COLORS, add 5 new palettes (teal, brown, purple, fuchsia, yellow), realign semantic tokens (primary=navy, info=bright blue), fix border radii to 8px base, add responsive heading typography. Component migration: swap primary/info references across all existing components, update Button (44px/semibold), Switch (green/compact), Chip (30px/8px radius + colour variants), SideNav (80px rail), Tag (11 colours). New components: SideNav, TopBar, Avatar, Tabs, PageHeader, Slider, RangeSlider, FileInput, DataTable, List, Autocomplete. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
282
src/components/molecules/DataTable/DataTable.tsx
Normal file
282
src/components/molecules/DataTable/DataTable.tsx
Normal file
@@ -0,0 +1,282 @@
|
||||
import {
|
||||
forwardRef,
|
||||
useCallback,
|
||||
useMemo,
|
||||
useState,
|
||||
type HTMLAttributes,
|
||||
type ReactNode,
|
||||
} from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
// --- Types ---
|
||||
|
||||
export interface DataTableColumn<T = Record<string, unknown>> {
|
||||
key: string
|
||||
header: string
|
||||
sortable?: boolean
|
||||
align?: 'left' | 'center' | 'right'
|
||||
render?: (value: unknown, row: T, index: number) => ReactNode
|
||||
}
|
||||
|
||||
export interface DataTableProps<T = Record<string, unknown>> extends Omit<HTMLAttributes<HTMLDivElement>, 'children'> {
|
||||
columns: DataTableColumn<T>[]
|
||||
data: T[]
|
||||
selectable?: boolean
|
||||
pagination?: boolean
|
||||
pageSize?: number
|
||||
pageSizeOptions?: number[]
|
||||
loading?: boolean
|
||||
emptyMessage?: string
|
||||
onSelectionChange?: (selected: T[]) => void
|
||||
}
|
||||
|
||||
type SortState = { key: string; dir: 'asc' | 'desc' } | null
|
||||
|
||||
const ChevronUpIcon = () => (
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" className="size-4">
|
||||
<path d="m18 15-6-6-6 6" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
const ChevronDownIcon = () => (
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" className="size-4">
|
||||
<path d="m6 9 6 6 6-6" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
// --- DataTable ---
|
||||
|
||||
function DataTableInner<T extends Record<string, unknown>>(
|
||||
{
|
||||
columns,
|
||||
data,
|
||||
selectable = false,
|
||||
pagination = true,
|
||||
pageSize: initialPageSize = 5,
|
||||
pageSizeOptions = [5, 10, 25],
|
||||
loading = false,
|
||||
emptyMessage = 'No data available',
|
||||
onSelectionChange,
|
||||
className,
|
||||
...props
|
||||
}: DataTableProps<T>,
|
||||
ref: React.ForwardedRef<HTMLDivElement>,
|
||||
) {
|
||||
const [sort, setSort] = useState<SortState>(null)
|
||||
const [page, setPage] = useState(0)
|
||||
const [pageSize, setPageSize] = useState(initialPageSize)
|
||||
const [selected, setSelected] = useState<Set<number>>(new Set())
|
||||
|
||||
const sortedData = useMemo(() => {
|
||||
if (!sort) return data
|
||||
const { key, dir } = sort
|
||||
return [...data].sort((a, b) => {
|
||||
const va = a[key]
|
||||
const vb = b[key]
|
||||
if (va == null && vb == null) return 0
|
||||
if (va == null) return 1
|
||||
if (vb == null) return -1
|
||||
const cmp = String(va).localeCompare(String(vb), undefined, { numeric: true })
|
||||
return dir === 'asc' ? cmp : -cmp
|
||||
})
|
||||
}, [data, sort])
|
||||
|
||||
const pageCount = pagination ? Math.max(1, Math.ceil(sortedData.length / pageSize)) : 1
|
||||
const pageData = pagination ? sortedData.slice(page * pageSize, (page + 1) * pageSize) : sortedData
|
||||
const rangeStart = page * pageSize + 1
|
||||
const rangeEnd = Math.min((page + 1) * pageSize, sortedData.length)
|
||||
|
||||
const toggleSort = useCallback((key: string) => {
|
||||
setSort((prev) => {
|
||||
if (prev?.key === key) {
|
||||
return prev.dir === 'asc' ? { key, dir: 'desc' } : null
|
||||
}
|
||||
return { key, dir: 'asc' }
|
||||
})
|
||||
}, [])
|
||||
|
||||
const toggleRow = useCallback(
|
||||
(index: number) => {
|
||||
setSelected((prev) => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(index)) next.delete(index)
|
||||
else next.add(index)
|
||||
onSelectionChange?.(
|
||||
[...next].map((i) => sortedData[i]).filter(Boolean),
|
||||
)
|
||||
return next
|
||||
})
|
||||
},
|
||||
[sortedData, onSelectionChange],
|
||||
)
|
||||
|
||||
const toggleAll = useCallback(() => {
|
||||
setSelected((prev) => {
|
||||
if (prev.size === sortedData.length) {
|
||||
onSelectionChange?.([])
|
||||
return new Set()
|
||||
}
|
||||
const all = new Set(sortedData.map((_, i) => i))
|
||||
onSelectionChange?.([...sortedData])
|
||||
return all
|
||||
})
|
||||
}, [sortedData, onSelectionChange])
|
||||
|
||||
return (
|
||||
<div ref={ref} className={cn('overflow-hidden rounded-default bg-surface', className)} {...props}>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-left text-body">
|
||||
<thead>
|
||||
<tr className="border-b border-border">
|
||||
{selectable && (
|
||||
<th className="w-12 px-4 py-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selected.size === sortedData.length && sortedData.length > 0}
|
||||
onChange={toggleAll}
|
||||
className="accent-primary"
|
||||
/>
|
||||
</th>
|
||||
)}
|
||||
{columns.map((col) => (
|
||||
<th
|
||||
key={col.key}
|
||||
className={cn(
|
||||
'px-4 py-3 font-normal text-primary',
|
||||
col.sortable && 'cursor-pointer select-none hover:bg-text/[0.04]',
|
||||
col.align === 'right' && 'text-right',
|
||||
col.align === 'center' && 'text-center',
|
||||
)}
|
||||
onClick={col.sortable ? () => toggleSort(col.key) : undefined}
|
||||
>
|
||||
<span className="inline-flex items-center gap-1">
|
||||
{col.header}
|
||||
{col.sortable && sort?.key === col.key && (
|
||||
sort.dir === 'asc' ? <ChevronUpIcon /> : <ChevronDownIcon />
|
||||
)}
|
||||
</span>
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{loading ? (
|
||||
<tr>
|
||||
<td colSpan={columns.length + (selectable ? 1 : 0)} className="px-4 py-8 text-center text-text-secondary">
|
||||
Loading…
|
||||
</td>
|
||||
</tr>
|
||||
) : pageData.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={columns.length + (selectable ? 1 : 0)} className="px-4 py-8 text-center text-text-secondary">
|
||||
{emptyMessage}
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
pageData.map((row, rowIdx) => {
|
||||
const globalIdx = page * pageSize + rowIdx
|
||||
return (
|
||||
<tr
|
||||
key={globalIdx}
|
||||
className={cn(
|
||||
'border-b border-border last:border-b-0 transition-colors',
|
||||
selected.has(globalIdx) ? 'bg-info/5' : 'hover:bg-text/[0.02]',
|
||||
)}
|
||||
>
|
||||
{selectable && (
|
||||
<td className="w-12 px-4 py-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selected.has(globalIdx)}
|
||||
onChange={() => toggleRow(globalIdx)}
|
||||
className="accent-primary"
|
||||
/>
|
||||
</td>
|
||||
)}
|
||||
{columns.map((col) => (
|
||||
<td
|
||||
key={col.key}
|
||||
className={cn(
|
||||
'px-4 py-3',
|
||||
col.align === 'right' && 'text-right',
|
||||
col.align === 'center' && 'text-center',
|
||||
)}
|
||||
>
|
||||
{col.render
|
||||
? col.render(row[col.key], row, globalIdx)
|
||||
: String(row[col.key] ?? '')}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
)
|
||||
})
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{pagination && sortedData.length > 0 && (
|
||||
<div className="flex items-center justify-end gap-4 border-t border-border px-4 py-2 text-small text-text-secondary">
|
||||
<label className="flex items-center gap-2">
|
||||
Rows per page:
|
||||
<select
|
||||
value={pageSize}
|
||||
onChange={(e) => {
|
||||
setPageSize(Number(e.target.value))
|
||||
setPage(0)
|
||||
}}
|
||||
className="rounded-sm border border-border bg-surface px-2 py-1 text-small text-text"
|
||||
>
|
||||
{pageSizeOptions.map((opt) => (
|
||||
<option key={opt} value={opt}>{opt}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<span>{rangeStart}-{rangeEnd} of {sortedData.length}</span>
|
||||
<div className="flex gap-1">
|
||||
<button
|
||||
type="button"
|
||||
disabled={page === 0}
|
||||
onClick={() => setPage(0)}
|
||||
className="rounded-sm p-1 hover:bg-text/[0.04] disabled:opacity-40"
|
||||
aria-label="First page"
|
||||
>
|
||||
<svg viewBox="0 0 24 24" fill="currentColor" className="size-5"><path d="M18.41 16.59L13.82 12l4.59-4.59L17 6l-6 6 6 6zM6 6h2v12H6z" /></svg>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={page === 0}
|
||||
onClick={() => setPage((p) => p - 1)}
|
||||
className="rounded-sm p-1 hover:bg-text/[0.04] disabled:opacity-40"
|
||||
aria-label="Previous page"
|
||||
>
|
||||
<svg viewBox="0 0 24 24" fill="currentColor" className="size-5"><path d="M15.41 7.41L14 6l-6 6 6 6 1.41-1.41L10.83 12z" /></svg>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={page >= pageCount - 1}
|
||||
onClick={() => setPage((p) => p + 1)}
|
||||
className="rounded-sm p-1 hover:bg-text/[0.04] disabled:opacity-40"
|
||||
aria-label="Next page"
|
||||
>
|
||||
<svg viewBox="0 0 24 24" fill="currentColor" className="size-5"><path d="M10 6L8.59 7.41 13.17 12l-4.58 4.59L10 18l6-6z" /></svg>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={page >= pageCount - 1}
|
||||
onClick={() => setPage(pageCount - 1)}
|
||||
className="rounded-sm p-1 hover:bg-text/[0.04] disabled:opacity-40"
|
||||
aria-label="Last page"
|
||||
>
|
||||
<svg viewBox="0 0 24 24" fill="currentColor" className="size-5"><path d="M5.59 7.41L10.18 12l-4.59 4.59L7 18l6-6-6-6zM16 6h2v12h-2z" /></svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const DataTable = forwardRef(DataTableInner) as <T extends Record<string, unknown>>(
|
||||
props: DataTableProps<T> & { ref?: React.Ref<HTMLDivElement> },
|
||||
) => React.ReactElement | null
|
||||
Reference in New Issue
Block a user