Table scrubber map
ProWhere 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.
Version 5.2.0· other versions
This page is for 5.2.0, the current release. An earlier release’s page is the Markdown that release published, so the docs match the version you have installed (candor-lock.json records it).
Not in the docs of 1.3.0, 1.4.0, 1.5.0, 1.5.1, 1.6.0, 1.7.0, 1.8.0, 1.8.1, 1.9.0, 1.9.1, 2.0.0, 2.1.0, 2.2.0, 2.3.0, 2.4.0, 2.5.0, 2.5.1, 2.6.0, 2.6.1, 2.6.2, 2.7.0, 2.8.0, 2.9.0, 2.10.0, 3.0.0, 4.0.0, 4.0.1, 4.0.2 and 4.1.0: the page came later, or had another name then.
Docs pages weren’t kept for 1.0.0, 1.0.1, 1.1.0, 1.1.1, 1.2.0 and 1.2.1: they are kept from 1.3.0 on, and before that only llms.txt was published.
Preview
Loading the preview
Install
First time? Get started sets up the registry and theme. Or with shadcn directly: npx shadcn@latest add @candor/table-scrubber-map
Brings in: bidi, chart-focus, locale, tokens, utils
Usage
"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>
| Prop | Type | Default | Description |
|---|---|---|---|
| labelrequired | string | — | What the flags are, naming the list: "Failed rows". |
| totalrequired | number | null | — | How many rows the whole result holds; `null` when the server doesn't count them. |
| pageSizerequired | number | — | Rows per page, as the table's. |
| pagerequired | number | — | The page shown, from 0: the same as the DataTable's `page`. |
| onPageChangerequired | (page: number) => void | — | Called with the page, from 0, the person picks. |
| marksrequired | 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 | "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
The fixed words, in English by default. Change any of them under tableScrubberMap in <LocaleProvider strings={{ … }}>; the table is exported as tableScrubberMapStrings.
| Key | English |
|---|---|
| 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 |
Motion
| Moment | Family | What it does |
|---|---|---|
| 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.
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`).
{ index: number; kind?: string | undefined }- ScrubberKind
- What a kind of flag is called and its tone.
{ label: string; tone?: "default" | "warning" | "destructive" | undefined }