Behive Tech Registry

Table

v0.2.0

TODO: Add component description.

pnpm add @behivetech/atoms.table

registry/behivetech/atoms/table · depends on @behivetech/get-class-name

table.tsx
1import { type ReactNode } from "react";
2import { getClassName } from "@behivetech/get-class-name";
3import styles from "./table.module.scss";
4
5export interface TableColumn<TRow> {
6 /** Unique identity for this column; also used as the header cell's React key */
7 key: string;
8 /** Column header content */
9 title: ReactNode;
10 /** Reads a raw field off each row for this column, when no custom `render` is given */
11 dataIndex?: keyof TRow;
12 /** Renders a cell's content. Receives `row[dataIndex]` as `value` when `dataIndex` is set. */
13 render?: (value: unknown, row: TRow) => ReactNode;
14 /** Additional class name applied to every cell (header and body) in this column */
15 className?: string;
16}
17
18export interface TableProps<TRow> {
19 /** Column definitions, rendered left to right */
20 columns: TableColumn<TRow>[];
21 /** Rows to render, in the order given — Table does not sort, paginate, or filter */
22 data: TRow[];
23 /** Returns a stable, unique key for a row, used as its React key */
24 rowKey: (row: TRow) => string;
25 /** Additional class names to merge with the component root element */
26 className?: string;
27}
28
29/**
30 * A bare data table — describe `columns` once (each with a `title` and either a
31 * `dataIndex` or a custom `render`), pass `data`, get a `<table>` back. No sorting,
32 * pagination, or empty-state handling — those are the consuming app's concern to add
33 * around this, not props on the component itself.
34 */
35export const Table = <TRow,>({
36 columns,
37 data,
38 rowKey,
39 className,
40}: TableProps<TRow>) => {
41 const [rootClass] = getClassName({
42 className,
43 rootClass: "BHT__Table",
44 styles,
45 });
46
47 return (
48 <table className={rootClass}>
49 <thead>
50 <tr>
51 {columns.map((column) => (
52 <th key={column.key} className={column.className}>
53 {column.title}
54 </th>
55 ))}
56 </tr>
57 </thead>
58 <tbody>
59 {data.map((row) => (
60 <tr key={rowKey(row)}>
61 {columns.map((column) => {
62 const value = column.dataIndex
63 ? row[column.dataIndex]
64 : undefined;
65 return (
66 <td key={column.key} className={column.className}>
67 {column.render
68 ? column.render(value, row)
69 : value !== undefined
70 ? String(value)
71 : null}
72 </td>
73 );
74 })}
75 </tr>
76 ))}
77 </tbody>
78 </table>
79 );
80};
table.module.scss
1// Material Design 3 has no table spec; this follows its data-table guidance:
2// title-small headers on `on-surface-variant`, body-medium cells, and
3// `outline-variant` row dividers.
4
5.BHT__Table {
6 width: 100%;
7 border-collapse: collapse;
8
9 th {
10 text-align: left;
11 padding: var(--spacing-2, 0.5rem) var(--spacing-4, 1rem);
12 font-family: var(--md-sys-typescale-title-small-font, var(--font-body));
13 font-size: var(--md-sys-typescale-title-small-size, 0.875rem);
14 font-weight: var(--md-sys-typescale-title-small-weight, 500);
15 line-height: var(--md-sys-typescale-title-small-line-height, 1.25rem);
16 letter-spacing: var(--md-sys-typescale-title-small-tracking, 0.0071em);
17 color: var(--md-sys-color-on-surface-variant, var(--color-text-muted));
18 background: var(
19 --md-sys-color-surface-container,
20 var(--color-surface-raised)
21 );
22 border-bottom: 1px solid
23 var(--md-sys-color-outline-variant, var(--color-border));
24 }
25
26 td {
27 position: relative;
28 padding: var(--spacing-3, 0.75rem) var(--spacing-4, 1rem);
29 font-family: var(--md-sys-typescale-body-medium-font, var(--font-body));
30 font-size: var(--md-sys-typescale-body-medium-size, 0.875rem);
31 font-weight: var(--md-sys-typescale-body-medium-weight, 400);
32 line-height: var(--md-sys-typescale-body-medium-line-height, 1.25rem);
33 letter-spacing: var(--md-sys-typescale-body-medium-tracking, 0.0179em);
34 color: var(--md-sys-color-on-surface, var(--color-text-body));
35 border-bottom: 1px solid
36 var(--md-sys-color-outline-variant, var(--color-border));
37
38 // Row hover state layer. It sits on each cell rather than the `<tr>`
39 // because a positioned table row is still unreliable as a containing
40 // block for an absolutely positioned pseudo-element.
41 &::after {
42 content: "";
43 position: absolute;
44 inset: 0;
45 background: currentColor;
46 opacity: 0;
47 pointer-events: none;
48 transition: opacity var(--md-sys-motion-duration-short-2, 100ms)
49 var(--md-sys-motion-easing-standard, ease);
50 }
51 }
52
53 tr:last-child td {
54 border-bottom: none;
55 }
56
57 tr:hover td::after {
58 opacity: var(--md-sys-state-hover-opacity, 0.08);
59 }
60}
index.ts
1export { Table } from "./table.js";
2export type { TableProps, TableColumn } from "./table.js";
table.composition.tsx
1import { Table, type TableColumn } from "./table.js";
2
3interface Invoice {
4 id: string;
5 customer: string;
6 amount: number;
7 status: "paid" | "overdue";
8}
9
10const invoices: Invoice[] = [
11 { id: "inv-1", customer: "Acme Co", amount: 240, status: "paid" },
12 { id: "inv-2", customer: "Globex", amount: 90, status: "overdue" },
13 { id: "inv-3", customer: "Initech", amount: 512, status: "paid" },
14];
15
16// Simplest case — dataIndex only, no custom rendering needed.
17const basicColumns: TableColumn<Invoice>[] = [
18 { key: "customer", title: "Customer", dataIndex: "customer" },
19 { key: "amount", title: "Amount", dataIndex: "amount" },
20];
21
22export const BasicTable = () => (
23 <Table columns={basicColumns} data={invoices} rowKey={(row) => row.id} />
24);
25
26// render() for custom cell content — formatting a value and reacting to the row.
27const richColumns: TableColumn<Invoice>[] = [
28 { key: "customer", title: "Customer", dataIndex: "customer" },
29 {
30 key: "amount",
31 title: "Amount",
32 dataIndex: "amount",
33 render: (value) => `$${value}`,
34 },
35 {
36 key: "status",
37 title: "Status",
38 render: (_value, row) =>
39 row.status === "overdue" ? "⚠️ overdue" : "✓ paid",
40 },
41];
42
43export const TableWithCustomCells = () => (
44 <Table columns={richColumns} data={invoices} rowKey={(row) => row.id} />
45);
46
47export const EmptyTable = () => (
48 <Table columns={basicColumns} data={[]} rowKey={(row) => row.id} />
49);
table.spec.tsx
1import { render, screen } from "@testing-library/react";
2import { Table, type TableColumn } from "./table.js";
3
4interface Person {
5 id: string;
6 name: string;
7 age: number;
8}
9
10const people: Person[] = [
11 { id: "p1", name: "Ada", age: 36 },
12 { id: "p2", name: "Grace", age: 85 },
13];
14
15const columns: TableColumn<Person>[] = [
16 { key: "name", title: "Name", dataIndex: "name" },
17 { key: "age", title: "Age", dataIndex: "age" },
18];
19
20describe("Table", () => {
21 it("renders a header cell per column", () => {
22 render(<Table columns={columns} data={people} rowKey={(p) => p.id} />);
23 expect(screen.getByText("Name")).toBeTruthy();
24 expect(screen.getByText("Age")).toBeTruthy();
25 });
26
27 it("renders a row per data item using dataIndex", () => {
28 render(<Table columns={columns} data={people} rowKey={(p) => p.id} />);
29 expect(screen.getByText("Ada")).toBeTruthy();
30 expect(screen.getByText("36")).toBeTruthy();
31 expect(screen.getByText("Grace")).toBeTruthy();
32 expect(screen.getByText("85")).toBeTruthy();
33 });
34
35 it("uses render over dataIndex when both are given", () => {
36 const customColumns: TableColumn<Person>[] = [
37 { key: "name", title: "Name", dataIndex: "name" },
38 {
39 key: "age",
40 title: "Age",
41 dataIndex: "age",
42 render: (value) => `${value} years`,
43 },
44 ];
45 render(
46 <Table columns={customColumns} data={people} rowKey={(p) => p.id} />,
47 );
48 expect(screen.getByText("36 years")).toBeTruthy();
49 });
50
51 it("renders no rows when data is empty", () => {
52 render(<Table columns={columns} data={[]} rowKey={(p) => p.id} />);
53 expect(screen.queryByText("Ada")).toBeNull();
54 expect(screen.getByText("Name")).toBeTruthy();
55 });
56});