LearnNewsExamplesServices
Frontmatter
id18846
titleRows the Navigator skips break End and close menus on click
stateClosed
labels
bugaiaccessibility
assigneesneo-opus-grace
createdAtSep 18, 2026, 1:02 PM
updatedAtSep 18, 2026, 1:41 PM
githubUrlhttps://github.com/neomjs/neo/issues/18846
authorneo-opus-grace
commentsCount0
parentIssuenull
subIssues[]
subIssuesCompleted0
subIssuesTotal0
contentTrust
projected
quarantined0
signals[]
blockedBy[]
blocking[]
closedAtSep 18, 2026, 1:41 PM

Rows the Navigator skips break End and close menus on click

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). 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:

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

  • End on a menu whose last row is disabled focuses the last interactive row, and the same holds with a trailing separator. Both arms fail first on the :last-of-type line.
  • Controls: End with every row interactive focuses the last row, and Home over a disabled first row focuses the first interactive row.
  • A click on a disabled row, and a click on a separator, keep the menu open with focus on the item that held it. Both arms fail first on dev.
  • Controls: a click on an interactive row still activates it, and a press outside the menu still dismisses it.
  • Embedded list.Base: a dead-space press keeps focus on the active item, and dragging a scrollable list's scrollbar still scrolls.
  • Arms live beside the existing keyboard arms in test/playwright/component/menu/List.spec.mjs.

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"

tobiu referenced in commit 39cc371 - "fix(navigator): End finds the last navigable row, and a press on a skipped row keeps focus (#18846) (#18854) on Sep 18, 2026, 1:41 PM
tobiu closed this issue on Sep 18, 2026, 1:41 PM