Context
The operator asked for more tests of menu hover and keyboard navigation, submenus included (2026-09-18). Scratch Playwright arms against dev@3f859a4803 found that keyboard focus falls to the <body> whenever a menu level that holds focus closes, except through Left or Escape inside a submenu. The runs used the component app test/playwright/component/apps/empty-viewport: a floating menu.List, and a button.Base with a menu, both carrying a three-level tree.
| scenario |
document.activeElement after |
menu |
then Escape |
| Right into a submenu, pointer rests on a root leaf |
body |
root stays open |
nothing happens |
| Right into a submenu, pointer rests on another root parent |
body |
the new preview opens |
— |
| focus at depth 3, pointer rests on a depth-2 leaf |
body |
two levels stay |
— |
| button menu: row 1 |
body |
stays open |
nothing happens |
| button menu: Right into a submenu, then Enter / Space / click on a leaf |
body (expected: the button) |
closes |
— |
| button menu: Right, Left back to the root, then Escape (or Enter on a root leaf) |
body (expected: the button) |
closes |
— |
| CONTROL, button menu: no submenu visit (a focus-rest preview only), then Escape |
the button ✓ |
closes |
— |
The Problem
component.Base#revertFocus() (src/component/Base.mjs:1872-1878) is the only hand-back when a component that holds focus unmounts. It focuses Neo.getComponent(focusEnterData.relatedTarget.id), and only while containsFocus is set.
- Every level is its own focus branch. A submenu is created with the root's
parentComponent and parentId: app.mainView.id (src/menu/List.mjs:893-894), so manager.Focus sees each level separately. Moving into a submenu raises focusLeave on the parent level, and component.Base#onFocusLeave clears its focusEnterData. Moving back re-enters with the submenu item as relatedTarget, which is a node id, not a component.
- A partial close has nothing to revert to. When a level unmounts while it holds focus, its
relatedTarget is the parent's item node. getComponent() finds nothing and focus lands on the body.
- A full close has lost its source. Once focus has visited any submenu, the root no longer knows its entry source (the trigger), so no dismissal can return focus to it.
- The damage. With focus on the body, the root stays open, because its
menuFocus never saw a leave. The Navigator's keydown listener sits on the menu element, so arrows and Escape are dead until an outside click.
- The one path that works is
leaveSubMenu() (Left, or Escape inside a submenu). It focuses the parent item before it hides the level (src/menu/List.mjs:557-567). The pointer-rest paths (onItemRest → hideSubMenu(), or showSubMenu() switching siblings) and the cascade dismissals do not.
The Architectural Reality
menu.List treats a cascade as one interaction island for pointer dismissal (isInteractionPath, onFocusLeave). Focus hand-back is the one island behaviour it leaves to the per-component revertFocus().
onFocusMove's JSDoc depends on the levels being separate branches: a move into a submenu must never reach the parent menu's onFocusMove. That rules out re-parenting the levels, so the hand-back belongs in menu.List.
The Fix
src/menu/List.mjs:
- Partial close.
hideSubMenu() hands focus to the item that opened the branch before it unmounts it, whenever that branch holds focus and this level stays. "The branch holds focus" means some level on the activeSubMenu chain has containsFocus. leaveSubMenu() then reduces to asking its parent to hide it, because its focus step moves into the shared path. A level hiding its branch inside its own unmount() does not hand focus back, since the level itself is leaving. unmount() says so explicitly, the way showSubMenu() takes a focus flag.
- Full close. The root remembers where focus entered the cascade from: the first focus enter after mount. When it unmounts with focus anywhere in the cascade, it returns focus there. This replaces the inherited
revertFocus(), which cannot see across levels.
Acceptance Criteria
Out of Scope
- A pointer rest moving keyboard focus. A rest stays a preview (#18759). Focus moves only because its host is closing.
- Keys typed while a submenu mount is still in flight. Two fast Right presses land on depth 2, not 3. That is a separate, unfiled observation.
Avoided Traps
- Re-parenting submenus (
parentComponent: me) so manager.Focus sees one island. Item-to-submenu moves would then reach the parent's onFocusMove, which resolves rows against its own store.
- Keying "this level stays" on
mounted. unmount() hides the branch before super.unmount(), so a closing level still reads as mounted while its own cascade runs.
- Refocusing from a timer after the removal. The class contract is structural: "timing is never the ownership signal".
Related
#18846 · #18848 · #18759 (hover preview) · #18760 (keyboard cascade, leaveSubMenu) · #18780 (focus rest) · #18803 (focus on a button menu once it opens) · #18290 (removal-stamped focusout)
Live latest-open sweep: latest 20 open issues read at 2026-09-18T11:01:41Z, plus an all-state title search (menu focus body / Navigator End / last-of-type / disabled menu): no equivalent (nearest: #17851 separator + disabled field, closed; #17825 outside-pointer dismissal, closed). A2A sweep: latest 30 messages. @neo-opus-vega posted a lane-intent on menu non-interactive rows at 10:39Z and stood down at 10:54Z in my favour; no other claim. Memory Core sweep, on the problem's nouns: no prior decision. Own-assignment sweep: 8 open, none on menus or focus.
Origin Session ID: 0a997d38-ccdb-4352-ac21-566606a6ab2d
Retrieval Hint: "menu focus falls to body submenu closes revertFocus relatedTarget item node focusEnterData cleared cascade trigger"
Context
The operator asked for more tests of menu hover and keyboard navigation, submenus included (2026-09-18). Scratch Playwright arms against
dev@3f859a4803found that keyboard focus falls to the<body>whenever a menu level that holds focus closes, except through Left or Escape inside a submenu. The runs used the component apptest/playwright/component/apps/empty-viewport: a floatingmenu.List, and abutton.Basewith amenu, both carrying a three-level tree.document.activeElementafterbodybodybodybodybody(expected: the button)body(expected: the button)The Problem
component.Base#revertFocus()(src/component/Base.mjs:1872-1878) is the only hand-back when a component that holds focus unmounts. It focusesNeo.getComponent(focusEnterData.relatedTarget.id), and only whilecontainsFocusis set.parentComponentandparentId: app.mainView.id(src/menu/List.mjs:893-894), somanager.Focussees each level separately. Moving into a submenu raisesfocusLeaveon the parent level, andcomponent.Base#onFocusLeaveclears itsfocusEnterData. Moving back re-enters with the submenu item asrelatedTarget, which is a node id, not a component.relatedTargetis the parent's item node.getComponent()finds nothing and focus lands on the body.menuFocusnever saw a leave. The Navigator's keydown listener sits on the menu element, so arrows and Escape are dead until an outside click.leaveSubMenu()(Left, or Escape inside a submenu). It focuses the parent item before it hides the level (src/menu/List.mjs:557-567). The pointer-rest paths (onItemRest→hideSubMenu(), orshowSubMenu()switching siblings) and the cascade dismissals do not.The Architectural Reality
menu.Listtreats a cascade as one interaction island for pointer dismissal (isInteractionPath,onFocusLeave). Focus hand-back is the one island behaviour it leaves to the per-componentrevertFocus().onFocusMove's JSDoc depends on the levels being separate branches: a move into a submenu must never reach the parent menu'sonFocusMove. That rules out re-parenting the levels, so the hand-back belongs inmenu.List.The Fix
src/menu/List.mjs:hideSubMenu()hands focus to the item that opened the branch before it unmounts it, whenever that branch holds focus and this level stays. "The branch holds focus" means some level on theactiveSubMenuchain hascontainsFocus.leaveSubMenu()then reduces to asking its parent to hide it, because its focus step moves into the shared path. A level hiding its branch inside its ownunmount()does not hand focus back, since the level itself is leaving.unmount()says so explicitly, the wayshowSubMenu()takes afocusflag.revertFocus(), which cannot see across levels.Acceptance Criteria
dev. The arms live intest/playwright/component/menu/List.spec.mjs, with the button-menu arms beside the existing ones intest/playwright/component/button/Base.spec.mjs.Out of Scope
Avoided Traps
parentComponent: me) somanager.Focussees one island. Item-to-submenu moves would then reach the parent'sonFocusMove, which resolves rows against its own store.mounted.unmount()hides the branch beforesuper.unmount(), so a closing level still reads as mounted while its own cascade runs.Related
#18846 · #18848 · #18759 (hover preview) · #18760 (keyboard cascade,
leaveSubMenu) · #18780 (focus rest) · #18803 (focus on a button menu once it opens) · #18290 (removal-stamped focusout)Live latest-open sweep: latest 20 open issues read at 2026-09-18T11:01:41Z, plus an all-state title search (menu focus body / Navigator End / last-of-type / disabled menu): no equivalent (nearest: #17851 separator + disabled field, closed; #17825 outside-pointer dismissal, closed). A2A sweep: latest 30 messages. @neo-opus-vega posted a lane-intent on menu non-interactive rows at 10:39Z and stood down at 10:54Z in my favour; no other claim. Memory Core sweep, on the problem's nouns: no prior decision. Own-assignment sweep: 8 open, none on menus or focus.
Origin Session ID: 0a997d38-ccdb-4352-ac21-566606a6ab2d Retrieval Hint: "menu focus falls to body submenu closes revertFocus relatedTarget item node focusEnterData cleared cascade trigger"