SPB Git

spb/findata

Public
3.4 KB · 89 lines tsx
1"use client";2import clsx from "clsx";3import type { ReactNode } from "react";4import { Empty, Skeleton } from "./ui";56export interface Column<T> {7  key: string;8  header: ReactNode;9  render?: (row: T, i: number) => ReactNode;10  num?: boolean;11  className?: string;12  /** hide below `sm` to save width on phones; still visible in the card view */13  hideSm?: boolean;14  mono?: boolean;15}1617interface Props<T> {18  columns: Column<T>[];19  rows: T[] | null | undefined;20  rowKey: (row: T, i: number) => string | number;21  loading?: boolean;22  empty?: ReactNode;23  /** Card layout under 640px (default) or always horizontal scroll */24  mobile?: "cards" | "scroll";25  maxHeight?: string;26  onRowClick?: (row: T) => void;27  footer?: ReactNode;28}2930function cell<T>(c: Column<T>, row: T, i: number): ReactNode {31  if (c.render) return c.render(row, i);32  const v = (row as Record<string, unknown>)[c.key];33  if (v === null || v === undefined) return <span className="text-ink-3">—</span>;34  if (typeof v === "object") return <code className="text-xs">{JSON.stringify(v)}</code>;35  return String(v);36}3738export function DataTable<T>({ columns, rows, rowKey, loading, empty, mobile = "cards", maxHeight, onRowClick, footer }: Props<T>) {39  if (loading && !rows) return <Skeleton rows={5} />;40  if (!rows || rows.length === 0) return <Empty>{empty ?? "No rows."}</Empty>;41  return (42    <div className={clsx(loading && "opacity-60 transition-opacity")}>43      {/* Cards (mobile) */}44      {mobile === "cards" && (45        <ul className="flex flex-col gap-2 sm:hidden">46          {rows.map((r, i) => (47            <li key={rowKey(r, i)} className={clsx("rounded-md border border-border bg-surface-1 p-3", onRowClick && "cursor-pointer active:bg-surface-2")} onClick={onRowClick ? () => onRowClick(r) : undefined}>48              <dl className="grid grid-cols-[minmax(0,40%)_1fr] gap-x-3 gap-y-1 text-sm">49                {columns.map((c) => (50                  <div key={c.key} className="contents">51                    <dt className="truncate text-xs text-ink-3">{c.header}</dt>52                    <dd className={clsx("min-w-0 break-words", c.mono && "font-mono text-xs", c.num && "tnum")}>{cell(c, r, i)}</dd>53                  </div>54                ))}55              </dl>56            </li>57          ))}58        </ul>59      )}60      {/* Table */}61      <div className={clsx("table-wrap", mobile === "cards" && "hidden sm:block")} style={maxHeight ? { maxHeight, overflowY: "auto" } : undefined}>62        <table className="table">63          <thead>64            <tr>65              {columns.map((c) => (66                <th key={c.key} className={clsx(c.num && "text-right", c.hideSm && "hidden md:table-cell", c.className)}>67                  {c.header}68                </th>69              ))}70            </tr>71          </thead>72          <tbody>73            {rows.map((r, i) => (74              <tr key={rowKey(r, i)} className={clsx(onRowClick && "cursor-pointer")} onClick={onRowClick ? () => onRowClick(r) : undefined}>75                {columns.map((c) => (76                  <td key={c.key} className={clsx(c.num && "num", c.mono && "font-mono text-xs", c.hideSm && "hidden md:table-cell", c.className)}>77                    {cell(c, r, i)}78                  </td>79                ))}80              </tr>81            ))}82          </tbody>83        </table>84      </div>85      {footer && <div className="mt-2 flex items-center justify-between gap-2 text-xs text-ink-3">{footer}</div>}86    </div>87  );88}89
Files · 0467d449e66a5e62fb10ba4fcbf2f09fc3d1ef38
Menu

Theme