LearnNewsExamplesServices
Frontmatter
id19281
titleA pointer drag of a tab leaves focus on the body after the drop
stateClosed
labels
bugaicoreaccessibility
assigneesneo-opus-vega
createdAtSep 26, 2026, 3:25 PM
updatedAtSep 26, 2026, 3:49 PM
githubUrlhttps://github.com/neomjs/neo/issues/19281
authorneo-opus-vega
commentsCount0
parentIssuenull
subIssues[]
subIssuesCompleted0
subIssuesTotal0
contentTrust
projected
quarantined0
signals[]
blockedBy[]
blocking[]
closedAtSep 26, 2026, 3:49 PM

A pointer drag of a tab leaves focus on the body after the drop

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

Context

#17844's census lists TabHeaderActionsNL red. Since #19277 its first error is :269: after the drag terminal, no More tabs control appears ("the queued projection drains after SortZone restores natural layout"). Measured today on dev e3d83f1b91 with a probe copy of the spec (headless, --workers=1, runtime root bound), reading document.activeElement and the gated previous-tab action's node count at each step of the arm:

step active element previous-tab nodes
before the mouse-down (after the body card click) the card 1
after mouse.down() on the last tab the tab button (<button role="tab" tabindex="0">) 1
150 ms later the tab button 1
after the drag move to the rail BODY 0
500 ms after mouse.up() BODY 0

Identical at ab2ae8c73c (2026-09-05), so this is not a recent regression of the drag. The rest of the arm follows from it: the plugin's sortDragProjectionQueued goes true mid-drag and false 500 ms after the terminal, the drain runs, and it measures a header that fits (three 73 px tabs + the 80 px grown action + a 12 px spacer against a 310 px bar). The arm's mid-drag growth overflows that bar only with the 20 px gated action present.

Control: the same probe with one card.click() right after mouse.up() (focus back in the container) passes the entire spec on e3d83f1b91, growth 80 px and all assertions after :269 included.

The Problem

A mouse-down on a tab header focuses its <button>. Neo.draggable.container.SortZone#onDragStart then hides the dragged original (itemStyle.visibility = 'hidden', src/draggable/container/SortZone.mjs:972). The browser moves focus off an element that becomes visibility: hidden, so focus lands on the body; manager.Focus delivers the leave, the TabContainer's containsFocus turns false, and toolbar.Base#applyContextualActionState withdraws the focus-gated actions (node-less since #18069). Nothing at the terminal hands focus back: after the drop the user's focus is on the body.

Three consequences:

  • A pointer user dragging a tab watches the header's contextual actions vanish mid-drag and stay gone after the drop, although they are still working in that container.
  • Keyboard focus is lost after every tab drag; a drop should leave focus on the moved item, as a plain click on it would.
  • The census arm cannot witness the drain: its overflow depends on the gated action staying revealed.

History, from the file's log: the arm was authored 2026-08-20 (#17423) while the actions were persistent; focus gating arrived 2026-08-30 (#17875); a withdrawn action stopped taking rail space 2026-09-01 (#18016), which is when this arm's overflow began depending on focus; the :65 hidden-node read (2026-09-02, #18069) masked it until #19277.

The Architectural Reality

  • src/draggable/container/SortZone.mjs: onDragStart sets me.dragComponent = draggedItem (:881) and hides it (:972); processDragEnd restores the item styles and the dragged component's visibility (:597–:602), reorders, resets the gesture state and waits one frame (:647) before dragEnd. No focus handling exists anywhere under src/draggable (grep).
  • Neo.component.Base#focus(id, children, preventScroll, modality) (src/component/Base.mjs:1458) routes to main.DomAccess.focus; modality: 'pointer' suppresses the accidental focus ring, which is what a pointer gesture wants.
  • containsFocus_ (src/component/Base.mjs:104) is the worker's reactive view of focus ownership, kept by manager.Focus; on the dragged tab it is true at drag start (the mouse-down's focusin has settled, see the table).
  • tab/header/Button.mjs renders <button role="tab">; test/playwright/setup.mjs:165 stubs Neo.main.DomAccess for the unit tier.

The Fix

src/draggable/container/SortZone.mjs:

  1. onDragStart, beside :881: record whether the gesture took focus, me.dragFocusOwner = draggedItem.containsFocus ? draggedItem : null.
  2. processDragEnd, after the one-frame wait at :647 (the visibility restore has flushed by then): if dragFocusOwner is set and not destroyed, dragFocusOwner.focus(dragFocusOwner.id, false, true, 'pointer'); clear the field. A cancelled drag restores the same way.

Coverage:

  • test/playwright/unit/draggable/container/SortZone.spec.mjs: one arm records the stub's focus calls, runs a drag start and end for an item with containsFocus: true and asserts exactly one call for that item with modality: 'pointer', then the same for an item without focus and asserts none.
  • The e2e witness is the census arm itself, unchanged: TabHeaderActionsNL passes at the head with the runtime root bound.

Acceptance Criteria

  • AC-1: test/playwright/e2e/tab/TabHeaderActionsNL.spec.mjs, unchanged, passes :269 at the head (headless, --workers=1, NEO_AGENTOS_RUNTIME_ROOT bound; CI does not select it) and its first error moves to :288, the consistency read that #19282 owns; with #19282 applied the spec passes. Measured after filing: the restored focus sits on the dropped tab, which the overflow projection then withholds, so focus is lost a second time (#19285); that loss does not reach any assertion once #19282 lands.
  • AC-2: the unit arm: a drag whose dragged item owned focus at start ends with one DomAccess.focus call for that item, modality pointer; a drag whose item did not own focus ends with none; a cancelled drag behaves like a committed one.
  • AC-3: red-first: the unit arm fails on dev, and the e2e spec fails at :269 on dev, both with the fix reverted.

Out of Scope

  • Keeping the gated actions revealed during the drag. That needs either a hiding mechanism the browser does not blur for (visibility: hidden is what removes the original from the accessibility tree today) or parking focus on a still-visible node at drag start; a separate call, once the terminal is right.
  • verifyComponentConsistency reporting a removeDom vdom node as a DOM mismatch: #19282. The projection withholding a focused tab and dropping focus to the body: #19285.
  • The other #17844 rows.

Decision Record impact

none

Related

#17844 (the census row) · #19282 (the checker) · #19285 (the overflow projection's focus) · #19276 / #19277 (the read that masked this arm) · #17423 (the arm's origin) · #17875 (focus gating) · #18016 (a withdrawn action takes no rail space) · #18069 (a withdrawn action has no node)

Origin Session ID: e2fd8a01-5bfc-4d82-ad5b-5c889f4b6710 Retrieval Hint: "tab drag focus body SortZone dragFocusOwner TabHeaderActionsNL More tabs drain"

tobiu referenced in commit 8feb763 - "fix(draggable): a drag that took focus with its item hands it back at the drop (#19281) (#19286) on Sep 26, 2026, 3:49 PM
tobiu closed this issue on Sep 26, 2026, 3:49 PM