Files
stonks-oracle/frontend/src/components/DataTable.tsx
T

152 lines
5.4 KiB
TypeScript

import { useState, useMemo, type ReactNode } from 'react';
import { ChevronUp, ChevronDown, ChevronsUpDown } from 'lucide-react';
export interface Column<T> {
key: string;
header: string;
render?: (row: T) => ReactNode;
sortable?: boolean;
className?: string;
}
interface Props<T> {
data: T[];
columns: Column<T>[];
keyField: keyof T & string;
pageSize?: number;
onRowClick?: (row: T) => void;
filterFn?: (row: T, query: string) => boolean;
emptyMessage?: string;
footerRow?: ReactNode;
}
export function DataTable<T extends object>({
data,
columns,
keyField,
pageSize = 25,
onRowClick,
filterFn,
emptyMessage = 'No data',
footerRow,
}: Props<T>) {
const [sortKey, setSortKey] = useState<string | null>(null);
const [sortDir, setSortDir] = useState<'asc' | 'desc'>('asc');
const [page, setPage] = useState(0);
const [filter, setFilter] = useState('');
const filtered = useMemo(() => {
if (!filter || !filterFn) return data;
return data.filter((row) => filterFn(row, filter));
}, [data, filter, filterFn]);
const sorted = useMemo(() => {
if (!sortKey) return filtered;
return [...filtered].sort((a, b) => {
const av = (a as Record<string, unknown>)[sortKey];
const bv = (b as Record<string, unknown>)[sortKey];
if (av == null && bv == null) return 0;
if (av == null) return 1;
if (bv == null) return -1;
const cmp = av < bv ? -1 : av > bv ? 1 : 0;
return sortDir === 'asc' ? cmp : -cmp;
});
}, [filtered, sortKey, sortDir]);
const totalPages = Math.max(1, Math.ceil(sorted.length / pageSize));
const paged = sorted.slice(page * pageSize, (page + 1) * pageSize);
function toggleSort(key: string) {
if (sortKey === key) {
setSortDir((d) => (d === 'asc' ? 'desc' : 'asc'));
} else {
setSortKey(key);
setSortDir('asc');
}
}
return (
<div>
{filterFn && (
<input
type="text"
placeholder="Filter…"
value={filter}
onChange={(e) => { setFilter(e.target.value); setPage(0); }}
className="mb-3 w-full max-w-xs rounded-md border border-surface-700 bg-surface-900 px-3 py-1.5 text-sm text-gray-200 placeholder-gray-500 focus:border-brand-500 focus:outline-none"
aria-label="Filter table"
/>
)}
<div className="overflow-x-auto rounded-lg border border-surface-700">
<table className="w-full text-sm" role="grid">
<thead>
<tr className="border-b border-surface-700 bg-surface-900">
{columns.map((col) => (
<th
key={col.key}
className={`px-3 py-2 text-left font-medium text-gray-400 ${col.className ?? ''} ${col.sortable !== false ? 'cursor-pointer select-none hover:text-gray-200' : ''}`}
onClick={() => col.sortable !== false && toggleSort(col.key)}
aria-sort={sortKey === col.key ? (sortDir === 'asc' ? 'ascending' : 'descending') : undefined}
>
<span className="inline-flex items-center gap-1">
{col.header}
{col.sortable !== false && (
sortKey === col.key
? (sortDir === 'asc' ? <ChevronUp size={14} /> : <ChevronDown size={14} />)
: <ChevronsUpDown size={14} className="opacity-30" />
)}
</span>
</th>
))}
</tr>
</thead>
<tbody>
{paged.length === 0 ? (
<tr>
<td colSpan={columns.length} className="px-3 py-8 text-center text-gray-500">
{emptyMessage}
</td>
</tr>
) : (
paged.map((row) => (
<tr
key={String((row as Record<string, unknown>)[keyField])}
className={`border-b border-surface-700/50 ${onRowClick ? 'cursor-pointer hover:bg-surface-800/50' : ''}`}
onClick={() => onRowClick?.(row)}
>
{columns.map((col) => (
<td key={col.key} className={`px-3 py-2 text-gray-300 ${col.className ?? ''}`}>
{col.render ? col.render(row) : String((row as Record<string, unknown>)[col.key] ?? '—')}
</td>
))}
</tr>
))
)}
</tbody>
{footerRow && (
<tfoot className="border-t border-surface-700 bg-surface-900">
{footerRow}
</tfoot>
)}
</table>
</div>
{totalPages > 1 && (
<div className="mt-2 flex items-center justify-between text-xs text-gray-500">
<span>{sorted.length} rows</span>
<div className="flex gap-2">
<button onClick={() => setPage((p) => Math.max(0, p - 1))} disabled={page === 0} className="rounded px-2 py-1 hover:bg-surface-800 disabled:opacity-30" aria-label="Previous page">
Prev
</button>
<span className="py-1">{page + 1} / {totalPages}</span>
<button onClick={() => setPage((p) => Math.min(totalPages - 1, p + 1))} disabled={page >= totalPages - 1} className="rounded px-2 py-1 hover:bg-surface-800 disabled:opacity-30" aria-label="Next page">
Next
</button>
</div>
</div>
)}
</div>
);
}