LearnNewsExamplesServices
Frontmatter
id17825
titleDismiss floating menus and pickers on outside pointers
stateClosed
labels
bugdeveloper-experienceaitestingcoreaccessibility
assigneesneo-gpt
createdAtAug 28, 2026, 10:17 AM
updatedAtAug 28, 2026, 11:46 AM
githubUrlhttps://github.com/neomjs/neo/issues/17825
authorneo-gpt
commentsCount0
parentIssuenull
subIssues[]
subIssuesCompleted0
subIssuesTotal0
contentTrust
projected
quarantined0
signals[]
blockedBy[]
blocking[x] 17826 Add a real-pointer context-menu example and proof
closedAtAug 28, 2026, 11:46 AM

Dismiss floating menus and pickers on outside pointers

Closed Backlog/active-chunk-18 bugdeveloper-experienceaitestingcoreaccessibility
neo-gpt
neo-gpt commented on Aug 28, 2026, 10:17 AM

Context

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

  1. 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.
  2. 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.
  3. An outside pointer dismisses even when document.activeElement does not change. Inside pointer interactions never dismiss.
  4. 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.
  5. 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 Inside field/picker pointer preserves the picker Picker JSDoc Picker focusable/non-focusable controls
Menu submenu focus ownership parentMenu, activeSubMenu, subMenuMap, manager.Focus paths Parent and descendant menus remain one logical focus island without a frame-sized correctness timer True document blur, tab-out, outside pointer, Escape, and leaf policy still dismiss Explain structural partners Delayed/ordered focus controls without sleeps as assertions
App-root DOM listener lifecycle Existing addDomListeners() / removeDomListeners() One scoped listener exists only while needed and is removed by identity Reopen is idempotent; destroy leaves no listener Anchor teardown Listener-count and destroy controls

Decision Record impact

none — this completes existing Menu and Picker interaction contracts using their current App-worker event/focus owners.

Acceptance Criteria

  • A real-equivalent outside mousedown closes Menu and Picker when the target path is non-focusable and focus does not move.
  • Clicking inside the root menu, any nested submenu, the Button trigger, the Picker field, or the Picker body never triggers outside dismissal.
  • First- and second-level submenu focus survives without a 20ms correctness timer.
  • Focus tab-out, document blur, Escape, Menu leaf policy, and Button trigger toggling retain their current semantics.
  • App-root listeners are added once, removed by exact config identity, and absent after hide/unmount/destroy.
  • Picker coverage is limited to the shared dismissal/focus contract; current selection/typeahead behavior stays green.

Out of Scope

  • Alignment, pointer-coordinate placement, viewport constraint/flip, or the permanent context-menu example.
  • Renaming pickerIsMounted, removing clientRects, or general Picker cleanup.
  • A generic overlay manager, new Main-thread event registry, focus-manager timing changes, or document listeners in applications.
  • Touch long-press context-menu semantics.

Avoided Traps

  • Longer timeout: moves the race without defining ownership.
  • Force every app surface focusable: changes keyboard navigation to compensate for pointer dismissal.
  • Treat the whole parent widget as context-menu focus: prevents ordinary clicks within that widget from dismissing.
  • Main-thread singleton: unnecessary; the App worker already owns component paths and lifecycle.

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-748667ab2563

Retrieval Hint: "menuFocus 20ms submenu parentComponent Picker nonfocusable outside mousedown dismissal"

tobiu cross-referenced by PR #17827 on Aug 28, 2026, 10:52 AM
tobiu cross-referenced by PR #17828 on Aug 28, 2026, 11:15 AM