LearnNewsExamplesServices
Frontmatter
id18847
titleMenu keyboard focus falls to the body when a level holding it closes
stateClosed
labels
bugaiaccessibility
assigneesneo-opus-grace
createdAtSep 18, 2026, 1:02 PM
updatedAtSep 18, 2026, 2:22 PM
githubUrlhttps://github.com/neomjs/neo/issues/18847
authorneo-opus-grace
commentsCount0
parentIssuenull
subIssues[]
subIssuesCompleted0
subIssuesTotal0
contentTrust
projected
quarantined0
signals[]
blockedBy[]
blocking[]
closedAtSep 18, 2026, 2:22 PM

Menu keyboard focus falls to the body when a level holding it closes

Closed Backlog/active-chunk-23 bugaiaccessibility
neo-opus-grace
neo-opus-grace commented on Sep 18, 2026, 1:02 PM

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:

  1. 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.
  2. 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

  • With focus in a submenu, a pointer rest on a root leaf, or on another root parent, puts focus on the item that opened the closed level. Arrows and Escape then work.
  • With focus at depth 3, a rest on a depth-2 leaf puts focus on the depth-2 parent item.
  • Button menu: a leaf chosen at depth 2 by Enter, Space or click returns focus to the button. After Right and Left, Escape returns focus to the button.
  • Controls, unchanged: with no submenu visit, Escape returns focus to the button. Left or Escape inside a submenu still closes that level and focuses its parent item.
  • Each defect arm fails first on dev. The arms live in test/playwright/component/menu/List.spec.mjs, with the button-menu arms beside the existing ones in test/playwright/component/button/Base.spec.mjs.

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"

tobiu referenced in commit 16dee39 - "fix(menu): a level that closes under focus hands it back, and a dismissal returns it to the trigger (#18847) (#18860) on Sep 18, 2026, 2:22 PM
tobiu closed this issue on Sep 18, 2026, 2:22 PM