Grid Selection
Selection
Cell-range selection model, row selection, controlled state, and three-state checkbox column.
Pretable has two selection slices, and knowing which one you are touching saves an afternoon. The grid below runs both at once, each wired to its own callback and echoed in its own caption — shift-click to extend a range, Cmd/Ctrl-click to add a discontiguous one, drag for a marquee, tick a checkbox, and watch which caption moves:
The two selection slices side by side: cell ranges controlled through PretableSelectionFor<typeof columns> and onSelectionChange, and the rowSelectionColumn checkbox set reported by onRowSelectionChange — shift-click extends a range, Cmd/Ctrl-click adds a discontiguous one, dragging marquees a rectangle, and a caption under each shows which callback just fired.
The captions move independently because the slices are independent:
- Cell ranges — Excel/Sheets semantics, driven by clicking and dragging over cells, and the thing the first caption above is printing. This is the slice
PretableSelectionFordescribes,state.selectioncontrols, andonSelectionChangereports. - Row selection — the ticked set behind the
rowSelectionColumncheckbox column, and the thing the second caption is printing. It is stored separately, as a sparse program that can mean "all rows" without ever listing them, so a select-all over a million rows costs nothing.PretableRowSelectionStatedescribes it,state.rowSelectioncontrols it, andonRowSelectionChangereports it.
The two meet in exactly one place: a checkbox shows as checked when its row is ticked or when the cell ranges happen to cover every column of that row. Nowhere else. Ticking a checkbox does not produce a cell range, and does not fire onSelectionChange — which is why ticking one above leaves the first caption exactly where it was.
The same engine state powers <Pretable>, <PretableSurface>, and the usePretable hook.
Under row grouping, group header rows are focus targets but are never selectable or editable — Enter / Space toggles the group instead of selecting it, and arrowing onto one leaves the previously selected row selected.
Selection model
The cell-range slice is an anchor plus a list of ranges. The checkbox slice is not in here — see Checkbox column:
interface PretableSelectionFor<TColumns> {
ranges: PretableCellRangeFor<TColumns>[];
anchor: PretableCellAddressFor<TColumns> | null;
}
interface PretableCellRangeFor<TColumns> {
startRowId: string;
endRowId: string;
startColumnId: string; // narrowed to TColumns' ids
endColumnId: string; // narrowed to TColumns' ids
}
interface PretableCellAddressFor<TColumns> {
rowId: string;
columnId: string; // narrowed to TColumns' ids
}PretableSelectionFor, PretableCellRangeFor, and PretableCellAddressFor are exported from @pretable/react. They take the same column tuple as your columns declaration — PretableSelectionFor<typeof columns> — and narrow columnId to your actual column ids, the same way PretableQueryFor<typeof columns> narrows a controlled query. Use this family whenever columns comes from createColumnHelper + as const, which is what the filtering and grouping docs teach and what preserves the tuple typed query inference depends on:
import { createColumnHelper } from "@pretable/core";
import type { PretableSelectionFor } from "@pretable/react";
const column = createColumnHelper<Row>();
const columns = [
column.accessor("name", { type: "text", header: "Name" }),
column.accessor("city", { type: "text", header: "City" }),
] as const;
// selection.ranges[number].startColumnId: "name" | "city" | ...
const initialSelection: PretableSelectionFor<typeof columns> = {
ranges: [],
anchor: null,
};@pretable/core's PretableSelectionState (with sibling PretableCellRange / PretableCellAddress) is the loose, string-id counterpart the underlying engine uses, and is the right type when columns is not a literal tuple — built at runtime, for instance — so there is no column-id union to narrow against.
A few invariants worth knowing:
- IDs, not indices. Ranges reference rows and columns by stable id (
getRowId,column.id). Sort, filter, and column reorder do not invalidate the selection — the cells you selected stay selected even when their visual position changes. - Focus has its own slice. Surface focus is
{ ref, columnId }, whererefis discriminated as{ kind: "data", rowId }or{ kind: "group", groupId }. A focused data cell is part of a range; group focus remains unambiguous even if a data row has the same serialized id. Collapsing selection (Esc, plain click) reduces data-cell ranges to a single cell at the focused address. - Multiple ranges allowed. Cmd/Ctrl+click adds discontiguous ranges. The anchor moves to the most recent range start.
Click + drag
Cell-level click semantics match Excel/Sheets — the same gestures you just tried in the grid above. Selection visuals tint the cell background (--pretable-selection-bg) and the focused cell shows a 2px outline, inset by its own width (--pretable-focus-ring).
| Gesture | Effect |
|---|---|
| Click body cell | Move focus to the cell; collapse selection to that single cell. |
| Shift+click | Extend the active range from the anchor to the clicked cell. With no prior anchor, behaves as plain click. |
| Cmd/Ctrl+click | Add a discontiguous single-cell range; anchor moves to the clicked cell. |
| Drag (pointer down → enter cells → up) | Marquee selection from the drag-start cell to the cell under the pointer at release. |
| Esc during drag | Revert to the pre-drag selection. |
Drag is suppressed when shift or cmd/ctrl is held on pointer-down — those click variants apply instead.
Checkbox column
The grid above already has one — set rowSelectionColumn={{ enabled: true }} on <PretableSurface> for a left-pinned checkbox column with three-state header checkbox + per-row toggles.
<PretableSurface
ariaLabel="Inspection grid"
columns={columns}
rows={rows}
getRowId={(row) => row.id}
rowSelectionColumn={{ enabled: true, headerCheckbox: true }}
viewportHeight={520}
/>| Config option | Default | Effect |
|---|---|---|
enabled | — | Required. Pass true to inject the column. |
headerCheckbox | true | Show the select-all-visible header checkbox. |
pinned | true | Pin the column to the left. |
width | 36 | Column width in pixels. |
The checkbox column is independent from cell-range gestures: clicking a checkbox ticks the row in the row-selection slice, without moving focus, without creating a cell range, and without collapsing any selection you already had. Shift+click on a body checkbox ticks every row from the last-checked one to the clicked one.
Reading the checked set
onRowSelectionChange is the callback for this column. It fires whenever the checked set changes, with the ticked row ids in rendered order — the order matters, and cannot be recovered from cell ranges, because a range is a pair of endpoint ids whose meaning depends on the sort the grid is currently applying.
<PretableSurface
ariaLabel="Inspection grid"
columns={columns}
rows={rows}
getRowId={(row) => row.id}
rowSelectionColumn={{ enabled: true }}
onRowSelectionChange={(rowIds) => setApprovable(rowIds)}
viewportHeight={520}
/>| Gesture | Fires |
|---|---|
| Click a body cell | onSelectionChange |
| Tick a row checkbox | onRowSelectionChange |
| Tick the header checkbox | onRowSelectionChange is silent — see below; onSelectionChange fires with an emptied ranges, because select-all clears the cell ranges |
The header checkbox stays deliberately silent because select-all is symbolic: the grid records "all rows" rather than a list, so a million-row grid does not pay a million row ids to report the click. If you need the resolved set, read it from your own data using the same filter the grid is showing. If you need to save the selection rather than resolve it, keep it symbolic — see Controlling the checked set.
The silence lasts as long as the selection stays symbolic, including "all except the three I unticked". It ends the moment the slice becomes an explicit list again, and onRowSelectionChange fires with that list.
Three states
aria-checked="true"— the row is ticked, or the cell ranges cover every column of it.aria-checked="mixed"— cell ranges cover some-but-not-all of the row.aria-checked="false"— neither.
The header checkbox uses the same three-state derivation across visible rows.
Visual customization via --pretable-checkbox-bg, --pretable-checkbox-border, --pretable-checkbox-checked-bg, --pretable-checkbox-checked-fg tokens.
Controlled vs uncontrolled
By default <PretableSurface> owns the cell-range slice — the engine maintains state internally and emits user-driven changes via onSelectionChange. To control it, pass the state.selection slice:
import { useState } from "react";
import { createColumnHelper } from "@pretable/core";
import { PretableSurface } from "@pretable/react";
import type { PretableSelectionFor } from "@pretable/react";
const column = createColumnHelper<Row>();
const columns = [
column.accessor("name", { type: "text", header: "Name" }),
column.accessor("city", { type: "text", header: "City" }),
] as const;
export function ControlledGrid({ rows }: { rows: Row[] }) {
const [selection, setSelection] = useState<
PretableSelectionFor<typeof columns>
>({ ranges: [], anchor: null });
return (
<PretableSurface
ariaLabel="Controlled selection"
columns={columns}
rows={rows}
getRowId={(row) => row.id}
state={{ selection }}
onSelectionChange={setSelection}
/>
);
}Each slice in state (sort, filters, selection, focus) is independently controlled — pass the slices you want to own, omit the rest. The engine still owns viewport, virtualization, and any uncontrolled slices. The example above uses exactly this pattern: its first caption is rendered straight from the controlled selection state, which is how it stays in lockstep with whatever you click or drag.
state.selection does not control the checkboxes
state.selection is the cell-range slice, and only that. Adding rowSelectionColumn alongside it does not fold the checkboxes into the same state:
- Ticking a checkbox does not fire
onSelectionChange. AddonRowSelectionChange— this is the mistake to expect, because a grid whose checkboxes tick but whose application state never moves looks like a broken checkbox rather than the wrong callback. - Writing
{ ranges: [], anchor: null }clears the cell ranges and leaves every ticked row ticked. - The checkboxes have their own controlled slice,
state.rowSelection, described next.
Controlling the checked set
state.rowSelection is to the checkbox column what state.selection is to the cell ranges. Pass it and the ticked set is yours to drive: restore what a user had last session, tick everything an action applies to, undo.
Its type is PretableRowSelectionState, exported from @pretable/core and @pretable/react. It is deliberately not a list of row ids, because the engine's own representation is not one either — flattening it here would make select-all over a million rows cost a million ids to express:
type PretableRowSelectionState<TRowId> =
| {
kind: "explicit";
rowIds: readonly TRowId[];
ranges?: readonly { startRowId: TRowId; endRowId: TRowId }[];
excludedRowIds?: readonly TRowId[];
}
| { kind: "all"; excludedRowIds?: readonly TRowId[] };{ kind: "all" } is symbolic: applying it costs the same on three rows and on five hundred thousand, because it never enumerates the population. ranges carries a shift-checked span as its two endpoints. excludedRowIds is points rather than spans, matching what the engine can store — it is what "everything except these" is made of.
The everyday case is feeding onRowSelectionChange straight back:
const [rowSelection, setRowSelection] = useState<
PretableRowSelectionState<string>
>({ kind: "explicit", rowIds: [] });
return (
<PretableSurface
ariaLabel="Inspection grid"
columns={columns}
rows={rows}
getRowId={(row) => row.id}
rowSelectionColumn={{ enabled: true }}
state={{ rowSelection }}
onRowSelectionChange={(rowIds) =>
setRowSelection({ kind: "explicit", rowIds })
}
/>
);To save a selection that might be symbolic — a select-all, or a shift-checked span — do not go through onRowSelectionChange, which reports resolved ids and is silent while the selection is symbolic. Take the engine's own value from the grid handle and convert it:
import { describeRowSelection } from "@pretable/react";
// inside onGridReady: keep the handle, then whenever you want a snapshot
const saved = describeRowSelection(grid.getState().selection.rows);
// `saved` is a `PretableRowSelectionState` — store it, and hand it back to
// `state.rowSelection` later. A select-all round-trips as `{ kind: "all" }`.Two behaviours worth knowing:
- Applied when the value changes, not on every render.
onRowSelectionChangefires from an effect rather than from the click, so for one commit after a tick the value you hold is a generation behind the grid; re-asserting it there would untick the row the user just ticked, and the report would follow the untick rather than the tick. Echo the callback back and the two stay in lockstep. - Resolved against the rows the grid currently shows. Ids it cannot see are dropped, exactly as ticking them by hand would be — and the request is re-applied when the row model publishes, so a grid that streams its rows in ends up with what you asked for rather than with what it meant at mount.
To clear both slices in one go, keep the grid handle from onGridReady and call clearSelection() on it. On a grid whose selection slice is controlled, clear your own state in the same handler — otherwise the controlled value is written straight back on the next render and the cell ranges reappear:
const gridRef = useRef<null | { clearSelection: () => void }>(null);
function clearEverything() {
gridRef.current?.clearSelection(); // unticks every row
setSelection({ ranges: [], anchor: null }); // and empties the cell ranges
}
return (
<PretableSurface
// ...
state={{ selection }}
onSelectionChange={setSelection}
onGridReady={(grid) => {
gridRef.current = grid;
}}
/>
);See also
- Keyboard — full keyboard contract for navigating and extending the selection.
- Clipboard — Cmd/Ctrl+C TSV defaults and overrides.
- Row grouping — why group rows are focusable but never selectable.
- API reference — complete type signatures.