LearnNewsExamplesServices
Frontmatter
id18065
titleGridDragScroll cancels mousedown, so a click on a grid moves no focus
stateClosed
labels
bugaicoregrid
assigneesneo-opus-vega
createdAtSep 2, 2026, 11:46 AM
updatedAtSep 2, 2026, 1:19 PM
githubUrlhttps://github.com/neomjs/neo/issues/18065
authorneo-opus-vega
commentsCount0
parentIssuenull
subIssues[]
subIssuesCompleted0
subIssuesTotal0
contentTrust
projected
quarantined0
signals[]
blockedBy[]
blocking[]
closedAtSep 2, 2026, 1:19 PM

GridDragScroll cancels mousedown, so a click on a grid moves no focus

neo-opus-vega
neo-opus-vega commented on Sep 2, 2026, 11:46 AM

Context

Neo.main.addon.GridDragScroll (drag-to-scroll for grid bodies, on by default through Neo.grid.ScrollManager#dragScroll) listens to mousedown on every registered grid body with capture: true and, for a left button, calls event.preventDefault() "to prevent text selection" (src/main/addon/GridDragScroll.mjs:253-255). The default action of mousedown is not only the selection anchor — it is also the browser's focus move to the nearest focusable ancestor of the target. Cancelling it keeps DOM focus exactly where it was.

Observed in a consumer workspace whose dock panes host grids, then reproduced against the engine in a real Chromium (headless, 2026-09-02):

gesture expected observed
click into a grid pane's empty viewport (no row under the pointer) the pane's tab container gains focus, its focus-gated header actions (showOnFocus) reveal document.activeElement is unchanged; no focusin, no focusEnter, the middle rail stays collapsed
a rail reveal is open, click into a grid pane's empty viewport the reveal leaves — its revealed-focused state dismisses on overlayFocusLeave the overlay root keeps focus, the reveal stays open
click on a ROW (control) the grid View gains focus it does — grid.Body's row-click handler focuses the View programmatically, so a row click is unaffected by this defect

The last row is the boundary of the defect: only a click that hits no row depends on the browser's default to reach the View. (An earlier draft of this ticket listed "Enter after a cell click mounts no editor" as a third symptom; the probe falsified that attribution — the View is focused after a cell click, the editing plugin's Enter/Space registry sits on it, and Enter on the editable cell mounts the editor. The report was a gesture mismatch, see Out of Scope.)

The selection concern the preventDefault was written for is already owned by CSS: resources/scss/src/grid/Body.scss:22-23 sets user-select: none on .neo-mouse-drag-scroll, the class ScrollManager adds to the body for exactly this addon (#8911). A mousedown inside a user-select: none subtree starts no selection, so the JavaScript cancel is redundant belt-and-braces whose only remaining effect is the focus loss.

The Problem

Every consumer of "the grid holds focus after a click anywhere in it" breaks silently for the empty-viewport case:

  • Focus-gated dock header actions (toolbar.Base#focusSubjectId / reactive containsFocus, #17874, #18014): a grid pane never reveals its rail by a pointer click into its empty area — and short stores leave most of a pane empty.
  • Rail reveal dismissal (dashboard/dock/interaction/RevealStateMachine.mjs — a click-born reveal is revealed-focused and leaves on overlayFocusLeave, RevealOverlay.mjs:401): clicking the empty area of a grid outside the overlay is not "moving on", the transient stays.
  • Keyboard navigation from an empty click: arrow keys and the editing plugin's Enter/Space (registered on the View anchor) go to whatever was focused before.

Grid View is designed as the single focus anchor (src/grid/View.mjs:36-41: root tabIndex: '-1', programmatically focusable, not tab-reachable), precisely so that a click anywhere in the grid focuses it via the browser's default. The addon defeats the design at the main-thread layer, below anything the App Worker can observe — the worker sees a click and a mousedown, never a focusin.

Reproduction (any grid with dragScroll on, i.e. every grid by default): focus a button outside the grid, click the grid viewport below its last row, then document.activeElement — it is still the button. Same in the Workstation app's grid panes.

The Architectural Reality

  • src/main/addon/GridDragScroll.mjs:346 — bodyElement.addEventListener('mousedown', me.onDragStart, {capture: true}); :212 ignores non-left buttons; :253-255 if (event.type === 'mousedown') { event.preventDefault() } before the monitor phase starts (:257-285). The drag itself only activates after delay (100 ms) AND minDistance (5 px) in onMonitorMove (:292-302) — so the cancel applies to every plain click, not to drags.
  • src/grid/ScrollManager.mjs:83 adds neo-mouse-drag-scroll to the body; resources/scss/src/grid/Body.scss:22-23 → user-select: none.
  • src/grid/View.mjs:36-41 — the focus anchor contract (tabIndex: '-1').
  • src/manager/Focus.mjs:149-173 — focusin/focusout are the only inputs; a cancelled mousedown produces neither.
  • src/main/DomEvents.mjs:591 — the engine's own global mousedown forwards without cancelling; the addon is the one place a grid mousedown default dies.

The Fix

  1. src/main/addon/GridDragScroll.mjs: remove the mousedown preventDefault() in onDragStart. Leave the touch path untouched (touchmove cancel at :182 disables native scrolling, a different default). Document on the handler why the mousedown default must survive: it carries the focus move, and selection suppression is the CSS rule's job.
  2. Guard the selection claim, not just the focus claim: a positive control asserts window.getSelection().toString() === '' after a real drag-scroll across cell text, so the removal cannot regress the reason the line was written.
  3. Red-first component witness, real Chromium: fixture app test/playwright/component/apps/grid-focus/ (a short grid whose viewport has empty space below its last row, a long grid whose viewport scrolls, an outside <button>), spec test/playwright/component/grid/ClickFocus.spec.mjs:
    • click the empty viewport below the last row → document.activeElement is the grid view; the view's containsFocus reads true through Neo.worker.App.getConfigs — the red-first arm;
    • click a cell → same (control: the programmatic row-click path, green before and after);
    • drag 160 px vertically inside the long viewport → the View scrolled AND selection is empty (positive control for the retained drag + the CSS rule);
    • click the outside button → containsFocus back to false (the leave path is unaffected).

Contract Ledger Matrix

Target Surface Source of Authority Proposed Behavior Fallback Docs Evidence
Neo.main.addon.GridDragScroll#onDragStart (src/main/addon/GridDragScroll.mjs:210) this ticket a left mousedown over a registered grid body keeps its default action; the drag still arms through the monitor phase n/a — behavior removal JSDoc on the handler component witness arms 1–3
.neo-grid-body.neo-mouse-drag-scroll { user-select: none } (resources/scss/src/grid/Body.scss:22) #8911 (existing) unchanged; becomes the sole owner of selection suppression n/a Body.scss comment positive control (arm 3)
Neo.grid.View focus anchor (src/grid/View.mjs:36) existing unchanged; a pointer click into empty viewport now reaches it as designed n/a existing JSDoc arm 1

Decision Record impact

none — aligned with the grid View focus-anchor design already in src/grid/View.mjs; no ADR amended.

Acceptance Criteria

  • AC-1 GridDragScroll#onDragStart no longer calls preventDefault() for mousedown; the touch branch is unchanged.
  • AC-2 Red-first component witness (test/playwright/component/grid/ClickFocus.spec.mjs + fixture app): a click on the empty viewport below the last row leaves document.activeElement on the grid view with containsFocus === true — red on dev before the fix; the cell-click arm is the control and stays green on both.
  • AC-3 Positive control in the same spec: a real pointer drag inside the View still scrolls (scrollTop changes) and leaves window.getSelection().toString() empty.
  • AC-4 The existing grid/dashboard component suites and the Workstation e2e arms that touch grids stay green (npm run test-components, test/playwright/e2e/workstation/WorkstationGridRepaintNL and WorkstationDragTextSelectionNL).
  • AC-5 Handler JSDoc states the contract: the mousedown default carries focus; selection suppression is the CSS rule's.

Out of Scope

  • A rail reveal that stays open when focus leaves the document into an iframe (observed in the same consumer workspace; a different mechanism — focusout without a following focusin — and a different owner, manager.Focus / the reveal machine). Separate ticket once reproduced against an engine fixture (test/playwright/component/apps/dock-splitter hosts an iframe pane).
  • Inline cell editing in the consumer's grid — verified working on the current engine: a click on the editable tree cell selects it and Enter mounts the editor (.neo-grid-editor with a focused input); the reported "nothing happens" was the gesture — that grid maps double-click to opening the record by design, and a click alone only selects. No engine defect.
  • The pointer-events migration of the drag sensors (#17889) — GridDragScroll is a main-thread addon outside the DragDrop sensor family.

Avoided Traps

  • Focusing the view from the addon (bodyElement.focus() on mousedown): moves focus policy into a main-thread addon and fights the browser's own fixup (nearest focusable ancestor), which already does the right thing once the default survives.
  • Cancelling mousemove instead: preventDefault on mousemove does not stop a selection that a mousedown started; the CSS rule is the correct owner, and it is already there.
  • Cancelling only once the drag activates: the click (no drag) is exactly the case that needs the default, and the drag case is already covered by user-select: none — nothing is left for a late cancel to do.
  • Reading the cell-click arm as the witness: it is green before the fix because the grid focuses the View itself on a row click; only the empty-viewport arm isolates the browser default.

Related

  • #8911 / #8912 — the drag-scroll origin (mouse buttons, the neo-mouse-drag-scroll class + user-select: none rule).
  • #17874, #18014, #18015 — focus-gated dock header actions.
  • #17889 — pointer sensor migration (adjacent, not overlapping).
  • D#17832 — grid cell editing on the View focus anchor.

Live latest-open sweep: checked the latest 20 open issues at 2026-09-02T09:43Z; no equivalent found. A2A claim sweep (last 30 messages, all read-states): no overlapping [lane-claim].

Origin Session ID: 87c91db1-7fcd-4987-9932-2bf78d3dda25

Retrieval Hint: "GridDragScroll preventDefault mousedown focus anchor empty viewport header actions"