LearnNewsExamplesServices
Frontmatter
id18759
titlemenu.List: a pointer resting on a parent item shows its submenu
stateClosed
labels
enhancementai
assigneesneo-opus-ada
createdAtSep 16, 2026, 10:08 AM
updatedAtSep 16, 2026, 11:28 AM
githubUrlhttps://github.com/neomjs/neo/issues/18759
authorneo-opus-ada
commentsCount0
parentIssuenull
subIssues[]
subIssuesCompleted0
subIssuesTotal0
contentTrust
projected
quarantined0
signals[]
blockedBy[x] 18758 menu.List looks submenus up under a key they are not cached by
blocking[]
closedAtSep 16, 2026, 11:28 AM

menu.List: a pointer resting on a parent item shows its submenu

neo-opus-ada
neo-opus-ada commented on Sep 16, 2026, 10:08 AM

Context

Operator request (2026-09-16): a boolean on Neo.menu.List so nested submenus open on hover, default true. Successor to #2585 (@tobiu, 2021: "hovering items with the mouse should expand sub-menus … delegating mouseEnter & leave to list items"), which the stale bot closed in 2024 without a decision.

Observed on dev@d688710175, examples/button/base: resting the pointer on the parent Item 3 leaves one menu level, and clicking it opens the second. menu.List, list.Base, selection.ListModel and selection.menu.ListModel contain no hover handling.

The Problem

A submenu shows only through selection: a click, or the Navigator's Enter (which clicks), selects the item, and onSelect() swaps the submenu. The desktop convention is missing: rest on a parent and its submenu appears.

Three failure modes decide the design:

  1. Diagonal travel. A submenu aligns l0-r0 to its parent item, so reaching its second or third row cuts across the parent menu's rows below. If entering a row switches submenus at once, the target closes mid-travel.
  2. Hover, then click. Pointer users click items they already hover, and a touch tap emits mouseenter before click. If that click toggles — #18758 restores the toggle, and this ticket is blocked by it — it closes the submenu the hover just showed.
  3. A popup takes the caret. component.Base#afterSetMounted focuses a floating component's first focusable child on mount, documented as deliberate ("a popup takes the caret with it"). That is why Enter on a parent lands inside its submenu today. A submenu shown because the pointer passed over its parent is a preview, not an entry, and must leave focus where it is. Otherwise the keyboard cursor jumps under the user.

The Architectural Reality

  • Delegated hover is an engine primitive. manager.DomEvent#verifyMouseEnterLeave fires mouseenter/mouseleave only at the delegate's own boundary (consumers: tooltip.Base, app.header.Toolbar with delegate: '.neo-button'). main.DomEvents#onMouseEnter already forwards every event, so a listener adds no main-thread messages.
  • Showing is already separable from selecting. activeSubMenu is the shown state; only onSelect() couples it to selection.
  • Keyboard is focus-driven since #5123: arrows move Navigator focus, Enter/Space select (operator ruling, 2026-09-16). Unchanged here.
  • Precedent for a delay config: tooltip.Base#showDelay: 200.

The Fix — src/menu/List.mjs (+ menu SCSS)

  1. showSubMenuOnHover: true. The name follows the class's subMenu family (activeSubMenu, subMenuGap, showSubMenu()).
  2. subMenuHoverDelay: 200: how long the pointer must rest on an item before the rest counts.
  3. Delegated mouseenter/mouseleave on interactive items, excluded by the same nonInteractiveItemCls rule as the click delegate. Enter schedules a rest; leave cancels it. A completed rest on a parent shows its submenu, and on a leaf hides the shown one. It neither selects nor moves focus. A row crossed faster than the delay changes nothing.
  4. showSubMenu() gains a focus intent, carried by a new component.Base#focusOnMount config (default true) that gates the floating mount-focus. A preview mounts with it false; click and Enter keep today's focus-into behaviour, and every other floating component keeps taking the caret.
  5. The parent whose submenu is showing carries aria-expanded="true" and is styled like the hovered item, so the parent stays marked while the pointer is inside the submenu.
  6. Both settings are read from the root menu, so a cascade cannot disagree with itself and a runtime change reaches cached submenus.
  7. Click on a parent: shows (idempotent) while showSubMenuOnHover is true; toggles when false.
  8. unmount() and destroy() cancel a pending rest, and the resume re-checks liveness (#18731).

Contract Ledger

Target Surface Source of Authority Proposed Behavior Fallback Docs Evidence
menu.List#showSubMenuOnHover (new) this ticket, #2585 true: a rest shows; click shows false: click-only, click toggles JSDoc component spec
menu.List#subMenuHoverDelay (new) this ticket ms of rest before it counts; 0 = on enter — JSDoc component spec, 0 vs default arms
menu.List#showSubMenu() this ticket preview show leaves focus in place default: focus into the submenu, as today JSDoc component spec
parent item aria-expanded (new) WAI-ARIA menuitem mirrors whether its submenu is showing — JSDoc component spec
component.Base#focusOnMount (new) this ticket; afterSetMounted's "a popup takes the caret with it" true keeps today's mount-focus for every floating component; false mounts without taking focus true, so no existing consumer changes JSDoc on the config unit: preview arm asserts activeSubMenu.focusOnMount === false, Enter on the parent of a preview asserts true; component AC-1 arm: document.activeElement unchanged

Acceptance Criteria

  • AC-1 Resting on a parent shows its submenu with no click; the parent reads aria-expanded="true"; selection and document.activeElement are unchanged (component spec, real pointer).
  • AC-2 A diagonal pointer path from a parent to its submenu's third row keeps the submenu at the default delay, and closes it with subMenuHoverDelay: 0 — the control arm proving the path crosses a sibling.
  • AC-3 Resting on a leaf hides the sibling submenu that is showing.
  • AC-4 Hover, then click, on a parent: the submenu never closes (level-count transitions recorded, no dip).
  • AC-5 showSubMenuOnHover: false: a rest shows nothing, and click keeps #18758's toggle.
  • AC-6 Changing either config on the root at runtime governs already cached levels.
  • AC-7 A rest pending when its menu unmounts or is destroyed shows nothing.
  • AC-8 Existing menu unit and component specs stay green.

Out of Scope

  • Keyboard walking of the cascade (Right/Left/Escape/Space) — #18760.
  • Keyboard focus resting on a parent showing its submenu — recorded as #18760's follow-up.
  • Trajectory aiming (a "safe triangle").

Avoided Traps

  • Hover selects: couples a preview to activation state and fights the focus model, where Space/Enter select.
  • Safe-triangle aiming: needs a stream of pointer coordinates in the App Worker plus submenu rects. A rest delay covers the diagonal case with enter/leave alone.
  • Showing at once while nothing is shown: a pointer sweeping across the menu would flash every parent's submenu. One rule for every rest is simpler and flicker-free.
  • Per-level copies synced by afterSet loops (the animateSpawn_ / theme shape): right for rendering state each level paints; a behaviour policy of the cascade belongs to its root.

Related

Blocked by #18758 · #2585 · #4606 · #5123 · #18731

Live latest-open sweep: latest 20 open issues at 2026-09-16T08:07:16Z; no equivalent found A2A claim sweep: latest 30 inbox messages, all read-states, at 2026-09-16T08:07Z; no [lane-claim] / [lane-intent] on menus MC sweep: query_raw_memories on the symptom ("need to click a menu list item to expand the child list; submenu does not open when hovering the parent item") plus a KB ticket query, 6 + 8 results; prior art #2585 (stale-closed, no ruling), no prior decision against hover Own-assignment sweep: 5 open, none on src/menu

Origin Session ID: fda7faab-19c5-43a0-b192-003966068172 Retrieval Hint: "menu.List submenu shows on pointer rest; subMenuHoverDelay; preview show without mount focus; aria-expanded parent"

tobiu referenced in commit e6c10d8 - "fix(menu): a submenu that unmounts itself stops being its parent's active submenu (#18759) on Sep 16, 2026, 10:41 AM
tobiu referenced in commit ebdc0e4 - "feat(menu): a pointer resting on a parent item shows its submenu (#18759) on Sep 16, 2026, 11:00 AM
tobiu referenced in commit f9fade6 - "fix(menu): a submenu that unmounts itself stops being its parent's active submenu (#18759) on Sep 16, 2026, 11:00 AM
tobiu referenced in commit d486a03 - "feat(menu): a pointer resting on a parent item shows its submenu (#18759) (#18765) on Sep 16, 2026, 11:28 AM
tobiu closed this issue on Sep 16, 2026, 11:28 AM