# Data table

Rows of records: search, sort, filter chips, column settings, selection and pages. Counts are of what's actually loaded ("Showing 1–10 of 248"). A blank cell shows a dash, never an empty gap that might be read as zero. Empty explains itself — no rows at all, or none matching the search.

- Registry name: `data-table` (`@candor/data-table`)
- Tier: Pro. Installing it needs a Pro, Premium or Team licence key: the person runs `npx candor-ui key <key>` themselves; never ask for the key in chat or write it to a file.
- Docs: https://candor.themethodic.com/docs/data-table

## Install

```bash
npx candor-ui add data-table
```

Run `npx candor-ui@latest init` once first (https://candor.themethodic.com/get-started.md). Without the CLI: `npx shadcn@latest add @candor/data-table`.
It brings in: data, dropdown-menu, filter-chips, locale, skeleton, utils.
npm packages: lucide-react@^1.67.0.

## Example

```tsx
"use client"

import * as React from "react"
import { DataTable } from "@/components/data-table"
import type { Column, FilterChip } from "@/lib/data"
import { Button } from "@/components/ui/button"

type Migration = { id: string; name: string; repo: string; env: "Production" | "Staging"; rows: number | null; author: string; applied: string }

const migrations: Migration[] = [
  { id: "m142", name: "0142_add_region_to_projects", repo: "acme/api", env: "Staging", rows: 18_240, author: "Dev agent", applied: "2026-11-04" },
  { id: "m141", name: "0141_index_deployments_sha", repo: "acme/api", env: "Production", rows: null, author: "Dana", applied: "2026-11-02" },
  { id: "m140", name: "0140_drop_legacy_score", repo: "acme/api", env: "Production", rows: 9_812, author: "Jonas", applied: "2026-10-29" },
  { id: "m139", name: "0139_backfill_owner_id", repo: "acme/workers", env: "Production", rows: 42_100, author: "Dev agent", applied: "2026-10-27" },
  { id: "m138", name: "0138_add_flags_table", repo: "acme/web", env: "Production", rows: 0, author: "Mei", applied: "2026-10-22" },
  { id: "m137", name: "0137_split_invoice_lines", repo: "acme/api", env: "Staging", rows: 133_480, author: "Dev agent", applied: "2026-10-20" },
  { id: "m136", name: "0136_rename_plan_tier", repo: "acme/api", env: "Production", rows: 6_031, author: "Dana", applied: "2026-10-15" },
]

const columns: Column<Migration>[] = [
  { id: "name", label: "Migration", value: (m) => m.name, render: (m) => <code className="font-mono text-xs">{m.name}</code> },
  { id: "repo", label: "Repository", value: (m) => m.repo },
  { id: "env", label: "Environment", value: (m) => m.env },
  { id: "rows", label: "Rows touched", value: (m) => m.rows, numeric: true },
  { id: "author", label: "Author", value: (m) => m.author, optional: true },
  { id: "applied", label: "Applied", value: (m) => m.applied },
]

export default function Example() {
  const [filters, setFilters] = React.useState<FilterChip[]>([{ id: "repo", label: "Repository", value: "Not acme/web" }])
  const rows = filters.some((f) => f.id === "repo") ? migrations.filter((m) => m.repo !== "acme/web") : migrations
  return (
    <DataTable<Migration>
      title="Migrations"
      columns={columns}
      rows={rows}
      rowId={(m) => m.id}
      selectable
      pageSize={5}
      filters={filters}
      onRemoveFilter={(id) => setFilters((all) => all.filter((f) => f.id !== id))}
      onClearFilters={() => setFilters([])}
      actions={(selected) => (
        <Button size="xs" variant="outline" onClick={() => {}}>
          Ask Keel about {selected.size}
        </Button>
      )}
    />
  )
}
```

## <DataTable>

Props:

- `columns: Column<Row>[]`: The columns: header, how to read and show a row's value, and whether it's hidden until turned on.
- `rows: Row[]`: The loaded records. Search, sort and paging work on these in the browser.
- `rowId: (row: Row) => string`: A stable id for a row, used for keys and selection.
- `title?: string | undefined`: Heading over the table, also its accessible name. Defaults to "Records" for the name, with no heading shown.
- `loading?: boolean | undefined` (default `false`): Rows are being fetched; shows skeleton rows instead of the empty message.
- `searchable?: boolean | undefined` (default `true`): Shows a search box that matches any column's value.
- `filters?: FilterChip[] | undefined` (default `[]`): Filters already applied, shown as removable chips.
- `onRemoveFilter?: ((id: string) => void) | undefined`: Called with a filter's id when its chip is removed.
- `onClearFilters?: (() => void) | undefined`: Called when a person clears all filters.
- `selectable?: boolean | undefined` (default `false`): Adds a checkbox to each row and a select-all for the page.
- `selected?: Set<string> | undefined`: Ids of selected rows, when you control selection. Without it the table keeps its own.
- `onSelectedChange?: ((ids: Set<string>) => void) | undefined`: Called with the new set of selected ids when a person selects or clears rows.
- `onRowClick?: ((row: Row) => void) | undefined`: Called with the row a person clicks; rows look clickable when set.
- `pageSize?: number | undefined` (default `10`): Rows per page.
- `empty?: string | undefined`: Shown when there are no rows at all (not when a search or filter matches nothing).
- `actions?: ((selected: Set<string>) => React.ReactNode) | undefined`: Shown in the header when rows are selected, e.g. bulk actions.
- `className?: string | undefined`

## Strings (`dataTable`)

The fixed words, English by default. Override any of them under `dataTable` in `<LocaleProvider strings={{ dataTable: { … } }}>`; the table is exported as `dataTableStrings`.

- `records`: Records
- `empty`: No records yet.
- `selected`: {count} selected
- `search`: Search
- `columns`: Columns
- `showColumns`: Show columns
- `selectAll`: Select all on this page
- `selectRow`: Select row
- `blank`: blank
- `noMatch`: Nothing matches. Try a different search or remove a filter.
- `showing`: Showing {from}–{to} of {total}
- `inAll`: ({total} in all)
- `previousPage`: Previous page
- `nextPage`: Next page
- `page`: {page} / {pages}

## Rules for using Candor UI

- Components take plain typed data. Never pass model output as HTML or render it as a link; messages are typed blocks.
- A control that can't be used says why: wrap it in `<Locked reason="…">`, don't just disable it.
- Don't show a value you don't have: leave it out (it renders as —) rather than guess. An estimate is labelled as one.
- Nothing is pre-selected in a decision a person makes, and the agent is named from your app's data, never from model output.
- Your app's words go in `LocaleProvider` (fixed strings) and `TermsProvider` (nouns); entity kinds and agent roles in `EntityKindsProvider` and `AgentRolesProvider`.
