# Table scrubber map

Where a paginated table's flagged rows are: a strip of the whole result, with a tick for each flagged row and the page shown outlined, and a toolbar of the pages that hold flags ("Page 4: 3 flagged"), each a button to that page, one tab stop with the arrow keys between them, starting on the page shown when it has flags. It sits beside a `DataTable` that shares one page with it: the table's `page` and `onPageChange` and this map's `page` and `onPageChange` read and set the same state. That works in memory and in server mode (where your `onQueryChange` fetches the page).

`marks` index the rows in the order the table shows them, not the order you hold them in: an in-memory DataTable reorders its rows as a person searches or sorts, and groups them, so take the positions from its `onOrderChange`, which gives every matching row's id in its current order. In server mode they are positions in your server's result for the current query.

Positions are drawn only against a known total: with `total={null}` it says the total isn't known and lists the pages with flags without placing them.

- Registry name: `table-scrubber-map` (`@candor/table-scrubber-map`)
- 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/table-scrubber-map

## Install

```bash
npx candor-ui add table-scrubber-map
```

Run `npx candor-ui@latest init` once first (https://candor.themethodic.com/get-started.md). Without the CLI: `npx shadcn@latest add @candor/table-scrubber-map`.
It brings in: bidi, chart-focus, locale, tokens, utils.

## Example

```tsx
"use client"

import * as React from "react"
import { TableScrubberMap } from "@/components/candor/table-scrubber-map"
import { DataTable, type DataTableColumn, type DataTableQuery } from "@/components/candor/data-table"

type Run = { id: string; name: string; status: "passed" | "failed" | "slow"; seconds: number }

// 120 runs held in memory. The table and the map share one page: each one's `page` and `onPageChange` are the same state.
const runs: Run[] = Array.from({ length: 120 }, (_, i) => ({
  id: `run-${i + 1}`,
  name: `Nightly import #${i + 1}`,
  status: [4, 37, 41, 86].includes(i) ? "failed" : [38, 103].includes(i) ? "slow" : "passed",
  seconds: 40 + ((i * 7) % 30),
}))
const byId = new Map(runs.map((r) => [r.id, r]))
/** The flagged runs, by their position in the order the table shows the rows. */
const marksIn = (order: string[]) =>
  order.flatMap((id, index) => {
    const status = byId.get(id)?.status
    return status === undefined || status === "passed" ? [] : [{ index, kind: status }]
  })
const marks = marksIn(runs.map((r) => r.id))
const columns: DataTableColumn<Run>[] = [
  { id: "name", label: "Run", value: (r) => r.name },
  { id: "status", label: "Status", value: (r) => r.status },
  { id: "seconds", label: "Seconds", value: (r) => r.seconds, numeric: true },
]

export default function Example() {
  return (
    <div className="flex flex-col gap-8">
      <InMemory />
      <ServerMode />
    </div>
  )
}

/**
 * In memory: the table pages, searches and sorts the rows it holds, and turns to the page the map picks. The map's
 * marks are positions in the table's current order, which `onOrderChange` gives, so they stay true after a sort.
 */
function InMemory() {
  const [page, setPage] = React.useState(0)
  const [order, setOrder] = React.useState(() => runs.map((r) => r.id))
  return (
    <div className="flex flex-col gap-4 sm:flex-row sm:items-start">
      <TableScrubberMap
        label="Flagged runs"
        total={order.length}
        pageSize={10}
        page={page}
        onPageChange={setPage}
        marks={marksIn(order)}
        kinds={{ failed: { label: "failed", tone: "destructive" }, slow: { label: "slow", tone: "warning" } }}
      />
      <DataTable<Run> title="Nightly imports" columns={columns} rows={runs} rowId={(r) => r.id} pageSize={10} page={page} onPageChange={setPage} onOrderChange={setOrder} className="min-w-0 flex-1" />
    </div>
  )
}

/** Server mode: the page goes to your server through `onQueryChange`; the map's `onPageChange` asks for it the same way. */
function ServerMode() {
  const [query, setQuery] = React.useState<DataTableQuery>({ search: "", sort: null, page: 0, pageSize: 10 })
  // Stands in for your server: one page of the runs, and how many there are.
  const shown = runs.slice(query.page * query.pageSize, (query.page + 1) * query.pageSize)
  return (
    <div className="flex flex-col gap-4 sm:flex-row sm:items-start">
      <TableScrubberMap label="Flagged runs, from the server" total={runs.length} pageSize={query.pageSize} page={query.page} onPageChange={(p) => setQuery((q) => ({ ...q, page: p }))} marks={marks} />
      <DataTable<Run> title="Nightly imports (server)" columns={columns} rows={shown} rowId={(r) => r.id} pageSize={query.pageSize} total={runs.length} searchable={false} page={query.page} onQueryChange={setQuery} className="min-w-0 flex-1" />
    </div>
  )
}
```

## <TableScrubberMap>

Props:

- `label: string`: What the flags are, naming the list: "Failed rows".
- `total: number | null`: How many rows the whole result holds; `null` when the server doesn't count them.
- `pageSize: number`: Rows per page, as the table's.
- `page: number`: The page shown, from 0: the same as the DataTable's `page`.
- `onPageChange: (page: number) => void`: Called with the page, from 0, the person picks.
- `marks: ScrubberMark[]`: The flagged rows, by their position in the whole result in the table's current order (DataTable's `onOrderChange` gives it in memory).
- `kinds?: Record<string, ScrubberKind> | undefined`: The kinds of flag `marks` name, with their words and tones. Kind labels are drawn as isolated text.
- `orientation?: "vertical" | "horizontal" | undefined` (default `"vertical"`): A tall strip beside the table (the default) or a wide one above it.
- `className?: string | undefined`: Classes for the root, merged with Candor's.

## Strings (`tableScrubberMap`)

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

- `page`: { one: "Page {page}: {count} flagged", other: "Page {page}: {count} flagged" }
- `kindCount`: {count} {kind}
- `withKinds`: {page} ({kinds})
- `unknownTotal`: The total isn't known, so where these pages fall isn't shown.
- `none`: No flagged rows.
- `kinds`: Kinds of flag

## Types

### ScrubberMark

A flagged row: its position in the whole result as the table shows it, from 0, and its kind (a key of `kinds`).

```ts
{ index: number; kind?: string | undefined }
```

### ScrubberKind

What a kind of flag is called and its tone.

```ts
{ label: string; tone?: "default" | "warning" | "destructive" | undefined }
```

## Motion

- **tint** (tint): A page's background changes when the pointer is over it, and a ring fades in when it takes keyboard focus.

Set with `--cd-motion-table-scrubber-map-<moment>-*` in your brand file; the families are in the design reference.

## 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`: its fixed strings in `strings`, its nouns in `nouns`; entity kinds and agent roles in `EntityKindsProvider` and `AgentRolesProvider`.
