Grid Selection
Selection
Cell-range selection model, row selection, controlled state, and three-state checkbox column.
Pretable's selection model is cell-range first — Excel/Sheets semantics. Row selection is derived from the cell-range state, exposed via a built-in three-state checkbox column. The same engine state powers <Pretable>, <PretableSurface>, and the usePretable hook.
Selection model
Engine selection state is two slices:
interface PretableSelectionState {
ranges: PretableCellRange[];
anchor: PretableCellAddress | null;
}
interface PretableCellRange {
startRowId: string;
endRowId: string;
startColumnId: string;
endColumnId: string;
}
interface PretableCellAddress {
rowId: string;
columnId: string;
}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.
PretableFocusStateis{ rowId, columnId }. The focused cell is always part of some range; there is no "focused with empty selection" state. Collapsing selection (Esc, plain click) reduces 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. Selection visuals tint the cell background (--pt-color-selection-bg) and the focused cell shows a 2px inset ring (--pt-color-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
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 toggles the row's full-row range without moving focus or collapsing other selections. Shift+click on a body checkbox extends the range from the last-checked anchor to the clicked row.
Per-row checkbox state derives from the cell-range model:
aria-checked="true"— every cell in the row is in some selected range.aria-checked="mixed"— some-but-not-all cells in the row are selected.aria-checked="false"— no cells in the row are selected.
The header checkbox uses the same three-state derivation across visible rows.
Visual customization via --pt-color-checkbox-bg, --pt-color-checkbox-border, --pt-color-checkbox-checked-bg, --pt-color-checkbox-checked-fg tokens.
Controlled vs uncontrolled
By default <PretableSurface> owns the selection 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 { PretableSurface, type PretableSelectionState } from "@pretable/react";
const initialSelection: PretableSelectionState = { ranges: [], anchor: null };
export function ControlledGrid({ rows, columns }) {
const [selection, setSelection] =
useState<PretableSelectionState>(initialSelection);
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.
Runnable example
Cell-range selection + checkbox column + controlled state, in one piece:
import { useState } from "react";
import {
PretableSurface,
type PretableColumn,
type PretableSelectionState,
} from "@pretable/react";
interface Row extends Record<string, unknown> {
id: string;
name: string;
city: string;
status: "ok" | "warn" | "error";
}
const columns: PretableColumn<Row>[] = [
{ id: "name", header: "Name", widthPx: 200, value: (r) => r.name },
{ id: "city", header: "City", widthPx: 160, value: (r) => r.city },
{ id: "status", header: "Status", widthPx: 100, value: (r) => r.status },
];
export function SelectionDemo({ rows }: { rows: Row[] }) {
const [selection, setSelection] = useState<PretableSelectionState>({
ranges: [],
anchor: null,
});
return (
<PretableSurface<Row>
ariaLabel="Selection demo"
columns={columns}
rows={rows}
getRowId={(row) => row.id}
rowSelectionColumn={{ enabled: true }}
state={{ selection }}
onSelectionChange={setSelection}
viewportHeight={420}
/>
);
}See also
- Keyboard — full keyboard contract for navigating and extending the selection.
- Clipboard — Cmd/Ctrl+C TSV defaults and overrides.
- API reference — complete type signatures.