LearnNewsExamplesServices
Frontmatter
id18780
titlemenu.List: focus neither opens a submenu nor closes the one it left
stateClosed
labels
enhancementaiaccessibility
assigneesneo-opus-ada
createdAtSep 16, 2026, 12:40 PM
updatedAtSep 16, 2026, 1:58 PM
githubUrlhttps://github.com/neomjs/neo/issues/18780
authorneo-opus-ada
commentsCount0
parentIssuenull
subIssues[]
subIssuesCompleted0
subIssuesTotal0
contentTrust
projected
quarantined0
signals[]
blockedBy[]
blocking[]
closedAtSep 16, 2026, 1:58 PM

menu.List: focus neither opens a submenu nor closes the one it left

Closed v13.2.0/archive-v13-2-0-chunk-21 enhancementaiaccessibility
neo-opus-ada
neo-opus-ada commented on Sep 16, 2026, 12:40 PM

Context

The operator's framing while scoping the menu stack, and the piece deliberately left out of #18759 and #18760 so each could be reviewed on its own:

to SHOW sub-lists, focus and hover could be the better UX. also meaning: arrow up or down, when a child list is shown for the current item should hide it again.

Both halves are now measurable on dev (17f5a83b95, with #18761 / #18765 / #18766 merged). Two witness arms, run in the component harness against a three-item menu whose first item has children:

witness driven result
A — a hovered submenu survives the focus moving away hover Share → 2 menus → focus the first item → ArrowDown (focus on Rename) → wait out the 200 ms rest 2 menus. Share's submenu is still showing under an item that no longer has focus
B — focus alone opens nothing focus Share, wait out the rest delay 1 menu. A pointer rest shows the submenu; a keyboard rest on the same item does not

Witness A is a defect: a submenu is a child of the item it belongs to, and the item lost focus. Witness B is the missing half of #18759 — the config is spelled showSubMenuOnHover, and the implementation took "hover" literally.

The Problem

showSubMenu() has had a non-focusing preview mode since #18759 (focus=false), so the mechanism the keyboard needs already exists — what is missing is a caller and a symmetric hide.

  • Nothing binds focus to a rest. menu/List.mjs#onItemMouseEnter is wired to mouseenter only (construct), and it is the only path into onItemRest().
  • Nothing hides on the way out. selection/ListModel#onKeyDownUp and #onKeyDownDown are empty placeholders (src/selection/ListModel.mjs:40, :64), and selection/menu/ListModel does not override them — arrow movement is the main thread's Navigator moving focus, which the App Worker never sees as a menu event. hideSubMenu() is called from a rest on a leaf, from onSelect, from leaveSubMenu() and from afterSetMenuFocus; nothing calls it when focus moves between siblings.

The trap this leaf has to get right: ArrowRight moves focus INTO the submenu. A naive "focus left the parent → hide" rule closes the submenu the same keystroke just opened. The hide must fire when focus lands on a sibling of the parent, not on a descendant of the submenu.

The Architectural Reality

  • src/menu/List.mjs#onItemRest(nodeId) — the preview entry point, already liveness-guarded, already exclusive through showSubMenu().
  • src/menu/List.mjs#showSubMenu(nodeId, record, focus=false) — shows without selecting and without moving focus, via component.Base#focusOnMount.
  • src/menu/List.mjs#onFocusEnter / #onFocusLeave — the menu already observes focus at the LIST level (menuFocus), including isInteractionPath(data.oldPath), which is the discriminator the sibling-vs-descendant rule needs. The item-level focus signal is component.Base's focus events with their path.
  • src/menu/List.mjs configs showSubMenuOnHover and subMenuHoverDelay, read from getRootMenu().

The Fix

  1. A focus landing on an interactive item is a rest, on the same showSubMenuOnHover / subMenuHoverDelay contract as a pointer rest, and takes the same non-focusing showSubMenu(..., false) path. The submenu appears; focus stays on the parent until the user presses ArrowRight.
  2. A focus landing on an item whose sibling has a submenu showing hides that submenu — and a focus moving into the submenu does not.

Naming fork, recommendation first. showSubMenuOnHover ships on dev as of this morning and is unreleased.

option for against
keep the name (recommended) zero churn; the docblock can say the switch covers both rests the word "hover" under-describes the keyboard half
rename to showSubMenuOnRest, no alias the name matches the behaviour while the cost is at its lowest a public config renamed inside a day, and the Contract Ledger carries both spellings

Recommendation is the first row, decided here rather than escalated (reversible in one line). Reviewers who disagree can overturn it in a round.

Acceptance Criteria

  • AC-1 — focus resting on a parent item shows its submenu without selecting it and without moving focus off the parent, gated by showSubMenuOnHover and delayed by subMenuHoverDelay.
  • AC-2 — focus moving to a sibling hides a submenu that was showing, whether a pointer rest or a focus rest opened it. Witness A above is the red-first state.
  • AC-3 — ArrowRight still opens and enters the submenu: focus moving into a descendant must not hide it. A CONTROL arm that would pass under a naive rule and fail under the correct one.
  • AC-4 — hover and focus do not fight: with the pointer resting on one parent and focus moved to another, exactly one submenu is showing, and the arms say which one wins rather than tolerating either.
  • AC-5 — the keyboard cascade and pointer rest arms from #18760 / #18759 stay green, and the new arms are red-first against dev.
  • AC-6 — showSubMenuOnHover: false suppresses the focus rest exactly as it suppresses the pointer rest.

Out of Scope

  • Selection on navigate. The focus model is the settled outcome of #18760: arrows move focus, Enter / Space / click activate.
  • Any change to ArrowRight / ArrowLeft / Escape semantics beyond AC-3's guard.
  • The showSubMenuOnHover rename — the fork is recorded above; taking the second row is a one-line follow-up, not this leaf's job.
  • menu/List.spec.mjs's pointer-rest measurement flake — #18772.

Avoided Traps

  • Hiding on any focus change. ArrowRight moves focus into the submenu; a rule that does not distinguish descendant from sibling closes what it just opened. AC-3 exists to make that failure visible instead of plausible.
  • Re-deriving the rest through a second timer. onItemRest and cancelRest already own the delay, its cancellation and the liveness check; a focus path with its own timer would be a second source of truth for the same UX.
  • Taking focus into the submenu on a focus rest. That is the focus=true path and belongs to ArrowRight; a preview that steals focus makes ArrowDown unusable.
  • Asserting "a submenu is open" by counting levels. #18759 measured that a close and a reopen inside one worker task are invisible to a level count; where a transition is the subject, count removals.

Related

  • #18758 / #18759 / #18760 — the three merged leaves this completes. #18765's component.Base#focusOnMount is the mechanism AC-1 reuses.
  • #5123 — the Navigator change that moved menus to focus-driven keynav and left these two gaps.
  • #2585 — the 2021 hover-expand request, stale-closed.

Live latest-open sweep: checked the latest 20 open issues at 2026-09-16T10:36:56Z; no equivalent found. A2A in-flight claim sweep over the last 30 messages (all read-states): no [lane-claim] on menu/; the live claims are @neo-opus-grace's grid and unit-isolation lanes and @neo-opus-vega's showcase lane. Memory Core rationale sweep on the symptom nouns: surfaced my own scoping note from this morning recording this as the deliberate follow-up, and no prior decision against it. Own-assignment sweep: my open items are #18772 and this; no same-surface hit. Structure-map gate: N/A — no .mjs created or relocated, nothing under ai/, Agent OS, MCP, Memory Core, orchestration or .agents/skills.

Decision Record impact: none.

Origin Session ID: cd67869d-cec2-4c36-99a6-aa521a73fddb

Retrieval Hint: "menu focus rest shows submenu, arrow off parent hides it" · query_raw_memories on "focus and hover are two rests on the same contract"

tobiu referenced in commit 08f9d60 - "feat(menu): focus rests like a pointer rests (#18780) (#18783) on Sep 16, 2026, 1:58 PM
tobiu closed this issue on Sep 16, 2026, 1:58 PM
tobiu referenced in commit e6196d5 - "docs(menu): a submenu rest is the pointer's or keyboard focus's (#18800) (#18801) on Sep 16, 2026, 4:00 PM