Context
Filed from a downstream consumer whose inline-editing e2e arm had been skipping for lack of data; once it ran it failed 4/4. The original filing named the keyboard path as the defect. Building the engine-side reproducer found a larger one underneath it, and this body is rewritten around the measured cause — the earlier framing is preserved in the comment history.
Cell editing is non-functional on a grid by every gesture, not only by keyboard. The engine ships examples/grid/cellEditing/ (cellEditing: true, editable columns, editors), and it cannot mount an editor.
New in-repo reproducer: test/playwright/component/grid/CellEditingKeys.spec.mjs on fixture component/apps/grid-cell-editing. Measured at ca83102346: 4 passed / 3 failed — and the three failures are double-click, Enter and Space, i.e. every activation path there is.
The Problem — three independent breaks, in the order a gesture meets them
1. getCellId has incompatible signatures in the two Body classes, and the shared plugin calls the table's.
cellId = body.getCellId(record, dataField)
|
signature |
table.Body.getCellId (:419-423) |
@param {Object} record |
grid.Body.getCellId (:966-970) |
@param {Number} rowIndex |
On a grid the base therefore passes a record object where a row index is expected. grid.Body.getCellId derives a pooled id through getRowId(rowIndex) → `${me.id}__row-${rowIndex % poolSize}` — an object modulo a number is NaN, so the id is garbage, VdomUtil.find(body.vdom, cellId) resolves nothing, and mountEditor cannot find the cell it is meant to cover. This alone kills every gesture, which is why the double-click control fails.
This is also the deeper form of the same divergence the DOM shows: a grid's cell ids are pool-slot based (…__row-0__cell-0), while table.Body ids are record-based. The plugin was written against the latter.
2. The activation keys are registered on a component the keydown never reaches.
NEO_CODE_BLOCK_1
manager.DomEvent routes by walking the event path upward (ComponentManager.getParentPath), so a component's listener fires only when that component is on the target's ancestor path. Focus lives on grid.View and the bodies are its descendants — a body listener is never on the path. And grid/View.mjs:52 already declares the right home: "grid.View is the single key registry, the keyboard half of" the View-owned contract. selection.grid.CellModel:188 and ColumnModel:135 both comply by pushing onto view.keys._keys; CellEditing is the only key registration in the grid stack aimed at the body.
3. The guard reads focus where it means selection.
NEO_CODE_BLOCK_2
target is the focused element; neo-selected lands on the cell. Measured in a real browser: exactly one element inside a grid container declares tabindex, and it is grid.View (a cell's element.tabIndex reads -1 because that is the default for a non-focusable element — its tabindex attribute is null). So the guard can never hold on a grid.
And the base already anticipated this. onSelectionChange is an empty handler carrying:
NEO_CODE_BLOCK_3
#15195 performed that separation — it made grid.View the single focus anchor. The precondition in the todo has been met and nobody returned to it.
The Architectural Reality
src/table/plugin/CellEditing.mjs — the only implementation; grid/plugin/CellEditing.mjs is 34 lines of config (cellCls, editorCls, focusCells) and inherits every behaviour above.
- Break 1 is a contract divergence, not a typo: two Body classes, one method name, two meanings. Any shared consumer of
getCellId is exposed the same way; this ticket fixes the one caller it can prove.
- Break 2 is a registry divergence, with the correct registry already documented and already used by two siblings.
focusCells governs only the post-dismissal restore — its single use is in selectCell, whose only callers are onEditorKeyEnter and onEditorKeyEscape. It does not establish focus, and its name invites the opposite reading.
- Zero prior coverage: before this ticket nothing under
test/playwright/** referenced onTableKeyDown, CellEditing, or neo-grid-editor, which is how three stacked breaks and a shipped broken example survived.
The Fix
- Give the cell-node lookup a signature the grid can satisfy. Either resolve the cell id through a hook each Body implements to its own contract, or have the grid variant translate record → rowIndex before delegating. The base must stop assuming the table's
getCellId meaning.
- Register the grid variant's activation keys on
view.keys, per the registry grid/View.mjs declares and CellModel/ColumnModel already use.
- Resolve the target cell from the View-owned selection model (
selectionModel.hasSelection(), .items[0], .getRecord(id), view.getDataField(id) — the accessors CellModel#onNavKeyColumn already uses) rather than from the keydown target.
Breaks 2 and 3 are both required: registering on the View alone still fails the neo-selected guard, and fixing the guard alone still never runs. Break 1 is required for either to produce a visible editor.
Contract Ledger Matrix
| Target Surface |
Source of Authority |
Behavior |
Fallback |
Docs |
Evidence |
grid.Container#cellEditing (src/grid/Container.mjs:109) |
#15195; grid/View.mjs:52 |
editing works on a grid by double-click AND by Enter/Space |
none — it works by no gesture today |
plugin class docs |
the 7-arm spec, red-first |
table.plugin.CellEditing#mountEditor cell lookup |
table/Body.mjs:419 vs grid/Body.mjs:966 |
resolves a cell node under both Body contracts |
n/a |
method JSDoc naming the divergence |
double-click control red before, green after |
| the activation key registry |
grid/View.mjs:52 |
grid keys live on view.keys |
table keeps body.keys unchanged |
both plugin class docs |
Enter/Space arms red before, green after |
Decision Record impact
none, and explicitly aligned-with #15195. A fix that moves focus onto cells would challenge that contract and is rejected under Avoided Traps.
Acceptance Criteria
Out of Scope
- Changing the grid's focus model.
#15195 is deliberate; this adapts the plugin to it.
- Auditing every other
getCellId consumer. The divergence is general, and this ticket fixes the caller it can prove. A sweep is worth its own ticket rather than a blind widening here.
- The editor's own key handling (
onEditorKeyEnter / Escape / Tab) and the commit path — a separate surface, not reported defective.
- The
onSelectionChange todo's larger design (mounting editors from selection events). Recorded because it dates the intent; acting on it is a design change, not this repair.
Avoided Traps
- Blaming the editor. The consumer arm asserted the editor becomes visible and failed there, pointing at the editor and the mount path — both of which are fine.
- Stopping at the keyboard. My own first filing did. A double-click control is what exposed break 1; without it, fixing breaks 2 and 3 would have produced a still-dead path and a confident wrong conclusion.
- Reading a cell's
element.tabIndex === -1 as focusable. It is the default for non-focusable elements; the attribute is what matters, and cells have none.
- Reading
focusCells: true as evidence that cells get focus. Its only use is the post-dismissal restore.
- "Just make cells focusable." Re-creates the per-body focus scatter
#15195 removed.
Related
#15195 — the View-owned focus contract, and the separation the base's todo was waiting for; test/playwright/e2e/grid/GridViewFocus.spec.mjs is its witness
#18065 — closed: a grid click moving no focus. Adjacent and distinct; asserted as a control here so the two cannot be confused
#18151 — the dock epic on consumers re-deriving engine knowledge; the same silent-decline shape in another subsystem
Origin Session ID: e8ea32a2-b9a8-43ea-84ba-b83081ec4b3d
Handoff Retrieval Hints
Retrieval Hint: "grid cell editing dead by every gesture — getCellId takes a record in table and a rowIndex in grid"
Retrieval Hint: table/plugin/CellEditing mountEditor body.getCellId(record) vs grid/Body.getCellId(rowIndex) pooled id NaN
Retrieval Hint: onSelectionChange todo "once we separate cell selections & focus" satisfied by <a href="#/news/tickets/15195">#15195</a>
Context
Filed from a downstream consumer whose inline-editing e2e arm had been skipping for lack of data; once it ran it failed 4/4. The original filing named the keyboard path as the defect. Building the engine-side reproducer found a larger one underneath it, and this body is rewritten around the measured cause — the earlier framing is preserved in the comment history.
Cell editing is non-functional on a grid by every gesture, not only by keyboard. The engine ships
examples/grid/cellEditing/(cellEditing: true, editable columns, editors), and it cannot mount an editor.New in-repo reproducer:
test/playwright/component/grid/CellEditingKeys.spec.mjson fixturecomponent/apps/grid-cell-editing. Measured atca83102346: 4 passed / 3 failed — and the three failures are double-click,EnterandSpace, i.e. every activation path there is.The Problem — three independent breaks, in the order a gesture meets them
1.
getCellIdhas incompatible signatures in the two Body classes, and the shared plugin calls the table's.// src/table/plugin/CellEditing.mjs:114 — the shared base cellId = body.getCellId(record, dataField)table.Body.getCellId(:419-423)@param {Object} recordgrid.Body.getCellId(:966-970)@param {Number} rowIndexOn a grid the base therefore passes a record object where a row index is expected.
grid.Body.getCellIdderives a pooled id throughgetRowId(rowIndex)→`${me.id}__row-${rowIndex % poolSize}`— an object modulo a number isNaN, so the id is garbage,VdomUtil.find(body.vdom, cellId)resolves nothing, andmountEditorcannot find the cell it is meant to cover. This alone kills every gesture, which is why the double-click control fails.This is also the deeper form of the same divergence the DOM shows: a grid's cell ids are pool-slot based (
…__row-0__cell-0), whiletable.Bodyids are record-based. The plugin was written against the latter.2. The activation keys are registered on a component the keydown never reaches.
NEO_CODE_BLOCK_1
manager.DomEventroutes by walking the event path upward (ComponentManager.getParentPath), so a component's listener fires only when that component is on the target's ancestor path. Focus lives ongrid.Viewand the bodies are its descendants — a body listener is never on the path. Andgrid/View.mjs:52already declares the right home: "grid.View is the single key registry, the keyboard half of" the View-owned contract.selection.grid.CellModel:188andColumnModel:135both comply by pushing ontoview.keys._keys;CellEditingis the only key registration in the grid stack aimed at the body.3. The guard reads focus where it means selection.
NEO_CODE_BLOCK_2
targetis the focused element;neo-selectedlands on the cell. Measured in a real browser: exactly one element inside a grid container declarestabindex, and it isgrid.View(a cell'selement.tabIndexreads-1because that is the default for a non-focusable element — itstabindexattribute isnull). So the guard can never hold on a grid.And the base already anticipated this.
onSelectionChangeis an empty handler carrying:NEO_CODE_BLOCK_3
#15195performed that separation — it madegrid.Viewthe single focus anchor. The precondition in the todo has been met and nobody returned to it.The Architectural Reality
src/table/plugin/CellEditing.mjs— the only implementation;grid/plugin/CellEditing.mjsis 34 lines of config (cellCls,editorCls,focusCells) and inherits every behaviour above.getCellIdis exposed the same way; this ticket fixes the one caller it can prove.focusCellsgoverns only the post-dismissal restore — its single use is inselectCell, whose only callers areonEditorKeyEnterandonEditorKeyEscape. It does not establish focus, and its name invites the opposite reading.test/playwright/**referencedonTableKeyDown,CellEditing, orneo-grid-editor, which is how three stacked breaks and a shipped broken example survived.The Fix
getCellIdmeaning.view.keys, per the registrygrid/View.mjsdeclares andCellModel/ColumnModelalready use.selectionModel.hasSelection(),.items[0],.getRecord(id),view.getDataField(id)— the accessorsCellModel#onNavKeyColumnalready uses) rather than from the keydown target.Breaks 2 and 3 are both required: registering on the View alone still fails the
neo-selectedguard, and fixing the guard alone still never runs. Break 1 is required for either to produce a visible editor.Contract Ledger Matrix
grid.Container#cellEditing(src/grid/Container.mjs:109)#15195;grid/View.mjs:52Enter/Spacetable.plugin.CellEditing#mountEditorcell lookuptable/Body.mjs:419vsgrid/Body.mjs:966grid/View.mjs:52view.keysbody.keysunchangedEnter/Spacearms red before, green afterDecision Record impact
none, and explicitly aligned-with#15195. A fix that moves focus onto cells would challenge that contract and is rejected under Avoided Traps.Acceptance Criteria
cellEditing, a double-click on an editable cell mounts the editor with the cell's value. Red-first: fails ondevtoday.Enteron a selected editable cell mounts the editor. Red-first ondev.Space— registered on the same handler — behaves asEnter, or is explicitly declared out of scope with its reason.getCellIdsignature divergence is resolved so the base makes no assumption about which Body it holds, andtablecell editing is proven unchanged by its own arm rather than by inspection.Enterwith no selection mounts nothing — asserted after the fix, because both pass vacuously today (nothing mounts at all, so neither can go red).grid.View, and exactly one element in the grid declarestabindex. Distinguishes this defect from#18065's neighbourhood.focusCells' JSDoc states that it governs the post-dismissal restore only.examples/grid/cellEditing/is exercised by eye and reported working, since it is the shipped artifact a reader would try first.Out of Scope
#15195is deliberate; this adapts the plugin to it.getCellIdconsumer. The divergence is general, and this ticket fixes the caller it can prove. A sweep is worth its own ticket rather than a blind widening here.onEditorKeyEnter/Escape/Tab) and the commit path — a separate surface, not reported defective.onSelectionChangetodo's larger design (mounting editors from selection events). Recorded because it dates the intent; acting on it is a design change, not this repair.Avoided Traps
element.tabIndex === -1as focusable. It is the default for non-focusable elements; the attribute is what matters, and cells have none.focusCells: trueas evidence that cells get focus. Its only use is the post-dismissal restore.#15195removed.Related
#15195— the View-owned focus contract, and the separation the base'stodowas waiting for;test/playwright/e2e/grid/GridViewFocus.spec.mjsis its witness#18065— closed: a grid click moving no focus. Adjacent and distinct; asserted as a control here so the two cannot be confused#18151— the dock epic on consumers re-deriving engine knowledge; the same silent-decline shape in another subsystemOrigin Session ID: e8ea32a2-b9a8-43ea-84ba-b83081ec4b3d
Handoff Retrieval Hints
Retrieval Hint: "grid cell editing dead by every gesture — getCellId takes a record in table and a rowIndex in grid"Retrieval Hint: table/plugin/CellEditing mountEditor body.getCellId(record) vs grid/Body.getCellId(rowIndex) pooled id NaNRetrieval Hint: onSelectionChange todo "once we separate cell selections & focus" satisfied by <a href="#/news/tickets/15195">#15195</a>