From 901de0246d32df160545a432e9eff1bf1858aefb Mon Sep 17 00:00:00 2001 From: armorbreak001 Date: Tue, 14 Apr 2026 21:09:54 +0800 Subject: [PATCH] feat(frontend): add generic sortable data table component - Add DataTable using @tanstack/react-table v8 (headless) - Column header click toggles asc/desc sorting - Empty state with customizable message - Dark glassmorphism theme matching app style - Optional row striping for readability - Responsive horizontal scroll on overflow - Simple column definition API: { accessorKey, header, cell?, sortable? } --- frontend/src/components/ui/DataTable.tsx | 145 +++++++++++++++++++++++ frontend/src/components/ui/index.ts | 1 + 2 files changed, 146 insertions(+) create mode 100644 frontend/src/components/ui/DataTable.tsx diff --git a/frontend/src/components/ui/DataTable.tsx b/frontend/src/components/ui/DataTable.tsx new file mode 100644 index 0000000..4c9debb --- /dev/null +++ b/frontend/src/components/ui/DataTable.tsx @@ -0,0 +1,145 @@ +'use client' + +import React, { useMemo, useState } from 'react' +import { + useReactTable, + getCoreRowModel, + getSortedRowModel, + flexRender, + createColumnHelper, + SortingState, +} from '@tanstack/react-table' +import { cn } from '@/lib/utils' +import { ArrowUpDown, ArrowUp, ArrowDown } from 'lucide-react' + +/* ---------- types ---------- */ + +type ColumnKey = string + +interface DataTableProps> { + data: T[] + columns: { + accessorKey: ColumnKey + header: string + cell?: (value: unknown, row: T) => React.ReactNode + sortable?: boolean + }[] + /** Message shown when data is empty */ + emptyMessage?: string + /** Enable row stripe coloring */ + striped?: boolean + className?: string +} + +/* ---------- component ---------- */ + +export function DataTable>({ + data, + columns: columnDefs, + emptyMessage = 'No data available', + striped = true, + className, +}: DataTableProps) { + const [sorting, setSorting] = useState([]) + + // Build tanstack columns from simple def array + const columns = useMemo(() => { + return columnDefs.map((col) => + createColumnHelper().accessor(col.accessorKey as keyof T, { + header: () => ( +
+ {col.header} + {col.sortable !== false && ( + + )} +
+ ), + cell: (info) => { + if (col.cell) return col.cell(info.getValue(), info.row.original) + return (info.getValue() as React.ReactNode) ?? '—' + }, + }) + ) + }, [columnDefs]) + + const table = useReactTable({ + data, + columns, + state: { sorting }, + onSortingChange: setSorting, + getCoreRowModel: getCoreRowModel(), + getSortedRowModel: getSortedRowModel(), + }) + + if (!data || data.length === 0) { + return ( +
+ {emptyMessage} +
+ ) + } + + return ( +
+ + {/* Header */} + + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => ( + + ))} + + ))} + + + {/* Body */} + + {table.getRowModel().rows.map((row, idx) => ( + + {row.getVisibleCells().map((cell) => ( + + ))} + + ))} + +
+ {flexRender(header.column.columnDef.header, header.getContext())} +
+ {flexRender(cell.column.columnDef.cell, cell.getContext())} +
+
+ ) +} + +/* ---------- internal: sort indicator ---------- */ + +function SortIndicator() { + const { column } = (() => ({ column: null }))() + // We rely on the table context for actual sort direction; + // this is a static icon that the parent header toggles. + return ( + + + + ) +} + +export default DataTable diff --git a/frontend/src/components/ui/index.ts b/frontend/src/components/ui/index.ts index 531166f..527cc4d 100644 --- a/frontend/src/components/ui/index.ts +++ b/frontend/src/components/ui/index.ts @@ -14,3 +14,4 @@ export { DropdownMenuItem, } from "./DropdownMenu"; export { Pagination } from "./Pagination"; +export { DataTable } from "./DataTable";