spb/market-atlas
Public
TypeScript 96.7%
SQL 1.6%
CSS 0.8%
JavaScript 0.5%
1"use client";23import { ChevronDown, ChevronUp } from "lucide-react";4import { useMemo, useState, type ReactNode } from "react";5import { cx } from "@/lib/format";67export interface Column<T> {8 key: string;9 header: ReactNode;10 cell: (row: T) => ReactNode;11 /** Sort accessor; omit for unsortable columns. */12 sort?: (row: T) => number | string | null | undefined;13 num?: boolean;14 className?: string;15 hideBelow?: "sm" | "md" | "lg";16}1718const HIDE: Record<string, string> = { sm: "hidden sm:table-cell", md: "hidden md:table-cell", lg: "hidden lg:table-cell" };1920/** Dense, client-sortable table with sticky header. Rows are rendered by the caller's cells (which may be live components). */21export function DataTable<T>({ rows, columns, rowKey, defaultSort, defaultDir = "desc", className, maxHeight, emptyText = "Nothing to show yet.", onRowHref }: { rows: T[]; columns: Column<T>[]; rowKey: (row: T) => string; defaultSort?: string; defaultDir?: "asc" | "desc"; className?: string; maxHeight?: string; emptyText?: string; onRowHref?: (row: T) => string }) {22 const [sortKey, setSortKey] = useState<string | undefined>(defaultSort);23 const [dir, setDir] = useState<"asc" | "desc">(defaultDir);24 const sorted = useMemo(() => {25 const col = columns.find((c) => c.key === sortKey);26 if (!col?.sort) return rows;27 const acc = col.sort;28 return [...rows].sort((a, b) => {29 const va = acc(a);30 const vb = acc(b);31 if (va == null && vb == null) return 0;32 if (va == null) return 1;33 if (vb == null) return -1;34 const c = typeof va === "number" && typeof vb === "number" ? va - vb : String(va).localeCompare(String(vb));35 return dir === "asc" ? c : -c;36 });37 }, [rows, columns, sortKey, dir]);38 const toggle = (key: string) => {39 if (sortKey === key) setDir((d) => (d === "asc" ? "desc" : "asc"));40 else {41 setSortKey(key);42 setDir("desc");43 }44 };45 return (46 <div className={cx("overflow-x-auto rounded-md border border-rule bg-surface", className)} style={maxHeight ? { maxHeight, overflowY: "auto" } : undefined}>47 <table className="table-dense">48 <thead>49 <tr>50 {columns.map((c) => (51 <th key={c.key} className={cx(c.num && "text-right", c.hideBelow && HIDE[c.hideBelow], c.className)}>52 {c.sort ? (53 <button type="button" onClick={() => toggle(c.key)} className={cx("inline-flex h-6 items-center gap-0.5 uppercase hover:text-ink", sortKey === c.key && "text-ink")}>54 {c.header}55 {sortKey === c.key && (dir === "asc" ? <ChevronUp size={11} /> : <ChevronDown size={11} />)}56 </button>57 ) : (58 c.header59 )}60 </th>61 ))}62 </tr>63 </thead>64 <tbody>65 {sorted.length === 0 && (66 <tr>67 <td colSpan={columns.length} className="py-8 text-center text-ink-3">68 {emptyText}69 </td>70 </tr>71 )}72 {sorted.map((r) => (73 <tr key={rowKey(r)} className={cx(onRowHref && "cursor-pointer")} onClick={onRowHref ? () => (window.location.href = onRowHref(r)) : undefined}>74 {columns.map((c) => (75 <td key={c.key} className={cx(c.num && "num", c.hideBelow && HIDE[c.hideBelow], c.className)}>76 {c.cell(r)}77 </td>78 ))}79 </tr>80 ))}81 </tbody>82 </table>83 </div>84 );85}86