Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 36 additions & 1 deletion src/SpatialNavigation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,11 @@ interface FocusableComponent {
saveLastFocusedChild: boolean;
trackChildren: boolean;
preferredChildFocusKey?: string;
/**
* Directions for which children should be focused based on spatial proximity
* rather than preferredChildFocusKey or lastFocusedChild
*/
closestChildFocusDirections?: Direction[];
focusable: boolean;
isFocusBoundary: boolean;
focusBoundaryDirections?: Direction[];
Expand All @@ -101,6 +106,7 @@ interface FocusableComponent {
interface FocusableComponentUpdatePayload {
node: HTMLElement;
preferredChildFocusKey?: string;
closestChildFocusDirections?: Direction[];
focusable: boolean;
isFocusBoundary: boolean;
focusBoundaryDirections?: Direction[];
Expand Down Expand Up @@ -1048,15 +1054,40 @@ class SpatialNavigationService {
this.writingDirection
);

/**
* Find parent containers that have closestChildFocusDirections for this direction.
* Their children will participate in sibling navigation with the current component's siblings,
* enabling spatial navigation across different parent containers.
*/
const looseChildrenParents = Object.values(this.focusableComponents).reduce((set: Set<string>, c: FocusableComponent) => {
if (c.closestChildFocusDirections?.includes(direction as Direction)) {
set.add(c.focusKey);
}
return set;
}, new Set<string>());

/**
* Get only the siblings with the coords on the way of our moving direction
*/
const siblings = filter(this.focusableComponents, (component) => {
if (
component.parentFocusKey === parentFocusKey &&
(component.parentFocusKey === parentFocusKey || looseChildrenParents.has(component.parentFocusKey)) &&
component.focusable
) {
this.updateLayout(component.focusKey);
/**
* Exclude parent containers with closestChildFocusDirections from being siblings themselves.
* We want to navigate to their children, not to the container.
*/
if (component.closestChildFocusDirections?.includes(direction as Direction)) {
this.log(
'smartNavigate',
'excluding container with closestChildFocusDirections',
component.focusKey,
`(direction: ${direction})`
);
return false;
}
const siblingCutoffCoordinate =
SpatialNavigationService.getCutoffCoordinate(
isVerticalDirection,
Expand Down Expand Up @@ -1315,6 +1346,7 @@ class SpatialNavigationService {
onUpdateFocus,
onUpdateHasFocusedChild,
preferredChildFocusKey,
closestChildFocusDirections,
autoRestoreFocus,
forceFocus,
focusable,
Expand All @@ -1336,6 +1368,7 @@ class SpatialNavigationService {
saveLastFocusedChild,
trackChildren,
preferredChildFocusKey,
closestChildFocusDirections,
focusable,
isFocusBoundary,
focusBoundaryDirections,
Expand Down Expand Up @@ -1706,6 +1739,7 @@ class SpatialNavigationService {
{
node,
preferredChildFocusKey,
closestChildFocusDirections,
focusable,
isFocusBoundary,
focusBoundaryDirections,
Expand All @@ -1724,6 +1758,7 @@ class SpatialNavigationService {

if (component) {
component.preferredChildFocusKey = preferredChildFocusKey;
component.closestChildFocusDirections = closestChildFocusDirections;
component.focusable = focusable;
component.isFocusBoundary = isFocusBoundary;
component.focusBoundaryDirections = focusBoundaryDirections;
Expand Down
287 changes: 287 additions & 0 deletions src/__tests__/SpatialNavigation.closestChild.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,287 @@
import {
SpatialNavigation,
destroy,
init
} from '../SpatialNavigation';
import {
createGridLayoutWithClosestChild,
createOffsetRowsLayout,
createMixedRowsLayout
} from './domNodes';

describe('SpatialNavigation with closestChildFocusDirections', () => {
beforeEach(() => {
window.innerWidth = 1920;
window.innerHeight = 1280;
init();
});

afterEach(() => {
destroy();
});

describe('Basic spatial navigation between rows', () => {
it('should navigate down to closest child in next row (column 1)', () => {
createGridLayoutWithClosestChild();

SpatialNavigation.setFocus('child-1-1');

expect(SpatialNavigation.getCurrentFocusKey()).toBe('child-1-1');

SpatialNavigation.navigateByDirection('down', {});

expect(SpatialNavigation.getCurrentFocusKey()).toBe('child-2-1');
});

it('should navigate down to closest child in next row (column 2)', () => {
createGridLayoutWithClosestChild();

SpatialNavigation.setFocus('child-1-2');

expect(SpatialNavigation.getCurrentFocusKey()).toBe('child-1-2');

SpatialNavigation.navigateByDirection('down', {});

expect(SpatialNavigation.getCurrentFocusKey()).toBe('child-2-2');
});

it('should navigate down to closest child in next row (column 3)', () => {
createGridLayoutWithClosestChild();

SpatialNavigation.setFocus('child-1-3');

expect(SpatialNavigation.getCurrentFocusKey()).toBe('child-1-3');

SpatialNavigation.navigateByDirection('down', {});

expect(SpatialNavigation.getCurrentFocusKey()).toBe('child-2-3');
});

it('should navigate up to closest child in previous row', () => {
createGridLayoutWithClosestChild();

SpatialNavigation.setFocus('child-2-2');

expect(SpatialNavigation.getCurrentFocusKey()).toBe('child-2-2');

SpatialNavigation.navigateByDirection('up', {});

expect(SpatialNavigation.getCurrentFocusKey()).toBe('child-1-2');
});

it('should navigate down from row 2 to row 3 spatially', () => {
createGridLayoutWithClosestChild();

SpatialNavigation.setFocus('child-2-1');

expect(SpatialNavigation.getCurrentFocusKey()).toBe('child-2-1');

SpatialNavigation.navigateByDirection('down', {});

expect(SpatialNavigation.getCurrentFocusKey()).toBe('child-3-1');
});

it('should navigate up from row 3 to row 2 spatially', () => {
createGridLayoutWithClosestChild();

SpatialNavigation.setFocus('child-3-3');

expect(SpatialNavigation.getCurrentFocusKey()).toBe('child-3-3');

SpatialNavigation.navigateByDirection('up', {});

expect(SpatialNavigation.getCurrentFocusKey()).toBe('child-2-3');
});
});

describe('Horizontal navigation within rows', () => {
it('should allow horizontal navigation within row 1', () => {
createGridLayoutWithClosestChild();

SpatialNavigation.setFocus('child-1-1');

expect(SpatialNavigation.getCurrentFocusKey()).toBe('child-1-1');

SpatialNavigation.navigateByDirection('right', {});

expect(SpatialNavigation.getCurrentFocusKey()).toBe('child-1-2');

SpatialNavigation.navigateByDirection('right', {});

expect(SpatialNavigation.getCurrentFocusKey()).toBe('child-1-3');
});

it('should allow horizontal navigation within row 2', () => {
createGridLayoutWithClosestChild();

SpatialNavigation.setFocus('child-2-1');

expect(SpatialNavigation.getCurrentFocusKey()).toBe('child-2-1');

SpatialNavigation.navigateByDirection('right', {});

expect(SpatialNavigation.getCurrentFocusKey()).toBe('child-2-2');

SpatialNavigation.navigateByDirection('left', {});

expect(SpatialNavigation.getCurrentFocusKey()).toBe('child-2-1');
});
});

describe('Partial overlap / Offset rows', () => {
it('should navigate down from child-1-1 to spatially closest child-2-1', () => {
createOffsetRowsLayout();

SpatialNavigation.setFocus('child-1-1');

expect(SpatialNavigation.getCurrentFocusKey()).toBe('child-1-1');

SpatialNavigation.navigateByDirection('down', {});

expect(SpatialNavigation.getCurrentFocusKey()).toBe('child-2-1');
});

it('should navigate down from child-1-2 to spatially closest child in row 2', () => {
createOffsetRowsLayout();

SpatialNavigation.setFocus('child-1-2');

expect(SpatialNavigation.getCurrentFocusKey()).toBe('child-1-2');

SpatialNavigation.navigateByDirection('down', {});

// child-1-2 is at 600-1000, child-2-1 is at 350-750, child-2-2 is at 850-1250
// Both are equidistant, so spatial algorithm picks first one found (child-2-1)
expect(SpatialNavigation.getCurrentFocusKey()).toBe('child-2-1');
});

it('should navigate down from child-1-3 to spatially closest child-2-2', () => {
createOffsetRowsLayout();

SpatialNavigation.setFocus('child-1-3');

expect(SpatialNavigation.getCurrentFocusKey()).toBe('child-1-3');

SpatialNavigation.navigateByDirection('down', {});

expect(SpatialNavigation.getCurrentFocusKey()).toBe('child-2-2');
});
});

describe('Interaction with preferredChildFocusKey', () => {
it('should use preferredChildFocusKey when closestChildFocusDirections is NOT set', () => {
createMixedRowsLayout();

// Row 1 has preferredChildFocusKey but NO closestChildFocusDirections
// So when we navigate into row-1, it should use preferredChildFocusKey
SpatialNavigation.setFocus('row-1');

// Should focus child-1-1 because of preferredChildFocusKey
expect(SpatialNavigation.getCurrentFocusKey()).toBe('child-1-1');
});

it('should use spatial navigation when closestChildFocusDirections IS set for that direction', () => {
createMixedRowsLayout();

// Row 2 has closestChildFocusDirections: ['up'], so navigating up should use spatial
SpatialNavigation.setFocus('child-1-2');

expect(SpatialNavigation.getCurrentFocusKey()).toBe('child-1-2');

SpatialNavigation.navigateByDirection('down', {});

// Should use spatial (closest to child-1-2 is child-2-2)
expect(SpatialNavigation.getCurrentFocusKey()).toBe('child-2-2');
});

it('should use preferredChildFocusKey when direction is NOT in closestChildFocusDirections', () => {
createMixedRowsLayout();

SpatialNavigation.setFocus('child-2-2');

expect(SpatialNavigation.getCurrentFocusKey()).toBe('child-2-2');

// Manually trigger navigation to the row (simulating left navigation that goes to parent)
SpatialNavigation.navigateByDirection('left', {});

// Row 2 has closestChildFocusDirections: ['up'] only, not ['left']
// So it should use preferredChildFocusKey
expect(SpatialNavigation.getCurrentFocusKey()).toBe('child-2-1');
});
});

describe('Container exclusion', () => {
it('should not focus on row containers themselves', () => {
createGridLayoutWithClosestChild();

SpatialNavigation.setFocus('child-1-1');

expect(SpatialNavigation.getCurrentFocusKey()).toBe('child-1-1');

SpatialNavigation.navigateByDirection('down', {});

// Should skip row-2 container and focus child-2-1
expect(SpatialNavigation.getCurrentFocusKey()).toBe('child-2-1');
expect(SpatialNavigation.getCurrentFocusKey()).not.toBe('row-2');
});
});

describe('Navigation boundaries', () => {
it('should stop at row boundaries when navigating up from first row', () => {
createGridLayoutWithClosestChild();

SpatialNavigation.setFocus('child-1-1');

expect(SpatialNavigation.getCurrentFocusKey()).toBe('child-1-1');

SpatialNavigation.navigateByDirection('up', {});

// Should stay in child-1-1 (no navigation beyond first row)
expect(SpatialNavigation.getCurrentFocusKey()).toBe('child-1-1');
});

it('should stop at row boundaries when navigating down from last row', () => {
createGridLayoutWithClosestChild();

SpatialNavigation.setFocus('child-3-1');

expect(SpatialNavigation.getCurrentFocusKey()).toBe('child-3-1');

SpatialNavigation.navigateByDirection('down', {});

// Should stay in child-3-1 (no navigation beyond last row)
expect(SpatialNavigation.getCurrentFocusKey()).toBe('child-3-1');
});

it('should stop at horizontal boundaries within rows', () => {
createGridLayoutWithClosestChild();

SpatialNavigation.setFocus('child-1-3');

expect(SpatialNavigation.getCurrentFocusKey()).toBe('child-1-3');

SpatialNavigation.navigateByDirection('right', {});

// Should stay in child-1-3 (rightmost item)
expect(SpatialNavigation.getCurrentFocusKey()).toBe('child-1-3');
});
});

describe('Multiple directions configuration', () => {
it('should respect multiple directions in closestChildFocusDirections', () => {
createGridLayoutWithClosestChild();

// Row 2 has closestChildFocusDirections: ['up', 'down']

// Test 'up' direction
SpatialNavigation.setFocus('child-2-2');
SpatialNavigation.navigateByDirection('up', {});
expect(SpatialNavigation.getCurrentFocusKey()).toBe('child-1-2');

// Test 'down' direction
SpatialNavigation.setFocus('child-2-2');
SpatialNavigation.navigateByDirection('down', {});
expect(SpatialNavigation.getCurrentFocusKey()).toBe('child-3-2');
});
});
});
Loading