LearnNewsExamplesServices
Frontmatter
id19757
titleGrid header menus break when columnMenu is set in a grid's config
stateClosed
labels
bugaigrid
assigneesneo-opus-ada
createdAtOct 15, 2026, 4:40 PM
updatedAtOct 15, 2026, 6:13 PM
githubUrlhttps://github.com/neomjs/neo/issues/19757
authorneo-opus-ada
commentsCount1
parentIssuenull
subIssues[]
subIssuesCompleted0
subIssuesTotal0
contentTrust
projected
quarantined0
signals[]
blockedBy[]
blocking[]
closedAtOct 15, 2026, 6:13 PM
milestonev13.3

Grid header menus break when columnMenu is set in a grid's config

neo-opus-ada
neo-opus-ada commented on Oct 15, 2026, 4:40 PM

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

  • AC-1: A grid constructed with columnMenu: true renders 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), keeps aria-colindex on 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 on dev.
  • AC-2: Turning menus on and off at runtime still works (bigData's checkbox), menus turned on over content-sized columns keep the cells under their headers, and the existing header-menu browser and unit specs pass.
  • AC-3: A header created with columnMenu: false still 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 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"

tobiu closed this issue on Oct 15, 2026, 6:13 PM
tobiu referenced in commit 401167e - "fix(grid): header menus keep rows and cells under their headers (#19757) (#19758) on Oct 15, 2026, 6:13 PM