| title | Plugin Grid |
|---|
import { InteractiveDemo } from '@/app/components/InteractiveDemo'; import { PluginLoader } from '@/app/components/PluginLoader';
Advanced data grid with sorting, filtering, pagination, and row selection capabilities.
npm install @object-ui/plugin-gridThis package publishes a stylesheet. Import it after the base sheets, or the grid renders unstyled — the themed utilities it uses have no other source in a published app (#4929):
/* src/index.css */
@import "tailwindcss";
@import "@object-ui/components/style.css";
@import "@object-ui/fields/style.css";
@import "@object-ui/plugin-grid/style.css";<PluginLoader plugins={['grid']}>
Each preview below is an object-grid node drawn by this plugin — the JSON
in the Code tab is the whole example, and the rows on screen came out of a
find() call, not out of that JSON. The records are served by the docs site's
demo data source, because dataSource is not a schema key: it is the prop the
registered renderer pulls off SchemaRendererProvider context (see
ObjectQL Integration below), so in your own app these
same nodes read whatever object your data source serves. That fixture answers
search and sort but not filters, which is the only reason no filter is
authored here.
- Sorting - Multi-column sorting support
- Filtering - Column-level filtering
- Pagination - Built-in pagination controls
- Row Selection - Single and multi-row selection
- Custom Cells - Custom cell renderers
- Responsive - Mobile-friendly layouts
An authored grid node takes its props in its properties bag, whose members are
@objectstack/spec's ComponentPropsMap['object-grid'] row: one required
objectName — or the node's dataSource binding naming the object — plus keys
drawn from the list this plugin declares as its authoring surface
(GRID_QUERY_INPUTS, packages/plugin-grid/src/index.tsx) — the same list that
feeds the designer panel and the generated sdui-intrinsics.d.ts, so what is
authorable here is what the renderer reads. objectui validate judges the bag
against that row and refuses a prop written flat on the node by name, naming
where it goes (Did you mean objectName → properties.objectName?), as the spec's
own page component does (objectui#11276). SchemaRenderer hoists the bag onto
the node before the grid runs, so ObjectGridSchema (@object-ui/types) is the
node as the grid reads it.
{
"type": "object-grid",
"properties": {
"objectName": "users",
"columns": ["name", "email"],
"sort": [{ "field": "created", "order": "desc" }],
"pagination": { "pageSize": 20 }
}
}Note the type. It is object-grid (or view:grid), never grid — bare
grid is the CSS Grid layout container from @object-ui/components, whose
columns is a column count. See Registration
below for why this plugin deliberately does not claim it.
Key (in properties) |
Type | Notes |
|---|---|---|
objectName |
string (required) |
The object queried. There is no object. |
columns |
string[] | ListColumn[] |
Field names or column objects — see below. |
label |
I18nLabel |
Table caption and export file title. |
description |
I18nLabel |
One line of help text above the grid — see description and emptyState below. |
emptyState |
EmptyState (@objectstack/spec/ui) |
{ title?, message?, icon? }, drawn in place of an empty table — see below. |
filter |
ViewFilterRule[] |
Baked into the query, lowered to $filter. |
sort |
[{ field, order }] |
Initial order; a header click replaces it. |
pagination |
PaginationConfig |
{ pageSize?, pageSizeOptions? } — strict, and its presence is what enables paging. |
searchableFields |
string[] |
A non-empty list is what puts the search box in the toolbar. |
data |
ViewData |
Inline rows that bypass the object query — see Inline data. |
selection |
SelectionConfig |
{ type: 'none' | 'single' | 'multiple' }. |
rowActions / bulkActions |
string[] |
Names of actions, not inline definitions. |
editable / singleClickEdit |
boolean |
Inline editing — see Inline Editing. |
keyboardNavigation |
boolean |
Arrow keys move focus between cells, and the cells are one Tab stop — see Keyboard navigation. On by default when the grid renders editable. |
navigation |
NavigationConfig |
What a row click does: { mode: 'page' | 'drawer' | 'modal' | 'split' | 'none', … }. |
operations |
object |
Toggles the built-in CRUD/export/import affordances, e.g. { delete: false }. |
rowHeight, frozenColumns, resizable, reorderableColumns, showColumnTypeIcons, rowColor, conditionalFormatting, aggregations, exportOptions |
The rest of the declared surface. (className is a base prop: it stays on the node, beside the bag.) |
|
grouping |
GroupingConfig |
Row grouping — server-side: group set, counts and aggregations come from the query, rows are paged per group; see Grouping is server-side. |
There is no sortable, filterable, onRowClick, onSelectionChange,
onCellChange, onRowSave, onBatchSave or object on this schema. The two
booleans do not exist at all; the five on* names are component props
(ObjectGridComponentProps), which no metadata document can carry — see
Row callbacks are component props.
Two keys the grid honours (objectui#11068). The upstream protocol's object-grid
row declares both (objectstack#20694), so both are bag members and both are in
GRID_QUERY_INPUTS (objectui#11227):
description— one line of help text drawn above the grid. A string, or an inline locale map resolved against the display locale the waylabelis.emptyState: { title?, message?, icon? }— the list view's own empty-state shape (EmptyState), drawn in place of an empty table: a Lucideicon, atitle(default: the table's own "No results found") and amessage(default: none).titleandmessageeach take a string or an inline locale map, resolved against the display locale; a map with no usable entry keeps that member's default. It is not drawn when a term typed into the grid's own server-side search box is what emptied it — the table and its search box stay, so the term can be cleared. Leave the key out and an empty grid draws the table's own empty row, as before.
{
"type": "object-grid",
"properties": {
"objectName": "contacts",
"description": "Everyone you work with",
"emptyState": {
"title": { "en": "No contacts yet", "fr": "Aucun contact" },
"message": "Add one to get started",
"icon": "users"
}
}
}Written flat on the node, either key is refused by name and pointed at the bag
(description → properties.description), as the spec's own page component
refuses it. Until the row declared description, this page told you to write it
on the node; that is now the refused spelling.
keyboardNavigation, the third key objectstack#20694 added to the row, is
honoured too (objectui#11068) and is in GRID_QUERY_INPUTS — see
Keyboard navigation. The row's own description may still
carry the [EXPERIMENTAL — not enforced] marker it was published with before this
build; the installed row (ComponentPropsMap['object-grid']) is the place to read
its current text.
name, placeholder, rowSpecActions and bulkSpecActions are retired on
this node (objectui#11068): nothing ever read them, and both faces of
@object-ui/types refuse them by name. Write id or label,
emptyState: { message }, rowActions and bulkActions instead.
showFilters is retired on this node too (objectui#11068). The grid has no
filter UI, and nothing read the key. The filter builder is the list-view
toolbar's, switched by its userActions.filter; to narrow the rows a grid
fetches, write filter. An object-view's own showFilters is a different key
and is unchanged.
A column is either a field name ("name") or a ListColumn object.
ListColumn is declared by @objectstack/spec/ui (ListColumnSchema) and
re-exported from @object-ui/types; it is the type of ObjectGridSchema's
columns, so it is the same column vocabulary the saved-view metadata uses.
| Key | Type | Meaning |
|---|---|---|
field |
string (required) |
The field this column reads. There is no accessorKey. |
label |
string | Record<string, string> |
Header text, or an inline locale map. There is no header. |
width |
number |
Column width in pixels. |
align |
'left' | 'center' | 'right' |
Cell alignment. |
hidden |
boolean |
Hidden by default, revealable in the column chooser. |
sortable |
boolean |
Allow sorting on this column. |
resizable |
boolean |
Allow dragging this column's width. |
wrap |
boolean |
Wrap long cell text instead of eliding it. |
type |
string |
Override the rendered cell type instead of inferring it from the field. |
pinned |
'left' | 'right' |
Freeze the column to one edge. |
summary |
ColumnSummary | { type, field? } |
Footer aggregation — see Column Summaries. |
prefix |
{ field, type? } |
Render a second field inline before the value. |
link |
boolean |
Render the value as a link to the record. |
action |
string |
Run a named action when the cell is clicked. |
ListColumnSchema is a strict Zod object, so an unknown key is rejected
rather than ignored — a column is spelled this one way. header and
accessorKey are not softer spellings of label and field; they fail
validation.
A column can declare a footer aggregation with summary, either as a shorthand
string or as an object that aggregates a different field than the one displayed:
{
"columns": [
{ "field": "name", "summary": "count_filled" },
{ "field": "amount", "type": "currency", "summary": "sum" },
{ "field": "owner", "summary": { "type": "count_unique", "field": "owner_id" } }
]
}The accepted values are ColumnSummarySchema from @objectstack/spec:
summary |
Footer shows | Reads |
|---|---|---|
none |
nothing — the column opts out | — |
count |
number of rows | every row |
count_filled |
rows whose cell is non-empty | raw values |
count_empty |
rows whose cell is empty | raw values |
count_unique |
distinct non-empty values | raw values |
percent_filled |
share of rows that are non-empty | raw values |
percent_empty |
share of rows that are empty | raw values |
sum |
total | numeric values |
avg |
mean | numeric values |
min |
smallest | numeric values |
max |
largest | numeric values |
A cell counts as empty when it is null, undefined, "" or an empty array,
so an unset multi-select or lookup reads as empty rather than as a filled [].
The count and percent families read raw cell values, so they work on text,
select and lookup columns. sum/avg/min/max need numeric values (numeric
strings are parsed) and render nothing when the column has none.
A currency or percent column formats its sum/avg/min/max in that
unit. Counts stay plain cardinalities and percentages carry their own %, so
count_unique on a currency column reads Unique: 3, not $3.00.
The unit comes from the column's type, or its object field's. Every other
formatting hint (currency, defaultCurrency, currencyConfig, precision,
scale and max) is read off the object field only, the way the list cell
reads it. ListColumnSchema declares none of them, so a column that carries one
does not change the footer (objectui#11588).
Which rows the figures describe (objectui#12081). The footer is drawn
beside the count of the matched set, so its figures are that set's. When the
grid holds every matched row (rows handed in whole, or one page that holds every
match) they are computed from those rows. When the grid holds one page of a
larger set — it pages on the server, or a host such as ListView hands it one
page with manualPagination, rowCount and findParams — it asks the data
source for the whole set's figures in ONE aggregate query through
queryGroupHeaders, with no groupBy: the query behind the rows (its filter,
search and search fields), without its page, sort or projection. Turning the
page or sorting asks nothing; a refresh or a write to the object asks again.
A grid whose columns declare no summary that maps to an aggregate (none at
all, only none, or only unknown members) has no footer and asks nothing.
These figures are the server's, so "empty" means a stored null. The answer is
used only when its row count equals the count the grid shows. Otherwise the
footer shows the page's figure and says so: Sum (this page): 32,500. That is
the case when the data source has no queryGroupHeaders, refuses the query or
answers without one row, when the query carries a key this read cannot repeat,
and when the count shown is an estimate. The platform's paged count under a
search is one.
The footer row renders only when at least one column resolves to a summary — a
view whose columns are all none (or carry no summary) has no footer.
import '@object-ui/plugin-grid';That single import is the whole of registration — there is no components map to
iterate over. Importing the entry runs the two ComponentRegistry.register(...)
calls in packages/plugin-grid/src/index.tsx, which claim exactly these schema
types:
| Namespaced key | Bare-name fallback | Renderer behind it |
|---|---|---|
plugin-grid:object-grid |
object-grid |
ObjectGridRenderer — the data grid, queried from an object |
view:grid |
none — skipFallback: true |
the same renderer, under the view protocol |
ComponentRegistry.register publishes namespace:type, and — unless the call
passes skipFallback: true — the bare type as a back-compat fallback
(packages/core/src/registry/Registry.ts:194, fallback at :226).
The import-wizard node key is RETIRED (objectui#10859 batch 8): no schema
produced it, and objectui validate refused it at type. ImportWizard is still
exported, and @object-ui/app-shell mounts it directly (the object view's import
flow).
Bare grid is deliberately not this plugin's. skipFallback: true on the
view:grid call keeps the data grid from claiming it, because grid belongs to
the CSS Grid layout container in @object-ui/components
(packages/components/src/renderers/layout/grid.tsx:50). Reach the data grid as
object-grid, or as view:grid when you want the namespaced spelling.
To serve the data grid under a key of your own, register the exported renderer — that is what a manual registration is here:
import { ComponentRegistry } from '@object-ui/core';
import { ObjectGridRenderer } from '@object-ui/plugin-grid';
ComponentRegistry.register('my-grid', ObjectGridRenderer, {
namespace: 'my-app',
label: 'My Grid',
category: 'plugin',
});ObjectGrid, ObjectGridRenderer, VirtualGrid, SplitPaneGrid,
ImportWizard, InlineEditing and FormulaBar are on the package's export
surface, alongside the hooks (useGroupedData, useColumnSummary,
useCellClipboard, …) and the component prop types
(ObjectGridComponentProps, VirtualGridProps, …). There is no aggregate map
among them, and the schema types are not here either — they live in
@object-ui/types, because the schema is the shared authoring contract rather
than this package's component API.
{
"type": "object-grid",
"properties": {
"objectName": "users",
"columns": [
{ "field": "name", "label": "Name", "width": 200, "sortable": true },
{ "field": "email", "label": "Email" },
{ "field": "role", "label": "Role" },
{ "field": "status", "label": "Status", "type": "select" }
],
"sort": [{ "field": "name", "order": "asc" }],
"pagination": { "pageSize": 20 }
}
}A column does not carry a render function. What it can say is which cell type to use and how to decorate the value — the same vocabulary the saved-view metadata uses, so a grid authored by hand and one authored in the designer render alike.
{
"type": "object-grid",
"properties": {
"objectName": "opportunities",
"columns": [
{ "field": "stage", "type": "select" },
{ "field": "amount", "type": "currency", "align": "right", "summary": "sum" },
{ "field": "name", "link": true, "prefix": { "field": "health", "type": "badge" } },
{ "field": "owner_id", "action": "reassign" }
]
}
}link: true renders the value as a link to the record and action: "reassign"
runs a named action on click — that is the metadata form of the "Actions column"
a render function used to be written for. A genuinely custom cell renderer is
a component-layer concern: VirtualGridColumn.cell on VirtualGrid, a React
prop, not an authoring key.
A grid normally queries objectName. To render fixed rows instead — demos,
fixtures, tests — give it a ViewData with the value provider. The rows go
under items.
{
"type": "object-grid",
"properties": {
"objectName": "users",
"columns": [
{ "field": "name", "label": "Name" },
{ "field": "email", "label": "Email" }
],
"data": {
"provider": "value",
"items": [
{ "id": 1, "name": "John Doe", "email": "john@example.com", "status": "Active" },
{ "id": 2, "name": "Jane Smith", "email": "jane@example.com", "status": "Active" }
]
}
}
}{
"type": "object-grid",
"properties": {
"objectName": "users",
"columns": ["name", "email"],
"selection": { "type": "multiple" },
"bulkActions": ["delete", "export"]
}
}selection.type is the canonical spelling; the boolean selectable is a
deprecated legacy alias, read only when selection is absent. Declaring bulk
actions auto-enables multi-select, so the two keys agree by construction.
To react to a selection in React, pass the onRowSelect component prop —
see Row callbacks are component props.
{
"type": "object-grid",
"properties": {
"objectName": "users",
"columns": ["name", "email"],
"pagination": { "pageSize": 10, "pageSizeOptions": [10, 20, 50, 100] }
}
}PaginationConfig is a strict object of exactly pageSize and
pageSizeOptions — there is no showSizeChanger, and none is needed: the pager
always carries a rows-per-page picker, and pageSizeOptions only replaces the
choices it offers with your own.
With no pageSize declared, every page the grid shows — the server-paged table,
the table over inline data, the grouped view's page of groups, and a group's
own page of rows — uses the default @objectstack/spec declares for
pagination.pageSize; the grid reads it from the spec rather than keeping a
number of its own. Declare pagination.pageSize to choose the count yourself.
A window of rows the grid groups in the browser, because the server does not
group it, is not a page: undeclared, it is a fixed fetch batch of the grid's
own, and it does not follow the display default.
grouping is answered by the server, not by bucketing the records the
browser happens to hold (objectui#7189, maintainer ruling A). The set of groups
and every number in a group header — the count and any per-group aggregation —
are properties of the query; the rows inside a group are paged.
A grouped grid that fetches its own rows asks its data source for the group
headers — the query @objectstack/spec/ui's compileListViewGroupQuery
compiles, sent through dataSource.queryGroupHeaders (the ObjectStack adapter
posts it to POST /data/:object/query) — and then pages each open group's
rows with that group's own query (compileListViewGroupRowsQuery: the view's
filter AND the group's key, limit / offset per group). So:
- Every group appears, with its true size, whatever the page size and whatever order the rows are stored in — a store of 186 records over five units renders five headers reading 86/61/31/7/1.
- A group larger than the page gets its own pager, and turning it asks the server for that group's next page, so every record is reachable.
- A collapsed group costs no row query at all.
aggregationsare computed by the same header query, over the group's whole row set.- Multi-level grouping asks one header query per level, so an outer header's numbers are that level's own (an average is never an average of averages).
- A
lookup/master_detail/usergrouping key is the referenced record's id on the wire; the grid labels it from the referenced record.
When a ListView hosts the grid (the console's list views) over a data source
that answers the group header query, it hands a grouped grid its own fetch and
the view's effective filter, rather than a window of rows.
Rows handed in whole (data: { provider: 'value', items }, or a host's
whole result set) are grouped where they are, in the browser: nothing was
withheld, so the grouping is exact. The grid takes rows a host hands it to be
the whole set — unless that host declares them one page of more
(manualPagination, onPageChange and a rowCount above the rows it
handed). A grouped grid refuses such a window with an error saying grouping
needs every record; hand the rows in whole, or let the grid fetch them.
Everything else needs the group header query (objectui#10881). Over a data
source that declares no queryGroupHeaders, a grouped grid that fetches its
own rows does not group a page of them — every count would be a page slice,
and a group whose records all fall past the page would be missing. It shows an
error naming queryGroupHeaders instead, and asks for no rows. A ListView
makes the same refusal before it mounts such a grid. To group, implement
queryGroupHeaders on the data source, or hand the rows in whole.
A search term the grid queries with goes on the group header query and on
every group's row query, as one pair (search / searchFields, which the
header query declares beside where): the headers count the
searched rows, and the rows under them are those rows (objectui#11021). The
term is the one typed into the grid's box, or the one a host hands down as the
search prop: a host that passes search owns the term even where the grid
fetches for itself. That is how a ListView hands its toolbar search to the
grid that groups for it, with the view's searchableFields on the grid's node.
The object comes from objectName; there is no object key. Filtering is the
metadata filter (lowered to $filter) and search is searchableFields
(lowered to $searchFields).
{
"type": "object-grid",
"properties": {
"objectName": "users",
"columns": [
{ "field": "name", "label": "Name" },
{ "field": "email", "label": "Email" },
{ "field": "created_at", "label": "Created", "type": "datetime" }
],
"filter": [{ "field": "status", "operator": "equals", "value": "active" }],
"searchableFields": ["name", "email"],
"pagination": { "pageSize": 20 }
}
}The adapter itself is not a schema key: a schema is a serialisable document,
while a live adapter is an object with methods. The grid reads its adapter from
React context, which the host installs once above the whole tree with
<SchemaRendererProvider dataSource={...} />.
Columns sort by default. sortable is a per-column key, used to turn a
column off; the grid-level sort declares the order the grid opens with. There
is no top-level sortable switch.
{
"type": "object-grid",
"properties": {
"objectName": "users",
"sort": [{ "field": "created", "order": "desc" }],
"columns": [
{ "field": "name", "label": "Name" },
{ "field": "email", "label": "Email", "sortable": false }
]
}
}There is no per-column filter key and no top-level filterable switch. A grid
narrows its query two ways: a filter baked into the metadata, and a toolbar
search over the fields named in searchableFields.
{
"type": "object-grid",
"properties": {
"objectName": "users",
"filter": [
{ "field": "status", "operator": "equals", "value": "active" },
{ "field": "created", "operator": "after", "value": "2026-01-01" }
],
"searchableFields": ["name", "email"],
"columns": ["name", "email", "status"]
}
}rowActions and bulkActions are lists of action names — the actions
themselves live in the object's action set, so the same action behaves
identically wherever it is offered. They are string[], not inline definitions
carrying callbacks.
{
"type": "object-grid",
"properties": {
"objectName": "users",
"columns": ["name", "email"],
"rowActions": ["view", "edit", "delete"],
"selection": { "type": "multiple" },
"bulkActions": ["delete", "export"]
}
}A name in bulkActions runs that action once per selected record. If the
action declares params, the selection bar's dialog collects them once, before
the run, and resolves them as the record page's action dialog does. A
field-backed param ({ "field": "crm_campaign", "objectOverride": "crm_campaign_member" }) takes its type, label, options, default and picker
target from that field, so a lookup param renders the record picker and the
confirm step shows the picked record's label. A param whose field is not in
the object metadata is refused, and the run is blocked.
The selection bar offers a write only to a caller who may make it. A bulk Delete
is the rowDelete row of the affordance-to-grant map (@object-ui/core), and a
bulkActionDefs entry with operation: 'update' — a patch written to every
selected record — is the rowEdit row, the same rows the row menu's Delete and
Edit read: the object's policy, the effective API operation set and the
caller's delete or update grant. A closed row removes the button, and
with it the dialog, its undo and its retry. custom entries are unchanged.
With no permission provider mounted both read open.
onRowClick, onRowSelect, onCellChange, onRowSave, onBatchSave,
onEdit, onDelete, onBulkDelete and onAddRecord are React props on
ObjectGridComponentProps. They are functions, so no metadata document can hold
them, and writing one of them into a schema does nothing at all: the grid builds
the inner table's handlers itself and never reads any of these nine off the
schema.
The one callback the grid does read off the schema is onNavigate, declared on
ObjectGridSchema for programmatic callers only. It is a function value too, so
it is no more authorable than the nine — it is deliberately absent from the
manifest and the designer panel, and prefer passing it as a prop
(objectui#5234, maintainer ruling of 2026-08-19).
import { ObjectGrid } from '@object-ui/plugin-grid';
import type { ObjectGridComponentProps } from '@object-ui/plugin-grid';
export const Grid = (props: ObjectGridComponentProps) => (
<ObjectGrid
{...props}
onRowClick={(record) => console.log('Row clicked:', record)}
onRowSelect={(rows) => console.log('Selection changed:', rows)}
/>
);Note onRowSelect — the prop that reports a selection change is spelled that
way; there is no onSelectionChange on this component.
The declarative alternative, which is metadata and survives a round trip
through storage, is navigation: its mode decides what a row click does
without any host code.
operations and the onEdit / onDelete wiring are GRID-level: they decide
whether the generic Edit / Delete entries exist at all, identically for every
row. When the refusal belongs to one RECORD — a system field a designer may not
drop — use rowOperations, a component prop called with a row record that
answers for that row alone. Mounting <ObjectGrid> directly hands it the node as
it reads it, so its schema prop is the flat ObjectGridSchema, with no bag:
import { ObjectGrid } from '@object-ui/plugin-grid';
import type { ObjectGridSchema } from '@object-ui/types';
const schema: ObjectGridSchema = {
type: 'object-grid',
objectName: 'field_definition',
columns: ['name', 'label', 'type'],
};
export const FieldList = ({ isSystem }: { isSystem: (name: string) => boolean }) => (
<ObjectGrid
schema={schema}
onEdit={(record) => console.log('edit', record)}
onDelete={(record) => console.log('delete', record)}
rowOperations={(record) => ({ delete: !isSystem(String(record.name)) })}
/>
);It speaks the same update / delete vocabulary as the authored operations
block, and it is an intersection, never a union: false withholds the
entry, while true, an omitted member, and a null / undefined return all
leave the grid's own verdict alone. Nothing it returns can re-open what the
object's lifecycle bucket, its userActions, the server's effective operations,
the principal's grant or the record-level verdict already closed — and a grid
that passes no rowOperations renders exactly as it did before the prop
existed.
Prefer this over refusing inside the callback. A refusal that runs after the click ships a button that is drawn as available and then does nothing, which is indistinguishable from a broken build (objectui#8674).
Enable inline cell editing for quick data updates:
{
"type": "object-grid",
"properties": {
"objectName": "users",
"columns": [
{ "field": "id", "label": "ID" },
{ "field": "name", "label": "Name" },
{ "field": "email", "label": "Email" },
{ "field": "status", "label": "Status", "type": "select" }
],
"editable": true,
"singleClickEdit": false
}
}editable is the only switch: it is a grid-level flag, and edits persist
through the host's data source (dataSource.update) with no callback to wire.
Features:
- Double-click to edit: double-click any editable cell to enter edit mode
(
singleClickEdit: trueopens it on the first click instead) - Keyboard shortcuts: press Enter on a focused cell to start editing, Enter again to save, Escape to cancel
- Per-field read-only: which cells open is decided by the field
definition, not by a column key — a field marked
readonly, and computed/binary field types (formula, autonumber, file, …), never open an editor. There is noeditablekey onListColumn. - Visual feedback: editable cells show a hover state, and the input is focused and selected when editing begins
To own persistence in a React host, supply onCellChange as a component
prop — it is not a schema key.
keyboardNavigation turns the grid's data cells into one roving Tab stop, on the
WAI-ARIA grid pattern (objectui#11068):
{
"type": "object-grid",
"properties": {
"objectName": "users",
"columns": ["name", "email", "status"],
"keyboardNavigation": true
}
}- One Tab stop. Tab reaches the cells once — on the cell that last held focus, or the first cell of the first row — and the next Tab moves past them. A widget a cell renders (the record link, a row's action menu, a selection checkbox) keeps its own Tab stop.
- Arrow keys move focus one cell; Home / End go to the first / last cell of the row, and Ctrl+Home / Ctrl+End to the first / last cell of the page. At an edge, focus stays put.
- Editing. On an editable grid, Enter still opens the focused cell, and an open cell's editor keeps every key. An edit ended with Enter or Escape hands focus back to its cell, so the arrows carry on from there.
- Default. On when the grid renders editable — the authored
editableand the viewer's permission to update the object, the same value inline editing obeys. A read-only grid keeps every cell its own Tab stop unless you writetrue, andfalseturns it off on an editable grid. - While it is on, the table is exposed to assistive technology as a
grid. A grouped grid navigates within each group's table; the mobile card layout has no cells and is unaffected.
Edit multiple cells across multiple rows and save them individually or all at
once. The schema half is just editable — the save/cancel affordances appear on
their own once a row has pending changes:
{
"type": "object-grid",
"properties": {
"objectName": "products",
"columns": [
{ "field": "sku", "label": "SKU" },
{ "field": "name", "label": "Name" },
{ "field": "price", "label": "Price", "type": "currency", "align": "right" },
{ "field": "stock", "label": "Stock", "type": "number", "align": "right" }
],
"editable": true
}
}Left alone, saving goes through the host's data source. A React host that needs
to own persistence supplies onRowSave / onBatchSave as component props —
and because they are props, they take the adapter from the host's own scope
rather than from anything in the schema:
import type { ObjectGridComponentProps } from '@object-ui/plugin-grid';
type Persistence = Pick<ObjectGridComponentProps, 'onRowSave' | 'onBatchSave'>;
const persistence = (
dataSource: NonNullable<ObjectGridComponentProps['dataSource']>,
): Persistence => ({
onRowSave: async (rowIndex, changes, row) => {
await dataSource.update('products', row.id, changes);
},
onBatchSave: async (allChanges) => {
await Promise.all(
allChanges.map(({ row, changes }) => dataSource.update('products', row.id, changes)),
);
},
});Features:
- Pending changes tracking: edit multiple cells across rows before saving; a cell edited back to the value it loaded with is not a pending change, and its row is no longer counted as modified
- Visual indicators: modified rows highlighted in amber, modified cells in bold
- Row-level save/cancel: individual row save and cancel buttons
- Batch operations: Save All and Cancel All buttons for bulk actions
- Flexible callbacks — all three are
ObjectGridComponentProps, never schema keys:onRowSavefor a single row,onBatchSavefor many,onCellChangefor each committed cell edit, including one that leaves the value as loaded (only a real change is staged)
The schema and column types come from @object-ui/types; this package exports
the component types. Neither GridSchema nor GridColumn is on this
package's export surface, and both names are taken elsewhere by different
things — GridSchema in @object-ui/types is the CSS Grid layout
container, and GridColumn in @object-ui/fields is a column of the
line-items form widget (keyed name). The data-grid pair is
ObjectGridSchema + ListColumn. ObjectGridSchema types the component's
schema prop — the node as the grid reads it, after SchemaRenderer hoists an
authored node's properties bag onto it — so its keys sit flat here.
import type { ObjectGridSchema, ListColumn } from '@object-ui/types';
import type { ObjectGridComponentProps } from '@object-ui/plugin-grid';
const nameColumn: ListColumn = {
field: 'name',
label: 'Full Name',
sortable: true
};
const grid: ObjectGridSchema = {
type: 'object-grid',
objectName: 'users',
columns: [nameColumn],
pagination: { pageSize: 20 }
};
// Row callbacks are COMPONENT props, not schema keys.
const gridProps: ObjectGridComponentProps = {
schema: grid,
onRowClick: (record) => console.log('Row clicked:', record)
};Annotating the literal is the point: an un-annotated const schema = { … }
type-checks no matter what is written in it, so a snippet that carries no
annotation cannot tell you whether its keys are real.
MIT