SPB Git forge

spb/market-atlas

Public
12commits 1branches 0releases
1.1 MBsize
maindefault branch
10 days agolast push
TypeScript 96.7% SQL 1.6% CSS 0.8% JavaScript 0.5%
3.5 KB · 86 lines tsx
Raw Blame History
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