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:
- 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.
- 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.
- 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)
showSubMenuOnHover: true. The name follows the class's subMenu family (activeSubMenu, subMenuGap, showSubMenu()).
subMenuHoverDelay: 200: how long the pointer must rest on an item before the rest counts.
- 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.
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.
- 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.
- Both settings are read from the root menu, so a cascade cannot disagree with itself and a runtime change reaches cached submenus.
- Click on a parent: shows (idempotent) while
showSubMenuOnHover is true; toggles when false.
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
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"
Context
Operator request (2026-09-16): a boolean on
Neo.menu.Listso nested submenus open on hover, defaulttrue. 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 parentItem 3leaves one menu level, and clicking it opens the second.menu.List,list.Base,selection.ListModelandselection.menu.ListModelcontain 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:
l0-r0to 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.mouseenterbeforeclick. If that click toggles — #18758 restores the toggle, and this ticket is blocked by it — it closes the submenu the hover just showed.component.Base#afterSetMountedfocuses 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
manager.DomEvent#verifyMouseEnterLeavefiresmouseenter/mouseleaveonly at the delegate's own boundary (consumers:tooltip.Base,app.header.Toolbarwithdelegate: '.neo-button').main.DomEvents#onMouseEnteralready forwards every event, so a listener adds no main-thread messages.activeSubMenuis the shown state; onlyonSelect()couples it to selection.tooltip.Base#showDelay: 200.The Fix —
src/menu/List.mjs(+ menu SCSS)showSubMenuOnHover: true. The name follows the class'ssubMenufamily (activeSubMenu,subMenuGap,showSubMenu()).subMenuHoverDelay: 200: how long the pointer must rest on an item before the rest counts.mouseenter/mouseleaveon interactive items, excluded by the samenonInteractiveItemClsrule 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.showSubMenu()gains a focus intent, carried by a newcomponent.Base#focusOnMountconfig (defaulttrue) that gates the floating mount-focus. A preview mounts with itfalse; click and Enter keep today's focus-into behaviour, and every other floating component keeps taking the caret.aria-expanded="true"and is styled like the hovered item, so the parent stays marked while the pointer is inside the submenu.showSubMenuOnHoveris true; toggles when false.unmount()anddestroy()cancel a pending rest, and the resume re-checks liveness (#18731).Contract Ledger
menu.List#showSubMenuOnHover(new)true: a rest shows; click showsfalse: click-only, click togglesmenu.List#subMenuHoverDelay(new)0= on enter0vs default armsmenu.List#showSubMenu()aria-expanded(new)component.Base#focusOnMount(new)afterSetMounted's "a popup takes the caret with it"truekeeps today's mount-focus for every floating component;falsemounts without taking focustrue, so no existing consumer changesactiveSubMenu.focusOnMount === false, Enter on the parent of a preview assertstrue; component AC-1 arm:document.activeElementunchangedAcceptance Criteria
aria-expanded="true"; selection anddocument.activeElementare unchanged (component spec, real pointer).subMenuHoverDelay: 0— the control arm proving the path crosses a sibling.showSubMenuOnHover: false: a rest shows nothing, and click keeps #18758's toggle.Out of Scope
Avoided Traps
afterSetloops (theanimateSpawn_/themeshape): 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_memorieson 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 onsrc/menuOrigin 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"