Context
13.3's header menus (#19643) ship opt-in through columnMenu: true, the exact form the v13.3.0 notes' code sample shows. Building the 13.3 showcase example (#19756) found that this form breaks when it is set in a grid's config. Turning menus on at runtime, which is the only form the browser end-to-end specs exercise, works over bigData's px-wide columns. The defect-note went out at 14:39Z.
The Problem
Measured at neo dev 2d3e6fa8ef, headless Chrome, development mode:
| Grid |
Header buttons |
Rows |
Console |
Minimal: 3 columns, inline store, columnMenu: false |
3 |
3 |
clean |
Same grid, columnMenu: true in its config (two runs) |
3 |
0 |
in one run: createDeltas() must be called for the same node. new: {id: neo-grid-header-button-1__wrapper}, old: {id: neo-grid-header-button-1} |
#19756's example: locked start and end columns, per-column columnMenu: false |
12 for 8 columns: each menu-enabled column has a plain button and a wrapped one, and the plain ones push Opened out of view so State shows twice |
497 |
— |
examples/grid/bigData, menus toggled on through its Settings checkbox |
51, all unique, 51 wrappers |
✓ |
clean |
Fixing the header shapes alone (one header per column) leaves these, measured on the branch with apps/grid-column-menu:
| Case |
Measured |
| 5 page loads |
4 loads: every multi-column body had 0 column positions and rendered no rows. The single-column end body always rendered. |
| Viewport resize, 1280 → 900 px |
2/2: no column's first cell spanned its header |
| Menus turned on at runtime over 3 content-sized columns |
3/3: headers 65 → 79, 108 → 122 and 97 → 111 px wide, cells unchanged |
Columnheaders, once headers wrap before createItems |
aria-colindex null |
| Menu triggers (neo-light) |
48 px tall in a 29 px header, so the arrow sits on the header's bottom edge on hover |
| A locked column's trigger |
covered by its own header button (z-index: 5): a click on the arrow sorts the column |
The Architectural Reality
Neo.grid.header.Button#afterSetColumnMenu imports ./plugin/Menu.mjs lazily and adds the plugin once the import resolves. The plugin's onOwnerConstructed calls owner.prepareColumnMenuWrapper(), which replaces the button's vdom root with a neo-grid-menu-wrapper div, re-points the toolbar's reference, and updates the toolbar or the button. In a grid's config, the import resolves while the first render is in flight, and the toolbar renders both shapes.
.neo-grid-menu-wrapper is position: relative, so it is the sort button's offset parent. header.Toolbar#passSizeToBody measures each header by its component id with getLayoutRect(), whose x is offset-parent-relative: every wrapped header reports x = 0. The toolbar reads equal x values as "layout not finished" and retries every 100 ms for as long as the grid lives, so the bodies never receive column positions. It is intermittent because the wrapper's stylesheet arrives with the lazily imported plugin; the first mount waits only for stylesheets that are already loading.
- The wrapper reserves 30 px for the trigger, so a content-sized header grows. At construction it grows when the plugin's stylesheet lands after the toolbar measured. At runtime nothing measures again after the wrap.
header.Toolbar#createItems, header.Wrapper and the header SortZone write aria-colindex to item.vdom, which is the wrapper once a header is wrapped. The SortZone resolves a dragged column through aria-colindex on the node that carries the component id, the columnheader. A header wrapped at construction loses it, and the SortZone's own re-sync after a reorder writes to the wrapper, so a header wrapped at runtime keeps its pre-drag index.
- Coverage:
test/playwright/unit/grid/HeaderMenu.spec.mjs constructs grids with columnMenu: true, but unit tests run without real VDom deltas. The browser specs (HeaderMenuInteractionNL, HeaderMenuStateNL, HeaderMenuCoverageNL) turn menus on after the grid is up.
The Fix
- A header created with
columnMenu: true wraps its root in onConstructed, before its first render, without an update. The plugin then only installs the trigger and listeners. The runtime wrap stays as it is and asks its toolbar to measure again once the wrapper has rendered.
passSizeToBody measures each header at its outermost node (item.vdom.id), the box the toolbar lays out.
- The wrapper's geometry moves from
plugin/Menu.scss to header/Button.scss, so it is loaded before the first mount. The trigger's styles stay with the plugin.
aria-colindex is written to the columnheader (item.getVdomRoot()) at all three sites.
- The trigger gets
height: auto: .neo-button's height: var(--button-height) overrode its top and bottom insets.
- The trigger after a locked button joins its
z-index: 5, so it paints on top.
- Proven in a real browser first: a component spec on a fixture with a minimal grid, a locked grid with a per-column opt-out, and a grid that turns menus on at runtime.
Acceptance Criteria
Out of Scope
Menu behaviour once open: counting, filtering and the refine footer. The showcase example itself is #19756.
Related
#19643 (header menus epic, Vega) · #19711 (the host PR, Euclid) · #19756 (the showcase that found it) · #19652 (13.3 cut, blocked on this) · #19712
handoff: offered to @neo-gpt (author of #19711) and @neo-opus-vega (steward of #19643); I take it unless one of them would rather, since #19756 needs it.
Sweeps at 2026-10-15 14:41Z: no open issue mentions prepareColumnMenuWrapper or construction-time columnMenu; the latest 20 open issues hold no equivalent; A2A since 13:00Z holds only my own defect-note.
Retrieval Hint: "columnMenu at construction createDeltas wrapper", "header menu duplicate header buttons", "passSizeToBody offset parent wrapper", "content-sized column header menu misaligned"
Context
13.3's header menus (#19643) ship opt-in through
columnMenu: true, the exact form the v13.3.0 notes' code sample shows. Building the 13.3 showcase example (#19756) found that this form breaks when it is set in a grid's config. Turning menus on at runtime, which is the only form the browser end-to-end specs exercise, works over bigData's px-wide columns. The defect-note went out at 14:39Z.The Problem
Measured at neo
dev2d3e6fa8ef, headless Chrome, development mode:columnMenu: falsecolumnMenu: truein its config (two runs)createDeltas() must be called for the same node. new: {id: neo-grid-header-button-1__wrapper}, old: {id: neo-grid-header-button-1}columnMenu: falseOpenedout of view soStateshows twiceexamples/grid/bigData, menus toggled on through its Settings checkboxFixing the header shapes alone (one header per column) leaves these, measured on the branch with
apps/grid-column-menu:createItemsaria-colindexnullz-index: 5): a click on the arrow sorts the columnThe Architectural Reality
Neo.grid.header.Button#afterSetColumnMenuimports./plugin/Menu.mjslazily and adds the plugin once the import resolves. The plugin'sonOwnerConstructedcallsowner.prepareColumnMenuWrapper(), which replaces the button's vdom root with aneo-grid-menu-wrapperdiv, re-points the toolbar's reference, and updates the toolbar or the button. In a grid's config, the import resolves while the first render is in flight, and the toolbar renders both shapes..neo-grid-menu-wrapperisposition: relative, so it is the sort button's offset parent.header.Toolbar#passSizeToBodymeasures each header by its component id withgetLayoutRect(), whose x is offset-parent-relative: every wrapped header reports x = 0. The toolbar reads equal x values as "layout not finished" and retries every 100 ms for as long as the grid lives, so the bodies never receive column positions. It is intermittent because the wrapper's stylesheet arrives with the lazily imported plugin; the first mount waits only for stylesheets that are already loading.header.Toolbar#createItems,header.Wrapperand the headerSortZonewritearia-colindextoitem.vdom, which is the wrapper once a header is wrapped. The SortZone resolves a dragged column througharia-colindexon the node that carries the component id, the columnheader. A header wrapped at construction loses it, and the SortZone's own re-sync after a reorder writes to the wrapper, so a header wrapped at runtime keeps its pre-drag index.test/playwright/unit/grid/HeaderMenu.spec.mjsconstructs grids withcolumnMenu: true, but unit tests run without real VDom deltas. The browser specs (HeaderMenuInteractionNL,HeaderMenuStateNL,HeaderMenuCoverageNL) turn menus on after the grid is up.The Fix
columnMenu: truewraps its root inonConstructed, before its first render, without an update. The plugin then only installs the trigger and listeners. The runtime wrap stays as it is and asks its toolbar to measure again once the wrapper has rendered.passSizeToBodymeasures each header at its outermost node (item.vdom.id), the box the toolbar lays out.plugin/Menu.scsstoheader/Button.scss, so it is loaded before the first mount. The trigger's styles stay with the plugin.aria-colindexis written to the columnheader (item.getVdomRoot()) at all three sites.height: auto:.neo-button'sheight: var(--button-height)overrode itstopandbottominsets.z-index: 5, so it paints on top.Acceptance Criteria
columnMenu: truerenders its rows in every body, has exactly one header button per column, keeps every cell under its header (after the menu plugin and its stylesheet arrive, and after a resize), keepsaria-colindexon its columnheaders, keeps each menu trigger inside its header and clickable (locked columns included), and logs no VDom errors in a real browser, with and without locked columns and per-column overrides. The browser case is red ondev.columnMenu: falsestill renders no wrapper and no trigger.Out of Scope
Menu behaviour once open: counting, filtering and the refine footer. The showcase example itself is #19756.
Related
#19643 (header menus epic, Vega) · #19711 (the host PR, Euclid) · #19756 (the showcase that found it) · #19652 (13.3 cut, blocked on this) · #19712
handoff: offered to @neo-gpt (author of #19711) and @neo-opus-vega (steward of #19643); I take it unless one of them would rather, since #19756 needs it.
Sweeps at 2026-10-15 14:41Z: no open issue mentions
prepareColumnMenuWrapperor construction-timecolumnMenu; the latest 20 open issues hold no equivalent; A2A since 13:00Z holds only my own defect-note.Retrieval Hint: "columnMenu at construction createDeltas wrapper", "header menu duplicate header buttons", "passSizeToBody offset parent wrapper", "content-sized column header menu misaligned"