Data table
A sortable, searchable, filterable table with row selection, column visibility, and pagination.
Built on TanStack Table v9, which registers features explicitly: dataTableFeatures is exported so a column helper or your own useTable call shares the same types. Everything the folder holds is re-exported from the item root, so one import — DataTable, DataTableRows, DataTableColumnHeader, DataTableRowActions, DataTableToolbar, DataTablePagination, DataTableFacetedFilter, DataTableViewOptions, selectionColumn — builds a whole table. DataTableRows is the panel on its own: the header, the body and the empty state around a table instance you own, for a page that draws its own toolbar and pagination; DataTable renders it too, so the two never drift. The rows are keyboard-navigable by default: the table is one tab stop, j/k walk a roving cursor down and up the rows, x ticks the row under it and Enter opens it through onRowClick; keyboard={false} turns that off and leaves each clickable row its own tab stop. Column defs are typed as DataTableColumnDef<TData>, not ColumnDef<TData>; TData is constrained to v9's RowData, so a row declared with an interface rather than a type alias needs an index signature or a type alias to satisfy it. Keep columns and data referentially stable — memoise them — or every model rebuilds on each render. The two filters this component drives name their own filter function rather than leaning on filterFn "auto": a facet column gets "arrHas" and the searchKey column gets "includesString A column with no header of its own takes TanStack's — its accessor key, else nothing — except a row menu's: an id: "actions" column gets a visually hidden "Actions" (or its meta.label), so its header cell is never empty to a screen reader or to axe.", unless the column declares its own. The feature set registers only the filter and sort functions those paths and filterFn/sortFn "auto" can reach; add a name to filterFns or sortFns in columns.tsx before using it on a column def, and do not remove includesString or arrHas — the search box and the facets resolve through them. Set meta.align to "right" on numeric columns and the cells get tabular figures with the alignment; the alignment is logical, so "right" is the line's end and "left" its start — a right-to-left table sets its figures, and their titles, on the left; meta.label names a column in the View menu; a column that sets size gets that width in pixels, and everything else is sized by its content. The checkbox column comes from selectionColumn(), which you place yourself so it can name each checkbox after its row; the selection count under the table appears whenever a column with the id "select" is present. State lives inside the table until you ask for a slice of it: pass state with any of pagination, sorting, columnFilters, rowSelection or columnVisibility and that slice is yours, reported back through the matching on*Change. That is also how a server-paged table is built — manualPagination with a rowCount, plus manualSorting and manualFiltering when the query does that work too — and in that mode the table renders the page it was handed rather than slicing it again, counts the pages from rowCount, counts a selection from the ticks themselves so a row on another page still counts, and stops printing per-value facet counts it could only take from the page in hand (give the option its own count instead). A slice you do not pass stays internal, so a fully client-side table needs none of this. Name every table: caption renders a <caption> kept for screen readers (captionVisible draws it above the rows), and aria-label or aria-labelledby land on the <table> itself rather than on the wrapper, where nothing read them — point aria-labelledby at the card's or the page's own heading (Widget, SectionHeader and PageHeader take a titleId for this). A table wider than its panel scrolls in Table's own box, which is then a tab stop named after the table; the panel draws its focus ring round the frame, since the pinned column and a sticky header would cover one drawn inside the box. Below md the first column is pinned, so a wide table scrolls its figures past it; the pinned cell wears its row's plane, and every plane a row can take — at rest, hovered, ticked, with its menu open — is opaque, so nothing scrolls through it. Row height and cell padding read --density-row and --density-cell-y at the default size, so a DensityToggle retightens the table; size="sm" is a caller who has already chosen tight rows and stays where it is put.
Install
npx shadcn@latest add @vibra/data-tableNeeds the @vibra registry in your components.json — set it up once.
Examples
"use client"
import * as React from "react"
import {
CircleCheckIcon,
CircleDashedIcon,
CircleXIcon,
CopyIcon,
UndoDotIcon,
UserRoundIcon,
} from "lucide-react"
import { cn } from "@/lib/utils"
import {
DataTable,
DataTableColumnHeader,
DataTableRowActions,
selectionColumn,
type DataTableColumnDef,
type DataTableFacet,
} from "@/components/ui/data-table"
import { CurrencyCell, TruncateCell } from "@/components/ui/table-cells"
type PaymentStatus = "paid" | "pending" | "failed" | "refunded"
type Payment = {
id: string
customer: string
email: string
status: PaymentStatus
amount: number
}
const STATUS: Record<PaymentStatus, { label: string; dot: string }> = {
paid: { label: "Paid", dot: "bg-success" },
pending: { label: "Pending", dot: "bg-warning" },
failed: { label: "Failed", dot: "bg-danger" },
refunded: { label: "Refunded", dot: "bg-muted-foreground" },
}
const ROWS: [string, string, PaymentStatus, number][] = [
["Olivia Martin", "olivia.martin@blueharbor.example", "paid", 316.0],
["Jackson Lee", "jackson.lee@northwind.example", "pending", 242.5],
["Isabella Nguyen", "isabella@brightloop.example", "paid", 1180.0],
["William Kim", "will.kim@harborlabs.example", "failed", 89.99],
["Sofia Davis", "sofia.davis@blueharbor.example", "paid", 4290.0],
["Ethan Brown", "ethan.brown@meridian.example", "refunded", 175.25],
["Ava Wilson", "ava.wilson@northwind.example", "paid", 620.0],
["Noah Patel", "noah.patel@brightloop.example", "pending", 58.0],
["Mia Rodriguez", "mia.r@harborlabs.example", "paid", 2340.75],
["Liam Chen", "liam.chen@meridian.example", "paid", 995.0],
["Charlotte Hall", "c.hall@blueharbor.example", "failed", 412.4],
["Benjamin Ortiz", "ben.ortiz@northwind.example", "paid", 148.0],
["Amelia Foster", "amelia@brightloop.example", "refunded", 730.0],
["Lucas Silva", "lucas.silva@harborlabs.example", "pending", 1512.9],
["Harper Quinn", "harper.quinn@meridian.example", "paid", 264.0],
["Elijah Novak", "elijah.novak@blueharbor.example", "paid", 3875.5],
["Evelyn Adeyemi", "evelyn.a@northwind.example", "pending", 97.5],
["James Okafor", "james.okafor@brightloop.example", "paid", 1044.0],
["Abigail Stone", "abby.stone@harborlabs.example", "failed", 220.0],
["Henry Iversen", "henry.i@meridian.example", "paid", 505.6],
["Emily Tran", "emily.tran@blueharbor.example", "refunded", 1320.0],
["Daniel Weiss", "daniel.weiss@northwind.example", "paid", 76.0],
["Scarlett Moore", "scarlett@brightloop.example", "pending", 2870.0],
["Matthew Ruiz", "m.ruiz@harborlabs.example", "paid", 189.99],
["Grace Lindqvist", "grace.l@meridian.example", "paid", 640.0],
["Samuel Bright", "samuel.bright@blueharbor.example", "failed", 1105.0],
["Chloe Bennett", "chloe.bennett@northwind.example", "paid", 348.25],
["Owen Fitzgerald", "owen.fitz@brightloop.example", "refunded", 82.0],
["Layla Haddad", "layla.haddad@harborlabs.example", "paid", 2015.0],
["Sebastian Cruz", "seb.cruz@meridian.example", "pending", 459.0],
]
const payments: Payment[] = ROWS.map(([customer, email, status, amount], index) => ({
id: `pay_${String(index + 1).padStart(4, "0")}`,
customer,
email,
status,
amount,
}))
const facets: DataTableFacet<Payment>[] = [
{
columnId: "status",
title: "Status",
options: [
{ label: "Paid", value: "paid", icon: CircleCheckIcon },
{ label: "Pending", value: "pending", icon: CircleDashedIcon },
{ label: "Failed", value: "failed", icon: CircleXIcon },
{ label: "Refunded", value: "refunded", icon: UndoDotIcon },
],
},
]
export default function DataTableDemo() {
const [note, setNote] = React.useState<string | null>(null)
const columns = React.useMemo<DataTableColumnDef<Payment>[]>(
() => [
selectionColumn<Payment>({ rowLabel: (payment) => payment.customer }),
{
accessorKey: "customer",
header: ({ column }) => <DataTableColumnHeader column={column} title="Customer" />,
cell: ({ row }) => (
<TruncateCell maxWidth={180} className="font-medium">
{row.original.customer}
</TruncateCell>
),
meta: { label: "Customer" },
},
{
accessorKey: "email",
header: ({ column }) => <DataTableColumnHeader column={column} title="Email" />,
cell: ({ row }) => (
<TruncateCell maxWidth={220} className="text-muted-foreground">
{row.original.email}
</TruncateCell>
),
meta: { label: "Email" },
},
{
accessorKey: "status",
header: ({ column }) => <DataTableColumnHeader column={column} title="Status" />,
cell: ({ row }) => {
const status = STATUS[row.original.status]
return (
<span className="inline-flex items-center gap-2 whitespace-nowrap">
<span aria-hidden="true" className={cn("size-1.5 rounded-full", status.dot)} />
{status.label}
</span>
)
},
meta: { label: "Status" },
},
{
accessorKey: "amount",
header: ({ column }) => <DataTableColumnHeader column={column} title="Amount" />,
cell: ({ row }) => <CurrencyCell value={row.original.amount} />,
meta: { align: "right", label: "Amount" },
},
{
id: "actions",
size: 44,
enableSorting: false,
enableHiding: false,
cell: ({ row }) => (
<DataTableRowActions
label={`Open menu for ${row.original.customer}`}
actions={[
{
label: "Copy payment ID",
icon: <CopyIcon />,
onSelect: () => setNote(`Copied ${row.original.id}`),
},
{
label: "View customer",
icon: <UserRoundIcon />,
onSelect: () => setNote(`Opening ${row.original.customer}`),
},
{
label: "Refund payment",
icon: <UndoDotIcon />,
destructive: true,
separatorBefore: true,
onSelect: () => setNote(`Refund requested for ${row.original.customer}`),
},
]}
/>
),
},
],
[]
)
return (
<DataTable
className="w-full"
caption="Payments"
columns={columns}
data={payments}
searchKey="email"
searchPlaceholder="Filter emails..."
facets={facets}
getRowId={(payment) => payment.id}
initialSorting={[{ id: "amount", desc: true }]}
toolbar={() =>
note ? (
<span role="status" className="text-xs text-muted-foreground">
{note}
</span>
) : null
}
/>
)
}Compact, sticky header
The small size with a pinned header over a scrolling body.
"use client"
import * as React from "react"
import { cn } from "@/lib/utils"
import {
DataTable,
DataTableColumnHeader,
type DataTableColumnDef,
} from "@/components/ui/data-table"
import { DateCell, NumberCell } from "@/components/ui/table-cells"
type Request = {
id: string
method: "GET" | "POST" | "PATCH" | "DELETE"
path: string
status: number
latency: number
at: Date
}
const MINUTE = 60_000
// A fixed reference instant, not Date.now(): a statically built page freezes
// the server's copy at build time, so a live clock here would read one thing in
// the HTML and another once the reader's own clock takes over. format-demo.tsx
// pins its instant for the same reason.
const NOW = new Date(2026, 8, 4, 12, 0, 0).getTime()
const ROWS: [Request["method"], string, number, number][] = [
["GET", "/v1/invoices", 200, 84],
["POST", "/v1/checkout/sessions", 201, 412],
["GET", "/v1/customers/cus_8fj2", 200, 61],
["PATCH", "/v1/subscriptions/sub_19b", 200, 233],
["GET", "/v1/balance", 200, 47],
["POST", "/v1/refunds", 402, 519],
["GET", "/v1/invoices?status=open", 200, 128],
["DELETE", "/v1/webhooks/wh_3k1", 204, 73],
["GET", "/v1/payouts", 200, 96],
["POST", "/v1/customers", 201, 305],
["GET", "/v1/events", 200, 152],
["PATCH", "/v1/customers/cus_71ab", 409, 188],
["GET", "/v1/products", 200, 58],
["POST", "/v1/prices", 201, 274],
["GET", "/v1/subscriptions", 200, 141],
["GET", "/v1/charges/ch_92fa", 404, 39],
["POST", "/v1/payment_intents", 201, 486],
["GET", "/v1/disputes", 200, 118],
["PATCH", "/v1/prices/price_4c8", 200, 167],
["GET", "/v1/tax_rates", 200, 52],
["POST", "/v1/transfers", 500, 931],
["GET", "/v1/accounts/acct_1x9", 200, 77],
["DELETE", "/v1/coupons/SPRING25", 204, 66],
["GET", "/v1/usage_records", 200, 204],
]
// A fixed offset per row keeps the relative timestamps stable between renders.
const requests: Request[] = ROWS.map(([method, path, status, latency], index) => ({
id: `req_${String(index + 1).padStart(4, "0")}`,
method,
path,
status,
latency,
at: new Date(NOW - (index + 1) * 7 * MINUTE),
}))
const STATUS_TONE = (status: number) =>
status >= 500 ? "text-danger" : status >= 400 ? "text-warning" : "text-success"
export default function DataTableCompact() {
const columns = React.useMemo<DataTableColumnDef<Request>[]>(
() => [
{
accessorKey: "method",
header: ({ column }) => <DataTableColumnHeader column={column} title="Method" />,
cell: ({ row }) => (
<span className="font-mono text-xs text-muted-foreground">{row.original.method}</span>
),
size: 84,
meta: { label: "Method" },
},
{
accessorKey: "path",
header: ({ column }) => <DataTableColumnHeader column={column} title="Path" />,
cell: ({ row }) => <span className="font-mono text-xs">{row.original.path}</span>,
meta: { label: "Path" },
},
{
accessorKey: "status",
header: ({ column }) => <DataTableColumnHeader column={column} title="Status" />,
cell: ({ row }) => (
<span className={cn("font-mono text-xs", STATUS_TONE(row.original.status))}>
{row.original.status}
</span>
),
meta: { align: "right", label: "Status" },
},
{
accessorKey: "latency",
header: ({ column }) => <DataTableColumnHeader column={column} title="Latency" />,
cell: ({ row }) => <NumberCell value={row.original.latency} format={(n) => `${n} ms`} />,
meta: { align: "right", label: "Latency" },
},
{
accessorKey: "at",
header: ({ column }) => <DataTableColumnHeader column={column} title="When" />,
cell: ({ row }) => (
<DateCell date={row.original.at} relative className="text-muted-foreground" />
),
meta: { align: "right", label: "When" },
},
],
[]
)
return (
<DataTable
// --data-table-max-h sets how tall the scrolling body gets.
className="w-full [--data-table-max-h:18rem]"
size="sm"
stickyHeader
caption="Recent API requests"
pageSize={12}
pageSizeOptions={[12, 24, 48]}
columns={columns}
data={requests}
searchKey="path"
searchPlaceholder="Filter paths..."
getRowId={(request) => request.id}
/>
)
}Rows that open
onRowClick opens an order in a sheet, from a click or from Enter on the row j and k walked to; closing it hands the focus back.
"use client"
import * as React from "react"
import { formatCurrency, formatDateTime } from "@/lib/format"
import { DataTable, DataTableColumnHeader, type DataTableColumnDef } from "@/components/ui/data-table"
import { ProductArt } from "@/components/ui/product-art"
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from "@/components/ui/sheet"
import { StatusBadge } from "@/components/ui/status-badge"
type Line = [product: string, qty: number, unit: number]
type Order = { number: string; customer: string; placed: string; status: string; payment: string; lines: Line[] }
const ORDERS: Order[] = [
{ number: "ORD-100384", customer: "Yuki Bauer", placed: "2026-09-04T12:44:00Z", status: "fulfilled", payment: "Card", lines: [["Camping Tent 4-Person", 2, 279]] },
{ number: "ORD-100149", customer: "Lena Silva", placed: "2026-09-03T21:26:00Z", status: "fulfilled", payment: "Card", lines: [["Corner Desk", 2, 249], ["Hiking Backpack 40L", 1, 149], ["Paper Shredder", 1, 59]] },
{ number: "ORD-100250", customer: "Luis Kovacs", placed: "2026-09-03T21:13:00Z", status: "fulfilled", payment: "Wallet", lines: [["Floating Wall Shelf", 1, 39]] },
{ number: "ORD-100037", customer: "Anouk Silva", placed: "2026-09-03T17:33:00Z", status: "fulfilled", payment: "Card", lines: [["Bookshelf 5-Tier", 2, 149], ["Laptop Stand", 1, 34], ["Non-Stick Baking Sheet", 2, 17.99]] },
{ number: "ORD-100086", customer: "Luis Andersen", placed: "2026-09-03T13:54:00Z", status: "cancelled", payment: "Card", lines: [["Running Shoes", 2, 79], ["Rain Jacket", 2, 99], ["Coffee Table", 2, 219]] },
{ number: "ORD-100219", customer: "Lena Castillo", placed: "2026-09-03T01:08:00Z", status: "paid", payment: "Card", lines: [["Webcam HD", 1, 59], ["Wireless Mouse", 2, 29], ["Aroma Diffuser", 2, 29.99]] },
{ number: "ORD-100302", customer: "Clara Hartley", placed: "2026-09-02T17:50:00Z", status: "paid", payment: "Card", lines: [["USB-C Hub", 2, 39], ["Glass Food Containers", 2, 27.99], ["Ankle Socks 6-Pack", 1, 16]] },
]
const total = (order: Order) => order.lines.reduce((sum, [, qty, unit]) => sum + qty * unit, 0)
const placedAt = (order: Order) => formatDateTime(order.placed, { timeZone: "UTC", dateStyle: "medium" })
const TONES = { fulfilled: "success", paid: "info" } as const
const COLUMNS: DataTableColumnDef<Order>[] = [
{ accessorKey: "number", header: "Order", cell: ({ row }) => <span className="font-mono text-xs font-medium">{row.original.number}</span> },
{ accessorKey: "customer", header: ({ column }) => <DataTableColumnHeader column={column} title="Customer" /> },
{ accessorKey: "placed", header: "Placed", cell: ({ row }) => <span className="text-muted-foreground">{placedAt(row.original)}</span> },
{ id: "total", accessorFn: total, header: "Total", cell: ({ getValue }) => formatCurrency(getValue() as number), meta: { align: "right" } },
{ accessorKey: "status", header: "Status", cell: ({ row }) => <StatusBadge status={row.original.status} map={TONES} size="sm" /> },
]
// A row is the way into its order: a click anywhere on it, or Enter on the row
// the j and k keys have walked to, opens the order in a sheet. Closing the sheet
// hands the focus back to the row it came from.
export default function DataTableRowDetails() {
const [open, setOpen] = React.useState(false)
// Kept after the sheet closes, so it slides away with its order still in it.
const [order, setOrder] = React.useState<Order | null>(null)
return (
<>
<DataTable
className="w-full"
caption="Orders"
columns={COLUMNS}
data={ORDERS}
getRowId={(order) => order.number}
enableRowSelection={false}
onRowClick={(row) => {
setOrder(row)
setOpen(true)
}}
/>
<Sheet open={open} onOpenChange={setOpen}>
<SheetContent>
{order ? (
<>
<SheetHeader>
<SheetTitle className="font-mono">{order.number}</SheetTitle>
<SheetDescription>
{order.customer} · {placedAt(order)} · {order.payment}
</SheetDescription>
</SheetHeader>
<ul aria-label="Lines" className="flex flex-col gap-3 px-4">
{order.lines.map(([product, qty, unit]) => (
<li key={product} className="flex items-center gap-3 text-sm">
<ProductArt name={product} className="size-10 shrink-0" />
<span className="min-w-0 flex-1">
{product}
<span className="block text-xs text-muted-foreground tabular-nums">
{qty} × {formatCurrency(unit)}
</span>
</span>
<span className="tabular-nums">{formatCurrency(qty * unit)}</span>
</li>
))}
</ul>
<p className="mx-4 flex justify-between border-t border-rule pt-3 text-sm font-medium tabular-nums">
<span>Total</span>
{formatCurrency(total(order))}
</p>
</>
) : null}
</SheetContent>
</Sheet>
</>
)
}Bulk actions
Facets, a search and a pager, with the ticks owned by the page: a bar counts them and acts on them, and suspending asks first.
"use client"
import * as React from "react"
import type { RowSelectionState } from "@tanstack/react-table"
import { BanIcon, DownloadIcon } from "lucide-react"
import { formatCurrency } from "@/lib/format"
import { SelectionActionBar } from "@/components/ui/action-bar"
import { Button } from "@/components/ui/button"
import { ConfirmDialog } from "@/components/ui/confirm-dialog"
import { DataTable, DataTableColumnHeader, selectionColumn, type DataTableColumnDef, type DataTableFacet } from "@/components/ui/data-table"
import { StatusBadge } from "@/components/ui/status-badge"
type Account = { id: string; company: string; contact: string; plan: string; status: string; seats: number; mrr: number }
const ACCOUNTS: Account[] = (
[
["cus_0023", "Brightline Robotics", "Owen Weiss", "team", "active", 59, 1416],
["cus_0017", "Lumen Robotics", "Tobias Weiss", "team", "active", 50, 1200],
["cus_0006", "Vantage Studio", "Tobias Moreau", "team", "active", 34, 816],
["cus_0008", "Harborview Health", "Maja Raman", "team", "active", 25, 600],
["cus_0005", "Trellis Robotics", "Diego Tanaka", "team", "suspended", 23, 552],
["cus_0002", "Vantage Analytics", "Anouk Kowalski", "team", "active", 19, 456],
["cus_0022", "Silverpine Health", "Noah Ferreira", "team", "active", 12, 288],
["cus_0007", "Harborview Software", "Rafael Ferreira", "starter", "active", 15, 180],
["cus_0021", "Quarry Retail", "Hugo Weiss", "starter", "active", 11, 132],
["cus_0009", "Blue Harbor Analytics", "Sofia Marchetti", "starter", "active", 7, 84],
["cus_0020", "Wavelength Software", "Nina Moreau", "starter", "active", 6, 72],
["cus_0016", "Granite Software", "Priya Duarte", "team", "trial", 46, 0],
["cus_0004", "Lumen Group", "Marcus Kovacs", "starter", "trial", 13, 0],
["cus_0001", "Trellis Analytics", "Ada Ferreira", "starter", "trial", 10, 0],
["cus_0010", "Northwind Partners", "Mira Delacroix", "team", "churned", 25, 0],
["cus_0003", "Northwind Health", "Lena Silva", "free", "active", 1, 0],
] as const
).map(([id, company, contact, plan, status, seats, mrr]) => ({ id, company, contact, plan, status, seats, mrr }))
const options = (values: string[]) => values.map((value) => ({ value, label: value[0].toUpperCase() + value.slice(1) }))
const FACETS: DataTableFacet<Account>[] = [
{ columnId: "plan", title: "Plan", options: options(["free", "starter", "team"]) },
{ columnId: "status", title: "Status", options: options(["active", "trial", "suspended", "churned"]) },
]
const TONES = { trial: "info", suspended: "warning", churned: "neutral" } as const
const list = new Intl.ListFormat("en", { type: "conjunction" })
// Ticks are owned here, keyed by account id, so the bar under the table can act
// on them across pages and filters. The bar appears with the first tick and
// counts what it would act on; suspending asks first, and every outcome is said
// in the status line.
export default function DataTableBulk() {
const [selection, setSelection] = React.useState<RowSelectionState>({})
const [suspended, setSuspended] = React.useState<string[]>([])
const [asking, setAsking] = React.useState(false)
const [done, setDone] = React.useState("")
const ids = Object.keys(selection).filter((id) => selection[id])
const names = ACCOUNTS.filter((account) => ids.includes(account.id)).map((account) => account.company)
// A suspension changes the row itself, so the Status facet finds it too.
const data = React.useMemo(
() => ACCOUNTS.map((account) => (suspended.includes(account.id) ? { ...account, status: "suspended" } : account)),
[suspended]
)
const columns = React.useMemo<DataTableColumnDef<Account>[]>(
() => [
selectionColumn<Account>({ rowLabel: (account) => account.company }),
{
accessorKey: "company",
header: ({ column }) => <DataTableColumnHeader column={column} title="Account" />,
cell: ({ row }) => (
<div className="flex flex-col">
<span className="font-medium">{row.original.company}</span>
<span className="text-xs text-muted-foreground">{row.original.contact}</span>
</div>
),
meta: { label: "Account" },
},
{ accessorKey: "plan", header: "Plan", cell: ({ row }) => <span className="capitalize">{row.original.plan}</span> },
{
accessorKey: "status",
header: "Status",
cell: ({ row }) => <StatusBadge status={row.original.status} map={TONES} size="sm" />,
},
{ accessorKey: "seats", header: ({ column }) => <DataTableColumnHeader column={column} title="Seats" />, meta: { align: "right" } },
{
accessorKey: "mrr",
header: ({ column }) => <DataTableColumnHeader column={column} title="MRR" />,
cell: ({ row }) => formatCurrency(row.original.mrr, "USD", { maximumFractionDigits: 0 }),
meta: { align: "right", label: "MRR" },
},
],
[]
)
const finish = (message: string) => {
setDone(message)
setSelection({})
}
return (
<div className="flex w-full flex-col gap-3">
<DataTable
caption="Accounts"
columns={columns}
data={data}
getRowId={(account) => account.id}
searchKey="company"
searchPlaceholder="Search accounts"
facets={FACETS}
pageSize={8}
pageSizeOptions={[8, 16]}
state={{ rowSelection: selection }}
onRowSelectionChange={setSelection}
/>
<p role="status" className="min-h-5 text-sm text-muted-foreground">
{done}
</p>
<SelectionActionBar count={ids.length} itemLabel="account" onClear={() => setSelection({})}>
<Button size="sm" variant="outline" onClick={() => finish(`Exported ${list.format(names)} as CSV.`)}>
<DownloadIcon data-icon="inline-start" aria-hidden="true" />
Export CSV
</Button>
<Button size="sm" variant="destructive" onClick={() => setAsking(true)}>
<BanIcon data-icon="inline-start" aria-hidden="true" />
Suspend…
</Button>
</SelectionActionBar>
<ConfirmDialog
variant="destructive"
open={asking}
onOpenChange={setAsking}
title={`Suspend ${ids.length === 1 ? names[0] : `${ids.length} accounts`}?`}
description="Their people are signed out and their API keys stop working until an admin lifts the suspension."
confirmText="Suspend"
onConfirm={() => {
setSuspended((current) => [...current, ...ids])
finish(`Suspended ${list.format(names)}.`)
}}
/>
</div>
)
}Loading
The loading prop swaps the rows for a skeleton of their shape and marks the panel busy while the button that asked keeps the focus.
"use client"
import * as React from "react"
import { RefreshCwIcon } from "lucide-react"
import { formatCurrency, formatDate } from "@/lib/format"
import { Button } from "@/components/ui/button"
import { DataTable, type DataTableColumnDef } from "@/components/ui/data-table"
import { StatusBadge } from "@/components/ui/status-badge"
type Payout = { id: string; account: string; amount: number; currency: string; status: string; at: string }
const PAYOUTS: Payout[] = [
{ id: "txn_0091", account: "Harbor Trust ··4402", amount: 2228.28, currency: "USD", status: "scheduled", at: "2026-09-13" },
{ id: "txn_0002", account: "Meridian Bank ··0917", amount: 3400.41, currency: "EUR", status: "pending", at: "2026-09-03" },
{ id: "txn_0020", account: "Harbor Trust ··4402", amount: 2148.1, currency: "USD", status: "pending", at: "2026-09-03" },
{ id: "txn_0037", account: "Harbor Trust ··4402", amount: 2448.75, currency: "USD", status: "completed", at: "2026-08-31" },
{ id: "txn_0044", account: "Meridian Bank ··0917", amount: 15945.47, currency: "EUR", status: "completed", at: "2026-08-31" },
{ id: "txn_0119", account: "Harbor Trust ··4402", amount: 9418.9, currency: "USD", status: "completed", at: "2026-08-26" },
]
const COLUMNS: DataTableColumnDef<Payout>[] = [
{ accessorKey: "id", header: "Payout", cell: ({ row }) => <span className="font-mono text-xs">{row.original.id}</span> },
{ accessorKey: "account", header: "Account" },
{ accessorKey: "at", header: "Arrives", cell: ({ row }) => formatDate(row.original.at, "medium", { timeZone: "UTC" }) },
{ accessorKey: "status", header: "Status", cell: ({ row }) => <StatusBadge status={row.original.status} size="sm" /> },
{
accessorKey: "amount",
header: "Amount",
cell: ({ row }) => formatCurrency(row.original.amount, row.original.currency),
meta: { align: "right" },
},
]
// While the payouts are fetched again the rows give way to a skeleton of the
// same shape, the pager steps aside and the panel says it is busy. The button
// that asked keeps the focus and says what it is doing, and the line beside it
// says what the table holds. The skeleton holds still for a reader who asked
// for less motion.
export default function DataTableLoading() {
const [loading, setLoading] = React.useState(false)
const [refreshed, setRefreshed] = React.useState(0)
React.useEffect(() => {
if (!loading) return
const timer = window.setTimeout(() => {
setLoading(false)
setRefreshed((count) => count + 1)
}, 1500)
return () => window.clearTimeout(timer)
}, [loading])
return (
<div className="flex w-full flex-col gap-3">
<div className="flex flex-wrap items-center justify-between gap-2">
<p role="status" className="text-sm text-muted-foreground">
{loading ? "Fetching payouts from both accounts…" : refreshed ? "Payouts are up to date." : "6 payouts, as of 15:40 UTC."}
</p>
<Button
size="sm"
variant="outline"
disabled={loading}
focusableWhenDisabled
aria-busy={loading || undefined}
onClick={() => setLoading(true)}
>
<RefreshCwIcon data-icon="inline-start" aria-hidden="true" className={loading ? "motion-safe:animate-spin" : undefined} />
{loading ? "Refreshing…" : "Refresh"}
</Button>
</div>
<DataTable caption="Payouts" columns={COLUMNS} data={PAYOUTS} getRowId={(payout) => payout.id} enableRowSelection={false} loading={loading} pageSize={6} />
</div>
)
}Empty, with the next step
emptyMessage chosen by the page: a month with no refunds offers the month that has them, a search that finds nothing clears itself.
"use client"
import * as React from "react"
import type { ColumnFiltersState } from "@tanstack/react-table"
import { ReceiptIcon, SearchXIcon } from "lucide-react"
import { formatCurrency, formatDate } from "@/lib/format"
import { Button } from "@/components/ui/button"
import { DataTable, type DataTableColumnDef } from "@/components/ui/data-table"
import { EmptyState } from "@/components/ui/empty-state"
import { SegmentedControl } from "@/components/ui/segmented-control"
type Refund = { number: string; customer: string; placed: string; amount: number }
const REFUNDS: Record<string, Refund[]> = {
September: [],
August: [
{ number: "ORD-100369", customer: "Clara Silva", placed: "2026-08-29", amount: 54.99 },
{ number: "ORD-100167", customer: "Ivan Vermeer", placed: "2026-08-21", amount: 123.99 },
{ number: "ORD-100327", customer: "Diego Tanaka", placed: "2026-08-17", amount: 178 },
],
}
const MONTHS = Object.keys(REFUNDS).map((month) => ({ value: month, label: month }))
const COLUMNS: DataTableColumnDef<Refund>[] = [
{ accessorKey: "number", header: "Order", cell: ({ row }) => <span className="font-mono text-xs">{row.original.number}</span> },
{ accessorKey: "customer", header: "Customer" },
{ accessorKey: "placed", header: "Placed", cell: ({ row }) => formatDate(row.original.placed, "medium", { timeZone: "UTC" }) },
{ accessorKey: "amount", header: "Refunded", cell: ({ row }) => formatCurrency(row.original.amount), meta: { align: "right" } },
]
// Nothing to show reads two ways, and each has its own way out. A month with
// no refunds says so and offers the month that has them; a search that matches
// nothing repeats the search and clears it. The table owns neither answer:
// the page reads the filter it controls and hands over the one that fits.
export default function DataTableEmpty() {
const [month, setMonth] = React.useState("September")
const [filters, setFilters] = React.useState<ColumnFiltersState>([])
const search = (filters.find((filter) => filter.id === "customer")?.value as string | undefined) ?? ""
return (
<div className="flex w-full flex-col gap-3">
<SegmentedControl
size="sm"
aria-label="Month"
options={MONTHS}
value={month}
onValueChange={(next) => {
setMonth(next)
setFilters([])
}}
className="self-start"
/>
<DataTable
caption={`Refunds in ${month}`}
columns={COLUMNS}
data={REFUNDS[month]}
getRowId={(refund) => refund.number}
searchKey="customer"
searchPlaceholder="Search refunds by customer"
enableRowSelection={false}
state={{ columnFilters: filters }}
onColumnFiltersChange={setFilters}
emptyMessage={
search ? (
<EmptyState
size="sm"
icon={<SearchXIcon />}
title={`No refund in ${month} for “${search}”`}
description="The search reads the customer's name."
action={
<Button size="sm" variant="outline" onClick={() => setFilters([])}>
Clear the search
</Button>
}
/>
) : (
<EmptyState
size="sm"
icon={<ReceiptIcon />}
title={`No refunds in ${month}`}
description="A refund issued from an order lands here. The last one went out in August."
action={
<Button size="sm" variant="outline" onClick={() => setMonth("August")}>
Show August
</Button>
}
/>
)
}
/>
</div>
)
}Props
| Prop | Type | Default | Description |
|---|---|---|---|
| columns | DataTableColumnDef<TData, TValue>[] | — | Column definitions; memoise them so the table does not rebuild every render. |
| data | TData[] | — | The rows, memoised for the same reason. |
| searchKey | string | — | Column id the toolbar search box filters, as a substring; without it there is no search box. |
| searchPlaceholder | string | Search... | Placeholder text, which also becomes the search box's accessible name. |
| facets | DataTableFacet<TData>[] | — | Multi-select filters shown in the toolbar, one per column, with live row counts. |
| toolbar | (table: DataTableInstance<TData>) => React.ReactNode | — | Extra toolbar controls, placed after the facets and before the View menu. |
| viewOptionsSlot | React.ReactNode | — | Replaces the toolbar's View menu — an export menu, a density switch. |
| keyboard | boolean | true | j/k walk the rows, x ticks the one under the cursor, Enter opens it, and the whole table is one tab stop. Set false for a table nobody reads with the keyboard, or one nested inside another widget's own key handling. |
| pageSize | number | 10 | Rows per page before anyone touches the page-size menu. |
| pageSizeOptions | number[] | 10, 20, 30, 50 | Choices offered in the rows-per-page menu. |
| enableRowSelection | boolean | true | Set false to make the checkbox column and the selection count inert. |
| initialSorting | SortingState | — | Sort order the table opens on, e.g. one entry of id and desc. |
| emptyMessage | React.ReactNode | — | Replaces the built-in empty state, which already distinguishes filtered from empty. Words are set as one muted line; an element — an EmptyState with the next step in it — is seated as it is. |
| loading | boolean | false | Swaps the rows for a skeleton, hides the pagination, and announces the wait. |
| onRowClick | (row: TData) => void | — | Makes rows clickable and focusable; clicks on a control inside a row are ignored. |
| stickyHeader | boolean | false | Pins the header and scrolls the body inside the panel, capped by --data-table-max-h. |
| caption | React.ReactNode | — | Names the table as its <caption>, kept for screen readers unless captionVisible. Prefer aria-labelledby when a heading on show already says it. |
| captionVisible | boolean | false | Draws the caption above the rows, start-aligned and muted, instead of keeping it out of sight. |
| aria-labelledby | string | — | Names the table by a heading on show — the card's title, the page's h1. Lands on the <table>, not the wrapper. |
| aria-label | string | — | Names the table when nothing on show does. Lands on the <table>, not the wrapper. |
| size | "sm" | "default" | "default" | sm drops rows from h-11 to h-9 and tightens the cell padding. |
| getRowId | (row: TData) => string | — | Keeps selection attached to the row rather than its index; use it whenever rows move. |
| state | Partial<{ pagination: PaginationState; sorting: SortingState; columnFilters: ColumnFiltersState; rowSelection: RowSelectionState; columnVisibility: ColumnVisibilityState }> | — | Slices of state the caller owns; each one passed is controlled and reported through its on*Change. Slices left out stay internal. |
| onPaginationChange | OnChangeFn<PaginationState> | — | Fires with the page the reader asked for; with manualPagination it is the signal to fetch it. |
| onSortingChange | OnChangeFn<SortingState> | — | Fires with the sort the header asked for. |
| onColumnFiltersChange | OnChangeFn<ColumnFiltersState> | — | Fires with the filters the toolbar asked for. |
| onRowSelectionChange | OnChangeFn<RowSelectionState> | — | Fires with the ticks, keyed by getRowId, so a bulk bar outside the table can read them. |
| onColumnVisibilityChange | OnChangeFn<ColumnVisibilityState> | — | Fires with the columns the View menu left showing. |
| manualPagination | boolean | false | The rows are one page a server already paged; the table renders them as they came and takes its page count from rowCount. |
| manualSorting | boolean | false | The rows arrive sorted; the header still reports what was clicked. |
| manualFiltering | boolean | false | The rows arrive filtered; the toolbar still reports what was chosen, and the facets stop counting the page in hand. |
| rowCount | number | — | How many rows the query matched, not how many arrived. Pass it with manualPagination — the page count and the selection total both read it. |
| className | string | — | Merged onto the root, which stacks the toolbar, the panel, and the pagination; the remaining div props are spread onto it too. |
| selectionColumn | (options?: { rowLabel?: (row: TData) => string }) => DataTableColumnDef<TData> | — | The leading checkbox column; rowLabel names each checkbox after its own row. |
| DataTableColumnHeader | { column: DataTableColumn<TData, TValue>; title: string; className?: string } | — | A sortable title button that opens Asc, Desc, and Hide, and announces the current direction. |
| DataTableRowActions | { actions: { label: React.ReactNode; onSelect: () => void; icon?: React.ReactNode; destructive?: boolean; shortcut?: string; separatorBefore?: boolean }[]; label?: string } | — | The trailing menu button; pass label to name it after its row. |
| DataTableFacet | { columnId: string; title: string; options: { label: string; value: string; icon?: React.ComponentType<{ className?: string }>; count?: number }[] } | — | One faceted filter: the column it drives, its button title, and the values it offers. An option's own count wins over the table's, which is the only honest count when the rows are filtered elsewhere. |
| DataTableRows | { table: DataTableInstance<TData>; size?: "sm" | "default"; stickyHeader?: boolean; busy?: boolean; emptyMessage?: React.ReactNode; onRowClick?: (row: TData) => void; keyboard?: boolean; footer?: React.ReactNode; caption?: React.ReactNode; captionVisible?: boolean; "aria-label"?: string; "aria-labelledby"?: string; className?: string } | — | The rows in their panel, around a table instance you own; busy dims them while the next page is on its way without collapsing the frame, and footer seats a strip — the pagination — under the rows inside the panel; caption, aria-label or aria-labelledby name the table, as on DataTable. |
| DataTableColumnMeta | { align?: "left" | "center" | "right"; label?: string; className?: string } | — | Per-column meta the table reads: alignment — logical, so "right" is the line's end and a right-to-left table mirrors it — the View menu label, and extra cell classes. |
Dependencies
Source
"use client"
import * as React from "react"
import {
useTable,
type ColumnFiltersState,
type OnChangeFn,
type PaginationState,
type RowData,
type RowSelectionState,
type SortingState,
type ColumnVisibilityState,
} from "@tanstack/react-table"
import { type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
import {
columnIdOf,
dataTableFeatures,
dataTableRowVariants,
type DataTableColumnDef,
type DataTableInstance,
} from "@/components/ui/data-table/columns"
import type { DataTableFacet } from "@/components/ui/data-table/faceted-filter"
import { DataTablePagination } from "@/components/ui/data-table/pagination"
import { DataTableRows, dataTablePanelClass } from "@/components/ui/data-table/rows"
import { DataTableToolbar } from "@/components/ui/data-table/toolbar"
import { TableSkeleton } from "@/components/ui/loading-skeletons"
// A barrel, so building a table never needs more than one import from
// `@/components/ui/data-table`. The sibling modules export exactly their own
// component and its props type; `columns.tsx` also holds internals
// (`columnIdOf`, `fromControl`, `EMPTY_COPY`, `ariaSortOf`), so it is listed by
// name rather than starred.
export {
dataTableCellVariants,
dataTableFeatures,
dataTableHeadVariants,
dataTableRowVariants,
selectionColumn,
type DataTableColumn,
type DataTableColumnDef,
type DataTableColumnMeta,
type DataTableFeatures,
type DataTableInstance,
type DataTableRow,
type SelectionColumnOptions,
} from "@/components/ui/data-table/columns"
export * from "@/components/ui/data-table/column-header"
export * from "@/components/ui/data-table/faceted-filter"
export * from "@/components/ui/data-table/pagination"
export * from "@/components/ui/data-table/rows"
export * from "@/components/ui/data-table/row-actions"
export * from "@/components/ui/data-table/toolbar"
export * from "@/components/ui/data-table/view-options"
export type DataTableProps<TData extends RowData, TValue = unknown> = React.ComponentProps<"div"> & {
columns: DataTableColumnDef<TData, TValue>[]
data: TData[]
/** Column id the search box filters on; without it there is no search box. */
searchKey?: string
searchPlaceholder?: string
facets?: DataTableFacet<TData>[]
/** Extra toolbar controls, placed after the facets. */
toolbar?: (table: DataTableInstance<TData>) => React.ReactNode
/** Replaces the toolbar's View menu — an export menu, a density switch. */
viewOptionsSlot?: React.ReactNode
/** j/k to walk the rows, x to select, Enter to open. On by default. */
keyboard?: boolean
pageSize?: number
pageSizeOptions?: number[]
enableRowSelection?: boolean
initialSorting?: SortingState
emptyMessage?: React.ReactNode
loading?: boolean
onRowClick?: (row: TData) => void
/** Scrolls the rows under a pinned header instead of growing the page. */
stickyHeader?: boolean
/**
* Names the table, as its `<caption>` — out of sight unless `captionVisible`.
* `aria-label` and `aria-labelledby` name the table too, not the wrapper;
* prefer `aria-labelledby` at the card's or the page's own heading.
*/
caption?: React.ReactNode
/** Draws the caption above the rows instead of keeping it for screen readers. */
captionVisible?: boolean
size?: NonNullable<VariantProps<typeof dataTableRowVariants>["size"]>
/** Keeps selection attached to the row, not its index. Use it whenever rows can move. */
getRowId?: (row: TData) => string
/**
* Slices of table state the caller owns. Pass one and it is controlled — the
* table renders what you hand it and reports every change through the
* matching `on*Change` — so a page fetched from a server can drive the same
* frame the client-side table uses. Slices you leave out stay internal.
*/
state?: Partial<{
pagination: PaginationState
sorting: SortingState
columnFilters: ColumnFiltersState
rowSelection: RowSelectionState
columnVisibility: ColumnVisibilityState
}>
onPaginationChange?: OnChangeFn<PaginationState>
onSortingChange?: OnChangeFn<SortingState>
onColumnFiltersChange?: OnChangeFn<ColumnFiltersState>
onRowSelectionChange?: OnChangeFn<RowSelectionState>
onColumnVisibilityChange?: OnChangeFn<ColumnVisibilityState>
/**
* The rows are one page a server already paged, so the table renders them as
* they came instead of slicing them again. `rowCount` is then the only way it
* can know how many pages there are.
*/
manualPagination?: boolean
/** The rows arrive sorted; the header still reports what was clicked. */
manualSorting?: boolean
/** The rows arrive filtered; the toolbar still reports what was chosen. */
manualFiltering?: boolean
/** How many rows the query matched, not how many arrived. Pass it with `manualPagination`. */
rowCount?: number
}
/** TanStack's default header, and an sr-only "Actions" for a row-menu column that names none. */
function defaultHeader({ header }: { header: { column: { id: string; columnDef: { accessorKey?: unknown; accessorFn?: unknown; meta?: { label?: string } } } } }): React.ReactNode {
const { id, columnDef } = header.column
if (typeof columnDef.accessorKey === "string") return columnDef.accessorKey
if (columnDef.accessorFn) return id
if (id === "actions") return <span className="sr-only">{columnDef.meta?.label ?? "Actions"}</span>
return null
}
/**
* What a column is before its own definition speaks. 0 reads as "size me from
* my content"; a column that sets `size` gets it. The header a column gets when
* it names none is TanStack's own — its accessor key, else nothing — except
* the row menu's: an `id: "actions"` column draws no title, but its header cell
* still says what it holds, to a screen reader walking the columns and to axe's
* empty-table-header. A table built on `useTable` directly (a server-paged
* one, a card grid) passes this too, so no header cell is ever empty.
*/
const dataTableDefaultColumn = { size: 0, header: defaultHeader }
/** A sortable, filterable, paginated table: toolbar above, rows in a panel with the pagination as its footer strip. */
function DataTable<TData extends RowData, TValue = unknown>({
columns,
data,
searchKey,
searchPlaceholder,
facets,
toolbar,
viewOptionsSlot,
keyboard = true,
pageSize = 10,
pageSizeOptions,
enableRowSelection = true,
initialSorting,
emptyMessage,
loading = false,
onRowClick,
stickyHeader = false,
caption,
captionVisible,
"aria-label": ariaLabel,
"aria-labelledby": ariaLabelledBy,
size = "default",
className,
getRowId,
state,
onPaginationChange,
onSortingChange,
onColumnFiltersChange,
onRowSelectionChange,
onColumnVisibilityChange,
manualPagination = false,
manualSorting = false,
manualFiltering = false,
rowCount,
...props
}: DataTableProps<TData, TValue>) {
// Serialised rather than passed straight through, so an inline `facets`
// literal does not rebuild every column on every render — and via JSON, so no
// separator can collide with a column id.
const facetKey = JSON.stringify((facets ?? []).map((facet) => facet.columnId))
// The two filters this component drives get their filter function named
// rather than left to `filterFn: "auto"`, whose choice depends on the value
// type it samples: a facet writes an array of values, and the search box
// writes a substring. A column that declares its own `filterFn` keeps it.
const resolvedColumns = React.useMemo(() => {
const facetIds = new Set<string>(JSON.parse(facetKey))
return columns.map((column) => {
if ("filterFn" in column && column.filterFn) return column
const id = columnIdOf(column) ?? ""
// arrHas keeps the rows whose own value is one of the chosen ones.
if (facetIds.has(id)) return { ...column, filterFn: "arrHas" } as DataTableColumnDef<TData, TValue>
// includesString stringifies the cell, so search works on any value type.
if (id === searchKey) {
return { ...column, filterFn: "includesString" } as DataTableColumnDef<TData, TValue>
}
return column
})
}, [columns, facetKey, searchKey])
const controlled = {
...(state ? { state } : {}),
...(onPaginationChange ? { onPaginationChange } : {}),
...(onSortingChange ? { onSortingChange } : {}),
...(onColumnFiltersChange ? { onColumnFiltersChange } : {}),
...(onRowSelectionChange ? { onRowSelectionChange } : {}),
...(onColumnVisibilityChange ? { onColumnVisibilityChange } : {}),
}
const table = useTable({
features: dataTableFeatures,
// ColumnDef is invariant in its value type, so a columns array narrowed
// to TValue is not a columns array of unknown, however fine each member is.
columns: resolvedColumns as DataTableColumnDef<TData>[],
data,
defaultColumn: dataTableDefaultColumn,
enableRowSelection,
getRowId,
manualPagination,
manualSorting,
manualFiltering,
rowCount,
// Every feature seeds its own slice with `{ ...defaults, ...initialState }`,
// so a `sorting: undefined` key here would wipe the default empty array out
// and the first sort would throw. Only pass the key when there is a value.
initialState: {
pagination: { pageIndex: 0, pageSize },
...(initialSorting ? { sorting: initialSorting } : {}),
},
// Same rule for the controlled half: each feature supplies its own
// `on*Change` — the one that writes the internal state — and an explicit
// `undefined` here would replace it, leaving the table unable to sort or
// filter itself. So only the keys the caller actually passed are spread.
...controlled,
})
const columnCount = Math.max(table.getVisibleFlatColumns().length, 1)
const hasSelectionColumn = resolvedColumns.some((column) => columnIdOf(column) === "select")
return (
<div
data-slot="data-table"
data-size={size}
data-sticky-header={stickyHeader || undefined}
className={cn(
"flex w-full flex-col gap-3",
// Declared on the root so a caller's className can override it —
// on the panel it would shadow whatever they set from out here.
stickyHeader && "[--data-table-max-h:32rem]",
className
)}
{...props}
>
<DataTableToolbar
table={table}
searchKey={searchKey}
searchPlaceholder={searchPlaceholder}
facets={facets}
viewOptionsSlot={viewOptionsSlot}
>
{toolbar?.(table)}
</DataTableToolbar>
{loading ? (
<div
data-slot="data-table-panel"
role="status"
aria-busy="true"
className={dataTablePanelClass(stickyHeader)}
>
<span className="sr-only">Loading rows</span>
{/* COUPLED TO registry/vibra/ui/loading-skeletons.tsx: TableSkeleton
draws its own frame, and this panel already has one. */}
<TableSkeleton rows={pageSize} columns={columnCount} className="rounded-none border-0" />
</div>
) : (
<DataTableRows
table={table}
size={size}
stickyHeader={stickyHeader}
caption={caption}
captionVisible={captionVisible}
aria-label={ariaLabel}
aria-labelledby={ariaLabelledBy}
emptyMessage={emptyMessage}
onRowClick={onRowClick}
keyboard={keyboard}
footer={
<DataTablePagination
table={table}
pageSizeOptions={pageSizeOptions}
showSelectedCount={enableRowSelection && hasSelectionColumn}
/>
}
/>
)}
</div>
)
}
export { DataTable, dataTableDefaultColumn }"use client"
import {
columnFacetingFeature,
columnFilteringFeature,
columnSizingFeature,
columnVisibilityFeature,
createFacetedRowModel,
createFacetedUniqueValues,
createFilteredRowModel,
createPaginatedRowModel,
createSortedRowModel,
filterFn_arrHas,
filterFn_arrIncludes,
filterFn_equals,
filterFn_inDateRange,
filterFn_includesString,
filterFn_inNumberRange,
filterFn_weakEquals,
rowPaginationFeature,
rowSelectionFeature,
rowSortingFeature,
sortFn_alphanumeric,
sortFn_basic,
sortFn_datetime,
sortFn_text,
tableFeatures,
type Column,
type ColumnDef,
type ReactTable,
type Row,
type RowData,
} from "@tanstack/react-table"
import { cva } from "class-variance-authority"
import { Checkbox } from "@/components/ui/checkbox"
export type DataTableColumnMeta = {
/**
* Where the header and the cells sit on the line. The names are the
* left-to-right ones and the alignment is logical: "right" is the line's end,
* the left of a right-to-left table, and "left" its start. Set "right" on
* every numeric column.
*/
align?: "left" | "center" | "right"
/** The name shown in the View menu, when the column id is not a good one. */
label?: string
/** Extra classes for this column's header and cells. */
className?: string
}
/**
* The features every Vibra data table runs on. Exported so a column helper or a
* hand-rolled useTable call can be typed against exactly the same set.
*
* The fn registries are named one by one rather than by spreading the exported
* `filterFns`/`sortFns` objects, which are deprecated upstream and pull all
* twenty-four built-ins (18 filter, 6 sort) into the bundle. What is registered is every name
* `filterFn: "auto"` and `sortFn: "auto"` can resolve to — so a column of any
* value type still filters and sorts correctly — plus `arrHas` for the faceted
* filters. Add a name here before using it on a column def.
*/
export const dataTableFeatures = tableFeatures({
columnFacetingFeature,
columnFilteringFeature,
columnSizingFeature,
columnVisibilityFeature,
rowPaginationFeature,
rowSelectionFeature,
rowSortingFeature,
facetedRowModel: createFacetedRowModel(),
facetedUniqueValues: createFacetedUniqueValues(),
filteredRowModel: createFilteredRowModel(),
paginatedRowModel: createPaginatedRowModel(),
sortedRowModel: createSortedRowModel(),
filterFns: {
arrHas: filterFn_arrHas,
arrIncludes: filterFn_arrIncludes,
equals: filterFn_equals,
inDateRange: filterFn_inDateRange,
includesString: filterFn_includesString,
inNumberRange: filterFn_inNumberRange,
weakEquals: filterFn_weakEquals,
},
sortFns: {
alphanumeric: sortFn_alphanumeric,
basic: sortFn_basic,
datetime: sortFn_datetime,
text: sortFn_text,
},
columnMeta: {} as DataTableColumnMeta,
})
export type DataTableFeatures = typeof dataTableFeatures
export type DataTableColumnDef<TData extends RowData, TValue = unknown> = ColumnDef<DataTableFeatures, TData, TValue>
export type DataTableColumn<TData extends RowData, TValue = unknown> = Column<DataTableFeatures, TData, TValue>
export type DataTableRow<TData extends RowData> = Row<DataTableFeatures, TData>
export type DataTableInstance<TData extends RowData> = ReactTable<DataTableFeatures, TData>
/** Resolves a column id the way the table itself does, so facets can name a column by its accessor key. */
function columnIdOf(def: object): string | undefined {
const column = def as { id?: string; accessorKey?: unknown; header?: unknown }
if (column.id) return column.id
if (column.accessorKey !== undefined) return String(column.accessorKey).replaceAll(".", "_")
return typeof column.header === "string" ? column.header : undefined
}
// COUPLED TO registry/vibra/ui/table.tsx: TableHead ships h-10 and
// text-foreground. Column titles are labels, not data: the label register in
// ink, one step shorter than the rows they sit above.
const dataTableHeadVariants = cva("type-label bg-surface text-foreground", {
variants: {
// The header is a row too: a quarter step shorter than a body row at
// either density, which is h-10 comfortable.
size: {
default: "h-[calc(var(--density-row,2.75rem)-0.25rem)] px-3",
sm: "h-8 px-2.5",
},
// Logical, like TableHead's own text-start: a right-to-left table's titles
// start at the right, and a numeric column's end at the left.
align: { left: "text-start", center: "text-center", right: "text-end" },
sticky: {
// A border on a sticky <th> scrolls away with the row it belongs to; an
// inset shadow is painted by the cell itself, so the hairline stays put.
true: "sticky top-0 z-10 shadow-[inset_0_-1px_0_0_var(--rule)]",
false: "",
},
},
defaultVariants: { size: "default", align: "left", sticky: false },
})
const dataTableCellVariants = cva("", {
variants: {
// Only the default size follows the density token; `sm` is a caller
// who has already chosen tight rows, and would overflow its own
// h-9 if the cells grew underneath it.
size: { default: "px-3 py-[var(--density-cell-y,0.75rem)]", sm: "px-2.5 py-2" },
// "right" means numeric, set at the line's end, and a column of numerals
// only lines up on tabular figures — the pairing simple-table.tsx makes.
align: { left: "", center: "text-center", right: "text-end tabular-nums" },
},
defaultVariants: { size: "default", align: "left" },
})
// Selection is TableRow's own business now — the accent's fifth role, drawn as
// a brand-muted plane with a 2px rail and 500 weight, so it never reads as a
// pointer position and never reads by colour alone.
// Every plane a row can take is opaque, because the pinned first column below
// md takes its row's plane: the card at rest, and for a hover or an open menu
// TableRow's half-muted plane mixed into the card rather than laid over it —
// the same colour on the card, but solid, so a figure scrolling under the
// pinned cell never shows through it. Selected (brand-muted) and focused
// (muted) are solid already.
// Written out in full: Tailwind finds a class by reading the source, not by
// running it.
const dataTableRowVariants = cva(
"bg-card hover:bg-[color-mix(in_srgb,var(--muted)_50%,var(--card))] has-aria-expanded:bg-[color-mix(in_srgb,var(--muted)_50%,var(--card))]",
{
variants: {
size: { default: "h-[var(--density-row,2.75rem)]", sm: "h-9" },
clickable: {
true: "cursor-pointer focus-ring focus-visible:bg-muted",
false: "",
},
},
defaultVariants: { size: "default", clickable: false },
}
)
// Nothing to show reads two ways: the data has not arrived yet, or the filters
// hid it. Only one of those is the reader's to fix.
const EMPTY_COPY = {
filtered: {
title: "No matching rows",
description: "Clear the search or the filters to see everything again.",
},
unfiltered: { title: "No rows yet", description: undefined },
} as const
// A click on the checkbox or the ⋯ button is a click on that control, not on
// the row underneath it.
const INTERACTIVE =
"a, button, input, select, textarea, label, [role='checkbox'], [role='menuitem'], [contenteditable='true']"
function fromControl(target: EventTarget | null) {
return target instanceof Element && target.closest(INTERACTIVE) !== null
}
/** The <th>'s own sort state — omitted entirely on a column that cannot be sorted. */
function ariaSortOf<TData extends RowData>(column: DataTableColumn<TData>) {
if (!column.getCanSort()) return undefined
const sorted = column.getIsSorted()
return sorted === "asc" ? "ascending" : sorted === "desc" ? "descending" : "none"
}
export type SelectionColumnOptions<TData extends RowData> = {
/** Names each checkbox after its own row — "Select row for Olivia Martin". */
rowLabel?: (row: TData) => string
}
/** The leading checkbox column. Put it first in `columns`; `DataTable` never adds one for you. */
function selectionColumn<TData extends RowData>(
options: SelectionColumnOptions<TData> = {}
): DataTableColumnDef<TData> {
const { rowLabel } = options
return {
id: "select",
size: 32,
enableSorting: false,
enableHiding: false,
meta: { label: "Select" },
header: ({ table }) => (
<Checkbox
checked={table.getIsAllPageRowsSelected()}
indeterminate={table.getIsSomePageRowsSelected() && !table.getIsAllPageRowsSelected()}
// Same reason as the per-row box below: nothing on this page can be
// selected, so the control should not look like it can select it.
disabled={!table.getRowModel().rows.some((row) => row.getCanSelect())}
onCheckedChange={(checked) => table.toggleAllPageRowsSelected(checked)}
aria-label="Select all rows on this page"
/>
),
cell: ({ row }) => (
<Checkbox
checked={row.getIsSelected()}
// enableRowSelection={false} makes the toggle a no-op; say so rather
// than leaving a checkbox that looks live and does nothing.
disabled={!row.getCanSelect()}
onCheckedChange={(checked) => row.toggleSelected(checked)}
aria-label={
rowLabel ? `Select row for ${rowLabel(row.original)}` : `Select row ${row.index + 1}`
}
/>
),
}
}
export {
ariaSortOf,
columnIdOf,
dataTableCellVariants,
dataTableHeadVariants,
dataTableRowVariants,
EMPTY_COPY,
fromControl,
selectionColumn,
}"use client"
import { ArrowDownIcon, ArrowUpIcon, ChevronsUpDownIcon, EyeOffIcon } from "lucide-react"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import type { DataTableColumn } from "@/components/ui/data-table/columns"
import type { RowData } from "@tanstack/react-table"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
// Read out after the column name, so the direction never depends on the arrow
// glyph alone. The <th> also carries aria-sort; this is what a button user hears.
const SORT_LABELS = {
asc: "sorted ascending",
desc: "sorted descending",
none: "not sorted",
} as const
const SORT_ICONS = {
asc: ArrowUpIcon,
desc: ArrowDownIcon,
none: ChevronsUpDownIcon,
} as const
export type DataTableColumnHeaderProps<TData extends RowData, TValue> = {
column: DataTableColumn<TData, TValue>
title: string
className?: string
}
/** A sortable column title: a real button that opens Asc / Desc / Hide and says which way it is sorted. */
function DataTableColumnHeader<TData extends RowData, TValue>({
column,
title,
className,
}: DataTableColumnHeaderProps<TData, TValue>) {
const align = column.columnDef.meta?.align ?? "left"
if (!column.getCanSort()) {
return (
<span data-slot="data-table-column-header" className={cn("block truncate", className)}>
{title}
</span>
)
}
const sorted = column.getIsSorted()
const state = sorted === "asc" ? "asc" : sorted === "desc" ? "desc" : "none"
const SortIcon = SORT_ICONS[state]
return (
<DropdownMenu>
<DropdownMenuTrigger
render={
<Button
type="button"
data-slot="data-table-column-header"
data-sorted={state}
variant="ghost"
size="sm"
// The visible title stays inside the name, so speech control still
// reaches this button by saying what is printed on it.
aria-label={`${title}, ${SORT_LABELS[state]}`}
className={cn(
// The label register in ink, like a title that cannot sort; the
// arrow, not the colour, says which column is ordering the rows.
"-mx-2.5 h-7 text-sm font-medium text-foreground",
align === "right" && "ms-auto",
className
)}
>
{title}
<SortIcon aria-hidden="true" className="size-3.5" />
</Button>
}
/>
{/* COUPLED TO registry/vibra/ui/dropdown-menu.tsx: the menu popup is sized
to its anchor, and this anchor is a short title button. */}
<DropdownMenuContent align={align === "right" ? "end" : "start"} className="w-auto min-w-32">
<DropdownMenuItem onClick={() => column.toggleSorting(false)}>
<ArrowUpIcon className="text-muted-foreground" />
Asc
</DropdownMenuItem>
<DropdownMenuItem onClick={() => column.toggleSorting(true)}>
<ArrowDownIcon className="text-muted-foreground" />
Desc
</DropdownMenuItem>
{column.getCanHide() ? (
<>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => column.toggleVisibility(false)}>
<EyeOffIcon className="text-muted-foreground" />
Hide
</DropdownMenuItem>
</>
) : null}
</DropdownMenuContent>
</DropdownMenu>
)
}
export { DataTableColumnHeader }"use client"
import * as React from "react"
import type { RowData } from "@tanstack/react-table"
import { SearchXIcon } from "lucide-react"
import { type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
import { useRovingRows } from "@/hooks/use-roving-rows"
import {
ariaSortOf,
dataTableCellVariants,
dataTableHeadVariants,
dataTableRowVariants,
EMPTY_COPY,
fromControl,
type DataTableInstance,
type DataTableRow,
} from "@/components/ui/data-table/columns"
import { EmptyState } from "@/components/ui/empty-state"
import {
Table,
TableBody,
TableCaption,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table"
/**
* Below md the first column is pinned, so a wide table scrolls its figures past
* a name that stays put. The cell takes the row's own background rather than a
* fixed one, so a hovered or selected row keeps its plane under the pinned cell
* instead of showing a card-coloured hole — which is why every plane
* dataTableRowVariants gives a row is opaque: a translucent hover showed the
* figures scrolling underneath straight through the pinned cell.
*/
const STICKY_FIRST_COLUMN =
"max-md:[&_[data-slot=table-cell]:first-child]:sticky max-md:[&_[data-slot=table-cell]:first-child]:start-0 max-md:[&_[data-slot=table-cell]:first-child]:z-10 max-md:[&_[data-slot=table-cell]:first-child]:bg-inherit max-md:[&_[data-slot=table-head]:first-child]:sticky max-md:[&_[data-slot=table-head]:first-child]:start-0 max-md:[&_[data-slot=table-head]:first-child]:z-20"
/**
* A table wider than its panel scrolls in Table's own box, which is then a tab
* stop with a ring drawn inside its edge — where the pinned first column and a
* sticky header, painted above it, would cover it. So the panel draws the ring
* instead, around its frame where nothing lies over it, and the box's own is
* turned off.
*/
const FOCUSED_SCROLL_BOX =
"has-[>[data-slot=table-container]:focus-visible]:focus-outline [&>[data-slot=table-container]:focus-visible]:outline-none"
/** The panel's frame, shared by the rows and by the skeleton that stands in for them. */
export function dataTablePanelClass(stickyHeader?: boolean, className?: string) {
return cn(
"overflow-hidden panel",
STICKY_FIRST_COLUMN,
FOCUSED_SCROLL_BOX,
// The header sticks to the table's own scroll container, so that container
// needs a height to scroll within.
stickyHeader && "[&_[data-slot=table-container]]:max-h-(--data-table-max-h)",
className
)
}
export type DataTableRowsProps<TData extends RowData> = {
table: DataTableInstance<TData>
size?: NonNullable<VariantProps<typeof dataTableRowVariants>["size"]>
/** Pins the header and scrolls the body inside the panel. */
stickyHeader?: boolean
/**
* Dims the rows while the next page is on its way. For a table whose rows
* come from a server: the frame keeps its border and its height, so a slow
* page never collapses the layout under the reader's cursor.
*/
busy?: boolean
/**
* Replaces the built-in empty state, which distinguishes filtered from empty.
* Words are set as one muted line; an element — an EmptyState with the next
* step in it — is seated as it is.
*/
emptyMessage?: React.ReactNode
onRowClick?: (row: TData) => void
/**
* j/k to walk the rows, x to select the one under the cursor, Enter to open
* it, and one tab stop for the whole table. Off for a table nobody reads with
* the keyboard, or one nested inside another widget's own key handling.
*/
keyboard?: boolean
/**
* A strip under the rows, inside the panel — the pagination, usually — so
* the count and the page controls sit on the sheet the rows do, under one
* rule, rather than floating beneath the frame.
*/
footer?: React.ReactNode
/**
* Names the table, as its `<caption>`: read out when a screen reader enters
* it or lists the page's tables, and kept out of sight unless
* `captionVisible`. Prefer `aria-labelledby` at a heading already on show.
*/
caption?: React.ReactNode
/** Draws the caption above the rows instead of keeping it for screen readers. */
captionVisible?: boolean
/** Names the table when nothing on show does. */
"aria-label"?: string
/** Names the table by a heading on show — the card's title, the page's h1. */
"aria-labelledby"?: string
className?: string
}
/**
* The rows themselves, in their panel: the header, the body, what stands in
* for them when there are none, and a footer strip when there is one.
*
* Split out of `DataTable` because a table whose rows arrive one page at a time
* needs the frame without the toolbar or the pagination — it draws those itself,
* around a `useTable` instance it owns, so that the query behind them is the
* page it fetched. `DataTable` renders this too, so both read the same.
*/
export function DataTableRows<TData extends RowData>({
table,
size = "default",
stickyHeader = false,
busy = false,
emptyMessage,
onRowClick,
keyboard = true,
footer,
caption,
captionVisible = false,
"aria-label": ariaLabel,
"aria-labelledby": ariaLabelledBy,
className,
}: DataTableRowsProps<TData>) {
const rows = table.getRowModel().rows
const columnCount = Math.max(table.getVisibleFlatColumns().length, 1)
const emptyState = table.state.columnFilters.length > 0 ? "filtered" : "unfiltered"
const byId = React.useMemo(() => new Map(rows.map((row) => [row.id, row])), [rows])
const roving = useRovingRows({
ids: React.useMemo(() => rows.map((row) => row.id), [rows]),
enabled: keyboard && rows.length > 0,
onToggle: (id) => {
const row = byId.get(id)
if (row?.getCanSelect()) row.toggleSelected()
},
onActivate: (id) => {
const row = byId.get(id)
if (row) onRowClick?.(row.original)
},
})
// Built once rather than inline per row, and only when there is somewhere to
// click through to.
const rowInteraction = (row: DataTableRow<TData>) =>
onRowClick
? {
// The roving grid hands out the tab stops when it is on. With it off
// the rows carried a key handler nothing could ever focus, so a
// clickable table was reachable by mouse only.
...(keyboard ? null : { tabIndex: 0 }),
onClick: (event: React.MouseEvent) => {
if (!fromControl(event.target)) onRowClick(row.original)
},
onKeyDown: (event: React.KeyboardEvent) => {
if (event.target !== event.currentTarget) return
if (event.key !== "Enter" && event.key !== " ") return
event.preventDefault()
onRowClick(row.original)
},
}
: undefined
return (
<div
data-slot="data-table-panel"
data-busy={busy || undefined}
className={dataTablePanelClass(stickyHeader, className)}
{...(keyboard ? roving.containerProps : null)}
>
<Table
aria-label={ariaLabel}
aria-labelledby={ariaLabelledBy}
aria-busy={busy || undefined}
className={cn("transition-opacity", busy && "opacity-60")}
>
{caption ? (
<TableCaption
data-visible={captionVisible || undefined}
className={
captionVisible
? "mt-0 caption-top px-3 pt-3 pb-2 text-start text-sm text-muted-foreground"
: "sr-only"
}
>
{caption}
</TableCaption>
) : null}
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id} className="hover:bg-transparent">
{headerGroup.headers.map((header) => {
const meta = header.column.columnDef.meta
const width = header.column.columnDef.size
return (
<TableHead
key={header.id}
colSpan={header.colSpan}
data-align={meta?.align ?? "left"}
aria-sort={ariaSortOf(header.column)}
style={width ? { width } : undefined}
className={cn(
dataTableHeadVariants({ size, align: meta?.align, sticky: stickyHeader }),
meta?.className
)}
>
{header.isPlaceholder ? null : <table.FlexRender header={header} />}
</TableHead>
)
})}
</TableRow>
))}
</TableHeader>
<TableBody>
{rows.length === 0 ? (
<TableRow className="hover:bg-transparent">
<TableCell colSpan={columnCount} className="p-0">
<div
data-slot="data-table-empty"
role="status"
className="flex items-center justify-center"
>
{emptyMessage === undefined ? (
<EmptyState size="sm" icon={<SearchXIcon />} {...EMPTY_COPY[emptyState]} />
) : typeof emptyMessage === "string" || typeof emptyMessage === "number" ? (
<span className="py-6 text-sm text-muted-foreground">{emptyMessage}</span>
) : (
// An element — an EmptyState with its own way out — is seated
// as it is: inside the muted line it lost its title's ink and
// sat a block inside an inline span.
emptyMessage
)}
</div>
</TableCell>
</TableRow>
) : (
rows.map((row) => (
<TableRow
key={row.id}
data-state={row.getIsSelected() ? "selected" : undefined}
{...(keyboard ? roving.rowProps(row.id) : null)}
{...rowInteraction(row)}
className={dataTableRowVariants({ size, clickable: Boolean(onRowClick) })}
>
{row.getVisibleCells().map((cell) => {
const meta = cell.column.columnDef.meta
return (
<TableCell
key={cell.id}
data-align={meta?.align ?? "left"}
className={cn(
dataTableCellVariants({ size, align: meta?.align }),
meta?.className
)}
>
<table.FlexRender cell={cell} />
</TableCell>
)
})}
</TableRow>
))
)}
</TableBody>
</Table>
{footer ? (
<div data-slot="data-table-footer" className="border-t border-rule px-3 py-2.5">
{footer}
</div>
) : null}
</div>
)
}"use client"
import * as React from "react"
import type { RowData } from "@tanstack/react-table"
import { SlidersHorizontalIcon, XIcon } from "lucide-react"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import {
DataTableFacetedFilter,
type DataTableFacet,
} from "@/components/ui/data-table/faceted-filter"
import type { DataTableInstance } from "@/components/ui/data-table/columns"
import { DataTableViewOptions } from "@/components/ui/data-table/view-options"
import { Input } from "@/components/ui/input"
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
SheetTrigger,
} from "@/components/ui/sheet"
export type DataTableToolbarProps<TData extends RowData> = {
table: DataTableInstance<TData>
/** Column id to search; the box disappears when it is unset. */
searchKey?: string
searchPlaceholder?: string
facets?: DataTableFacet<TData>[]
/** Extra controls, placed after the facets and before the View menu. */
children?: React.ReactNode
/**
* Replaces the View menu at the end of the row — an export menu beside it, a
* density switch, whatever the page needs there. Counts as content: a
* toolbar that has one renders even with no search and no facets.
*/
viewOptionsSlot?: React.ReactNode
className?: string
}
/**
* The row above the table: a search box, the facet filters, a reset, and the
* View menu.
*
* It renders nothing at all when it has nothing to hold. The View menu on its
* own is not content — a table with no search and no facets used to ship a bar
* whose only occupant was a column picker nobody had asked for, and an empty
* 32px strip above a table reads as a missing element.
*
* Below md the facets and the View menu move into one Filters sheet: six
* controls wrapping onto three lines is most of a phone's first screen, and the
* search box is the one of them worth keeping in view.
*/
function DataTableToolbar<TData extends RowData>({
table,
searchKey,
searchPlaceholder = "Search...",
facets,
children,
viewOptionsSlot,
className,
}: DataTableToolbarProps<TData>) {
const searchColumn = searchKey ? table.getColumn(searchKey) : undefined
const filtered = table.state.columnFilters.length > 0
const hasFacets = Boolean(facets?.length)
if (!searchColumn && !hasFacets && !children && !viewOptionsSlot) return null
const facetFilters = facets?.map((facet) => (
<DataTableFacetedFilter
key={facet.columnId}
column={table.getColumn(facet.columnId)}
title={facet.title}
options={facet.options}
// A server-filtered table holds one page of the result, so its own
// per-value counts would be counts of that page.
showCounts={!table.options.manualFiltering}
/>
))
const reset = filtered ? (
<Button
type="button"
data-slot="data-table-reset"
variant="ghost"
size="sm"
onClick={() => table.resetColumnFilters()}
>
Reset
<XIcon data-icon="inline-end" aria-hidden="true" />
</Button>
) : null
const viewOptions = viewOptionsSlot ?? <DataTableViewOptions table={table} />
return (
<div
data-slot="data-table-toolbar"
className={cn("flex flex-wrap items-center gap-2", className)}
>
{searchColumn ? (
<Input
value={(searchColumn.getFilterValue() as string | undefined) ?? ""}
onChange={(event) => searchColumn.setFilterValue(event.target.value || undefined)}
placeholder={searchPlaceholder}
// The placeholder disappears the moment there is a value in the box.
aria-label={searchPlaceholder}
className="h-8 w-full max-w-56 text-sm"
/>
) : null}
<div className="hidden flex-wrap items-center gap-2 md:flex">
{facetFilters}
{reset}
{children}
{viewOptions}
</div>
<Sheet>
<SheetTrigger
render={
<Button
type="button"
data-slot="data-table-filters-trigger"
variant="outline"
size="sm"
className="md:hidden"
>
<SlidersHorizontalIcon data-icon="inline-start" aria-hidden="true" />
Filters
{filtered ? (
<span
data-slot="data-table-filters-count"
className="ms-1 rounded-full bg-brand-muted px-1.5 text-2xs font-medium text-brand"
>
{table.state.columnFilters.length}
</span>
) : null}
</Button>
}
/>
<SheetContent side="bottom" className="max-h-[80svh] overflow-y-auto p-4">
<SheetHeader className="p-0">
<SheetTitle>Filters</SheetTitle>
<SheetDescription>Narrow the rows, and choose what the table shows.</SheetDescription>
</SheetHeader>
<div
data-slot="data-table-filters-panel"
className="flex flex-col items-stretch gap-2 [&>*]:w-full [&_[data-slot=button]]:justify-start"
>
{facetFilters}
{reset}
{children}
{viewOptions}
</div>
</SheetContent>
</Sheet>
</div>
)
}
export { DataTableToolbar }"use client"
import type { RowData } from "@tanstack/react-table"
import {
ChevronLeftIcon,
ChevronRightIcon,
ChevronsLeftIcon,
ChevronsRightIcon,
} from "lucide-react"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import type { DataTableInstance } from "@/components/ui/data-table/columns"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
export type DataTablePaginationProps<TData extends RowData> = {
table: DataTableInstance<TData>
pageSizeOptions?: number[]
/** Shows "3 of 128 row(s) selected." on the left. */
showSelectedCount?: boolean
className?: string
}
/**
* How many rows are ticked, and out of how many.
*
* A table that pages itself can count both off its own row models. One handed a
* page at a time cannot: the filtered model holds the rows that arrived, so it
* would report "2 of 10" for a selection spanning a 250-row result. In that
* mode the ticks themselves are the count — selection is keyed by `getRowId`
* and survives a page turn — and the total is the `rowCount` the caller gave.
*/
function selectionCounts<TData extends RowData>(table: DataTableInstance<TData>) {
const served = table.options.manualPagination || table.options.manualFiltering
return {
// getRowCount already prefers options.rowCount over the row model.
total: table.getRowCount(),
selected: served
? Object.values(table.state.rowSelection).filter(Boolean).length
: table.getFilteredSelectedRowModel().rows.length,
}
}
/** The row below the table: how much is selected, how many rows a page holds, and where you are. */
function DataTablePagination<TData extends RowData>({
table,
pageSizeOptions = [10, 20, 30, 50],
showSelectedCount = true,
className,
}: DataTablePaginationProps<TData>) {
const { pageIndex, pageSize } = table.state.pagination
const { selected, total } = selectionCounts(table)
// An empty table still sits on a page; "Page 1 of 0" would just be wrong.
const pageCount = Math.max(table.getPageCount(), 1)
return (
<div
data-slot="data-table-pagination"
className={cn("flex flex-wrap items-center justify-between gap-x-6 gap-y-2", className)}
>
<div className="flex-1 text-sm text-muted-foreground">
{showSelectedCount ? (
<span data-slot="data-table-selected-count">
{selected} of {total} row(s) selected.
</span>
) : null}
</div>
<div className="flex items-center gap-2">
<span className="hidden text-sm text-muted-foreground sm:inline">Rows per page</span>
<Select
value={String(pageSize)}
onValueChange={(value) => {
if (value) table.setPageSize(Number(value))
}}
>
<SelectTrigger size="sm" aria-label="Rows per page" className="w-[4.25rem]">
<SelectValue />
</SelectTrigger>
<SelectContent>
{pageSizeOptions.map((option) => (
<SelectItem key={option} value={String(option)}>
{option}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex items-center gap-2">
<span
data-slot="data-table-page-status"
// Moving between pages is a change worth hearing about.
aria-live="polite"
className="text-sm tabular-nums whitespace-nowrap text-muted-foreground"
>
Page {pageIndex + 1} of {pageCount}
</span>
<div className="flex items-center gap-1">
<Button
type="button"
variant="outline"
size="icon-sm"
aria-label="Go to first page"
disabled={!table.getCanPreviousPage()}
onClick={() => table.firstPage()}
>
<ChevronsLeftIcon aria-hidden="true" className="rtl:rotate-180" />
</Button>
<Button
type="button"
variant="outline"
size="icon-sm"
aria-label="Go to previous page"
disabled={!table.getCanPreviousPage()}
onClick={() => table.previousPage()}
>
<ChevronLeftIcon aria-hidden="true" className="rtl:rotate-180" />
</Button>
<Button
type="button"
variant="outline"
size="icon-sm"
aria-label="Go to next page"
disabled={!table.getCanNextPage()}
onClick={() => table.nextPage()}
>
<ChevronRightIcon aria-hidden="true" className="rtl:rotate-180" />
</Button>
<Button
type="button"
variant="outline"
size="icon-sm"
aria-label="Go to last page"
disabled={!table.getCanNextPage()}
onClick={() => table.lastPage()}
>
<ChevronsRightIcon aria-hidden="true" className="rtl:rotate-180" />
</Button>
</div>
</div>
</div>
)
}
export { DataTablePagination }"use client"
import * as React from "react"
import type { RowData } from "@tanstack/react-table"
import { PlusCircleIcon } from "lucide-react"
import { cn } from "@/lib/utils"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
CommandSeparator,
} from "@/components/ui/command"
import type { DataTableColumn } from "@/components/ui/data-table/columns"
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"
import { Separator } from "@/components/ui/separator"
export type DataTableFacetOption = {
label: string
value: string
icon?: React.ComponentType<{ className?: string }>
/** How many rows carry this value. Give it when the server counted them; otherwise the table counts the rows it holds. */
count?: number
}
export type DataTableFacet<TData extends RowData> = {
/** The column this filter drives: an accessor key, or an explicit column id. */
columnId: Extract<keyof TData, string> | (string & {})
title: string
options: DataTableFacetOption[]
}
export type DataTableFacetedFilterProps<TData extends RowData, TValue> = {
/** Undefined when no column matches the facet's columnId; the filter then renders nothing. */
column?: DataTableColumn<TData, TValue>
title: string
options: DataTableFacetOption[]
/**
* Whether the table's own count of each value may stand in for an option that
* gives none. False for a server-filtered table, where the rows in hand are
* one page of the result and counting them would report "3" for a value that
* matches three hundred.
*/
showCounts?: boolean
className?: string
}
/** A multi-select filter for one column, with the row count beside every option. */
function DataTableFacetedFilter<TData extends RowData, TValue>({
column,
title,
options,
showCounts = true,
className,
}: DataTableFacetedFilterProps<TData, TValue>) {
if (!column) return null
const selected = new Set((column.getFilterValue() as string[] | undefined) ?? [])
const counts = showCounts ? column.getFacetedUniqueValues() : undefined
const chosen = options.filter((option) => selected.has(option.value))
const toggle = (value: string) => {
const next = new Set(selected)
if (next.has(value)) next.delete(value)
else next.add(value)
column.setFilterValue(next.size > 0 ? Array.from(next) : undefined)
}
return (
<Popover>
<PopoverTrigger
render={
<Button
type="button"
data-slot="data-table-faceted-filter"
data-active={selected.size > 0 || undefined}
variant="outline"
size="sm"
className={cn("border-dashed data-[active]:border-solid", className)}
>
<PlusCircleIcon data-icon="inline-start" aria-hidden="true" />
{title}
{chosen.length > 0 ? (
<>
<Separator orientation="vertical" aria-hidden="true" className="mx-0.5 h-4" />
{/* Two names fit on the button; past that the count says more in less. */}
{chosen.length > 2 ? (
<Badge variant="secondary" className="rounded-sm px-1 font-normal">
{chosen.length} selected
</Badge>
) : (
chosen.map((option) => (
<Badge
key={option.value}
variant="secondary"
className="rounded-sm px-1 font-normal"
>
{option.label}
</Badge>
))
)}
</>
) : null}
</Button>
}
/>
{/* COUPLED TO registry/vibra/ui/popover.tsx: the popover ships a padded
w-72 panel, and a command list draws its own padding at its own width. */}
<PopoverContent align="start" className="w-56 p-0">
<Command>
<CommandInput placeholder={title} />
{/* Options are checked independently, and cmdk does not say so on its
own — see the aria-checked on each item below. */}
<CommandList aria-multiselectable="true">
<CommandEmpty>No options match.</CommandEmpty>
<CommandGroup>
{options.map((option) => {
const isSelected = selected.has(option.value)
// A count the caller worked out wins: only they can count rows
// the table was never handed.
const count = option.count ?? counts?.get(option.value)
return (
<CommandItem
key={option.value}
// Filter on the label, so the row count beside it never
// becomes part of what the search box matches.
value={option.label}
onSelect={() => toggle(option.value)}
// cmdk owns aria-selected, which it puts on the *highlighted*
// option — a cursor, not a choice. Checked state is its own
// attribute, so the two never contradict each other.
aria-checked={isSelected}
data-checked={isSelected ? "true" : undefined}
>
{option.icon ? (
<option.icon className="size-4 text-muted-foreground" />
) : null}
{option.label}
{count !== undefined ? (
<span className="ms-auto text-xs tabular-nums text-muted-foreground">
{count}
</span>
) : null}
</CommandItem>
)
})}
</CommandGroup>
{selected.size > 0 ? (
<>
<CommandSeparator />
<CommandGroup>
<CommandItem
onSelect={() => column.setFilterValue(undefined)}
className="justify-center text-muted-foreground"
>
Clear filter
</CommandItem>
</CommandGroup>
</>
) : null}
</CommandList>
</Command>
</PopoverContent>
</Popover>
)
}
export { DataTableFacetedFilter }"use client"
import type { RowData } from "@tanstack/react-table"
import { SlidersHorizontalIcon } from "lucide-react"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import type { DataTableInstance } from "@/components/ui/data-table/columns"
import {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuLabel,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
export type DataTableViewOptionsProps<TData extends RowData> = {
table: DataTableInstance<TData>
className?: string
}
/** The View menu: one checkbox per hideable column, labelled from its meta.label or its id. */
function DataTableViewOptions<TData extends RowData>({ table, className }: DataTableViewOptionsProps<TData>) {
const columns = table.getAllColumns().filter((column) => column.getCanHide())
if (columns.length === 0) return null
return (
<DropdownMenu>
<DropdownMenuTrigger
render={
<Button
type="button"
data-slot="data-table-view-options"
variant="outline"
size="sm"
// "View" alone says nothing about what it views.
aria-label="View columns"
className={cn("ms-auto", className)}
>
<SlidersHorizontalIcon data-icon="inline-start" aria-hidden="true" />
View
</Button>
}
/>
<DropdownMenuContent align="end" className="w-auto min-w-40">
{/* COUPLED TO registry/vibra/ui/dropdown-menu.tsx: the label is a
Base UI group label and throws outside a group. */}
<DropdownMenuGroup>
<DropdownMenuLabel>Columns</DropdownMenuLabel>
{columns.map((column) => (
<DropdownMenuCheckboxItem
key={column.id}
checked={column.getIsVisible()}
onCheckedChange={(checked) => column.toggleVisibility(checked)}
closeOnClick={false}
>
<span className="capitalize">{column.columnDef.meta?.label ?? column.id}</span>
</DropdownMenuCheckboxItem>
))}
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
)
}
export { DataTableViewOptions }"use client"
import * as React from "react"
import { EllipsisIcon } from "lucide-react"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
export type DataTableRowAction = {
label: React.ReactNode
onSelect: () => void
icon?: React.ReactNode
/** Renders in the danger tone and sits behind its own separator. */
destructive?: boolean
/** Displayed right-aligned, e.g. "⌘C". Purely a hint; wire the key up yourself. */
shortcut?: string
separatorBefore?: boolean
}
export type DataTableRowActionsProps = {
actions: DataTableRowAction[]
/** Name the row here — "Open menu for Olivia Martin" — so 30 identical buttons stop sounding identical. */
label?: string
className?: string
}
/** The trailing ⋯ button on a row, and the menu of things you can do to that row. */
function DataTableRowActions({
actions,
label = "Open row menu",
className,
}: DataTableRowActionsProps) {
return (
<DropdownMenu>
<DropdownMenuTrigger
render={
<Button
type="button"
data-slot="data-table-row-actions"
variant="ghost"
size="icon-sm"
aria-label={label}
className={cn("ms-auto text-muted-foreground", className)}
>
<EllipsisIcon aria-hidden="true" />
</Button>
}
/>
{/* COUPLED TO registry/vibra/ui/dropdown-menu.tsx: the popup is sized to
its anchor, and this anchor is a 28px icon button. */}
<DropdownMenuContent align="end" className="w-auto min-w-40">
{actions.map((action, index) => (
<React.Fragment key={index}>
{action.separatorBefore && index > 0 ? <DropdownMenuSeparator /> : null}
<DropdownMenuItem
data-slot="data-table-row-action"
variant={action.destructive ? "destructive" : "default"}
onClick={action.onSelect}
>
{action.icon ? (
<span className="flex items-center [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4">
{action.icon}
</span>
) : null}
{action.label}
{action.shortcut ? (
<DropdownMenuShortcut>{action.shortcut}</DropdownMenuShortcut>
) : null}
</DropdownMenuItem>
</React.Fragment>
))}
</DropdownMenuContent>
</DropdownMenu>
)
}
export { DataTableRowActions }