Components
Data Table
The brand's table with a search field, sortable heads, row selection, a Columns menu and page keys.
npx -y github:anwarlemu/opencommerce-brand-cli#release add data-table --surface siteRun from the target React 19.2+ app root with Tailwind CSS 4 and the @/* source alias. Source and examples as JSON.
| Order | Agent | |||
|---|---|---|---|---|
| ord_6CE | Tailspin Toys | travel-agent | Delivered | $487.14 |
| ord_6IK | Contoso Coffee | travel-agent | Delivered | $482.28 |
| ord_6OQ | Litware Books | travel-agent | Delivered | $477.42 |
| ord_6UW | Fabrikam Home | travel-agent | Delivered | $472.56 |
| ord_712 | Northwind Outfitters | travel-agent | Delivered | $467.70 |
| ord_6BD | Northwind Outfitters | gift-buyer | Shipped | $407.95 |
| ord_6HJ | Tailspin Toys | gift-buyer | Shipped | $403.09 |
| ord_6NP | Contoso Coffee | gift-buyer | Shipped | $398.23 |
| Order | Agent | ||||
|---|---|---|---|---|---|
| ord_668 | Northwind Outfitters | travel-agent | Delivered | $12.00 | |
| ord_679 | Tailspin Toys | office-supplies | Shipped | $91.19 | |
| ord_68A | Contoso Coffee | gift-buyer | Placed | $170.38 | |
| ord_69B | Litware Books | travel-agent | Delivered | $249.57 | |
| ord_6AC | Fabrikam Home | office-supplies | Failed | $328.76 | |
| ord_6BD | Northwind Outfitters | gift-buyer | Shipped | $407.95 |
| Order | Agent | |||
|---|---|---|---|---|
| No orders yet. Your agent's first checkout shows up here. | ||||
Usage
Use for records people sort, filter, select and page through (orders, merchants, API keys). Describe each column with an id, a header, an accessor for sorting and filtering, and a cell to render it; set sortable on the columns that can be sorted. Set selectable for a checkbox column and pageSizes to offer rows per page. Put bulk actions in toolbar.
- A data table for fewer than ten rows (use the table)
- Animating rows when sorting or paging
- Hiding the only identifying column
Exports: DataTable, DataTableColumnHeader, DataTablePagination, DataTableViewOptions, DataTableColumn, DataTableProps, SortState. This component includes a client boundary. Keep event handlers and state in a client component.
Dependencies: button, checkbox, dropdown-menu, input, pagination, select, table, lucide-react.
Example
"use client";
import * as React from "react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { DataTable, type DataTableColumn } from "@/components/ui/data-table";
type Order = { id: string; merchant: string; agent: string; total: number; status: "Delivered" | "Shipped" | "Placed" | "Failed"; placed: string };
const MERCHANTS = ["Northwind Outfitters", "Contoso Coffee", "Fabrikam Home", "Tailspin Toys", "Litware Books"];
const AGENTS = ["travel-agent", "office-supplies", "gift-buyer"];
const STATUSES: Order["status"][] = ["Delivered", "Shipped", "Placed", "Delivered", "Failed", "Shipped"];
const ORDERS: Order[] = Array.from({ length: 36 }, (_, i) => ({
id: `ord_${(8000 + i * 37).toString(36).toUpperCase()}`,
merchant: MERCHANTS[(i * 3) % MERCHANTS.length],
agent: AGENTS[i % AGENTS.length],
total: Math.round(((i * 7919) % 48000) + 1200) / 100,
status: STATUSES[i % STATUSES.length],
placed: `2026-10-${String(26 - (i % 20)).padStart(2, "0")}`,
}));
const STATUS_BADGE = { Delivered: "success", Shipped: "brand", Placed: "default", Failed: "destructive" } as const;
const COLUMNS: DataTableColumn<Order>[] = [
{ id: "id", header: "Order", accessor: (o) => o.id, cell: (o) => <span className="font-mono text-snippet">{o.id}</span>, hideable: false },
{ id: "merchant", header: "Merchant", accessor: (o) => o.merchant, sortable: true },
{ id: "agent", header: "Agent", accessor: (o) => o.agent, cell: (o) => <span className="font-mono text-snippet text-fg-muted">{o.agent}</span> },
{ id: "status", header: "Status", accessor: (o) => o.status, sortable: true, cell: (o) => <Badge variant={STATUS_BADGE[o.status]}>{o.status}</Badge> },
{ id: "placed", header: "Placed", accessor: (o) => o.placed, sortable: true, hidden: true, cell: (o) => <span className="font-mono text-snippet">{o.placed}</span> },
{ id: "total", header: "Total", accessor: (o) => o.total, sortable: true, align: "end", cell: (o) => <span className="font-mono text-snippet">${o.total.toFixed(2)}</span> },
];
export function Site() {
return <DataTable columns={COLUMNS} data={ORDERS} getRowId={(o) => o.id} filter={{ placeholder: "Filter orders" }} pageSize={8} pageSizes={[8, 16, 32]} defaultSort={{ id: "total", desc: true }} rowName="order" />;
}
export function Selection() {
const [selection, setSelection] = React.useState<string[]>([]);
const [message, setMessage] = React.useState("");
return (
<div className="grid gap-3">
<DataTable
columns={COLUMNS}
data={ORDERS}
getRowId={(o) => o.id}
filter={{ placeholder: "Filter orders", columns: ["id", "merchant", "agent"] }}
selectable
selection={selection}
onSelectionChange={setSelection}
pageSize={6}
rowName="order"
toolbar={
<Button
variant="secondary"
size="sm"
disabled={!selection.length}
onClick={() => {
setMessage(`Retried ${selection.length} ${selection.length === 1 ? "checkout" : "checkouts"}.`);
setSelection([]);
}}
>
Retry selected
</Button>
}
/>
<p role="status" className="text-body-sm text-fg-muted empty:hidden">
{message}
</p>
</div>
);
}
export function Empty() {
return <DataTable columns={COLUMNS} data={[]} getRowId={(o) => o.id} filter={{ placeholder: "Filter orders" }} empty="No orders yet. Your agent's first checkout shows up here." rowName="order" />;
}Source
"use client";
import * as React from "react";
import { ArrowDown, ArrowUp, ChevronsUpDown, Columns3 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import { DropdownMenu, DropdownMenuCheckboxItem, DropdownMenuContent, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
import { Input } from "@/components/ui/input";
import { Pagination, PaginationContent, PaginationEllipsis, PaginationItem, PaginationLink, PaginationNext, PaginationPrevious } from "@/components/ui/pagination";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { cn } from "@/lib/utils";
// A table of records people sort, filter, select and page through, built on the brand's table (the live merchant table,
// monitoring.tsx): one white frame with 12px corners on the 16 percent ink edge and the window's drop, 12px heads at 64
// percent ink, 14px rows on 9 percent rules that take the mist under the pointer (a selected row the pale blue). A
// sortable head is a bare label with an arrow for its direction that turns ink under the pointer. Above the table, a
// toolbar: the 36px search field and the white Columns key that opens a menu of checkboxes; below it, the selection
// count, rows per page and the page keys. Sorting, filtering and paging are instant (no motion): they are repeated many
// times in a row. On a phone each row stacks under its column names, as the table does.
type Value = string | number | Date | boolean | null | undefined;
type DataTableColumn<T> = {
id: string;
/** The column's label, in its head and in the Columns menu. */
header: React.ReactNode;
/** What the cell shows; defaults to the accessor's value. */
cell?: (row: T) => React.ReactNode;
/** The value used to sort and filter. */
accessor?: (row: T) => Value;
sortable?: boolean;
/** Whether the Columns menu can hide it (default true). */
hideable?: boolean;
/** Hidden until turned on in the Columns menu. */
hidden?: boolean;
align?: "start" | "end";
className?: string;
};
type SortState = { id: string; desc: boolean } | null;
const text = (v: Value) => (v instanceof Date ? v.toISOString() : v == null ? "" : String(v));
function compare(a: Value, b: Value) {
if (a == null && b == null) return 0;
if (a == null) return 1;
if (b == null) return -1;
if (a instanceof Date && b instanceof Date) return a.getTime() - b.getTime();
if (typeof a === "number" && typeof b === "number") return a - b;
return text(a).localeCompare(text(b), undefined, { numeric: true, sensitivity: "base" });
}
/** A sortable head label: a ghost key whose arrow shows the direction (both carets while unsorted). */
function DataTableColumnHeader({ title, sorted, onSort, className }: { title: React.ReactNode; sorted: false | "asc" | "desc"; onSort?: () => void; className?: string }) {
if (!onSort) return <span className={cn("inline-flex h-7 items-center", className)}>{title}</span>;
const Icon = sorted === "asc" ? ArrowUp : sorted === "desc" ? ArrowDown : ChevronsUpDown;
return (
<button
type="button"
data-slot="data-table-column-header"
onClick={onSort}
data-sorted={!!sorted}
className={cn(
"-mx-1.5 inline-flex h-7 items-center gap-1 rounded-menu px-1.5 font-sans text-caption leading-none font-normal text-fg-muted outline-none transition-[background-color,color] duration-150 ease-out hover:bg-wash-hover hover:text-fg focus-visible:outline-2 focus-visible:outline-solid focus-visible:outline-focus-ring data-[sorted=true]:text-fg motion-reduce:transition-none",
className,
)}
>
{title}
<Icon aria-hidden strokeWidth={1.5} className={cn("size-3", !sorted && "opacity-60")} />
</button>
);
}
/** The Columns key and its menu of checkboxes, one per column that can be hidden. */
function DataTableViewOptions({ columns, visible, onVisibleChange }: { columns: { id: string; header: React.ReactNode }[]; visible: Record<string, boolean>; onVisibleChange: (id: string, on: boolean) => void }) {
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="secondary" size="sm" className="shrink-0">
<Columns3 strokeWidth={1.5} />
Columns
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-52">
<DropdownMenuLabel>Show columns</DropdownMenuLabel>
<DropdownMenuSeparator />
{columns.map((c) => (
<DropdownMenuCheckboxItem key={c.id} checked={visible[c.id] !== false} onCheckedChange={(on) => onVisibleChange(c.id, on === true)} onSelect={(e) => e.preventDefault()}>
{c.header}
</DropdownMenuCheckboxItem>
))}
</DropdownMenuContent>
</DropdownMenu>
);
}
/** The page numbers around the current one: the ends, its neighbors and ellipses between. */
function pageList(page: number, count: number): (number | "gap")[] {
if (count <= 7) return Array.from({ length: count }, (_, i) => i);
const set = new Set([0, count - 1, page - 1, page, page + 1].filter((p) => p >= 0 && p < count));
const sorted = [...set].sort((a, b) => a - b);
return sorted.flatMap((p, i) => (i && p - sorted[i - 1] > 1 ? (["gap", p] as const) : [p]));
}
/** The row under the table: the selection count, rows per page and the page keys. */
function DataTablePagination({ page, pageCount, onPageChange, pageSize, pageSizes, onPageSizeChange, selected, total, className }: { page: number; pageCount: number; onPageChange: (page: number) => void; pageSize?: number; pageSizes?: number[]; onPageSizeChange?: (size: number) => void; selected?: number; total?: number; className?: string }) {
const go = (p: number) => (e: React.MouseEvent) => {
e.preventDefault();
if (p >= 0 && p < pageCount) onPageChange(p);
};
return (
<Pagination className={cn("flex-wrap justify-between gap-x-4 gap-y-2", className)}>
<div className="flex min-h-8.5 flex-wrap items-center gap-x-4 gap-y-1 text-body-sm leading-tight text-fg-muted">
{selected !== undefined && total !== undefined && (
<span aria-live="polite">
{selected} of {total} selected
</span>
)}
{pageSizes && pageSize && onPageSizeChange && (
<span className="flex items-center gap-2">
<span id="data-table-page-size">Rows per page</span>
<Select value={String(pageSize)} onValueChange={(v) => onPageSizeChange(Number(v))}>
<SelectTrigger compact aria-labelledby="data-table-page-size" className="w-18">
<SelectValue />
</SelectTrigger>
<SelectContent>
{pageSizes.map((s) => (
<SelectItem key={s} value={String(s)}>
{s}
</SelectItem>
))}
</SelectContent>
</Select>
</span>
)}
</div>
<PaginationContent className="ml-auto">
<PaginationItem>
<PaginationPrevious href="#" onClick={go(page - 1)} aria-disabled={page === 0 || undefined} tabIndex={page === 0 ? -1 : undefined} />
</PaginationItem>
{pageList(page, pageCount).map((p, i) =>
p === "gap" ? (
<PaginationItem key={`gap-${i}`}>
<PaginationEllipsis />
</PaginationItem>
) : (
<PaginationItem key={p}>
<PaginationLink href="#" isActive={p === page} onClick={go(p)} aria-label={`Page ${p + 1}`}>
{p + 1}
</PaginationLink>
</PaginationItem>
),
)}
<PaginationItem>
<PaginationNext href="#" onClick={go(page + 1)} aria-disabled={page >= pageCount - 1 || undefined} tabIndex={page >= pageCount - 1 ? -1 : undefined} />
</PaginationItem>
</PaginationContent>
</Pagination>
);
}
type DataTableProps<T> = {
columns: DataTableColumn<T>[];
data: T[];
getRowId: (row: T) => string;
/** A filter field over these columns' values (all filterable columns when omitted). */
filter?: { placeholder?: string; columns?: string[] } | false;
/** A checkbox column for choosing rows. */
selectable?: boolean;
selection?: string[];
onSelectionChange?: (ids: string[]) => void;
/** Hide the Columns key. */
hideViewOptions?: boolean;
pageSize?: number;
/** Offer these page sizes under the table. */
pageSizes?: number[];
defaultSort?: SortState;
/** Shown in the body when nothing matches. */
empty?: React.ReactNode;
/** Actions at the toolbar's right, before the Columns key. */
toolbar?: React.ReactNode;
/** What a row is called in labels ("mailbox"). */
rowName?: string;
className?: string;
};
function DataTable<T>({ columns, data, getRowId, filter = {}, selectable = false, selection: selectionProp, onSelectionChange, hideViewOptions = false, pageSize: pageSizeProp = 10, pageSizes, defaultSort = null, empty = "No results.", toolbar, rowName = "row", className }: DataTableProps<T>) {
const [query, setQuery] = React.useState("");
const [sort, setSort] = React.useState<SortState>(defaultSort);
const [page, setPage] = React.useState(0);
const [pageSize, setPageSize] = React.useState(pageSizeProp);
const [visible, setVisible] = React.useState<Record<string, boolean>>(() => Object.fromEntries(columns.map((c) => [c.id, !c.hidden])));
const [selectionInner, setSelectionInner] = React.useState<string[]>([]);
const selection = selectionProp ?? selectionInner;
const setSelection = (ids: string[]) => {
if (!selectionProp) setSelectionInner(ids);
onSelectionChange?.(ids);
};
const shown = columns.filter((c) => visible[c.id] !== false);
const filterColumns = filter ? columns.filter((c) => c.accessor && (!filter.columns || filter.columns.includes(c.id))) : [];
const q = query.trim().toLowerCase();
let rows = q ? data.filter((row) => filterColumns.some((c) => text(c.accessor!(row)).toLowerCase().includes(q))) : data;
const sortColumn = sort && columns.find((c) => c.id === sort.id);
if (sort && sortColumn?.accessor) {
const get = sortColumn.accessor;
rows = [...rows].sort((a, b) => compare(get(a), get(b)) * (sort.desc ? -1 : 1));
}
const pageCount = Math.max(1, Math.ceil(rows.length / pageSize));
const current = Math.min(page, pageCount - 1);
const pageRows = rows.slice(current * pageSize, current * pageSize + pageSize);
const pageIds = pageRows.map(getRowId);
const allOnPage = pageIds.length > 0 && pageIds.every((id) => selection.includes(id));
const someOnPage = pageIds.some((id) => selection.includes(id));
const toggleSort = (id: string) => setSort((s) => (s?.id !== id ? { id, desc: false } : s.desc ? null : { id, desc: true }));
return (
<div data-slot="data-table" className={cn("grid min-w-0 grid-cols-[minmax(0,1fr)] gap-3", className)}>
{(filter || !hideViewOptions || toolbar) && (
<div data-slot="data-table-toolbar" className="flex flex-wrap items-center gap-2">
{filter && (
<Input
variant="search"
className="max-w-80 min-w-48 flex-1"
value={query}
onChange={(e) => {
setQuery(e.target.value);
setPage(0);
}}
placeholder={filter.placeholder ?? "Filter"}
aria-label={filter.placeholder ?? "Filter"}
/>
)}
<div className="ml-auto flex items-center gap-2">
{toolbar}
{!hideViewOptions && (
<DataTableViewOptions
columns={columns.filter((c) => c.hideable !== false)}
visible={visible}
onVisibleChange={(id, on) => setVisible((v) => ({ ...v, [id]: on }))}
/>
)}
</div>
</div>
)}
<div>
<Table label={`${rowName[0].toUpperCase()}${rowName.slice(1)}s`}>
<TableHeader>
<TableRow>
{selectable && (
<TableHead className="w-10 align-middle">
<Checkbox
aria-label={`Select every ${rowName} on this page`}
checked={allOnPage ? true : someOnPage ? "indeterminate" : false}
onCheckedChange={(on) => setSelection(on === true ? [...new Set([...selection, ...pageIds])] : selection.filter((id) => !pageIds.includes(id)))}
/>
</TableHead>
)}
{shown.map((c) => (
<TableHead key={c.id} aria-sort={sort?.id === c.id ? (sort.desc ? "descending" : "ascending") : c.sortable ? "none" : undefined} className={cn(c.align === "end" && "text-right", c.className)}>
<DataTableColumnHeader title={c.header} sorted={sort?.id === c.id ? (sort.desc ? "desc" : "asc") : false} onSort={c.sortable && c.accessor ? () => toggleSort(c.id) : undefined} />
</TableHead>
))}
</TableRow>
</TableHeader>
<TableBody>
{pageRows.length ? (
pageRows.map((row) => {
const id = getRowId(row);
const selected = selection.includes(id);
return (
<TableRow key={id} data-state={selected ? "selected" : undefined}>
{selectable && (
<TableCell className="w-10 min-w-10">
<Checkbox aria-label={`Select ${rowName} ${id}`} checked={selected} onCheckedChange={(on) => setSelection(on === true ? [...selection, id] : selection.filter((s) => s !== id))} />
</TableCell>
)}
{shown.map((c) => (
<TableCell key={c.id} label={typeof c.header === "string" ? c.header : undefined} className={cn(c.align === "end" && "text-right", c.className)}>
{c.cell ? c.cell(row) : text(c.accessor?.(row))}
</TableCell>
))}
</TableRow>
);
})
) : (
<TableRow className="hover:bg-transparent">
<TableCell colSpan={shown.length + (selectable ? 1 : 0)} className="h-24 text-center whitespace-normal text-fg-muted">
{empty}
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
<DataTablePagination
className="mt-3"
page={current}
pageCount={pageCount}
onPageChange={setPage}
pageSize={pageSize}
pageSizes={pageSizes}
onPageSizeChange={(s) => {
setPageSize(s);
setPage(0);
}}
selected={selectable ? selection.length : undefined}
total={selectable ? data.length : undefined}
/>
</div>
</div>
);
}
export { DataTable, DataTableColumnHeader, DataTablePagination, DataTableViewOptions };
export type { DataTableColumn, DataTableProps, SortState };Reference: opencommerce-agentcard-web src/components/monitoring.tsx (the merchant table) with table.tsx and pagination.tsx.