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:
2026-06-03 14:24:23 +10:00
parent f4fd1fc04b
commit d915443b8c
45 changed files with 3029 additions and 54 deletions

View 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