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
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.
- 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.
- 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
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"
Context
Neo.main.addon.GridDragScroll(drag-to-scroll for grid bodies, on by default throughNeo.grid.ScrollManager#dragScroll) listens tomousedownon every registered grid body withcapture: trueand, for a left button, callsevent.preventDefault()"to prevent text selection" (src/main/addon/GridDragScroll.mjs:253-255). The default action ofmousedownis 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):
showOnFocus) revealdocument.activeElementis unchanged; nofocusin, nofocusEnter, the middle rail stays collapsedrevealed-focusedstate dismisses onoverlayFocusLeavegrid.Body's row-click handler focuses the View programmatically, so a row click is unaffected by this defectThe 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/Spaceregistry sits on it, andEnteron the editable cell mounts the editor. The report was a gesture mismatch, see Out of Scope.)The selection concern the
preventDefaultwas written for is already owned by CSS:resources/scss/src/grid/Body.scss:22-23setsuser-select: noneon.neo-mouse-drag-scroll, the classScrollManageradds to the body for exactly this addon (#8911). Amousedowninside auser-select: nonesubtree 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:
toolbar.Base#focusSubjectId/ reactivecontainsFocus, #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.dashboard/dock/interaction/RevealStateMachine.mjs— a click-born reveal isrevealed-focusedand leaves onoverlayFocusLeave,RevealOverlay.mjs:401): clicking the empty area of a grid outside the overlay is not "moving on", the transient stays.Enter/Space(registered on the View anchor) go to whatever was focused before.Grid
Viewis designed as the single focus anchor (src/grid/View.mjs:36-41: roottabIndex: '-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 aclickand amousedown, never afocusin.Reproduction (any grid with
dragScrollon, i.e. every grid by default): focus a button outside the grid, click the grid viewport below its last row, thendocument.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});:212ignores non-left buttons;:253-255if (event.type === 'mousedown') { event.preventDefault() }before the monitor phase starts (:257-285). The drag itself only activates afterdelay(100 ms) ANDminDistance(5 px) inonMonitorMove(:292-302) — so the cancel applies to every plain click, not to drags.src/grid/ScrollManager.mjs:83addsneo-mouse-drag-scrollto 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/focusoutare the only inputs; a cancelled mousedown produces neither.src/main/DomEvents.mjs:591— the engine's own globalmousedownforwards without cancelling; the addon is the one place a gridmousedowndefault dies.The Fix
src/main/addon/GridDragScroll.mjs: remove themousedownpreventDefault()inonDragStart. Leave the touch path untouched (touchmovecancel at:182disables 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.window.getSelection().toString() === ''after a real drag-scroll across cell text, so the removal cannot regress the reason the line was written.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>), spectest/playwright/component/grid/ClickFocus.spec.mjs:document.activeElementis the grid view; the view'scontainsFocusreadstruethroughNeo.worker.App.getConfigs— the red-first arm;containsFocusback tofalse(the leave path is unaffected).Contract Ledger Matrix
Neo.main.addon.GridDragScroll#onDragStart(src/main/addon/GridDragScroll.mjs:210)mousedownover a registered grid body keeps its default action; the drag still arms through the monitor phase.neo-grid-body.neo-mouse-drag-scroll { user-select: none }(resources/scss/src/grid/Body.scss:22)Neo.grid.Viewfocus anchor (src/grid/View.mjs:36)Decision Record impact
none — aligned with the grid View focus-anchor design already in
src/grid/View.mjs; no ADR amended.Acceptance Criteria
GridDragScroll#onDragStartno longer callspreventDefault()formousedown; the touch branch is unchanged.test/playwright/component/grid/ClickFocus.spec.mjs+ fixture app): a click on the empty viewport below the last row leavesdocument.activeElementon the grid view withcontainsFocus === true— red ondevbefore the fix; the cell-click arm is the control and stays green on both.scrollTopchanges) and leaveswindow.getSelection().toString()empty.npm run test-components,test/playwright/e2e/workstation/WorkstationGridRepaintNLandWorkstationDragTextSelectionNL).Out of Scope
focusoutwithout a followingfocusin— and a different owner,manager.Focus/ the reveal machine). Separate ticket once reproduced against an engine fixture (test/playwright/component/apps/dock-splitterhosts an iframe pane).Entermounts the editor (.neo-grid-editorwith 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.GridDragScrollis a main-thread addon outside theDragDropsensor family.Avoided Traps
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.mousemoveinstead:preventDefaultonmousemovedoes not stop a selection that a mousedown started; the CSS rule is the correct owner, and it is already there.user-select: none— nothing is left for a late cancel to do.Related
neo-mouse-drag-scrollclass +user-select: nonerule).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"