Context
The operator asked for more tests of menu hover and keyboard navigation, submenus included (2026-09-18). Probing that with scratch Playwright arms against dev@3f859a4803 found two defects with one root. The harness was the component app test/playwright/component/apps/empty-viewport, with a floating menu.List.
| rows |
action |
result |
| Alpha, Beta, Gamma, Delta |
End from Alpha |
Delta ✓ (control) |
| Alpha, Beta, Gamma, Delta (disabled) |
End from Alpha |
focus stays on Alpha ✗ (expected Gamma) |
| Alpha, Beta, separator |
End from Alpha |
focus stays on Alpha ✗ (expected Beta) |
| Alpha (disabled), Beta, Gamma |
Home from Gamma |
Beta ✓ |
| Alpha, Beta (disabled), separator, Gamma |
Down / Up |
Gamma / Alpha ✓ |
| Alpha, Locked (disabled), Gamma |
click on Locked |
the menu closes. Focus trail: focusout li → null, no focusin |
| same |
click on a separator |
the menu closes |
same, plus a page-level mousedown preventDefault() on non-navigable targets |
click on Locked |
the menu stays open and focus stays on Alpha |
The Problem
Both defects are rows that list.Base#nonInteractiveItemCls excludes from navigation, handled wrongly by Neo.main.addon.Navigator.
End. navigateKeyDownHandler resolves End as subject.querySelector(${data.selector}:last-of-type) (src/main/addon/Navigator.mjs:236-238). The previous-key wrap fallback does the same (:220-222). :last-of-type is a type pseudo-class: it matches the last li among its siblings, not the last element matching the selector. With getNavigableItemSelector()'s .neo-list-item:not(.neo-disabled,.neo-list-header,.neo-menu-separator), a non-navigable last row makes the compound match nothing, so End silently does nothing. Measured in the page, querySelector(sel + ':last-of-type') → null, and [...querySelectorAll(sel)].at(-1) → Gamma. The code arrived in 00a67f1020 (#5123).
A press on a skipped row. Disabled rows and separators compute pointer-events: none, so the press hits the menu ul. The ul has no tabindex, so the browser blurs the focused item to the body. manager.Focus delivers the leave after its gap, and menu.List#onFocusLeave (!data.relatedTarget) sets menuFocus = false, which unmounts the floating root. The outside-pointer listener is not the cause. onAppMouseDown reads the press as inside, because its path contains the menu. The control row shows that blocking only the focus change keeps the menu open. Native menus ignore a click on a disabled item or a separator.
The Architectural Reality
- The Navigator owns the roving-focus contract for its subject.
navigateMouseDownHandler (:256-265) already states it for items without a focusable: "we must not draw focus away from where it is, so preventDefault". A press on the subject's dead space breaks the same contract and is missing from that handler.
- Every
list.Base subscriber goes through these handlers: menus, list.Buffered, and the bound lists of form.field.ComboBox / Time. Those two navigate virtually from their input via eventSource, so there the dead-space press currently takes focus out of the field.
- The navigable set is declared once, in
nonInteractiveItemCls → getNavigableItemSelector() (src/list/Base.mjs). Home and the arrow keys already honour it, through querySelector and the TreeWalker filter. Only the two "last" lookups use :last-of-type.
navigateGetAdjacent's own wrap branch (:177-181) indexes the single element querySelector returns as if it were a NodeList (allItems[0] is undefined), so it cannot return an item. The callers' fallback is the live wrap path. No in-tree consumer sets wrap today.
The Fix
src/main/addon/Navigator.mjs:
- End. End and the previous-key wrap fallback both take the last match of
subject.querySelectorAll(data.selector), through one lookup. Delete navigateGetAdjacent's unreachable wrap branch, since its callers own wrapping.
- Dead-space press. In
navigateMouseDownHandler, a press inside the subject whose target is neither in a navigable item nor on a focusable element also gets preventDefault(). Focus stays on the active item. This has to be main-thread and synchronous, because the App worker cannot cancel the default.
Acceptance Criteria
Out of Scope
- Wrap-by-default for menus, and typeahead: UX decisions with no defect behind them.
- Focus hand-back across menu levels: #18847, which has a different root and touches
menu.List.
Avoided Traps
- Scoping the dead-space rule to menus in
main.DomEvents#onMouseDown, beside its Space rule for neo-menu-list. That leaves the same contract broken for every other Navigator subject, including the picker fields.
- Giving the menu
ul tabindex="-1". That keeps the menu open, but moves focus off the active item onto the list.
Related
#5123 (origin) · #18847 · #18848 · #18759 / #18780 (menu hover and focus rest) · #18803 (button menu focus)
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. Own-assignment sweep: 8 open, none on menus or focus. Memory Core sweep: "Navigator End key last-of-type disabled last item", "clicking a disabled menu item or separator closes the whole menu": no prior decision. KB ticket query and resources/content greps (last-of-type, End key, separator/disabled click): no equivalent.
Origin Session ID: 0a997d38-ccdb-4352-ac21-566606a6ab2d
Retrieval Hint: "Navigator last-of-type End disabled separator dead-space mousedown preventDefault menu dismissal"
Context
The operator asked for more tests of menu hover and keyboard navigation, submenus included (2026-09-18). Probing that with scratch Playwright arms against
dev@3f859a4803found two defects with one root. The harness was the component apptest/playwright/component/apps/empty-viewport, with a floatingmenu.List.focusout li → null, nofocusinmousedownpreventDefault()on non-navigable targetsThe Problem
Both defects are rows that
list.Base#nonInteractiveItemClsexcludes from navigation, handled wrongly byNeo.main.addon.Navigator.End.
navigateKeyDownHandlerresolves End assubject.querySelector(${data.selector}:last-of-type)(src/main/addon/Navigator.mjs:236-238). The previous-keywrapfallback does the same (:220-222).:last-of-typeis a type pseudo-class: it matches the lastliamong its siblings, not the last element matching the selector. WithgetNavigableItemSelector()'s.neo-list-item:not(.neo-disabled,.neo-list-header,.neo-menu-separator), a non-navigable last row makes the compound match nothing, so End silently does nothing. Measured in the page,querySelector(sel + ':last-of-type')→null, and[...querySelectorAll(sel)].at(-1)→ Gamma. The code arrived in 00a67f1020 (#5123).A press on a skipped row. Disabled rows and separators compute
pointer-events: none, so the press hits the menuul. Theulhas no tabindex, so the browser blurs the focused item to the body.manager.Focusdelivers the leave after its gap, andmenu.List#onFocusLeave(!data.relatedTarget) setsmenuFocus = false, which unmounts the floating root. The outside-pointer listener is not the cause.onAppMouseDownreads the press as inside, because its path contains the menu. The control row shows that blocking only the focus change keeps the menu open. Native menus ignore a click on a disabled item or a separator.The Architectural Reality
navigateMouseDownHandler(:256-265) already states it for items without a focusable: "we must not draw focus away from where it is, so preventDefault". A press on the subject's dead space breaks the same contract and is missing from that handler.list.Basesubscriber goes through these handlers: menus,list.Buffered, and the bound lists ofform.field.ComboBox/Time. Those two navigate virtually from their input viaeventSource, so there the dead-space press currently takes focus out of the field.nonInteractiveItemCls→getNavigableItemSelector()(src/list/Base.mjs). Home and the arrow keys already honour it, throughquerySelectorand the TreeWalker filter. Only the two "last" lookups use:last-of-type.navigateGetAdjacent's own wrap branch (:177-181) indexes the single elementquerySelectorreturns as if it were a NodeList (allItems[0]isundefined), so it cannot return an item. The callers' fallback is the live wrap path. No in-tree consumer setswraptoday.The Fix
src/main/addon/Navigator.mjs:subject.querySelectorAll(data.selector), through one lookup. DeletenavigateGetAdjacent's unreachable wrap branch, since its callers own wrapping.navigateMouseDownHandler, a press inside the subject whose target is neither in a navigable item nor on a focusable element also getspreventDefault(). Focus stays on the active item. This has to be main-thread and synchronous, because the App worker cannot cancel the default.Acceptance Criteria
:last-of-typeline.dev.list.Base: a dead-space press keeps focus on the active item, and dragging a scrollable list's scrollbar still scrolls.test/playwright/component/menu/List.spec.mjs.Out of Scope
menu.List.Avoided Traps
main.DomEvents#onMouseDown, beside its Space rule forneo-menu-list. That leaves the same contract broken for every other Navigator subject, including the picker fields.ultabindex="-1". That keeps the menu open, but moves focus off the active item onto the list.Related
#5123 (origin) · #18847 · #18848 · #18759 / #18780 (menu hover and focus rest) · #18803 (button menu focus)
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. Own-assignment sweep: 8 open, none on menus or focus. Memory Core sweep: "Navigator End key last-of-type disabled last item", "clicking a disabled menu item or separator closes the whole menu": no prior decision. KB ticket query and
resources/contentgreps (last-of-type,End key, separator/disabled click): no equivalent.Origin Session ID: 0a997d38-ccdb-4352-ac21-566606a6ab2d Retrieval Hint: "Navigator last-of-type End disabled separator dead-space mousedown preventDefault menu dismissal"