Skip to content

fix(spatialHandleNavigation): don't reset selection at grid edges - #54

Merged
chiefcll merged 1 commit into
mainfrom
fix/spatial-nav-edge-selection-reset
Aug 22, 2026
Merged

fix(spatialHandleNavigation): don't reset selection at grid edges#54
chiefcll merged 1 commit into
mainfrom
fix/spatial-nav-edge-selection-reset

Conversation

@chiefcll

Copy link
Copy Markdown
Contributor

What

spatialHandleNavigation reset the container's selected to -1 whenever a cross-axis move failed at an edge, which made the next key press jump focus back to the first child.

Why

In the cross-axis branch (up/down in a row-wrapped container, left/right in a column-wrapped one), the handler called selectChild(el, closestIdx) unconditionally — including when the scan found nothing and left closestIdx === -1. selectChild treats an invalid index as "clear the selection":

if (child == null || child.skipFocus) {
  el.selected = -1;
  return false;
}

So a no-op edge move silently wiped selected. On the next press, the guard at the top of the handler saw selected === -1, treated it as "nothing selected", and fell back to findFirstFocusableChildIdx → the first child.

Reported symptoms, both reproduced: holding Down on the bottom row bounces focus to the first item, and holding Up from a first-row item does the same.

The in-flex-direction branch already returned false without touching selected, which is why only cross-axis moves were affected.

The change

Return false when no adjacent row/column exists, before selectChild can clobber the selection:

// No child in an adjacent column/row - keep the current selection so the
// next key press resumes from here instead of resetting to the first child.
if (closestIdx === -1) return false;

Returning false also lets the key bubble to a parent container, which is the desired behavior at a boundary.

Notes for reviewers

I deliberately kept the guard local to spatialHandleNavigation rather than removing the el.selected = -1 reset inside selectChild. That reset is still correct for navigableForwardFocus and spatialForwardFocus, which call it for the genuinely-no-focusable-children case.

Tests

New tests/spatialNavigation.test.tsx builds a 3×2 wrapped flex grid (0 1 2 / 3 4) and drives it through the real focus manager with dispatched keydown events — it asserts the flex layout first, then that repeated Down on the bottom row and repeated Up on the top row hold their selection and still navigate correctly afterward.

  • Confirmed the new tests fail on main (expected -1 to equal 3, expected -1 to equal 1) and pass with the fix.
  • Full suite: 174 passed / 17 files.
  • npm run tsc clean; npm run lint 0 errors (pre-existing warnings unchanged).

One fixture note if these tests get extended: spatialForwardFocus selects the child closest to the previously active element, so rendering a fresh grid per test inherits the prior grid's position. The suite renders once in beforeAll and sets focus explicitly per case.

🤖 Generated with Claude Code

When a cross-axis move (up/down in a row-wrapped container, left/right in a
column-wrapped one) found no child in an adjacent row/column, the handler
still called selectChild with closestIdx === -1. selectChild sets
el.selected = -1 for an invalid index, so the failed move wiped the current
selection. On the next key press the guard at the top of the handler saw
selected === -1, treated it as "no selection", and fell back to the first
focusable child — focus jumped to the top-left instead of staying put.

Return false when no adjacent row/column exists so the current selection
survives. The in-flex-direction branch already did this.

Adds a regression test that drives a 3x2 wrapped grid through the focus
manager with real key events.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@chiefcll
chiefcll merged commit 7a5d39f into main Aug 22, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant