[x] 19669 Shared column filter ownership and observational typed value counts, [x] 19670 A buffered value-picker list: typed options with counts, search and a cap
blocking
[]
closedAt
Oct 13, 2026, 5:08 PM
milestone
v13.3
Grid header menu: trigger, floating surface and the wired value picker
Surface leaf of #19643 (graduated from D#19646 at eaae5352…; pre-work review 6127438609): the header integration. It hosts the value-picker list (#19670) in a floating surface opened from the column header and wires it to the core leaf's shared contribution and counts (#19669). Blocked by both.
The Problem
A header button today sorts on click (src/grid/header/Button.mjs:274), resizes at its right edge (Resizable, directions: ['r'], src/grid/header/plugin/Resizable.mjs:28, attached per button from afterSetResizable, Button.mjs:178–192), reorders by drag (src/grid/header/Toolbar.mjs:304 ignores .neo-resizable for drag) and optionally shows an inline filter field (Button.mjs:196–236). There is no way to open a per-column surface, and nothing writes an owned picker contribution or shows contextual counts. The accepted contract (epic body, Intended solution): a per-column floating surface with context-menu, hover and focus activation, keyboard and touch paths, Escape and outside-pointer dismissal and focus return; the surface projects authoritative Store state and never resurrects cached selections; sort, resize and drag keep working.
The Architectural Reality
Neo.component.Base owns the floating primitive: align_ (src/component/Base.mjs:79) and floating_ (:135), worker-side alignment, the neo-floating layer; a floating component that mounts moves focus into its first focusable item (:137).
Neo.form.field.Picker#createPicker (src/form/field/Picker.mjs:165–200) is the established owner-local pattern: a floating container.Base on document.body aligned to the field's input wrapper (edgeAlign, axisLock, matchSize), parentComponent set to the owner, hidden until shown, hiddenChange observed.
Neo.menu.List is the established interaction island: the app main view's outside-pointer listener kept symmetric with the root's mounted lifecycle (src/menu/List.mjs:266–282), isInteractionPath (:504) and onAppMouseDown (:508–516) dismiss on an outside pointer, onKeyDownEscape (:808–817) dismisses the root. Its full-cardinality rendering does not transfer (#19670).
contextmenu is a routed DOM event (src/manager/DomEvent.mjs:20, :39); onClick already ignores neo-resizable paths (Button.mjs:275); the header SortZone ignores .neo-resizable for drag (Toolbar.mjs:304), so the trigger uses the same exclusion to stay out of sort and drag.
grid.Container attaches plugins lazily behind a config (afterSetCellEditing, src/grid/Container.mjs:372–410): the config governs the plugin once created and a hand-declared plugin stays the app's own; header.Button#afterSetResizable is the per-button twin.
The core seam proposed in #19669: Filter.ownerRole ('header-picker' from this surface), getFilter(property, ownerRole), the typedIncluded operator, Store.getFilterValues(property, excludeFilter) → {key, value, count} buckets, reapplyOnRecordChange; the collection's filter event (src/collection/Base.mjs:982) is the re-projection trigger.
Scale witness: examples/grid/bigData (GridContainer.mjs:53–75: firstname, lastname and numberN columns over the Turbo MainStore).
Pre-Flight (structural fast-path): authoring src/grid/header/plugin/Menu.mjs matches the sibling pattern of src/grid/header/plugin/Resizable.mjs (a per-button header plugin imported lazily from a header.Button config); sibling-file-lift applies; no novel directory choice. Map-maintenance: not needed.
The Fix
Neo.grid.header.plugin.Menu (new): attached per header.Button from a new button config (proposed columnMenu_, propagated like resizable from a column-level opt-in and a grid.Container default; names decided at intake). It renders a trigger node at the button's right edge, left of the resize handle, visible on hover and on :focus-visible of the button; it handles contextmenu on the button, the trigger click, and Shift+F10 or the ContextMenu key on the focused button; all open the same surface.
The surface: a floating container.Base on document.body (the form.field.Picker shape), aligned to the button node (edgeAlign 't0-b0', axisLock), with items: a search TextField bound to the list's searchValue, the list.ValuePicker (#19670) and a Clear button. Outside-pointer and Escape dismissal through the menu.List island pattern (listener symmetric with mount); focus returns to the header button on dismissal; touch opens through the trigger.
Wiring: on open, the plugin resolves its own contribution with getFilter(dataField, 'header-picker'), reads store.getFilterValues(dataField, thatFilter) into the host-owned options Store (label = a pure formatter of the typed value, or String(value); never the cell renderer, which may depend on record, row or DOM context the picker does not have), and projects the contribution's value set into selection; the options Store is rebuilt as the union of the contribution's selected typed values and the returned buckets, absent keys at count 0, with Dates and explicit undefined preserved through the host's key-to-value mapping (a Model setter can turn undefined into null, so the mapping carries a discriminating control). On selectionChange it writes one typedIncluded filter with ownerRole: 'header-picker', or removes it when the set empties; on the Store's filter event and on record changes it re-reads buckets and selection while open; Clear removes the contribution. The plugin observes the button's dataField and the Store reference: a retarget detaches the previous Store's borrowed observers synchronously, re-resolves the contribution on the new tuple and re-projects without writing (projection echoes suppressed); an external store.filters replacement re-projects from the filter event. Destroy disposes listeners, the options Store and the surface, and leaves the Store contribution in place (the lifetime table in the epic body).
SCSS for the trigger and the surface under resources/scss/src/grid/header/ (siblings Button.scss, Toolbar.scss); a Grids guide section.
Evidence: a unit spec for the plugin's open, close, focus return and the contribution write and read (test/playwright/unit/grid/), whitebox e2e journeys on bigData through production input paths (test/playwright/e2e/grid/, sibling BigDataNL.spec.mjs), and the 100k witness.
Contract Ledger
Target surface
Source of authority
Proposed behavior
Fallback
Docs
Evidence
Neo.grid.header.plugin.Menu (new)
the plugin/Resizable.mjs attach path (Button.mjs:178)
a per-button plugin behind a button config; trigger, contextmenu and keyboard open one surface
off by default; no trigger rendered
class JSDoc; Grids guide
unit + e2e
header.Button config columnMenu_ (name at intake) plus the column and Container opt-in
the resizable propagation; the afterSetCellEditing pattern
lazy import of the plugin when true; governs it afterwards
false
config JSDoc
unit
Trigger node and contextmenu handling
DomEvent.mjs:20; Button.mjs:275; Toolbar.mjs:304
trigger left of the resize handle, excluded from the sort click and from drag; contextmenu opens the surface
none
JSDoc
e2e: sort, resize and drag unchanged with the plugin on
Floating surface
component.Basefloating_ and align_; form.field.Picker#createPicker; the menu.List island
an aligned container on document.body; outside-pointer and Escape dismissal; focus returns to the button
#19669: Store.getFilterValues(property, excludeFilter) plus the filter event
buckets keep first-occurrence population order; the surface groups scalar types deterministically, orders numbers numerically and Dates by epoch, then uses natural numeric label collation with a stable key tie-break; buckets are re-read on open, on filter changes and on record changes while open; a {refused: true, reason} result renders the honest refusal, one message per documented code (remote-projection, incomplete-population, raw-complex-fields, noncanonical-field, unsupported-value, tree-population, unknown-field; adopted on #19669 at 6127702097)
the refusal message in the surface; an unexpected callback fault propagates and surfaces as the error state
Grids guide
e2e on bigData plus one refusal journey per code on a fixture store
Design read (for the operator, before the PR)
The trigger is a small glyph at the header's right edge, just left of the resize handle, invisible until the header is hovered or keyboard-focused, so a plain header looks as it does today. A right-click anywhere on the header, a click on the trigger, Shift+F10 on the focused header, or a tap on the trigger opens the same surface under the header, left-aligned with it: a search field on top, the value list below (rows: checkbox, label, count), and a Clear link at the bottom while a selection exists. Checks apply immediately; the grid filters as you tick. Escape or a click outside closes the surface and focus returns to the header. Sorting by click, resizing by drag and reordering by drag behave exactly as before. On a store the picker cannot count honestly (remote, partial, tree, complex Turbo fields) the surface says so instead of showing numbers.
Acceptance Criteria
AC1: With the opt-in on, a header shows the trigger on hover and on focus; a right-click on the header, a trigger click, Shift+F10 on the focused header and a touch tap on the trigger each open the surface aligned under the header; off by default there is no trigger and no handler.
AC2: Sort on click, resize by drag and column reorder by drag are unchanged with the plugin on (the e2e drives all three on bigData through production pointer paths after a surface was opened and closed).
AC3: Escape and an outside pointer-down close the surface and return focus to the header button; a pointer-down inside the surface or on the trigger does not close it.
AC4: Checking values writes exactly one filter with ownerRole: 'header-picker' and the column's dataField; an inline header filter and an application predicate on the same property survive in both creation orders; unchecking the last value or Clear removes that contribution and nothing else.
AC5: Two grids on one Store: a selection in A shows checked in B's picker with equal counts; counts exclude only the picker's own contribution; options and counts update while the picker is open after an append and after a record edit, without reopening.
AC6: Header destroy and recreate re-attaches to the existing contribution without duplicating it; a column dataField retarget and a source Store swap each detach the old borrowed observers immediately and attach to the new tuple's contribution; an external store.filters = [...] replacement that drops the contribution unchecks the open picker, and one that keeps an equal tuple under a different owner leaves it unchecked; projection never echoes a write; destroying the plugin disposes the surface, the options Store and all listeners and leaves the Store contribution in place.
AC7: The 100k witness on examples/grid/bigData in its normal Turbo configuration with a firstname picker: sampled raw item keys and values are unchanged by open, search and recount; counts equal a reference pass over allItems; recount, option-list update, visible-row and input latency during scroll and resize are measured together in the same App Worker and recorded on the PR, with an id-like high-cardinality column included and the cap's refine row shown.
AC8: On an unsupported population the surface renders the honest refusal for the returned reason code and writes no filter; each documented code has a fixture-store journey.
AC9: A Grids guide section documents the opt-in, the surface, the selection semantics and the coverage boundary; unit and e2e specs run green in their tiers.
AC10: The existing inline header filter fields (Grid and Table header.Button#filterField) project their value and operator from the shared header-inline contribution on header recreation and on source rebind, and never write on projection; the core's writer isolation (#19669) is consumed, not duplicated. Whether this ships inside this leaf or as its own one-PR leaf is decided at intake; the obligation is not deferred with the Table menu.
Out of Scope
Counting, ownership and typed identity (#19669); the list's projection, search narrowing and cap (#19670); sort, hide, lock and width entries, a column chooser and a grid-level toolbar (deferred by the contract); case or regex search; an exclusion-set mode; the Table header menu surface (a follow-up once the grid surface lands); the Table inline field's projection obligation stays in AC10.
Avoided Traps
Rendering the surface inside the header button's vdom: it is a floating root on document.body, like every picker and submenu.
A main-thread measurement for positioning: align is worker-side.
Caching the selection in the plugin: the checked state is a projection of the Store contribution, re-read on the filter event.
Retaining the header or the surface in the filter's callback scope: the owned predicate is a pure Store and filter-lifetime object.
Putting the trigger inside the resize handle's hit area, or letting its click reach onClick (sort) or the SortZone (drag).
Counting from the mounted row buffer.
Related
Parent #19643 · blocked by #19670 (the list) and #19669 (the core) · source D#19646 · overlap: #19661 touches src/grid/Container.mjs for a row-geometry opt-in (this leaf rebases its opt-in onto it); #19515 touches Row (nothing here).
Decision Record: Not needed, as classified by D#19646.
Decision Record impact: none.
Structure-map: N/A — Engine src/grid/header/plugin/, fast path above.
Sweeps (2026-10-12 15:38Z): live latest 20 open issues (#19669 is the core leaf; no header-surface equivalent); A2A last 30 in all read-states (no competing surface claim); MC rationale sweep on the problem's nouns (6 results, no prior decision beyond D#19646); own-assignment sweep (4 open, none overlapping).
Context
Surface leaf of #19643 (graduated from D#19646 at
eaae5352…; pre-work review 6127438609): the header integration. It hosts the value-picker list (#19670) in a floating surface opened from the column header and wires it to the core leaf's shared contribution and counts (#19669). Blocked by both.The Problem
A header button today sorts on click (
src/grid/header/Button.mjs:274), resizes at its right edge (Resizable,directions: ['r'],src/grid/header/plugin/Resizable.mjs:28, attached per button fromafterSetResizable,Button.mjs:178–192), reorders by drag (src/grid/header/Toolbar.mjs:304ignores.neo-resizablefor drag) and optionally shows an inline filter field (Button.mjs:196–236). There is no way to open a per-column surface, and nothing writes an owned picker contribution or shows contextual counts. The accepted contract (epic body, Intended solution): a per-column floating surface with context-menu, hover and focus activation, keyboard and touch paths, Escape and outside-pointer dismissal and focus return; the surface projects authoritative Store state and never resurrects cached selections; sort, resize and drag keep working.The Architectural Reality
Neo.component.Baseowns the floating primitive:align_(src/component/Base.mjs:79) andfloating_(:135), worker-side alignment, theneo-floatinglayer; a floating component that mounts moves focus into its first focusable item (:137).Neo.form.field.Picker#createPicker(src/form/field/Picker.mjs:165–200) is the established owner-local pattern: a floatingcontainer.Baseondocument.bodyaligned to the field's input wrapper (edgeAlign,axisLock,matchSize),parentComponentset to the owner, hidden until shown,hiddenChangeobserved.Neo.menu.Listis the established interaction island: the app main view's outside-pointer listener kept symmetric with the root's mounted lifecycle (src/menu/List.mjs:266–282),isInteractionPath(:504) andonAppMouseDown(:508–516) dismiss on an outside pointer,onKeyDownEscape(:808–817) dismisses the root. Its full-cardinality rendering does not transfer (#19670).contextmenuis a routed DOM event (src/manager/DomEvent.mjs:20,:39);onClickalready ignoresneo-resizablepaths (Button.mjs:275); the header SortZone ignores.neo-resizablefor drag (Toolbar.mjs:304), so the trigger uses the same exclusion to stay out of sort and drag.grid.Containerattaches plugins lazily behind a config (afterSetCellEditing,src/grid/Container.mjs:372–410): the config governs the plugin once created and a hand-declared plugin stays the app's own;header.Button#afterSetResizableis the per-button twin.Filter.ownerRole('header-picker'from this surface),getFilter(property, ownerRole), thetypedIncludedoperator,Store.getFilterValues(property, excludeFilter)→{key, value, count}buckets,reapplyOnRecordChange; the collection'sfilterevent (src/collection/Base.mjs:982) is the re-projection trigger.examples/grid/bigData(GridContainer.mjs:53–75:firstname,lastnameandnumberNcolumns over the TurboMainStore).Pre-Flight (structural fast-path): authoring
src/grid/header/plugin/Menu.mjsmatches the sibling pattern ofsrc/grid/header/plugin/Resizable.mjs(a per-button header plugin imported lazily from aheader.Buttonconfig); sibling-file-lift applies; no novel directory choice. Map-maintenance: not needed.The Fix
Neo.grid.header.plugin.Menu(new): attached perheader.Buttonfrom a new button config (proposedcolumnMenu_, propagated likeresizablefrom a column-level opt-in and agrid.Containerdefault; names decided at intake). It renders a trigger node at the button's right edge, left of the resize handle, visible on hover and on:focus-visibleof the button; it handlescontextmenuon the button, the trigger click, and Shift+F10 or the ContextMenu key on the focused button; all open the same surface.container.Baseondocument.body(theform.field.Pickershape), aligned to the button node (edgeAlign 't0-b0',axisLock), with items: a searchTextFieldbound to the list'ssearchValue, thelist.ValuePicker(#19670) and a Clear button. Outside-pointer and Escape dismissal through themenu.Listisland pattern (listener symmetric with mount); focus returns to the header button on dismissal; touch opens through the trigger.getFilter(dataField, 'header-picker'), readsstore.getFilterValues(dataField, thatFilter)into the host-owned options Store (label = a pure formatter of the typed value, orString(value); never the cell renderer, which may depend on record, row or DOM context the picker does not have), and projects the contribution's value set intoselection; the options Store is rebuilt as the union of the contribution's selected typed values and the returned buckets, absent keys at count 0, with Dates and explicitundefinedpreserved through the host's key-to-value mapping (a Model setter can turnundefinedintonull, so the mapping carries a discriminating control). OnselectionChangeit writes onetypedIncludedfilter withownerRole: 'header-picker', or removes it when the set empties; on the Store'sfilterevent and on record changes it re-reads buckets and selection while open; Clear removes the contribution. The plugin observes the button'sdataFieldand the Store reference: a retarget detaches the previous Store's borrowed observers synchronously, re-resolves the contribution on the new tuple and re-projects without writing (projection echoes suppressed); an externalstore.filtersreplacement re-projects from thefilterevent. Destroy disposes listeners, the options Store and the surface, and leaves the Store contribution in place (the lifetime table in the epic body).resources/scss/src/grid/header/(siblingsButton.scss,Toolbar.scss); a Grids guide section.test/playwright/unit/grid/), whitebox e2e journeys on bigData through production input paths (test/playwright/e2e/grid/, siblingBigDataNL.spec.mjs), and the 100k witness.Contract Ledger
Neo.grid.header.plugin.Menu(new)plugin/Resizable.mjsattach path (Button.mjs:178)header.ButtonconfigcolumnMenu_(name at intake) plus the column and Container opt-inresizablepropagation; theafterSetCellEditingpatternfalsecontextmenuhandlingDomEvent.mjs:20;Button.mjs:275;Toolbar.mjs:304component.Basefloating_andalign_;form.field.Picker#createPicker; themenu.Listislanddocument.body; outside-pointer and Escape dismissal; focus returns to the buttongetFilter(property, ownerRole),typedIncluded,ownerRole: 'header-picker'Store.getFilterValues(property, excludeFilter)plus thefilterevent{refused: true, reason}result renders the honest refusal, one message per documented code (remote-projection,incomplete-population,raw-complex-fields,noncanonical-field,unsupported-value,tree-population,unknown-field; adopted on #19669 at 6127702097)Design read (for the operator, before the PR)
The trigger is a small glyph at the header's right edge, just left of the resize handle, invisible until the header is hovered or keyboard-focused, so a plain header looks as it does today. A right-click anywhere on the header, a click on the trigger, Shift+F10 on the focused header, or a tap on the trigger opens the same surface under the header, left-aligned with it: a search field on top, the value list below (rows: checkbox, label, count), and a Clear link at the bottom while a selection exists. Checks apply immediately; the grid filters as you tick. Escape or a click outside closes the surface and focus returns to the header. Sorting by click, resizing by drag and reordering by drag behave exactly as before. On a store the picker cannot count honestly (remote, partial, tree, complex Turbo fields) the surface says so instead of showing numbers.
Acceptance Criteria
ownerRole: 'header-picker'and the column'sdataField; an inline header filter and an application predicate on the same property survive in both creation orders; unchecking the last value or Clear removes that contribution and nothing else.dataFieldretarget and a source Store swap each detach the old borrowed observers immediately and attach to the new tuple's contribution; an externalstore.filters = [...]replacement that drops the contribution unchecks the open picker, and one that keeps an equal tuple under a different owner leaves it unchecked; projection never echoes a write; destroying the plugin disposes the surface, the options Store and all listeners and leaves the Store contribution in place.examples/grid/bigDatain its normal Turbo configuration with afirstnamepicker: sampled raw item keys and values are unchanged by open, search and recount; counts equal a reference pass overallItems; recount, option-list update, visible-row and input latency during scroll and resize are measured together in the same App Worker and recorded on the PR, with an id-like high-cardinality column included and the cap's refine row shown.header.Button#filterField) project their value and operator from the sharedheader-inlinecontribution on header recreation and on source rebind, and never write on projection; the core's writer isolation (#19669) is consumed, not duplicated. Whether this ships inside this leaf or as its own one-PR leaf is decided at intake; the obligation is not deferred with the Table menu.Out of Scope
Counting, ownership and typed identity (#19669); the list's projection, search narrowing and cap (#19670); sort, hide, lock and width entries, a column chooser and a grid-level toolbar (deferred by the contract); case or regex search; an exclusion-set mode; the Table header menu surface (a follow-up once the grid surface lands); the Table inline field's projection obligation stays in AC10.
Avoided Traps
document.body, like every picker and submenu.alignis worker-side.filterevent.onClick(sort) or the SortZone (drag).Related
Parent #19643 · blocked by #19670 (the list) and #19669 (the core) · source D#19646 · overlap: #19661 touches
src/grid/Container.mjsfor a row-geometry opt-in (this leaf rebases its opt-in onto it); #19515 touchesRow(nothing here).Decision Record: Not needed, as classified by D#19646. Decision Record impact: none. Structure-map: N/A — Engine
src/grid/header/plugin/, fast path above.Sweeps (2026-10-12 15:38Z): live latest 20 open issues (#19669 is the core leaf; no header-surface equivalent); A2A last 30 in all read-states (no competing surface claim); MC rationale sweep on the problem's nouns (6 results, no prior decision beyond D#19646); own-assignment sweep (4 open, none overlapping).
Origin Session ID: a9e3a190-156a-4fdb-9ea0-a554fa796d87 Retrieval Hint: "grid.header.plugin.Menu trigger floating surface value picker header-picker ownerRole" · "D19646 header surface contextmenu Shift+F10 focus return"