| 1 | "use client"; |
| 2 | |
| 3 | import { useState, type ReactNode } from "react"; |
| 4 | import { clsx } from "@/lib/cx"; |
| 5 | import { Card } from "@/components/ui"; |
| 6 | |
| 7 | |
| 8 | export type Column = { |
| 9 | key: string; |
| 10 | header: ReactNode; |
| 11 | align?: "left" | "right"; |
| 12 | |
| 13 | cardTitle?: boolean; |
| 14 | hideOnCard?: boolean; |
| 15 | |
| 16 | width?: string; |
| 17 | }; |
| 18 | |
| 19 | export type Row = { key: string; cells: Record<string, ReactNode> }; |
| 20 | |
| 21 | export function DataTable({ |
| 22 | caption, |
| 23 | columns, |
| 24 | rows, |
| 25 | className, |
| 26 | cardLabel, |
| 27 | pageSize, |
| 28 | }: { |
| 29 | caption: string; |
| 30 | columns: Column[]; |
| 31 | rows: Row[]; |
| 32 | className?: string; |
| 33 | cardLabel?: string; |
| 34 | |
| 35 | pageSize?: number; |
| 36 | }) { |
| 37 | const [shown, setShown] = useState(pageSize ?? rows.length); |
| 38 | |
| 39 | if (rows.length === 0) return null; |
| 40 | |
| 41 | const titleCol = columns.find((c) => c.cardTitle); |
| 42 | const cardCols = columns.filter((c) => !c.cardTitle && !c.hideOnCard); |
| 43 | |
| 44 | const cap = pageSize ? shown : rows.length; |
| 45 | const visibleRows = pageSize ? rows.slice(0, cap) : rows; |
| 46 | const remaining = rows.length - cap; |
| 47 | |
| 48 | return ( |
| 49 | <Card className={clsx("overflow-hidden [contain:layout]", className)}> |
| 50 | { |
| 51 | } |
| 52 | <div className="hidden w-full overflow-x-auto lg:block"> |
| 53 | <table className="w-full table-fixed border-collapse text-left"> |
| 54 | <caption className="sr-only">{caption}</caption> |
| 55 | <colgroup> |
| 56 | {columns.map((c) => { |
| 57 | const w = c.width ?? (c.align === "right" ? "10rem" : undefined); |
| 58 | return <col key={c.key} style={w ? { width: w } : undefined} />; |
| 59 | })} |
| 60 | </colgroup> |
| 61 | <thead> |
| 62 | <tr className="border-b border-line"> |
| 63 | {columns.map((c) => ( |
| 64 | <th |
| 65 | key={c.key} |
| 66 | scope="col" |
| 67 | className={clsx( |
| 68 | "label px-4 py-2.5 font-normal", |
| 69 | c.align === "right" ? "text-right" : "text-left" |
| 70 | )} |
| 71 | > |
| 72 | {c.header} |
| 73 | </th> |
| 74 | ))} |
| 75 | </tr> |
| 76 | </thead> |
| 77 | <tbody> |
| 78 | {visibleRows.map((r) => ( |
| 79 | <tr |
| 80 | key={r.key} |
| 81 | className="border-b border-line font-mono text-[13px] transition-colors last:border-0 hover:bg-graphite/60" |
| 82 | > |
| 83 | {columns.map((c) => ( |
| 84 | <td |
| 85 | key={c.key} |
| 86 | className={clsx( |
| 87 | "break-words align-middle px-4 py-3", |
| 88 | c.align === "right" ? "text-right" : "text-left" |
| 89 | )} |
| 90 | > |
| 91 | {r.cells[c.key]} |
| 92 | </td> |
| 93 | ))} |
| 94 | </tr> |
| 95 | ))} |
| 96 | </tbody> |
| 97 | </table> |
| 98 | </div> |
| 99 | |
| 100 | { |
| 101 | } |
| 102 | <ul |
| 103 | className="divide-y divide-[color:var(--dk-line)] lg:hidden" |
| 104 | aria-label={cardLabel ?? caption} |
| 105 | > |
| 106 | {visibleRows.map((r) => ( |
| 107 | <li key={r.key} className="p-4"> |
| 108 | {titleCol ? ( |
| 109 | <div className="mb-2.5 min-w-0 break-words font-mono text-[13px] text-paper"> |
| 110 | {r.cells[titleCol.key]} |
| 111 | </div> |
| 112 | ) : null} |
| 113 | <dl className="grid grid-cols-1 gap-2"> |
| 114 | {cardCols.map((c) => { |
| 115 | const val = r.cells[c.key]; |
| 116 | if (val == null) return null; |
| 117 | return ( |
| 118 | <div key={c.key} className="flex items-start gap-3"> |
| 119 | <dt className="label w-[5.5rem] shrink-0 pt-0.5 normal-case"> |
| 120 | {c.header} |
| 121 | </dt> |
| 122 | <dd className="min-w-0 flex-1 break-words text-left font-mono text-[13px] text-ash"> |
| 123 | {val} |
| 124 | </dd> |
| 125 | </div> |
| 126 | ); |
| 127 | })} |
| 128 | </dl> |
| 129 | </li> |
| 130 | ))} |
| 131 | </ul> |
| 132 | |
| 133 | {pageSize && remaining > 0 ? ( |
| 134 | <div className="flex items-center justify-between gap-3 border-t border-line px-4 py-3"> |
| 135 | <span className="label text-ash-2"> |
| 136 | Showing {cap.toLocaleString()} of {rows.length.toLocaleString()} |
| 137 | </span> |
| 138 | <button |
| 139 | type="button" |
| 140 | onClick={() => setShown((n) => n + (pageSize ?? 0))} |
| 141 | className="focus-ring label inline-flex min-h-[44px] items-center rounded-[3px] border border-line px-3 text-ash underline underline-offset-4 transition-colors hover:border-paper hover:text-paper" |
| 142 | > |
| 143 | Show {Math.min(pageSize, remaining).toLocaleString()} more |
| 144 | </button> |
| 145 | </div> |
| 146 | ) : null} |
| 147 | </Card> |
| 148 | ); |
| 149 | } |