A downstream right-click menu report exposed a focus-management assumption that was independently reproduced on Engine dev head 17b59aad8f95c55c916fd6bb8bd6a0f43bd2d687.
A temporary complete Neo Body app opened a floating Neo.menu.List from onContextMenu using real browser pointer input. Nested first- and second-level submenus stayed open on this machine, so the reported submenu loss is timing/load-sensitive rather than universal. Outside dismissal was deterministically broken: clicking a non-focusable application surface left the menu item as document.activeElement and all menus mounted, both with and without parentComponent. Making that surface focusable caused the existing focus-leave path to dismiss correctly.
The same control against Neo.form.field.ComboBox reproduced the shared limitation: clicking non-focusable application chrome left the input focused and the Picker expanded. The Picker works when focus moves; it is not pointer-complete.
The Problem
Focus and pointer containment are related but not interchangeable:
Neo.menu.List schedules root hide/unmount through a hard-coded 20ms timeout after menuFocus=false. The timeout was intended as approximately one animation frame, but App→VDom→Main→App focus propagation can exceed it.
Submenus bubble menuFocus back to their parent after mount/focus. A frame-sized timer can therefore unmount the root before structurally valid submenu focus arrives.
menu.List and form.field.Picker dismiss only when focus leaves their interaction islands. Clicking non-focusable workspace chrome may not move focus at all, so no focus event can close the floating surface.
Application-root mousedown workarounds solve one app while leaving the framework contract undiscoverable and easy to implement inconsistently.
The Architectural Reality
src/manager/Focus.mjs already owns the focusout/focusin combination window and component-path transitions. Menu must not add a shorter competing correctness timer.
src/form/field/Picker.mjs demonstrates the correct two-sided focus-island shape: field focus-leave checks the picker path, and picker focus-leave checks the field path.
src/menu/List.mjs has a larger interaction island: the current menu, every mounted descendant submenu, and the exact string align.target trigger anchor. parentComponent remains logical ancestry; it is not automatically a pointer-inside trigger.
Neo.mixin.DomEvents#addDomListeners() / removeDomListeners() allow a floating owner to attach a scoped mousedown listener to its own app main view. The App worker already receives component-path data; no new Main-thread global registry is required.
Button menus must keep their trigger inside the interaction island so a trigger click continues to toggle closed rather than close on mousedown and reopen on click. Every shipped Button-menu factory binds align.target to the Button id, making that identity exact.
The Fix
Add pointer-outside dismissal to floating root Menu lists and Pickers while visible/mounted by attaching one scoped App-main-view mousedown listener and removing the exact listener on hide/unmount/destroy.
Menu path classification treats the root, every mounted descendant submenu, and an exact string align.target trigger anchor as inside. Picker classification treats the field and picker subtree as inside. A serialized coordinate target deliberately has no trigger element.
An outside pointer dismisses even when document.activeElement does not change. Inside pointer interactions never dismiss.
Remove the 20ms frame heuristic as a Menu correctness gate. Model Menu focus partners structurally, mirroring Picker's two-sided checks; animation timing may not decide ownership.
Add focused Menu and Picker unit coverage for focusable and non-focusable outside paths, nested submenu transitions, trigger toggling, Escape, and exact listener teardown.
Contract Ledger
Target Surface
Source of Authority
Proposed Behavior
Fallback / Edge Case
Docs
Evidence
Floating root Neo.menu.List dismissal
Existing Menu focus/parent/submenu model
Dismiss on pointer outside the complete menu interaction island, independent of focus movement
Inline lists do not register outside dismissal; exact string align.target trigger anchors and mounted menu paths stay inside
Menu JSDoc
Menu focus/pointer unit matrix
Neo.form.field.Picker dismissal
Existing two-sided field/picker focus contract
Hide on pointer outside field+picker even when focus remains on the input
Live latest-open sweep: checked the latest 20 open issues plus exact Menu / Picker / outside-pointer / submenu-focus searches at 2026-08-28T08:16:51Z; no equivalent found. A2A all-status recency sweep: 30 messages; no competing claim.
Context
A downstream right-click menu report exposed a focus-management assumption that was independently reproduced on Engine
devhead17b59aad8f95c55c916fd6bb8bd6a0f43bd2d687.A temporary complete Neo Body app opened a floating
Neo.menu.ListfromonContextMenuusing real browser pointer input. Nested first- and second-level submenus stayed open on this machine, so the reported submenu loss is timing/load-sensitive rather than universal. Outside dismissal was deterministically broken: clicking a non-focusable application surface left the menu item asdocument.activeElementand all menus mounted, both with and withoutparentComponent. Making that surface focusable caused the existing focus-leave path to dismiss correctly.The same control against
Neo.form.field.ComboBoxreproduced the shared limitation: clicking non-focusable application chrome left the input focused and the Picker expanded. The Picker works when focus moves; it is not pointer-complete.The Problem
Focus and pointer containment are related but not interchangeable:
Neo.menu.Listschedules root hide/unmount through a hard-coded 20ms timeout aftermenuFocus=false. The timeout was intended as approximately one animation frame, but App→VDom→Main→App focus propagation can exceed it.menuFocusback to their parent after mount/focus. A frame-sized timer can therefore unmount the root before structurally valid submenu focus arrives.menu.Listandform.field.Pickerdismiss only when focus leaves their interaction islands. Clicking non-focusable workspace chrome may not move focus at all, so no focus event can close the floating surface.The Architectural Reality
src/manager/Focus.mjsalready owns the focusout/focusin combination window and component-path transitions. Menu must not add a shorter competing correctness timer.src/form/field/Picker.mjsdemonstrates the correct two-sided focus-island shape: field focus-leave checks the picker path, and picker focus-leave checks the field path.src/menu/List.mjshas a larger interaction island: the current menu, every mounted descendant submenu, and the exact stringalign.targettrigger anchor.parentComponentremains logical ancestry; it is not automatically a pointer-inside trigger.Neo.mixin.DomEvents#addDomListeners()/removeDomListeners()allow a floating owner to attach a scopedmousedownlistener to its own app main view. The App worker already receives component-path data; no new Main-thread global registry is required.align.targetto the Button id, making that identity exact.The Fix
mousedownlistener and removing the exact listener on hide/unmount/destroy.align.targettrigger anchor as inside. Picker classification treats the field and picker subtree as inside. A serialized coordinate target deliberately has no trigger element.document.activeElementdoes not change. Inside pointer interactions never dismiss.Contract Ledger
Neo.menu.Listdismissalalign.targettrigger anchors and mounted menu paths stay insideNeo.form.field.PickerdismissalparentMenu,activeSubMenu,subMenuMap,manager.FocuspathsaddDomListeners()/removeDomListeners()Decision Record impact
none— this completes existing Menu and Picker interaction contracts using their current App-worker event/focus owners.Acceptance Criteria
mousedowncloses Menu and Picker when the target path is non-focusable and focus does not move.Out of Scope
pickerIsMounted, removingclientRects, or general Picker cleanup.Avoided Traps
Related
Live latest-open sweep: checked the latest 20 open issues plus exact Menu / Picker / outside-pointer / submenu-focus searches at
2026-08-28T08:16:51Z; no equivalent found. A2A all-status recency sweep: 30 messages; no competing claim.Origin Session ID:
d893ccaa-a773-4796-a67c-748667ab2563Retrieval Hint: "menuFocus 20ms submenu parentComponent Picker nonfocusable outside mousedown dismissal"