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
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"
Context
Follow-up to #18847 / PR #18860, from a second look @tobiu asked for through the
manager.Focusmodel. Same seam as #18838 the same day, where a picker field's floating picker carried noparentComponentand a 43-line grid-plugin workaround collapsed into one line of ownership (PR #18852).handoff: @neo-opus-grace —
menu.Listfocus is your surface today (#18846–#18848, #18860). Decline freely; then it is claimable.The Problem
menu.List#showSubMenu()creates every level withparentComponent: me.parentComponentandparentId: app.mainView.id: all levels are siblings under the ROOT's owner.manager.Focusworks onComponentManager.getParentPath— the first component in the DOM path, thencomponent.parent— so a focus move between a level and its submenu shares no menu. The level losing focus getsfocusLeave,component.Base#onFocusLeaveclears itsfocusEnterData, andcontainsFocusanswers for one level only.menu.Listanswers that with bookkeeping of its own:menuFocus_bubbling upparentMenu, theoldPathscans inonFocusLeave(leftPathIsOwnTree,isInteractionPath(data.oldPath)), and — with PR #18860 —focusSourceand theholdsFocus()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 tiermenu+button, 45 arms:parentComponent: meinshowSubMenu(), nothing elsea 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 EnterholdsFocus()reduced tocontainsFocus, thefocusSourcehand-back inunmount()switched offrevertFocus()returns focus to the trigger, because the root never left focusonFocusMoverests only whenpathANDoldPathare inside this levelSpace on a parent…— undiagnosed; hypothesis: the keydown now reaches ancestor levels throughmanager.DomEvent#verifyDelegationPathphase 2 (logical ancestry)onFocusMove's JSDoc relies on the sibling shape ("ArrowRightmoving 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()focusesNeo.getComponent(relatedTarget.id), and an item is a node, not a component.button.Base(its menu),tab/plugin/Overflow,grid/column/Component, andform.field.Pickersince PR #18852.The Fix
showSubMenu()creates a submenu withparentComponent: me. Then delete what the tree now answers:focusSource,holdsFocus(), and — measure first — themenuFocusbubbling and theoldPathscans inonFocusLeave.onFocusMovedistinguishes 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
component/menuandcomponent/buttonstays green, PR #18860's 8 hand-back arms included.focusSourceandholdsFocus()are gone; a full dismissal returns focus through the inheritedrevertFocus().menuFocus_bubbling, theonFocusLeavescans) is either deleted or kept with the measurement that says why.src/menu/List.mjsgo down; the PR names the number.Out of Scope
Pointer dismissal (
isInteractionPathforonAppMouseDown) ·menu.Panel· teachingrevertFocus()to focus a node that is not a component.Avoided Traps
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"