LearnNewsExamplesServices
Frontmatter
id19285
titleThe tab Overflow projection withholds a focused tab and drops focus to the body
stateClosed
labels
bugaicoreaccessibility
assignees[]
createdAtSep 26, 2026, 3:31 PM
updatedAtSep 27, 2026, 12:06 AM
githubUrlhttps://github.com/neomjs/neo/issues/19285
authorneo-opus-vega
commentsCount3
parentIssuenull
subIssues[]
subIssuesCompleted0
subIssuesTotal0
contentTrust
projected
quarantined0
signals[]
blockedBy[]
blocking[ ] 19288 Headless Chrome''s 800 px screen clamps every script-moved popup, [ ] 19293 The browser arm for a focused non-active tab surviving the overflow projection: staging unresolved against the app''s real headers
closedAtSep 27, 2026, 12:06 AM

The tab Overflow projection withholds a focused tab and drops focus to the body

Closed v13.2.0/archive-v13-2-0-chunk-23 bugaicoreaccessibility
neo-opus-vega
neo-opus-vega commented on Sep 26, 2026, 3:31 PM

Context

Measured on dev e3d83f1b91 with the #19281 fix applied locally (a drag hands focus back to the dragged tab), in a probe copy of TabHeaderActionsNL (headless, --workers=1, runtime root bound). The arm drops the third tab, the header overflows because an action grew mid-drag, and the projection withholds the trailing tab into the More tabs menu:

moment active element bar children gated previous-tab nodes
120 ms after the drop BODY tab 1, tab 2, spacer, next-tab, previous-tab (tab 3 withheld) 1
after More tabs appeared BODY same 1
after the bar widened to 500 px BODY tab 1, tab 2, tab 3, spacer, next-tab 0

The drop had focused tab 3 (the #19281 restore; the gated action is revealed, so the container held focus for a moment). The projection then removed tab 3's node, the browser moved focus to the body, and once manager.Focus delivered the leave the container withdrew its contextual actions again.

A user reaches the same state without a drag: focus a tab that is not the active one, then narrow the window until it overflows.

The Problem

Neo.tab.plugin.Overflow keeps exactly one tab out of the hidden range, the active one (computeOverflow({items, extent, activeItemId, controlWidth}), src/tab/plugin/Overflow.mjs:77–:129: "The active tab id — never hidden"). A tab that holds focus but is not active is withheld like any other, and withholding removes its node (removeDom), so the browser's focus falls to the body. Nothing moves it to the More tabs control, to the active tab, or keeps the focused tab visible. The same class as #19281 (focus dropped by a node the engine hides), one layer up.

The Architectural Reality

  • computeOverflow is the pure partition (:81), re-run on resize, on activeIndexChange (:384–:386) and on mutations; the plugin already treats one id as protected and re-adds its width to the visible range (:108–:129).
  • containsFocus_ on component.Base (:104) tells the worker which tab holds focus; the tab container's containsFocus gates the header's contextual actions (toolbar.Base#applyContextualActionState).
  • The floating More tabs control is a body child with the toolbar as logical owner (TabHeaderActionsNL:175–:183), so focusing it keeps the container's focus island intact.

The Fix

Two shapes, the ticket does not pick:

  1. Keep the focused tab visible: computeOverflow takes a second never-hidden id (the tab whose containsFocus is true) and protects it the way it protects activeItemId. Symmetric with the existing rule; the header shows one more tab than it otherwise would while that tab is focused.
  2. Move focus with the projection: when the withheld range contains the focused tab, the projection focuses the More tabs control (focus(id, false, true, 'keyboard') for a keyboard origin) before removing the node, so focus stays inside the container and the menu is one key away.

Either way: a unit arm on computeOverflow (shape 1) or on the projection's focus hand-off (shape 2), and an e2e arm that focuses a non-active tab, narrows the container, and asserts document.activeElement is inside the tab container afterwards.

Acceptance Criteria

  • AC-1: computeOverflow never withholds the tab that holds focus: it protects activeItemId together with the focused item and fits the pair's combined width, and the displacement of other tabs never evicts the active tab to seat the focused one (unit arms, red-first on the base).
  • AC-2: project() supplies the focused button from containsFocus, reading it and never acting on it; the degenerate cap gate covers either protected button (unit arms).
  • AC-3: the active tab's protection is unchanged.

Transfer, 2026-09-26 (Vega, the author): the browser witness this ticket first listed as AC-1 and AC-2 (document.activeElement inside the tab container after the projection, the container's contextual actions kept, red-first with activeElement = body on dev) moved to #19293. Its staging does not hold against the app's current headers and that is the substantive part, so it is a ticket of its own rather than a squeezed arm here. The engine fix and its unit arms close this ticket (PR #19294 at 0969ca62e0); #19293 closes the observable outcome.

Out of Scope

  • #19281 (a drag hands focus back at the drop) and #19282 (the consistency checker's blindness to withheld nodes).

Decision Record impact

none

Related

#19281 · #19282 · #17844 · #18069 (withheld nodes have no DOM) · #17875 (focus-gated actions)

unowned-rationale: found while closing #19281 and deliberately not folded into it, since the fix is a design call between two shapes in a different class. One PR either way. Any seat may take it; I take it after #19281 and #19282 if it is still open.

Origin Session ID: e2fd8a01-5bfc-4d82-ad5b-5c889f4b6710 Retrieval Hint: "tab Overflow computeOverflow focused tab withheld removeDom focus body activeItemId never hidden"

tobiu referenced in commit a50ae57 - "fix(tab): the Overflow projection never withholds the tab that holds focus (#19285) (#19294) on Sep 27, 2026, 12:06 AM
tobiu closed this issue on Sep 27, 2026, 12:06 AM