DataGrid
Selection, sorting, and pagination
| ID | STATION | LATENCY / MS | STATUS | SELECT |
|---|---|---|---|---|
| ST-003 | Charlie station | 18 | success | |
| ST-001 | Alpha station | 24 | success | |
| ST-005 | Echo station | 32 | success |
5 ROWS · 0 SELECTED
const [selected, setSelected] = useState<string[]>([]);
const [sort, setSort] = useState<DataGridSort | null>(null);
<DataGrid
caption="Station telemetry"
columns={columns}
data={rows}
getRowId={(row) => String(row.id)}
selectedRowIds={selected}
onSelectionChange={setSelected}
sort={sort}
onSortChange={setSort}
pageSize={10}
/>;
Rows need unique, stable IDs. Enable selection with selectedRowIds or
onSelectionChange; Select page affects only displayed rows. Selections outside
filtered results persist until the owner removes them.
Controlled state and page changes
Controlled props require updated values from their owner. Omit page, sort, or
visibleColumnKeys for internal state. Pages start at 1. Filtering clamps unavailable
pages and reports the correction once through onPageChange. Sorting preserves the
page; reset it in the owner if needed.
Dense, empty, and loading
| ID | STATION | LATENCY / MS | STATUS |
|---|---|---|---|
| ST-001 | Alpha station | 24 | success |
| ST-002 | Bravo station | 51 | warning |
2 ROWS
| ID | STATION | LATENCY / MS | STATUS |
|---|---|---|---|
| No stations match this view. | |||
0 ROWS
| ID | STATION | LATENCY / MS | STATUS |
|---|---|---|---|
| ST-001 | Alpha station | 24 | success |
Loading results.
1 ROW
loading retains data, announces updates, and disables grid controls. disabled also
blocks row activation. Disable custom cell controls separately. With every column hidden,
a message appears and Columns remains available.
Server-owned results
Pass the current page as data, mode="server", and the total filtered rowCount.
The owner handles page/sort requests and fetches results; the grid does not re-sort
or slice server data.
<DataGrid
mode="server"
caption="Remote station telemetry"
columns={columns}
data={pageRows}
getRowId={(row) => String(row.id)}
rowCount={totalRows}
page={page}
onPageChange={setPage}
pageSize={25}
sort={sort}
onSortChange={setSort}
loading={loading}
/>
Custom cells
Columns support DataTable formatting plus render(value, row) and compare(a, b).
Comparisons use raw values. onRowClick receives the original row and its index in
supplied data, even after sorting. Cell controls do not trigger row actions.
Keep getRowId and each column's compare pure: the same row always gives the same ID, and
the same two values always compare the same way. The grid reads row IDs again only when data
changes, so an inline getRowId costs nothing on a re-render. It sorts again when data, the
sort, or the sorted column's compare changes, so define columns, and their compare
functions, outside render or memoize them.
Column widths and pinning
Open Columns to change visibility, set numeric widths, or pin columns to the leading
edge. Width controls accept direct entry; Arrow keys resize by 8 CSS pixels and Home/End
use the bounds. Column descriptors accept minWidth and maxWidth (defaults 96 and 640).
Pinned keys set the leading order; unpinned columns retain descriptor order. Hidden pinned
columns retain their preference for when they become visible again.
Pass columnWidths / onColumnWidthsChange and pinnedColumnKeys /
onPinnedColumnsChange to control these settings. Widths are clamped for rendering;
controlled owners apply callback values. Unconfigured tables retain automatic sizing.
Resizing, pinning, or virtualization activates fixed column sizing; unspecified widths
use 160 CSS pixels or a pixel width from the descriptor.
Large result sets
2,000 supplied observations. Open Columns to resize or pin. Selection follows stable IDs when scrolling or switching views.
| ID | Station | Latency / ms | Observation | SELECT |
|---|---|---|---|---|
| ST-0001 | Station 1 | 0 | Intermittent uplink. Last observation retained while the next scheduled report is pending. | |
| ST-0002 | Station 2 | 13 | Observation received. | |
| ST-0003 | Station 3 | 26 | Observation received. | |
| ST-0004 | Station 4 | 39 | Observation received. | |
| ST-0005 | Station 5 | 52 | Intermittent uplink. Last observation retained while the next scheduled report is pending. | |
| ST-0006 | Station 6 | 65 | Observation received. | |
| ST-0007 | Station 7 | 78 | Observation received. | |
| ST-0008 | Station 8 | 91 | Observation received. | |
| ST-0009 | Station 9 | 104 | Intermittent uplink. Last observation retained while the next scheduled report is pending. | |
| ST-0010 | Station 10 | 117 | Observation received. |
2000 ROWS · 0 SELECTED
<DataGrid
caption="Large station inventory"
columns={columns}
data={rows}
getRowId={(row) => String(row.id)}
virtualization={{ height: 320, estimateRowHeight: 48, overscan: 3 }}
pageSize={25}
/>
Virtualization is opt-in. Omit rowHeight to measure variable heights with ResizeObserver;
estimateRowHeight supplies the initial estimate (40 by default). Wrapped observations
then update the scroll geometry as rows are measured. Without ResizeObserver, the initial
measurements and estimate remain available; use the paginated alternative when needed.
For a dense list with predictable content, pass { height: 320, rowHeight: 40 }.
Fixed-height rows clip content that exceeds the specified height; use measured rows or
the paginated view to read wrapped content. Minimum row height is 24 CSS pixels.
Only mounted cells execute custom render callbacks. Overscan defaults to 3 rows and
never drops below 1.
Use paginated view is always available with virtualization. viewMode and
onViewModeChange can own this choice. In client mode, the virtual view windows the
complete filtered result set; Select results changes that full set of IDs. The
paginated alternative uses page and pageSize. In server mode both views use the
supplied page, and Select page affects only its IDs; pagination remains available.
The implementation retains native table headers, rows, and cells. aria-rowcount
includes the header, and each mounted row has its global aria-rowindex, including the
server page offset. The focused row and its adjacent rows remain mounted while scrolling;
tabbing through their controls continues in row order and brings the newly focused row
into view. Switching views through the outside toggle retains focus on that toggle.
Stable IDs are required. When an application removes the focused entity itself, that
application must decide the appropriate focus destination.
Save and restore a controlled view
Save and restore this view in this browser. Source rows and selection are not stored.
| ID | Station | Latency / ms | Observation | SELECT |
|---|---|---|---|---|
| ST-0001 | Station 1 | 0 | Intermittent uplink. Last observation retained while the next scheduled report is pending. | |
| ST-0002 | Station 2 | 13 | Observation received. | |
| ST-0003 | Station 3 | 26 | Observation received. | |
| ST-0004 | Station 4 | 39 | Observation received. | |
| ST-0005 | Station 5 | 52 | Intermittent uplink. Last observation retained while the next scheduled report is pending. |
25 ROWS · 0 SELECTED
A view consists of ordinary controlled props. The grid does not read browser storage. This complete example persists only the view preferences. Selection stays in memory and source rows remain owned by the application. Restoration rejects malformed or obsolete preferences, and both storage operations handle unavailable storage.
import { useState } from "react";
import {
Button,
DataGrid,
type DataGridSort,
type DataGridColumnWidths,
type DataGridViewMode,
type DataTableRow,
} from "@operator-ds/react";
const columns = [
{ key: "id", label: "ID" },
{ key: "name", label: "Station" },
{ key: "latency", label: "Latency / ms", as: "number" as const },
];
type View = {
version: 1;
sort: DataGridSort | null;
page: number;
pageSize: number;
visible: string[];
widths: DataGridColumnWidths;
pinned: string[];
viewMode: DataGridViewMode;
dense: boolean;
};
const initial: View = {
version: 1,
sort: null,
page: 1,
pageSize: 25,
visible: columns.map((column) => column.key),
widths: {},
pinned: [],
viewMode: "paginated",
dense: false,
};
const storageKey = "station-view.v1";
function parseView(raw: string | null): View | null {
try {
const value: unknown = JSON.parse(raw ?? "null");
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
const v = value as Record<string, unknown>;
const keys = new Set(columns.map((column) => column.key));
const keyList = (list: unknown) =>
Array.isArray(list) &&
list.every((key) => typeof key === "string" && keys.has(key)) &&
new Set(list).size === list.length;
if (v.version !== 1 || !keyList(v.visible) || !keyList(v.pinned)) return null;
if (v.viewMode !== "paginated" && v.viewMode !== "virtualized") return null;
if (typeof v.dense !== "boolean") return null;
if (typeof v.page !== "number" || !Number.isSafeInteger(v.page) || v.page < 1) return null;
if (
typeof v.pageSize !== "number" ||
!Number.isInteger(v.pageSize) ||
v.pageSize < 1 ||
v.pageSize > 100
)
return null;
const sort = v.sort as Record<string, unknown> | null;
if (
sort !== null &&
(!sort ||
typeof sort !== "object" ||
!keys.has(String(sort.key)) ||
(sort.direction !== "asc" && sort.direction !== "desc"))
)
return null;
if (!v.widths || typeof v.widths !== "object" || Array.isArray(v.widths)) return null;
if (
!Object.entries(v.widths).every(
([key, width]) =>
keys.has(key) &&
typeof width === "number" &&
Number.isFinite(width) &&
width >= 48 &&
width <= 640,
)
)
return null;
return v as View;
} catch {
return null;
}
}
export function SavedStations({ rows }: { rows: DataTableRow[] }) {
const [view, setView] = useState<View>(initial);
const [selected, setSelected] = useState<string[]>([]);
const [message, setMessage] = useState("");
const update = (next: Partial<View>) => setView((old) => ({ ...old, ...next }));
const save = () => {
try {
localStorage.setItem(storageKey, JSON.stringify(view));
setMessage("View saved.");
} catch {
setMessage("Storage unavailable. Current view remains usable.");
}
};
const restore = () => {
try {
const saved = parseView(localStorage.getItem(storageKey));
setView(saved ?? initial);
setMessage(saved ? "View restored." : "No valid saved view. Defaults restored.");
} catch {
setMessage("Storage unavailable. Current view remains usable.");
}
};
return (
<>
<Button onClick={save}>Save view</Button>
<Button onClick={restore}>Restore view</Button>
<p role="status">{message}</p>
<DataGrid
caption="Stations"
columns={columns}
data={rows}
getRowId={(row) => String(row.id)}
selectedRowIds={selected}
onSelectionChange={setSelected}
sort={view.sort}
onSortChange={(sort) => update({ sort, page: 1 })}
page={view.page}
onPageChange={(page) => update({ page })}
pageSize={view.pageSize}
dense={view.dense}
visibleColumnKeys={view.visible}
onVisibleColumnsChange={(visible) => update({ visible })}
columnWidths={view.widths}
onColumnWidthsChange={(widths) => update({ widths })}
pinnedColumnKeys={view.pinned}
onPinnedColumnsChange={(pinned) => update({ pinned })}
viewMode={view.viewMode}
onViewModeChange={(viewMode) => update({ viewMode })}
virtualization={{ height: 320, estimateRowHeight: 40 }}
/>
</>
);
}
Saved widths are still bounded by current column descriptors. A stale saved page is
clamped to the current result set and reported through onPageChange. A future schema
can migrate preferences by version before supplying the controlled props.
Cell editing and grouping are outside this component's contract.
Props
| PROP | TYPE | DEFAULT | REQUIRED |
|---|---|---|---|
columnsDataTableColumn descriptors plus render(value, originalRow) for display and compare(a, b) for raw-value sorting. compare must be pure; a new compare function sorts again, so define columns outside render or memoize them. Columns sort unless sortable is false. Keys must be unique. | array | — | yes |
dataClient mode: complete filtered row set. Server mode: current page only; order and row count are preserved. | array | — | yes |
getRowId(originalRow) => string. Return a unique, stable ID across filtering, sorting, and pagination; do not use an array index. Keep it pure: IDs are read again only when data changes, not when only the function does. | function | — | yes |
captionAccessible native table caption and controls group name. | string | "Data grid" | no |
selectedRowIdsControlled IDs. Supplying this or onSelectionChange enables selection. IDs outside the displayed data are preserved. | array | — | no |
onSelectionChange(ids: string[]) => void. Select page changes only current-page IDs. Without selectedRowIds, selection is stored internally. | function | — | no |
sortControlled { key: string, direction: 'asc' | 'desc' } or null for source order. Omit for internal sorting state. Uses raw values before custom rendering and announces the active direction with aria-sort on the corresponding table header. | node | — | no |
onSortChange(sort: DataGridSort | null) => void. Server mode only reports the requested sort; the caller supplies sorted rows. | function | — | no |
pageOne-based controlled page. Omit for internal pagination state. Out-of-range values are clamped and reported once through onPageChange. | number | 1 | no |
onPageChange(page: number) => void. A supplied page remains controlled until its owner updates it. | function | — | no |
pageSizeRows per client page and denominator for server page counts. Invalid or less-than-one values use 10; fractional values round down. | number | 10 | no |
mode | enumclient | server | "client" | no |
rowCountTotal filtered row count in server mode. Defaults to data.length; provide the total to enable navigation beyond the supplied page. | number | — | no |
visibleColumnKeysControlled displayed column keys. Omit for internal visibility state; all columns are visible initially. An empty list shows a recovery message. | array | — | no |
onVisibleColumnsChange(keys: string[]) => void. Receives visibility changes from the COLUMNS controls. | function | — | no |
columnWidthsControlled Record<columnKey, CSS pixel width>. Omit for internal widths. Values are clamped to each column's minWidth/maxWidth; default width is 160 or a pixel width from the descriptor. Stored keys outside the current columns are preserved in resize callbacks. | node | — | no |
onColumnWidthsChange(widths: DataGridColumnWidths) => void. Columns controls support direct numeric entry, Arrow keys (8 pixels), and Home/End (bounds). | function | — | no |
pinnedColumnKeysControlled leading-edge pinned keys in display order. Omit for internal state. Hidden pinned columns retain their preference; unknown keys are ignored for display. | array | — | no |
onPinnedColumnsChange(keys: string[]) => void. Called by keyboard-accessible Pin checkboxes in Columns. | function | — | no |
virtualizationOpt-in { height: number, rowHeight?: number, estimateRowHeight?: number, overscan?: number }, CSS pixels. Fixed rowHeight clips overlong cell contents; omit rowHeight to measure wrapped rows with ResizeObserver, starting at estimateRowHeight (default 40). Minimum row height 24; default overscan 3, minimum 1. Client mode windows all filtered rows; server mode windows only the supplied page. | node | — | no |
viewModeControlled display mode. Virtualization must be supplied for virtualized mode. Defaults to virtualized with configuration, paginated without it. The built-in toggle always offers a paginated native-table alternative. | enumpaginated | virtualized | — | no |
onViewModeChange(mode: DataGridViewMode) => void. Controlled owners must apply the requested mode for the fallback toggle to update the table. | function | — | no |
loadingMarks the table busy, announces loading, and disables grid-owned controls and row activation while retaining supplied data. | boolean | false | no |
disabledDisables grid-owned actions and row activation. Custom interactive cell content remains the caller's responsibility. | boolean | false | no |
dense | boolean | false | no |
emptyMessage | string | "No results." | no |
onRowClick(originalRow, indexInSuppliedData) => void. Uses the existing DataTable's native first-cell button; independent cell controls do not activate the row. | function | — | no |
Accessibility
- Named native table with keyboard-accessible controls and row-action buttons.
- When its columns do not fit, the table scrolls sideways inside its own region, named after the caption ("Station telemetry scroll viewport"), which takes keyboard focus while it scrolls. The page never scrolls sideways.
- Row checkboxes use stable IDs; Select page exposes partial selection as mixed. Each checkbox is named with the words beside it: "Select page" or "Select results", and "Pin to start: Station" in Columns.
- Page changes are announced. Loading sets
aria-busyon the table and writes "Loading results." into a status message that is on the page from the start, so screen readers announce it. - Column visibility uses a labeled fieldset. Controls have visible focus and minimum-size targets.
- Numeric width controls and pin checkboxes work with keyboard alone; preferences remain controlled when supplied.
- Virtual windows preserve native row indexing and focused rows, with a paginated alternative for assistive-technology navigation and expanded content.
Manifest entry
<DataGrid
caption="Station telemetry"
columns={[{ key: "name", label: "STATION" }, { key: "latency", label: "LATENCY / MS", as: "number" }]}
data={rows}
getRowId={(row) => String(row.id)}
selectedRowIds={selected}
onSelectionChange={setSelected}
pageSize={10}
/>View agent manifest
{
"description": "Native table with stable-ID selection, sorting, controlled column widths and pinning, saved-view state, pagination, and optional virtualization.",
"props": {
"columns": {
"type": "array",
"items": {
"key": "string (required, unique)",
"label": "string (required)",
"as": "text | number | code | timestamp | badge",
"highlight": "boolean (default: false)",
"width": "string (CSS width; a px value seeds the column width)",
"align": "left | right",
"sortable": "boolean",
"render": "(value, row) => ReactNode",
"compare": "(a, b) => number",
"minWidth": "number (CSS pixels; default 96; minimum 48)",
"maxWidth": "number (CSS pixels; default 640)"
},
"required": true,
"description": "DataTableColumn descriptors plus render(value, originalRow) for display and compare(a, b) for raw-value sorting. compare must be pure; a new compare function sorts again, so define columns outside render or memoize them. Columns sort unless sortable is false. Keys must be unique."
},
"data": {
"type": "array",
"required": true,
"description": "Client mode: complete filtered row set. Server mode: current page only; order and row count are preserved."
},
"getRowId": {
"type": "function",
"required": true,
"description": "(originalRow) => string. Return a unique, stable ID across filtering, sorting, and pagination; do not use an array index. Keep it pure: IDs are read again only when data changes, not when only the function does."
},
"caption": {
"type": "string",
"default": "Data grid",
"description": "Accessible native table caption and controls group name."
},
"selectedRowIds": {
"type": "array",
"description": "Controlled IDs. Supplying this or onSelectionChange enables selection. IDs outside the displayed data are preserved."
},
"onSelectionChange": {
"type": "function",
"description": "(ids: string[]) => void. Select page changes only current-page IDs. Without selectedRowIds, selection is stored internally."
},
"sort": {
"type": "node",
"description": "Controlled { key: string, direction: 'asc' | 'desc' } or null for source order. Omit for internal sorting state. Uses raw values before custom rendering and announces the active direction with aria-sort on the corresponding table header."
},
"onSortChange": {
"type": "function",
"description": "(sort: DataGridSort | null) => void. Server mode only reports the requested sort; the caller supplies sorted rows."
},
"page": {
"type": "number",
"default": 1,
"description": "One-based controlled page. Omit for internal pagination state. Out-of-range values are clamped and reported once through onPageChange."
},
"onPageChange": {
"type": "function",
"description": "(page: number) => void. A supplied page remains controlled until its owner updates it."
},
"pageSize": {
"type": "number",
"default": 10,
"description": "Rows per client page and denominator for server page counts. Invalid or less-than-one values use 10; fractional values round down."
},
"mode": {
"type": "enum",
"values": [
"client",
"server"
],
"default": "client"
},
"rowCount": {
"type": "number",
"description": "Total filtered row count in server mode. Defaults to data.length; provide the total to enable navigation beyond the supplied page."
},
"visibleColumnKeys": {
"type": "array",
"description": "Controlled displayed column keys. Omit for internal visibility state; all columns are visible initially. An empty list shows a recovery message."
},
"onVisibleColumnsChange": {
"type": "function",
"description": "(keys: string[]) => void. Receives visibility changes from the COLUMNS controls."
},
"columnWidths": {
"type": "node",
"description": "Controlled Record<columnKey, CSS pixel width>. Omit for internal widths. Values are clamped to each column's minWidth/maxWidth; default width is 160 or a pixel width from the descriptor. Stored keys outside the current columns are preserved in resize callbacks."
},
"onColumnWidthsChange": {
"type": "function",
"description": "(widths: DataGridColumnWidths) => void. Columns controls support direct numeric entry, Arrow keys (8 pixels), and Home/End (bounds)."
},
"pinnedColumnKeys": {
"type": "array",
"description": "Controlled leading-edge pinned keys in display order. Omit for internal state. Hidden pinned columns retain their preference; unknown keys are ignored for display."
},
"onPinnedColumnsChange": {
"type": "function",
"description": "(keys: string[]) => void. Called by keyboard-accessible Pin checkboxes in Columns."
},
"virtualization": {
"type": "node",
"description": "Opt-in { height: number, rowHeight?: number, estimateRowHeight?: number, overscan?: number }, CSS pixels. Fixed rowHeight clips overlong cell contents; omit rowHeight to measure wrapped rows with ResizeObserver, starting at estimateRowHeight (default 40). Minimum row height 24; default overscan 3, minimum 1. Client mode windows all filtered rows; server mode windows only the supplied page."
},
"viewMode": {
"type": "enum",
"values": [
"paginated",
"virtualized"
],
"description": "Controlled display mode. Virtualization must be supplied for virtualized mode. Defaults to virtualized with configuration, paginated without it. The built-in toggle always offers a paginated native-table alternative."
},
"onViewModeChange": {
"type": "function",
"description": "(mode: DataGridViewMode) => void. Controlled owners must apply the requested mode for the fallback toggle to update the table."
},
"loading": {
"type": "boolean",
"default": false,
"description": "Marks the table busy, announces loading, and disables grid-owned controls and row activation while retaining supplied data."
},
"disabled": {
"type": "boolean",
"default": false,
"description": "Disables grid-owned actions and row activation. Custom interactive cell content remains the caller's responsibility."
},
"dense": {
"type": "boolean",
"default": false
},
"emptyMessage": {
"type": "string",
"default": "No results."
},
"onRowClick": {
"type": "function",
"description": "(originalRow, indexInSuppliedData) => void. Uses the existing DataTable's native first-cell button; independent cell controls do not activate the row."
}
},
"composable_with": [
"Card",
"ComboBox"
],
"never_inside": [
"DataTable",
"DataGrid"
],
"example": "<DataGrid\n caption=\"Station telemetry\"\n columns={[{ key: \"name\", label: \"STATION\" }, { key: \"latency\", label: \"LATENCY / MS\", as: \"number\" }]}\n data={rows}\n getRowId={(row) => String(row.id)}\n selectedRowIds={selected}\n onSelectionChange={setSelected}\n pageSize={10}\n/>"
}