Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions .changeset/lazy-hounds-smoke.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
---
"@tailor-platform/app-shell": minor
---

Add expandable detail rows to `DataTable`. Pass `renderExpandedRow` to `useDataTable` and each row gets a chevron column (auto-pinned to the left edge, after the selection column) that reveals a full-width detail panel beneath the row — nothing new to compose in JSX.

```tsx
const table = useDataTable<Order>({
columns,
data,
control,
renderExpandedRow: (row) => <OrderLineItems orderId={row.id} />,
canExpandRow: (row) => row.lineItemCount > 0,
expandRowLabel: (row) => row.orderNumber, // → "Expand row INV-1001"
});
```

Rows must have a string or number `id` (the same constraint as row selection); rows without one render no chevron. Several rows can be open at once, and expansion survives page changes — `collapseAllRows()` resets it. Pass `expandedIds` + `onExpandedChange` to control expansion yourself.

Also fixes `onSelectionChange` firing twice per toggle under React StrictMode. It was dispatched from inside a state updater, which StrictMode intentionally double-invokes to surface impurity, so handlers doing real work (fetches, analytics, history entries) ran twice in development. It is now dispatched from the event handler and fires exactly once. No signature change; if you added your own de-duplication to work around this, it is no longer needed.
2 changes: 1 addition & 1 deletion catalogue/src/fundamental/components.md
Original file line number Diff line number Diff line change
Expand Up @@ -387,7 +387,7 @@ Plus `badgeVariants` CVA for custom-styled siblings.

**Import:** compound namespace + helpers from `'@tailor-platform/app-shell'`, e.g. `DataTable`, `useDataTable`, `useCollectionVariables`, `createColumnHelper`, and types such as `Column`, `UseDataTableReturn`.

**Purpose:** Production list screens over GraphQL **connections**. Owns toolbar filter chips (**`DataTable.Filters`** from column `filter` configs), header sort, **`DataTable.Pagination`** (cursor-first; First/Last when `total` is provided), loading skeleton/error row, **`onClickRow`**, **`rowActions`** (kebab column), **`onSelectionChange`** (checkbox column), **column pinning** (`pin: "left" | "right"`) and built-in **column settings** (`<DataTable.Toolbar columnSettings>` — user show/hide + reorder + pin, persisted per-user via **`tableId`**).
**Purpose:** Production list screens over GraphQL **connections**. Owns toolbar filter chips (**`DataTable.Filters`** from column `filter` configs), header sort, **`DataTable.Pagination`** (cursor-first; First/Last when `total` is provided), loading skeleton/error row, **`onClickRow`**, **`rowActions`** (kebab column), **`onSelectionChange`** (checkbox column), **expandable rows** (**`renderExpandedRow`** — a chevron column plus a full-width detail panel per row, with `canExpandRow` / `expandRowLabel` and controlled `expandedIds`), **column pinning** (`pin: "left" | "right"`) and built-in **column settings** (`<DataTable.Toolbar columnSettings>` — user show/hide + reorder + pin, persisted per-user via **`tableId`**).

**Primitives:** Builds on low-level **`Table`**; do not reinvent pagination/filters manually unless the dataset is trivial.

Expand Down
94 changes: 80 additions & 14 deletions docs/components/data-table.md

Large diffs are not rendered by default.

183 changes: 183 additions & 0 deletions examples/vite-app/src/pages/data-table-lab/page.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import {
Layout,
Badge,
Button,
DataTable,
useDataTable,
useCollectionVariables,
Expand Down Expand Up @@ -221,6 +222,152 @@ const rowActions = [
},
];

// ─── Expanded-row panels ─────────────────────────────────────────────────────
// The render prop owns its layout entirely — DataTable only supplies the
// full-width row, the sticky wrapper and the accessible region around it. It is
// a plain `(row) => ReactNode`, so the panel can differ per row. Here each
// status gets a different panel to make that concrete: a line-item table, a
// payment receipt, or a collections view.

const money = (n: number) => `$${n.toFixed(2)}`;

const SKUS = ["Platform licence", "Onboarding", "Support retainer", "Data migration", "Training"];

// Deterministic line items derived from the invoice, so they're stable across
// renders without any state.
function lineItemsFor(invoice: Invoice) {
const seed = Number(invoice.id.slice(4));
const count = (seed % 3) + 2;
const items = Array.from({ length: count }, (_, i) => {
const qty = ((seed + i * 7) % 5) + 1;
const unit = Math.round((invoice.amount / count / qty) * 100) / 100;
return {
sku: SKUS[(seed + i) % SKUS.length],
qty,
unit,
total: Math.round(qty * unit * 100) / 100,
};
});
return items;
}

function LineItems({ invoice }: { invoice: Invoice }) {
return (
<table className="w-full max-w-2xl text-sm">
<thead>
<tr className="border-b border-border text-left text-muted-foreground">
<th className="pb-1 font-medium">Item</th>
<th className="pb-1 text-right font-medium">Qty</th>
<th className="pb-1 text-right font-medium">Unit</th>
<th className="pb-1 text-right font-medium">Amount</th>
</tr>
</thead>
<tbody>
{lineItemsFor(invoice).map((item) => (
<tr key={item.sku} className="border-b border-border/50 last:border-0">
<td className="py-1">{item.sku}</td>
<td className="py-1 text-right tabular-nums">{item.qty}</td>
<td className="py-1 text-right tabular-nums">{money(item.unit)}</td>
<td className="py-1 text-right tabular-nums">{money(item.total)}</td>
</tr>
))}
</tbody>
<tfoot>
<tr className="font-medium">
<td className="pt-1" colSpan={3}>
Total incl. tax
</td>
<td className="pt-1 text-right tabular-nums">{money(invoice.total)}</td>
</tr>
</tfoot>
</table>
);
}

function PaymentReceipt({ invoice }: { invoice: Invoice }) {
const steps = [
{ label: "Issued", date: invoice.issued },
{ label: "Sent to customer", date: invoice.issued },
{ label: "Payment received", date: invoice.dueDate },
];
return (
<div className="grid gap-4 sm:grid-cols-[minmax(0,1fr)_280px]">
<ol className="space-y-2 text-sm">
{steps.map((step) => (
<li key={step.label} className="flex items-center gap-2">
<span aria-hidden className="size-1.5 rounded-full bg-status-completed" />
<span>{step.label}</span>
<span className="text-muted-foreground">{step.date}</span>
</li>
))}
</ol>
<div className="rounded-md border border-border p-3 text-sm">
<div className="mb-2 flex items-center justify-between">
<span className="font-medium">Paid in full</span>
<Badge variant="success">receipt</Badge>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Amount</span>
<span className="tabular-nums">{money(invoice.amount)}</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Tax</span>
<span className="tabular-nums">{money(invoice.tax)}</span>
</div>
<div className="mt-1 flex justify-between border-t border-border pt-1 font-medium">
<span>Total</span>
<span className="tabular-nums">{money(invoice.total)}</span>
</div>
</div>
</div>
);
}

function CollectionsPanel({ invoice }: { invoice: Invoice }) {
const daysOverdue = Math.max(
1,
Math.round((Date.parse("2026-06-01") - Date.parse(invoice.dueDate)) / 86_400_000),
);
return (
<div className="space-y-3">
<div className="rounded-md border border-status-attention/40 bg-status-attention/10 px-3 py-2 text-sm">
<strong>{daysOverdue} days overdue.</strong> Last contact was {invoice.owner}; the account
is flagged for follow-up.
</div>
<dl className="grid max-w-xl grid-cols-[140px_minmax(0,1fr)] gap-x-4 gap-y-1.5 text-sm">
<dt className="text-muted-foreground">Outstanding</dt>
<dd className="tabular-nums">{money(invoice.total)}</dd>
<dt className="text-muted-foreground">Due date</dt>
<dd>{invoice.dueDate}</dd>
<dt className="text-muted-foreground">Billing email</dt>
<dd>{invoice.email}</dd>
<dt className="text-muted-foreground">Notes</dt>
<dd className="text-muted-foreground">{invoice.notes}</dd>
</dl>
{/* Focusable content, so you can tab into the panel and watch focus
return to the chevron when the row collapses. */}
<div className="flex gap-2">
<Button
size="sm"
variant="outline"
onClick={() => alert(`Reminder sent for ${invoice.id}`)}
>
Send reminder
</Button>
<Button size="sm" variant="ghost" onClick={() => alert(`Escalated ${invoice.id}`)}>
Escalate
</Button>
</div>
</div>
);
}

function InvoiceDetail({ invoice }: { invoice: Invoice }) {
if (invoice.status === "paid") return <PaymentReceipt invoice={invoice} />;
if (invoice.status === "overdue") return <CollectionsPanel invoice={invoice} />;
return <LineItems invoice={invoice} />;
}

// ─── Section shell ───────────────────────────────────────────────────────────

function Section({
Expand Down Expand Up @@ -262,6 +409,22 @@ const DataTableLabPage = () => {
rowActions,
});

// Expandable rows next to everything they have to survive: selection, a
// left-pinned column, row actions and horizontal scroll. `expandRowLabel`
// returns the bare record identity — the accessible names ("Expand row
// INV-1000", "INV-1000 details") are composed from it by AppShell.
const expandTable = useDataTable<Invoice>({
columns: baseColumns.map((c) => (c.id === "id" ? { ...c, pin: "left" as const } : c)),
data: { rows, total: rows.length },
control,
tableId: "lab-invoices-expand",
rowActions,
onSelectionChange: () => {},
renderExpandedRow: (row) => <InvoiceDetail invoice={row} />,
canExpandRow: (row) => row.status !== "draft",
expandRowLabel: (row) => row.id,
});

return (
<Layout>
<Layout.Header title="DataTable Lab" />
Expand Down Expand Up @@ -291,6 +454,26 @@ const DataTableLabPage = () => {
<DataTable.Table />
</DataTable.Root>
</Section>

<Section
title="Expandable rows"
description={
<>
Passing <code>renderExpandedRow</code> adds the chevron column at the left edge
(auto-pinned after the selection column) and a full-width detail panel beneath each
open row. Several rows can be open at once. The render prop is just{" "}
<code>(row) =&gt; ReactNode</code>, so the panel differs per row here — <em>sent</em>{" "}
shows line items, <em>paid</em> a receipt, <em>overdue</em> a collections view — and{" "}
<code>canExpandRow</code> hides the chevron on <em>draft</em> invoices entirely.
Scroll horizontally with a row open — the panel stays pinned to the left edge — and
note that clicking the chevron never selects or triggers the row itself.
</>
}
>
<DataTable.Root value={expandTable}>
<DataTable.Table />
</DataTable.Root>
</Section>
</Layout.Column>
</Layout>
);
Expand Down
23 changes: 22 additions & 1 deletion packages/core/src/components/data-table/data-table-context.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { createContext, useContext } from "react";
import { createContext, useContext, type ReactNode } from "react";
import type { Column, RowAction } from "./types";
import type { PageInfo, SortState } from "@/types/collection";

Expand Down Expand Up @@ -74,6 +74,27 @@ export interface DataTableContextValue<TRow extends Record<string, unknown>> {
clearSelection?: () => void;
isAllSelected: boolean;
isIndeterminate: boolean;

// Row expansion
// toggleRowExpansion / collapseAllRows are undefined when renderExpandedRow is not provided.
// Every member here is optional: this interface is documented as hand-constructible
// (custom providers, test doubles, adapters over another data source), so adding a
// required member would break those callers' `tsc` on a minor release.
/** Ids of the currently expanded rows. */
expandedIds?: string[];
/** Whether `row` is currently expanded. Always `false` for rows without an `id`. */
isRowExpanded?: (row: TRow) => boolean;
toggleRowExpansion?: (row: TRow) => void;
collapseAllRows?: () => void;
/**
* Detail-panel renderer. Its presence is what enables the expand column and
* the detail rows.
*/
renderExpandedRow?: (row: TRow) => ReactNode;
/** Per-row expandability predicate. Defaults to `true` for rows with an `id`. */
canExpandRow?: (row: TRow) => boolean;
/** Per-row record identity used to build the accessible names. */
expandRowLabel?: (row: TRow) => string;
}

// eslint-disable-next-line @typescript-eslint/no-explicit-any
Expand Down
Loading
Loading