● Zion Boggan
repos/Docket/components/data-table.tsx
zionboggan.com ↗
149 lines · tsx
History for this file →
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
}