Table
v0.2.0TODO: Add component description.
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";45export 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}1718export 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}2829/**30 * A bare data table — describe `columns` once (each with a `title` and either a31 * `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 add33 * 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 });4647 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.dataIndex63 ? row[column.dataIndex]64 : undefined;65 return (66 <td key={column.key} className={column.className}>67 {column.render68 ? column.render(value, row)69 : value !== undefined70 ? 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, and3// `outline-variant` row dividers.45.BHT__Table {6 width: 100%;7 border-collapse: collapse;89 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 solid23 var(--md-sys-color-outline-variant, var(--color-border));24 }2526 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 solid36 var(--md-sys-color-outline-variant, var(--color-border));3738 // Row hover state layer. It sits on each cell rather than the `<tr>`39 // because a positioned table row is still unreliable as a containing40 // 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 }5253 tr:last-child td {54 border-bottom: none;55 }5657 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";23interface Invoice {4 id: string;5 customer: string;6 amount: number;7 status: "paid" | "overdue";8}910const 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];1516// 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];2122export const BasicTable = () => (23 <Table columns={basicColumns} data={invoices} rowKey={(row) => row.id} />24);2526// 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];4243export const TableWithCustomCells = () => (44 <Table columns={richColumns} data={invoices} rowKey={(row) => row.id} />45);4647export 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";34interface Person {5 id: string;6 name: string;7 age: number;8}910const people: Person[] = [11 { id: "p1", name: "Ada", age: 36 },12 { id: "p2", name: "Grace", age: 85 },13];1415const columns: TableColumn<Person>[] = [16 { key: "name", title: "Name", dataIndex: "name" },17 { key: "age", title: "Age", dataIndex: "age" },18];1920describe("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 });2627 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 });3435 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 });5051 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});