@loke/design-system
Version:
A design system with individually importable components
465 lines (395 loc) • 13 kB
Markdown
---
name: data-tables
description: >
Build data table views with TanStack Table + design system Table component.
Table, TableHeader, TableBody, TableFooter, TableRow, TableHead, TableCell,
TableCaption. ColumnDef object definitions with custom cell renderers using
DS components. Sortable headers with Button variant="ghost" + sort icons.
Row selection with Checkbox and data-state="selected" attribute. flexRender
for header/cell content. Infinite scroll with TanStack Query. Reusable
DataTable wrapper pattern. Activate when building data tables with sorting,
filtering, pagination, or row selection.
type: composition
library: '/design-system'
library_version: '2.0.0-rc.6'
requires:
- getting-started
- interactive-components
sources:
- 'LOKE/merchant-frontends:packages/design-system/src/components/table'
- 'LOKE/merchant-frontends:packages/design-system/src/components/pagination'
- 'LOKE/merchant-frontends:apps/office/src/components/data-table/index.tsx'
- 'LOKE/merchant-frontends:apps/office/src/components/data-table/standard-table.tsx'
- 'LOKE/merchant-frontends:apps/office/src/components/data-table/rows.tsx'
---
# Data Tables
This skill builds on **getting-started** and **interactive-components**. Read them first.
## Setup
Install TanStack Table alongside the design system:
```bash
bun add /react-table
```
Minimal integration connecting TanStack Table to DS Table components:
```tsx
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@loke/design-system/table";
import {
type ColumnDef,
flexRender,
getCoreRowModel,
useReactTable,
} from "@tanstack/react-table";
interface Payment {
id: string;
amount: number;
status: "pending" | "completed" | "failed";
}
const columns: ColumnDef<Payment>[] = [
{ accessorKey: "id", header: "ID" },
{ accessorKey: "amount", header: "Amount" },
{ accessorKey: "status", header: "Status" },
];
function PaymentsTable({ data }: { data: Payment[] }) {
const table = useReactTable({
columns,
data,
getCoreRowModel: getCoreRowModel(),
});
return (
<Table>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<TableHead key={header.id}>
{flexRender(
header.column.columnDef.header,
header.getContext(),
)}
</TableHead>
))}
</TableRow>
))}
</TableHeader>
<TableBody>
{table.getRowModel().rows.map((row) => (
<TableRow key={row.id}>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id}>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</TableCell>
))}
</TableRow>
))}
</TableBody>
</Table>
);
}
```
## Core Patterns
### Table component composition
The DS Table maps directly to semantic HTML table elements. Always nest them correctly:
```
Table
TableHeader
TableRow
TableHead (one per column)
TableBody
TableRow (one per data row)
TableCell (one per column)
TableFooter (optional)
TableRow
TableCell
TableCaption (optional, direct child of Table)
```
The `Table` component wraps itself in a `div.relative.w-full.overflow-auto` for horizontal scroll on small screens. `TableRow` includes built-in `hover:bg-muted/50` and `data-[state=selected]:bg-muted` styles.
### ColumnDef with custom cell renderers
Use DS components inside `cell` functions. Always use `flexRender` for both headers and cells:
```tsx
import { Button } from "@loke/design-system/button";
import type { ColumnDef } from "@tanstack/react-table";
const columns: ColumnDef<Discount>[] = [
{
accessorKey: "name",
cell: ({ row }) => (
<div className="min-w-0">
<div className="truncate font-medium text-sm">
{row.original.name}
</div>
</div>
),
header: "Discount",
minSize: 200,
},
{
accessorKey: "status",
cell: ({ row }) => <DiscountStatusBadge status={row.original.status} />,
header: "Status",
size: 120,
},
{
accessorFn: (d) => d.id,
cell: ({ row }) => (
<Button
className="size-10 rounded-full"
onClick={(e) => {
e.stopPropagation();
handleRowClick(row.original);
}}
size="icon"
variant="ghost"
>
<ArrowRight size="md" />
</Button>
),
enableSorting: false,
header: "",
id: "actions",
size: 40,
},
];
```
Use `accessorKey` for direct property access, `accessorFn` for computed values. Set `id` explicitly when using `accessorFn`. Control column widths with `size`, `minSize`, and `maxSize`.
### Sortable headers
The office app pattern uses `Button variant="ghost"` with sort icons from `/icons`. This is implemented in `StandardTable`:
```tsx
import { Button } from "@loke/design-system/button";
import { TableHead, TableRow } from "@loke/design-system/table";
import { ChevronsUpDownIcon, SortAscIcon, SortDescIcon } from "@loke/icons";
import { flexRender } from "@tanstack/react-table";
// Inside header rendering:
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => {
const canSort = header.column.getCanSort();
const sortDirection = header.column.getIsSorted();
if (canSort) {
return (
<TableHead key={header.id}>
<Button
className="w-full justify-between py-1.5"
onClick={header.column.getToggleSortingHandler()}
size="sm"
variant="ghost"
>
<span>
{flexRender(
header.column.columnDef.header,
header.getContext(),
)}
</span>
<span>
{sortDirection === "asc" && (
<SortAscIcon className="text-muted-foreground" />
)}
{sortDirection === "desc" && (
<SortDescIcon className="text-muted-foreground" />
)}
{!sortDirection && (
<ChevronsUpDownIcon className="text-muted-foreground" />
)}
</span>
</Button>
</TableHead>
);
}
return (
<TableHead key={header.id}>
{flexRender(
header.column.columnDef.header,
header.getContext(),
)}
</TableHead>
);
})}
</TableRow>
))}
```
Configure sorting state on `useReactTable`:
```tsx
import { type SortingState, getSortedRowModel } from "@tanstack/react-table";
import { useState } from "react";
const [sorting, setSorting] = useState<SortingState>([]);
const table = useReactTable({
columns,
data,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
onSortingChange: setSorting,
state: { sorting },
});
```
Disable sorting on specific columns with `enableSorting: false` in the column def.
### Row selection
Add a checkbox column as the first column. Use `data-state` on `TableRow` so the DS built-in `data-[state=selected]:bg-muted` style applies:
```tsx
import { Checkbox } from "@loke/design-system/checkbox";
import type { ColumnDef } from "@tanstack/react-table";
const selectionColumn: ColumnDef<MyData> = {
accessorKey: "isChecked",
cell: ({ row }) => (
<div className="flex items-center justify-center">
<Checkbox
checked={Boolean(row.original.isChecked)}
onCheckedChange={(v) => {
toggleSelection(row.original.id, Boolean(v));
}}
onClick={(e) => e.stopPropagation()}
/>
</div>
),
enableColumnFilter: false,
enableSorting: false,
header: "",
minSize: 40,
size: 40,
};
```
On the row, set `data-state` for styling:
```tsx
<TableRow
data-state={row.getIsSelected() && "selected"}
onClick={(event) => handleRowClick(row, event)}
style={{ cursor: hasSelectableRows ? "pointer" : "default" }}
>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id}>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</TableCell>
))}
</TableRow>
```
### Row actions with DropdownMenu
Add an actions column using DropdownMenu for contextual operations:
```tsx
import { Button } from "@loke/design-system/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@loke/design-system/dropdown-menu";
import { EllipsisVertical } from "@loke/icons";
const actionsColumn: ColumnDef<MyData> = {
cell: ({ row }) => (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button size="icon" variant="ghost">
<EllipsisVertical />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => handleEdit(row.original)}>
Edit
</DropdownMenuItem>
<DropdownMenuItem onClick={() => handleDelete(row.original)}>
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
),
enableSorting: false,
header: "",
id: "actions",
size: 40,
};
```
Always use `e.stopPropagation()` on action buttons when rows are clickable.
### Reusable DataTable wrapper
The office app uses a generic `DataTable` wrapper (`apps/office/src/components/data-table/index.tsx`) that accepts `columns` and `data` generically, with optional props for sorting, row selection, grouping, infinite scroll, toolbars, and empty state placeholders. It internally manages `useReactTable` state and delegates rendering to `StandardTable` (semantic `<table>`) or `InfiniteScrollTable` (virtualized with `@tanstack/react-virtual`).
Key props: `sorting` and `rowSelection` accept objects with both state and `OnChangeFn` callbacks, allowing either controlled (server-driven) or uncontrolled (local) modes. `infiniteScroll` takes `fetchNextPage`, `hasNextPage`, and `isFetching` from TanStack Query's `useInfiniteQuery`.
Usage:
```tsx
<DataTable
columns={columns}
data={tableData}
getRowId={(row) => row.uid}
infiniteScroll={{
fetchNextPage: onFetchNextPage,
hasNextPage: !reachedEnd,
isFetching: isLoadingCustomers && !reachedEnd,
}}
rowSelection={{ onRowSelectionChange, rowSelection }}
sorting={{ onSortingChange, sorting }}
/>
```
## Common Mistakes
### HIGH: Incorrect semantic table nesting
Wrong -- `TableCell` inside `TableHeader`, or `TableHead` inside `TableBody`:
```tsx
// WRONG
<TableHeader>
<TableRow>
<TableCell>Name</TableCell>
</TableRow>
</TableHeader>
<TableBody>
<TableRow>
<TableHead>John</TableHead>
</TableRow>
</TableBody>
```
Correct -- `TableHead` in header, `TableCell` in body:
```tsx
// CORRECT
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableRow>
<TableCell>John</TableCell>
</TableRow>
</TableBody>
```
`TableHead` renders `<th>` (with muted foreground, medium font weight). `TableCell` renders `<td>`. Mixing them breaks accessibility and styling.
### MEDIUM: Not using data-state for selected row styling
Wrong -- conditional className for selected rows:
```tsx
// WRONG
<TableRow className={row.getIsSelected() ? "bg-muted" : ""}>
```
Correct -- use the `data-state` attribute. The DS `TableRow` already includes `data-[state=selected]:bg-muted`:
```tsx
// CORRECT
<TableRow data-state={row.getIsSelected() && "selected"}>
```
This keeps styling consistent with the design system and avoids duplicating or conflicting with built-in styles.
### MEDIUM: Building sort UI from scratch
Wrong -- custom sort icon toggle logic with manual state:
```tsx
// WRONG
<TableHead onClick={() => setSort(col)}>
{sortCol === col ? (sortDir === "asc" ? "^" : "v") : "-"}
</TableHead>
```
Correct -- use `Button variant="ghost"` with TanStack Table's built-in sort handler and `/icons` sort icons:
```tsx
// CORRECT
<TableHead>
<Button
className="w-full justify-between py-1.5"
onClick={header.column.getToggleSortingHandler()}
size="sm"
variant="ghost"
>
<span>{flexRender(header.column.columnDef.header, header.getContext())}</span>
<span>
{sortDirection === "asc" && <SortAscIcon className="text-muted-foreground" />}
{sortDirection === "desc" && <SortDescIcon className="text-muted-foreground" />}
{!sortDirection && <ChevronsUpDownIcon className="text-muted-foreground" />}
</span>
</Button>
</TableHead>
```
The three icons are `SortAscIcon`, `SortDescIcon`, and `ChevronsUpDownIcon` from `/icons`.
## See also
- **interactive-components/SKILL.md** -- Button, Checkbox components used in tables
- **overlay-composition/SKILL.md** -- DropdownMenu for row actions, Dialog for table actions