1import React from "react";
2
3interface Column<T> {
4 key: keyof T | string;
5 label: string;
6 align?: "left" | "right" | "center";
7 render?: (value: unknown, row: T) => React.ReactNode;
8 width?: string;
9}
10
11interface TableProps<T extends Record<string, unknown>> {
12 columns: Column<T>[];
13 rows: T[];
14 title?: string;
15 description?: string;
16 className?: string;
17 onRowClick?: (row: T) => void;
18}
19
20const alignClass = (align?: "left" | "right" | "center") =>
21 align === "right" ? "text-right" : align === "center" ? "text-center" : "text-left";
22
23/* Title and column headers live in the bezel; rows sit on the raised face. */
24export function Table<T extends Record<string, unknown>>({
25 columns,
26 rows,
27 title,
28 description,
29 className = "",
30 onRowClick,
31}: TableProps<T>) {
32 return (
33 <div
34 className={["w-full rounded-[18px] p-1.5", title || description ? "pt-0" : "", className].join(" ")}
35 style={{ background: "var(--panel-bezel)", boxShadow: "var(--panel-shadow)" }}
36 >
37 {(title || description) && (
38 <div className="px-3.5 pt-3.5 pb-3">
39 {title && <p className="text-[13px] font-semibold text-neutral-900 tracking-[-0.01em]">{title}</p>}
40 {description && <p className="text-[12px] text-neutral-500 mt-0.5">{description}</p>}
41 </div>
42 )}
43
44 <div
45 className="bg-white rounded-[13px] overflow-x-auto scroll-thin"
46 style={{ boxShadow: "var(--panel-face-shadow)" }}
47 >
48 <table className="w-full text-left border-collapse">
49 {title && <caption className="sr-only">{title}</caption>}
50 <thead>
51 <tr className="border-b border-neutral-100">
52 {columns.map((col) => (
53 <th
54 key={String(col.key)}
55 scope="col"
56 style={col.width ? { width: col.width } : undefined}
57 className={[
58 "px-4 pt-3 pb-2.5 text-[12px] font-medium text-neutral-500 lowercase whitespace-nowrap",
59 alignClass(col.align),
60 ].join(" ")}
61 >
62 {col.label}
63 </th>
64 ))}
65 </tr>
66 </thead>
67 <tbody>
68 {rows.map((row, ri) => (
69 <tr
70 key={ri}
71 onClick={() => onRowClick?.(row)}
72 // Clickable rows are reachable and operable from the keyboard too
73 tabIndex={onRowClick ? 0 : undefined}
74 onKeyDown={
75 onRowClick
76 ? (e) => {
77 if (e.key === "Enter" || e.key === " ") {
78 e.preventDefault();
79 onRowClick(row);
80 }
81 }
82 : undefined
83 }
84 className={[
85 "border-b border-neutral-100 last:border-0 transition-colors duration-100",
86 onRowClick
87 ? "cursor-pointer hover:bg-neutral-50 focus-visible:bg-neutral-50 outline-none focus-visible:shadow-[inset_2px_0_0_#171717]"
88 : "",
89 ].join(" ")}
90 >
91 {columns.map((col, ci) => {
92 const val = row[col.key as keyof T] as unknown;
93 return (
94 <td
95 key={String(col.key)}
96 className={[
97 "px-4 py-3 text-[13px]",
98 ci === 0 ? "font-medium text-neutral-900" : "text-neutral-600 tabular-nums",
99 alignClass(col.align),
100 ].join(" ")}
101 >
102 {col.render ? col.render(val, row) : String(val ?? "")}
103 </td>
104 );
105 })}
106 </tr>
107 ))}
108 {rows.length === 0 && (
109 <tr>
110 <td colSpan={columns.length} className="px-5 py-12 text-center">
111 <p className="text-[13px] font-medium text-neutral-700">nothing here yet</p>
112 <p className="text-[12px] text-neutral-500 mt-0.5">rows will show up here once there are some.</p>
113 </td>
114 </tr>
115 )}
116 </tbody>
117 </table>
118 </div>
119 </div>
120 );
121}