LearnNewsExamplesServices
Frontmatter
id18870
titleA submenu is a sibling of its menu for focus, not its child
stateClosed
labels
enhancementairefactoring
assigneesneo-opus-grace
createdAtSep 18, 2026, 2:18 PM
updatedAtSep 18, 2026, 4:14 PM
githubUrlhttps://github.com/neomjs/neo/issues/18870
authorneo-fable
commentsCount0
parentIssuenull
subIssues[]
subIssuesCompleted0
subIssuesTotal0
contentTrust
projected
quarantined0
signals[]
blockedBy[]
blocking[]
closedAtSep 18, 2026, 4:14 PM

A submenu is a sibling of its menu for focus, not its child

Closed v13.2.0/archive-v13-2-0-chunk-21 enhancementairefactoring
neo-fable
neo-fable commented on Sep 18, 2026, 2:18 PM

Context

Follow-up to #18847 / PR #18860, from a second look @tobiu asked for through the manager.Focus model. Same seam as #18838 the same day, where a picker field's floating picker carried no parentComponent and a 43-line grid-plugin workaround collapsed into one line of ownership (PR #18852).

handoff: @neo-opus-grace — menu.List focus is your surface today (#18846–#18848, #18860). Decline freely; then it is claimable.

The Problem

menu.List#showSubMenu() creates every level with parentComponent: me.parentComponent and parentId: app.mainView.id: all levels are siblings under the ROOT's owner. manager.Focus works on ComponentManager.getParentPath — the first component in the DOM path, then component.parent — so a focus move between a level and its submenu shares no menu. The level losing focus gets focusLeave, component.Base#onFocusLeave clears its focusEnterData, and containsFocus answers for one level only.

menu.List answers that with bookkeeping of its own: menuFocus_ bubbling up parentMenu, the oldPath scans in onFocusLeave (leftPathIsOwnTree, isInteractionPath(data.oldPath)), and — with PR #18860 — focusSource and the holdsFocus() recursion. Each is correct; together they restate what the component tree answers when a submenu is owned by the level that opened it.

The Architectural Reality

Measured on PR #18860's head (8338bf1d57), component tier menu + button, 45 arms:

Change Result
parentComponent: me in showSubMenu(), nothing else 43 green · 2 red: a rest pending from arrowing onto a parent does not reopen what Right and Left closed, Space on a parent enters its submenu, and Space on a leaf dismisses like Enter
+ holdsFocus() reduced to containsFocus, the focusSource hand-back in unmount() switched off the same 2 red; all 8 arms PR #18860 adds stay green — the inherited revertFocus() returns focus to the trigger, because the root never left focus
+ onFocusMove rests only when path AND oldPath are inside this level 44 green · 1 red: Space on a parent… — undiagnosed; hypothesis: the keydown now reaches ancestor levels through manager.DomEvent#verifyDelegationPath phase 2 (logical ancestry)
  • onFocusMove's JSDoc relies on the sibling shape ("ArrowRight moving focus INTO a submenu shares an ancestor of both menus instead, and never reaches this method"). Under ownership it does reach it, in both directions.
  • hideSubMenu()'s explicit hand-back to the opening item stays either way: revertFocus() focuses Neo.getComponent(relatedTarget.id), and an item is a node, not a component.
  • Precedent for owning a floating part: button.Base (its menu), tab/plugin/Overflow, grid/column/Component, and form.field.Picker since PR #18852.

The Fix

showSubMenu() creates a submenu with parentComponent: me. Then delete what the tree now answers: focusSource, holdsFocus(), and — measure first — the menuFocus bubbling and the oldPath scans in onFocusLeave. onFocusMove distinguishes an item-to-item move from a move between a level and its submenu; key events handled by a level do not act again in its ancestors.

Acceptance Criteria

  • Every arm of component/menu and component/button stays green, PR #18860's 8 hand-back arms included.
  • focusSource and holdsFocus() are gone; a full dismissal returns focus through the inherited revertFocus().
  • Each remaining piece of menu-local focus bookkeeping (menuFocus_ bubbling, the onFocusLeave scans) is either deleted or kept with the measurement that says why.
  • Net engine lines in src/menu/List.mjs go down; the PR names the number.

Out of Scope

Pointer dismissal (isInteractionPath for onAppMouseDown) · menu.Panel · teaching revertFocus() to focus a node that is not a component.

Avoided Traps

  • Folding it into PR #18860: that PR fixes what users hit today and its arms are the net this change needs; it should not wait.
  • Reading the numbers above as done: they are one afternoon's experiment on a detached head, three edits, no review. The Space path is open.

Related

#18847 · PR #18860 (comment IC_kwDODSospM8AAAABVYbVxA) · #18838 / PR #18852 · #18850.

Decision Record impact: none.

Live latest-open sweep: latest 20 open issues at 2026-09-18T12:18Z plus an all-state search for submenu parentComponent — no equivalent (#18847 is the defect this follows). A2A sweep at 12:10Z: no claim on this scope.

Origin Session ID: 323cb989-06a2-4b3f-8d70-912d67796c11

Retrieval Hint: "menu.List submenu parentComponent sibling focus branch focusSource holdsFocus menuFocus bubbling getParentPath"