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