Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
40 commits
Select commit Hold shift + click to select a range
3796d59
chore(deps): resolve @objectstack/* 17.6.0 in pnpm-lock.yaml (objectu…
claude Oct 2, 2026
b6684fd
chore(scripts): spec-symbols allows @object-ui/components' EmptyState…
claude Oct 2, 2026
c2dc334
refactor(app-shell,data-objectstack): retire the `| 'computed'` widen…
claude Oct 2, 2026
3b3cc6b
test(plugin-dashboard): read the ADR-0087 registry from @objectstack/…
claude Oct 2, 2026
1eff255
fix(components): page:header stops publishing `breadcrumb`, which @ob…
claude Oct 2, 2026
6e76e90
test(console): strike the two `endpoint` bookings @objectstack/spec 1…
claude Oct 2, 2026
7ca5a36
fix(i18n): recognise a served translation document by its `picklists`…
claude Oct 2, 2026
5ef3050
test(plugin-designer): the `constructor` field-name refusal is read a…
claude Oct 2, 2026
74fd7eb
docs(types,core,app-shell,plugins): restamp the installed-pin claims …
claude Oct 2, 2026
d73f74e
chore(changeset): declare the @objectstack/* 17.6.0 bump (objectui#11…
claude Oct 2, 2026
8879fe5
test(scripts,app-shell,console): re-pin the counts and keys @objectst…
claude Oct 3, 2026
31c6905
test(types): object-grid's bag `grouping` is judged since @objectstac…
claude Oct 3, 2026
2ba091c
test(types): the card footer is a judged child position since @object…
claude Oct 3, 2026
060cec8
chore(scripts): lower the eager-closure ceilings by the 250,096 bytes…
claude Oct 3, 2026
40d9b5a
Merge remote-tracking branch 'origin/main' into claude/issue-11438-ob…
claude Oct 3, 2026
bb6d7d3
Merge origin/main (c73cdb5) into claude/issue-11438-objectstack-17-6-0
claude Oct 3, 2026
00c13e7
test(console): book record:line_items' 17.6.0 differences to objectui…
claude Oct 3, 2026
c9121f9
test(console): the group-5 booking's prose names no installed version…
claude Oct 3, 2026
acd62a3
fix(core): ValueDataSource executes is_empty / is_not_empty and $empt…
claude Oct 3, 2026
158204d
fix(core): convertFiltersToAST lowers $empty, the canonical operator …
claude Oct 3, 2026
9786e01
test: promote the staged-$empty tripwires to @objectstack/spec 17.6.0…
claude Oct 3, 2026
4ac6be7
feat(plugin-grid,plugin-list,types): follow object-grid's 17.6.0 desc…
claude Oct 3, 2026
518c855
chore(changeset): objectui#11227's changeset, and corrections to thre…
claude Oct 3, 2026
ed622b4
test: type the two casts the slice's new pins added instead of wideni…
claude Oct 3, 2026
8a9cf1b
test(core): the refusal-subject census names the $empty flag refusal,…
claude Oct 3, 2026
6a86dd1
test(types),docs: register EmptyStateSchema at the import boundary, r…
claude Oct 3, 2026
da4c629
Merge origin/main (6f5719e1c) into claude/issue-11438-objectstack-17-6-0
claude Oct 3, 2026
9187c5c
Merge trunk claude/issue-11438-objectstack-17-6-0 (da4c629f1) into ob…
claude Oct 3, 2026
0a518da
test(types): book the dimensionless measure-arity check on the 7715 c…
claude Oct 3, 2026
f22f27e
feat(components,plugin-grid,types): object-grid honours keyboardNavig…
claude Oct 3, 2026
058f88f
chore(changeset),test(types,components): objectui#11068's keyboardNav…
claude Oct 3, 2026
c4775ed
test(types,plugin-dashboard): book the dimensionless measure-arity ro…
claude Oct 3, 2026
6c5011d
test(console): book row 1 of the 17.6.0 bump, object-grid.keyboardNav…
claude Oct 3, 2026
bebd7ec
test(console): the row-1 booking's docblock cites ruling A″ by record…
claude Oct 3, 2026
8a18914
docs(components): data-table's keyboardNavigation — set in code by th…
claude Oct 3, 2026
e807326
docs(plugin-grid): drop the spec version literal from the keyboardNav…
claude Oct 3, 2026
580add1
Merge trunk claude/issue-11438-objectstack-17-6-0 (bebd7ecdf) into ob…
claude Oct 3, 2026
cc755a8
test(console): strike row 1's booking — objectui#11068's build publis…
claude Oct 3, 2026
43bcd3a
Merge main (6158e4c93, the 17.6.0 bump objectui#11531) into the keybo…
claude Oct 3, 2026
ca3c673
docs(changeset): objectui#11227's pending grid entry is corrected — k…
claude Oct 3, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/10976-table-slot-relay.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,3 +48,8 @@ doc or builder in this repository writes one.
The slot's key list and the withheld set are pinned against drift by
`packages/types/src/__tests__/object-view-slot-key-lists.test.ts`, which also requires the
validator to refuse every withheld key by name, so the two faces refuse the same keys.

**Correction, 2026-10-03 (objectui#11068).** `keyboardNavigation` moved from the first group to
the second: `ObjectGrid` honours it on an `object-grid` node since objectui#11068's build, and
the view still does not hand it on, so the slot still refuses it, now with that reason
(`.changeset/11068-keyboard-navigation.md`).
5 changes: 5 additions & 0 deletions .changeset/11068-grid-declared-keys.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,3 +46,8 @@ optional strings. It is the spec's `EmptyStateSchema` by reference, which `@obje
17.6.0 declares on its `object-grid` row: `title` and `message` are `string | I18nLabel` (a
plain string or an inline locale map, which the grid resolves against the display locale), and
an unknown member is still refused by name (`.changeset/11227-object-grid-17-6-keys.md`).

**Correction, 2026-10-03 (objectui#11068, the `keyboardNavigation` build).** `keyboardNavigation`
is no longer unread. The grid honours it, with arrow-key cell navigation that is on by default
when the grid renders editable, and it is in the grid's declared inputs
(`.changeset/11068-keyboard-navigation.md`).
41 changes: 41 additions & 0 deletions .changeset/11068-keyboard-navigation.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
---
'@object-ui/components': minor
'@object-ui/plugin-grid': minor
'@object-ui/types': minor
---

An `object-grid` honours `keyboardNavigation`: arrow-key cell navigation on the WAI-ARIA grid
pattern (objectui#11068). `@objectstack/spec` 17.6.0 declares the key on its `object-grid` row
(objectstack#20694), and the grid now reads it.

**`@object-ui/plugin-grid` (feature).**

- With the key on, the grid's data cells take one place in the Tab order instead of one each:
Tab reaches them once, on the cell that last held focus (the first cell to begin with), and
the next Tab moves past them. The 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. On an
editable grid, Enter still opens the focused cell, and an edit ended with Enter or Escape
hands focus back to its cell. A widget a cell renders (the record link, a row's action menu,
a selection checkbox) keeps its own Tab stop.
- The default is on when the grid renders editable: the authored `editable` and the viewer's
permission to update the object, the value inline editing itself obeys. A read-only grid
keeps every cell its own Tab stop unless `keyboardNavigation: true` is written, and
`keyboardNavigation: false` turns it off on an editable grid. **An editable grid changes
without an edit to its document:** its cells become one Tab stop, and the arrow keys move
between them.
- `keyboardNavigation` is in the grid's declared inputs, so the designer panel, the component
manifest and the generated `sdui-intrinsics.d.ts` offer it, and the SDUI parser no longer
reports it as `unknown-prop`. This supersedes the line in objectui#11227's entry that says
the key is not published and nothing reads it.

**`@object-ui/components` (feature).** `data-table` takes a `keyboardNavigation` flag that does
the above: the table is exposed to assistive technology as a `grid`, its data cells are one
roving Tab stop, and the keys move it. Off, which stays the default, the table is unchanged:
every data cell is its own Tab stop and the table carries no grid role. `ObjectGrid` sets the
flag in code; it is not an authoring key on a `data-table` node.

**`@object-ui/types` (feature).** `DataTableSchema` declares `keyboardNavigation?: boolean`, the
flag above, set in code by a host as `editable` is. `ObjectGridSchema.keyboardNavigation`'s
documentation describes the behaviour and the default. An `object-view`'s `table` slot still
refuses `keyboardNavigation`; its message now says the grid honours the key on an
`object-grid` node and the view does not hand it on.
2 changes: 2 additions & 0 deletions .changeset/11068-row1-booking-17-6.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,5 @@
---

Test-only change in `@object-ui/console`; no published behaviour changes. Under objectui#11438 ruling A″, row 1 of the `@objectstack/spec` 17.6.0 bump, `object-grid.keyboardNavigation`, is booked as owed to objectui#11068 in `registry-inputs-spec-parity.test.ts`, with an expiry (2026-11-02, or when objectui#11068's build lands). The entry joins the file's objectui#11111 ledger, whose `unpublishedKeys` cap rises by exactly this one entry. The GA-block split row admits this one id by name and refuses every other exemption on the four GA blocks as before. The file sits under `apps/console/src/__tests__/`, which nothing outside `__tests__/` imports, so neither the console bundle nor `plugin.*` carries it.

**Correction, 2026-10-03 (objectui#11068, the `keyboardNavigation` build).** The booking is struck. objectui#11068's build publishes `keyboardNavigation` in the grid's declared inputs together with its reader, so the entry went stale; the build removes it, lowers the `unpublishedKeys` cap by the same one entry, and leaves the GA-block split row admitting no booked id (`.changeset/11068-keyboard-navigation.md`).
7 changes: 7 additions & 0 deletions .changeset/11227-object-grid-17-6-keys.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,3 +37,10 @@ string is drawn as before.
- TypeScript code that reads `emptyState.title` or `.message` as a `string` no longer
type-checks. Resolve the value first, for example with `resolveI18nLabel` from
`@objectstack/spec/ui`.

**Correction, 2026-10-03 (objectui#11068, the `keyboardNavigation` build).** The bullet above
that says `keyboardNavigation` "is not published" and that "nothing in the grid reads it yet" is
superseded. The grid honours the key, with arrow-key cell navigation that is on by default when
the grid renders editable, and it is in the grid's declared inputs, so the designer panel, the
component manifest and the generated `sdui-intrinsics.d.ts` offer it
(`.changeset/11068-keyboard-navigation.md`).
78 changes: 30 additions & 48 deletions apps/console/src/__tests__/registry-inputs-spec-parity.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -758,11 +758,11 @@ function isRetiredUpstream(type: string): boolean {
* same way, to their own cards". Row 1 is such a row:
*
* - objectui#11068 — `object-grid.keyboardNavigation`, the one key on a GA
* block that 17.6.0's row declares and `inputs` does not publish. That card
* is BUILDING the key's reader and publishes the key with it; this is the
* only OWED entry on any of the four GA blocks, pinned by name in `the four
* GA blocks resolve their ruled split`, because objectui#4648's split
* otherwise refuses any exemption on them (see THE FOUR GA BLOCKS below).
* block that 17.6.0's row declares and `inputs` did not publish. STRUCK by
* that card's build, which publishes the key in `GRID_QUERY_INPUTS` together
* with its reader (arrow-key cell navigation on the WAI-ARIA grid pattern),
* so the card owns no entry now and the four GA blocks carry no OWED entry
* again (see THE FOUR GA BLOCKS below).
*
* Each owner card decides every key by its own measurement: declare what the
* renderer honours, refuse or retire what it does not. ⛔ Batch-declaring an
Expand All @@ -786,8 +786,9 @@ function isRetiredUpstream(type: string): boolean {
* their ruling as the 17.5.0 date is from its own: record 5902351047 was ruled
* 2026-09-30 and its entries expire 2026-10-30; record 5965062249 was ruled
* 2026-10-03, so objectui#11536's entries expire 2026-11-02. Record 5968177777
* (ruling A″) was ruled the same day, so objectui#11068's entry shares that
* date: it expires 2026-11-02, or when that card's build lands.
* (ruling A″) was ruled the same day, so objectui#11068's entry shared that
* date: it expired 2026-11-02, or when that card's build landed — the build
* landed first and struck it.
*/
const OBJECTUI_11111_EXPIRES = '2026-10-30';

Expand Down Expand Up @@ -889,7 +890,7 @@ const owedIdsOf = (ledger: Record<string, string>): string[] =>
const OBJECTUI_11111_LEDGER_CAPS = {
unjudgedBlocks: 0, // objectui#11168 loaded and judged all four: slice 3 object-map and object-tree, slice 4 object-gantt, slice 5 object-timeline
offSpecInputs: 0, // objectui#11168 slice 1 retired action:group.name
unpublishedKeys: 12, // objectui#11168: 1 (action:button undoable; the two `endpoint` entries left at the 17.6.0 bump, objectui#11438, when the spec stopped declaring the key); objectui#8652: 0 and objectui#8649: 0 (each struck by its landing); objectui#11536: 10 (record:line_items, booked by objectui#11438 ruling A′); objectui#11068: 1 (object-grid keyboardNavigation, booked by objectui#11438 ruling A″)
unpublishedKeys: 11, // objectui#11168: 1 (action:button undoable; the two `endpoint` entries left at the 17.6.0 bump, objectui#11438, when the spec stopped declaring the key); objectui#8652: 0 and objectui#8649: 0 (each struck by its landing); objectui#11536: 10 (record:line_items, booked by objectui#11438 ruling A′); objectui#11068: 0 (object-grid keyboardNavigation, booked by objectui#11438 ruling A″, struck by that card's build)
refusedArms: 0, // objectui#11168: slice 2 narrowed element:definition-list.columns, slice 3 object-form.layout
memberPins: 2, // objectui#11168 slice 2 pinned element:definition-list.items and element:repeater ×3; objectui#11536: 2 (record:line_items columns and dataSource, booked by objectui#11438 ruling A′)
} as const;
Expand Down Expand Up @@ -1555,27 +1556,11 @@ const UNPUBLISHED_EXEMPTIONS: Record<string, string> = {
'A SPEC KEY NOT PUBLISHED: `record:line_items` entered `covered` with 17.6.0 and its `inputs` omit this key its spec row declares.',
),

/*
* ⚠️ ROW 1 OF THE 17.6.0 BUMP — objectui#11438 ruling A″ (record 5968177777),
* which books a row whose owner card's slice is not accepted when the row-3
* booking is pushed, "the same way, to their own cards".
*
* `object-grid.keyboardNavigation`: the 17.6.0 row declares it and the
* grid's `inputs` do not publish it. objectui#11068 is building its reader
* (arrow-key cell navigation on the WAI-ARIA grid pattern) and publishes the
* key in `GRID_QUERY_INPUTS` together with that reader, never ahead of it.
* `object-grid` is a GA block, where objectui#4648's split admits no
* exemption but the ruled carve-out, so this entry is the one exception and
* `the four GA blocks resolve their ruled split` pins it by name. Capped
* with the entries above at `OBJECTUI_11111_LEDGER_CAPS.unpublishedKeys`;
* the build's landing makes it stale, and strikes it and lowers the cap.
*/
...owedEntries(
'object-grid',
['keyboardNavigation'],
'objectui#11068',
'A SPEC KEY NOT PUBLISHED, ITS READER IN FLIGHT: `object-grid`\'s 17.6.0 row declares `keyboardNavigation` and its `inputs` omit it; objectui#11068 publishes it with the reader it is building.',
),
// objectui#11068's one entry — row 1 of the 17.6.0 bump,
// `object-grid.keyboardNavigation`, booked by objectui#11438 ruling A″
// (record 5968177777) — is STRUCK: that card's build published the key in
// `GRID_QUERY_INPUTS` together with its reader, so the entry went stale and
// `carries no stale unpublished-key exemption` would refuse it.
};

/**
Expand Down Expand Up @@ -1756,14 +1741,13 @@ const isDormantOnThisPin = (exemptionKey: string): boolean => {
* these blocks gain is a plain A-class defect: declare it at the registration
* site. Do not add an entry here to silence one.
*
* ⚠️ ONE BOOKED EXCEPTION, BY RULING AND WITH AN EXPIRY — not a silenced key.
* objectui#11438 ruling A″ (record 5968177777) books row 1 of the 17.6.0 bump,
* `object-grid.keyboardNavigation`, OWED TO objectui#11068, which declares it
* at the registration site together with the reader it is building. The entry
* expires 2026-11-02 or at that card's landing, whichever is first.
* `the four GA blocks resolve their ruled split` admits exactly that id and no
* other OWED entry on these four blocks, so the rule above still holds for
* every other key.
* ⚠️ ONE BOOKED EXCEPTION STOOD HERE, BY RULING AND WITH AN EXPIRY, and is
* STRUCK. objectui#11438 ruling A″ (record 5968177777) booked row 1 of the
* 17.6.0 bump, `object-grid.keyboardNavigation`, OWED TO objectui#11068, until
* that card declared it at the registration site together with its reader. Its
* build did, and struck the entry. `the four GA blocks resolve their ruled
* split` now admits no OWED entry on these four blocks, so the rule above holds
* for every key again.
*/

const exemptedFor = (type: string): string[] =>
Expand Down Expand Up @@ -4959,16 +4943,14 @@ describe('registry `inputs` vs `@objectstack/spec` ComponentPropsMap (repo-wide)
).toContain(`object-grid.${key}`);
}

// ⚠️ THE ONE BOOKED KEY — objectui#11438 ruling A″ (record 5968177777).
// Row 1 of the 17.6.0 bump, `object-grid.keyboardNavigation`, is OWED TO
// objectui#11068, which publishes it together with the reader it is
// building. It is neither declared nor carved out, so it is admitted BY
// NAME: the OWED entries on the four blocks must be exactly this list, and a
// second one is red here whatever the ledger cap says. It expires 2026-11-02
// or at that card's landing; the landing declares the key, which turns the
// first assertion in the loop below red until this list and the entry are
// struck together.
const BOOKED_GA_KEYS = ['object-grid.keyboardNavigation'];
// ⚠️ THE BOOKED KEYS — objectui#11438 ruling A″ (record 5968177777) booked
// one, row 1 of the 17.6.0 bump, `object-grid.keyboardNavigation`, OWED TO
// objectui#11068. That card's build published the key together with its
// reader and struck the booking and this list together, as the booking
// prescribed. The list stays, EMPTY, so an OWED entry on a GA block is
// still red here whatever the ledger cap says: a booking admitted here is a
// ruling's, by name, and none stands.
const BOOKED_GA_KEYS: string[] = [];
expect(
owedIdsOf(UNPUBLISHED_EXEMPTIONS).filter((id) => GA_ONLY_BLOCKS.includes(splitExemptionKey(id)[0])),
'an OWED entry on a GA block that objectui#11438 ruling A″ did not book — declare the key instead',
Expand Down Expand Up @@ -6011,7 +5993,7 @@ describe('registry `inputs` vs `@objectstack/spec` ComponentPropsMap (repo-wide)
'objectui#8652': 0,
'objectui#8649': 0,
'objectui#11536': 12,
'objectui#11068': 1,
'objectui#11068': 0,
});
});
});
16 changes: 16 additions & 0 deletions content/docs/components/complex/data-table.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,22 @@ a sibling field — a `dependsOn` lookup — should read `pendingRow`, so a pare
edited in the same row re-scopes the child before anything is saved; `row` stays
the place to read what the data source last returned.

## Keyboard navigation

`keyboardNavigation` is set by the host in code too, beside `editable`, and is off
unless a host turns it on. `object-grid` is that host: it resolves its own
`keyboardNavigation` key (on by default when the grid renders editable) and sets
the answer on its table — see
[Keyboard navigation](/docs/plugins/plugin-grid#keyboard-navigation) in the grid
plugin (objectui#11068).

On, the table follows the WAI-ARIA grid pattern: it is exposed as a `grid`, its
data cells take one place in the Tab order instead of one each (the cell that last
held focus), the arrow keys move focus one cell, Home / End go to the ends of the
row and Ctrl+Home / Ctrl+End to the ends of the page, and an edit ended with Enter
or Escape hands focus back to its cell. A widget a cell renders keeps its own Tab
stop. Off, every data cell is its own Tab stop, as before.

## Masked columns

A column with `masked: true` holds a credential. The table withholds its raw value on
Expand Down
45 changes: 41 additions & 4 deletions content/docs/plugins/plugin-grid.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,7 @@ below for why this plugin deliberately does not claim it.
| `selection` | `SelectionConfig` | `{ type: 'none' \| 'single' \| 'multiple' }`. |
| `rowActions` / `bulkActions` | `string[]` | **Names** of actions, not inline definitions. |
| `editable` / `singleClickEdit` | `boolean` | Inline editing — see [Inline Editing](#inline-editing). |
| `keyboardNavigation` | `boolean` | Arrow keys move focus between cells, and the cells are one Tab stop — see [Keyboard navigation](#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.) |
Expand Down Expand Up @@ -152,10 +153,12 @@ Written flat on the node, either key is refused by name and pointed at the bag
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 marked
`[EXPERIMENTAL — not enforced]` there, and nothing in this plugin reads it yet,
so it is not in `GRID_QUERY_INPUTS`. The bag accepts it, as the spec row does,
and it changes nothing.
`keyboardNavigation`, the third key objectstack#20694 added to the row, is
honoured too (objectui#11068) and is in `GRID_QUERY_INPUTS` — see
[Keyboard navigation](#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
Expand Down Expand Up @@ -686,6 +689,40 @@ through the host's data source (`dataSource.update`) with no callback to wire.
To own persistence in a React host, supply `onCellChange` as a **component
prop** — it is not a schema key.

### Keyboard navigation

`keyboardNavigation` turns the grid's data cells into one roving Tab stop, on the
WAI-ARIA grid pattern (objectui#11068):

```json
{
"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 `editable` *and*
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 write
`true`, and `false` turns 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.

### Batch Editing & Multi-Row Save

Edit multiple cells across multiple rows and save them individually or all at
Expand Down
Loading
Loading