diff --git a/src/SpatialNavigation.ts b/src/SpatialNavigation.ts index 535342b..5f38704 100644 --- a/src/SpatialNavigation.ts +++ b/src/SpatialNavigation.ts @@ -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[]; @@ -101,6 +106,7 @@ interface FocusableComponent { interface FocusableComponentUpdatePayload { node: HTMLElement; preferredChildFocusKey?: string; + closestChildFocusDirections?: Direction[]; focusable: boolean; isFocusBoundary: boolean; focusBoundaryDirections?: Direction[]; @@ -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, c: FocusableComponent) => { + if (c.closestChildFocusDirections?.includes(direction as Direction)) { + set.add(c.focusKey); + } + return set; + }, new Set()); + /** * 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, @@ -1315,6 +1346,7 @@ class SpatialNavigationService { onUpdateFocus, onUpdateHasFocusedChild, preferredChildFocusKey, + closestChildFocusDirections, autoRestoreFocus, forceFocus, focusable, @@ -1336,6 +1368,7 @@ class SpatialNavigationService { saveLastFocusedChild, trackChildren, preferredChildFocusKey, + closestChildFocusDirections, focusable, isFocusBoundary, focusBoundaryDirections, @@ -1706,6 +1739,7 @@ class SpatialNavigationService { { node, preferredChildFocusKey, + closestChildFocusDirections, focusable, isFocusBoundary, focusBoundaryDirections, @@ -1724,6 +1758,7 @@ class SpatialNavigationService { if (component) { component.preferredChildFocusKey = preferredChildFocusKey; + component.closestChildFocusDirections = closestChildFocusDirections; component.focusable = focusable; component.isFocusBoundary = isFocusBoundary; component.focusBoundaryDirections = focusBoundaryDirections; diff --git a/src/__tests__/SpatialNavigation.closestChild.test.ts b/src/__tests__/SpatialNavigation.closestChild.test.ts new file mode 100644 index 0000000..670c8d7 --- /dev/null +++ b/src/__tests__/SpatialNavigation.closestChild.test.ts @@ -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'); + }); + }); +}); diff --git a/src/__tests__/domNodes.ts b/src/__tests__/domNodes.ts index 32c854b..ab07340 100644 --- a/src/__tests__/domNodes.ts +++ b/src/__tests__/domNodes.ts @@ -249,3 +249,1053 @@ export const createVerticalLayout = () => { onUpdateHasFocusedChild: () => {} }); }; + +/** + * Creates a grid layout with 3 rows and 3 columns for testing closestChildFocusDirections + * Row 1: child-1-1, child-1-2, child-1-3 (closestChildFocusDirections: ['up'] - allow up navigation into it) + * Row 2: child-2-1, child-2-2, child-2-3 (closestChildFocusDirections: ['up', 'down'] - allow both directions) + * Row 3: child-3-1, child-3-2, child-3-3 (closestChildFocusDirections: ['down'] - allow down navigation into it) + */ +export const createGridLayoutWithClosestChild = () => { + createRootNode(); + + // Row 1 container + SpatialNavigation.addFocusable({ + focusKey: 'row-1', + node: { + offsetLeft: 100, + offsetTop: 100, + offsetWidth: 1400, + offsetHeight: 200, + parentElement: { + offsetLeft: 0, + offsetTop: 0, + offsetWidth: 1920, + offsetHeight: 1280 + } as HTMLElement, + offsetParent: { + offsetLeft: 0, + offsetTop: 0, + scrollLeft: 0, + scrollTop: 0, + offsetWidth: 1920, + offsetHeight: 1280, + nodeType: Node.ELEMENT_NODE + } as HTMLElement + } as unknown as HTMLElement, + isFocusBoundary: false, + parentFocusKey: ROOT_FOCUS_KEY, + focusable: false, + trackChildren: false, + forceFocus: false, + autoRestoreFocus: true, + saveLastFocusedChild: false, + closestChildFocusDirections: ['up'], + onEnterPress: () => {}, + onEnterRelease: () => {}, + onFocus: () => {}, + onBlur: () => {}, + onArrowPress: () => true, + onArrowRelease: () => {}, + onUpdateFocus: () => {}, + onUpdateHasFocusedChild: () => {} + }); + + // Row 1 children + SpatialNavigation.addFocusable({ + focusKey: 'child-1-1', + node: { + offsetLeft: 100, + offsetTop: 100, + offsetWidth: 400, + offsetHeight: 200, + parentElement: { + offsetLeft: 0, + offsetTop: 0, + offsetWidth: 1920, + offsetHeight: 1280 + } as HTMLElement, + offsetParent: { + offsetLeft: 0, + offsetTop: 0, + scrollLeft: 0, + scrollTop: 0, + offsetWidth: 1920, + offsetHeight: 1280, + nodeType: Node.ELEMENT_NODE + } as HTMLElement + } as unknown as HTMLElement, + isFocusBoundary: false, + parentFocusKey: 'row-1', + focusable: true, + trackChildren: false, + forceFocus: false, + autoRestoreFocus: true, + saveLastFocusedChild: false, + onEnterPress: () => {}, + onEnterRelease: () => {}, + onFocus: () => {}, + onBlur: () => {}, + onArrowPress: () => true, + onArrowRelease: () => {}, + onUpdateFocus: () => {}, + onUpdateHasFocusedChild: () => {} + }); + + SpatialNavigation.addFocusable({ + focusKey: 'child-1-2', + node: { + offsetLeft: 600, + offsetTop: 100, + offsetWidth: 400, + offsetHeight: 200, + parentElement: { + offsetLeft: 0, + offsetTop: 0, + offsetWidth: 1920, + offsetHeight: 1280 + } as HTMLElement, + offsetParent: { + offsetLeft: 0, + offsetTop: 0, + scrollLeft: 0, + scrollTop: 0, + offsetWidth: 1920, + offsetHeight: 1280, + nodeType: Node.ELEMENT_NODE + } as HTMLElement + } as unknown as HTMLElement, + isFocusBoundary: false, + parentFocusKey: 'row-1', + focusable: true, + trackChildren: false, + forceFocus: false, + autoRestoreFocus: true, + saveLastFocusedChild: false, + onEnterPress: () => {}, + onEnterRelease: () => {}, + onFocus: () => {}, + onBlur: () => {}, + onArrowPress: () => true, + onArrowRelease: () => {}, + onUpdateFocus: () => {}, + onUpdateHasFocusedChild: () => {} + }); + + SpatialNavigation.addFocusable({ + focusKey: 'child-1-3', + node: { + offsetLeft: 1100, + offsetTop: 100, + offsetWidth: 400, + offsetHeight: 200, + parentElement: { + offsetLeft: 0, + offsetTop: 0, + offsetWidth: 1920, + offsetHeight: 1280 + } as HTMLElement, + offsetParent: { + offsetLeft: 0, + offsetTop: 0, + scrollLeft: 0, + scrollTop: 0, + offsetWidth: 1920, + offsetHeight: 1280, + nodeType: Node.ELEMENT_NODE + } as HTMLElement + } as unknown as HTMLElement, + isFocusBoundary: false, + parentFocusKey: 'row-1', + focusable: true, + trackChildren: false, + forceFocus: false, + autoRestoreFocus: true, + saveLastFocusedChild: false, + onEnterPress: () => {}, + onEnterRelease: () => {}, + onFocus: () => {}, + onBlur: () => {}, + onArrowPress: () => true, + onArrowRelease: () => {}, + onUpdateFocus: () => {}, + onUpdateHasFocusedChild: () => {} + }); + + // Row 2 container + SpatialNavigation.addFocusable({ + focusKey: 'row-2', + node: { + offsetLeft: 100, + offsetTop: 400, + offsetWidth: 1400, + offsetHeight: 200, + parentElement: { + offsetLeft: 0, + offsetTop: 0, + offsetWidth: 1920, + offsetHeight: 1280 + } as HTMLElement, + offsetParent: { + offsetLeft: 0, + offsetTop: 0, + scrollLeft: 0, + scrollTop: 0, + offsetWidth: 1920, + offsetHeight: 1280, + nodeType: Node.ELEMENT_NODE + } as HTMLElement + } as unknown as HTMLElement, + isFocusBoundary: false, + parentFocusKey: ROOT_FOCUS_KEY, + focusable: false, + trackChildren: false, + forceFocus: false, + autoRestoreFocus: true, + saveLastFocusedChild: false, + closestChildFocusDirections: ['up', 'down'], + onEnterPress: () => {}, + onEnterRelease: () => {}, + onFocus: () => {}, + onBlur: () => {}, + onArrowPress: () => true, + onArrowRelease: () => {}, + onUpdateFocus: () => {}, + onUpdateHasFocusedChild: () => {} + }); + + // Row 2 children + SpatialNavigation.addFocusable({ + focusKey: 'child-2-1', + node: { + offsetLeft: 100, + offsetTop: 400, + offsetWidth: 400, + offsetHeight: 200, + parentElement: { + offsetLeft: 0, + offsetTop: 0, + offsetWidth: 1920, + offsetHeight: 1280 + } as HTMLElement, + offsetParent: { + offsetLeft: 0, + offsetTop: 0, + scrollLeft: 0, + scrollTop: 0, + offsetWidth: 1920, + offsetHeight: 1280, + nodeType: Node.ELEMENT_NODE + } as HTMLElement + } as unknown as HTMLElement, + isFocusBoundary: false, + parentFocusKey: 'row-2', + focusable: true, + trackChildren: false, + forceFocus: false, + autoRestoreFocus: true, + saveLastFocusedChild: false, + onEnterPress: () => {}, + onEnterRelease: () => {}, + onFocus: () => {}, + onBlur: () => {}, + onArrowPress: () => true, + onArrowRelease: () => {}, + onUpdateFocus: () => {}, + onUpdateHasFocusedChild: () => {} + }); + + SpatialNavigation.addFocusable({ + focusKey: 'child-2-2', + node: { + offsetLeft: 600, + offsetTop: 400, + offsetWidth: 400, + offsetHeight: 200, + parentElement: { + offsetLeft: 0, + offsetTop: 0, + offsetWidth: 1920, + offsetHeight: 1280 + } as HTMLElement, + offsetParent: { + offsetLeft: 0, + offsetTop: 0, + scrollLeft: 0, + scrollTop: 0, + offsetWidth: 1920, + offsetHeight: 1280, + nodeType: Node.ELEMENT_NODE + } as HTMLElement + } as unknown as HTMLElement, + isFocusBoundary: false, + parentFocusKey: 'row-2', + focusable: true, + trackChildren: false, + forceFocus: false, + autoRestoreFocus: true, + saveLastFocusedChild: false, + onEnterPress: () => {}, + onEnterRelease: () => {}, + onFocus: () => {}, + onBlur: () => {}, + onArrowPress: () => true, + onArrowRelease: () => {}, + onUpdateFocus: () => {}, + onUpdateHasFocusedChild: () => {} + }); + + SpatialNavigation.addFocusable({ + focusKey: 'child-2-3', + node: { + offsetLeft: 1100, + offsetTop: 400, + offsetWidth: 400, + offsetHeight: 200, + parentElement: { + offsetLeft: 0, + offsetTop: 0, + offsetWidth: 1920, + offsetHeight: 1280 + } as HTMLElement, + offsetParent: { + offsetLeft: 0, + offsetTop: 0, + scrollLeft: 0, + scrollTop: 0, + offsetWidth: 1920, + offsetHeight: 1280, + nodeType: Node.ELEMENT_NODE + } as HTMLElement + } as unknown as HTMLElement, + isFocusBoundary: false, + parentFocusKey: 'row-2', + focusable: true, + trackChildren: false, + forceFocus: false, + autoRestoreFocus: true, + saveLastFocusedChild: false, + onEnterPress: () => {}, + onEnterRelease: () => {}, + onFocus: () => {}, + onBlur: () => {}, + onArrowPress: () => true, + onArrowRelease: () => {}, + onUpdateFocus: () => {}, + onUpdateHasFocusedChild: () => {} + }); + + // Row 3 container + SpatialNavigation.addFocusable({ + focusKey: 'row-3', + node: { + offsetLeft: 100, + offsetTop: 700, + offsetWidth: 1400, + offsetHeight: 200, + parentElement: { + offsetLeft: 0, + offsetTop: 0, + offsetWidth: 1920, + offsetHeight: 1280 + } as HTMLElement, + offsetParent: { + offsetLeft: 0, + offsetTop: 0, + scrollLeft: 0, + scrollTop: 0, + offsetWidth: 1920, + offsetHeight: 1280, + nodeType: Node.ELEMENT_NODE + } as HTMLElement + } as unknown as HTMLElement, + isFocusBoundary: false, + parentFocusKey: ROOT_FOCUS_KEY, + focusable: false, + trackChildren: false, + forceFocus: false, + autoRestoreFocus: true, + saveLastFocusedChild: false, + closestChildFocusDirections: ['down'], + onEnterPress: () => {}, + onEnterRelease: () => {}, + onFocus: () => {}, + onBlur: () => {}, + onArrowPress: () => true, + onArrowRelease: () => {}, + onUpdateFocus: () => {}, + onUpdateHasFocusedChild: () => {} + }); + + // Row 3 children + SpatialNavigation.addFocusable({ + focusKey: 'child-3-1', + node: { + offsetLeft: 100, + offsetTop: 700, + offsetWidth: 400, + offsetHeight: 200, + parentElement: { + offsetLeft: 0, + offsetTop: 0, + offsetWidth: 1920, + offsetHeight: 1280 + } as HTMLElement, + offsetParent: { + offsetLeft: 0, + offsetTop: 0, + scrollLeft: 0, + scrollTop: 0, + offsetWidth: 1920, + offsetHeight: 1280, + nodeType: Node.ELEMENT_NODE + } as HTMLElement + } as unknown as HTMLElement, + isFocusBoundary: false, + parentFocusKey: 'row-3', + focusable: true, + trackChildren: false, + forceFocus: false, + autoRestoreFocus: true, + saveLastFocusedChild: false, + onEnterPress: () => {}, + onEnterRelease: () => {}, + onFocus: () => {}, + onBlur: () => {}, + onArrowPress: () => true, + onArrowRelease: () => {}, + onUpdateFocus: () => {}, + onUpdateHasFocusedChild: () => {} + }); + + SpatialNavigation.addFocusable({ + focusKey: 'child-3-2', + node: { + offsetLeft: 600, + offsetTop: 700, + offsetWidth: 400, + offsetHeight: 200, + parentElement: { + offsetLeft: 0, + offsetTop: 0, + offsetWidth: 1920, + offsetHeight: 1280 + } as HTMLElement, + offsetParent: { + offsetLeft: 0, + offsetTop: 0, + scrollLeft: 0, + scrollTop: 0, + offsetWidth: 1920, + offsetHeight: 1280, + nodeType: Node.ELEMENT_NODE + } as HTMLElement + } as unknown as HTMLElement, + isFocusBoundary: false, + parentFocusKey: 'row-3', + focusable: true, + trackChildren: false, + forceFocus: false, + autoRestoreFocus: true, + saveLastFocusedChild: false, + onEnterPress: () => {}, + onEnterRelease: () => {}, + onFocus: () => {}, + onBlur: () => {}, + onArrowPress: () => true, + onArrowRelease: () => {}, + onUpdateFocus: () => {}, + onUpdateHasFocusedChild: () => {} + }); + + SpatialNavigation.addFocusable({ + focusKey: 'child-3-3', + node: { + offsetLeft: 1100, + offsetTop: 700, + offsetWidth: 400, + offsetHeight: 200, + parentElement: { + offsetLeft: 0, + offsetTop: 0, + offsetWidth: 1920, + offsetHeight: 1280 + } as HTMLElement, + offsetParent: { + offsetLeft: 0, + offsetTop: 0, + scrollLeft: 0, + scrollTop: 0, + offsetWidth: 1920, + offsetHeight: 1280, + nodeType: Node.ELEMENT_NODE + } as HTMLElement + } as unknown as HTMLElement, + isFocusBoundary: false, + parentFocusKey: 'row-3', + focusable: true, + trackChildren: false, + forceFocus: false, + autoRestoreFocus: true, + saveLastFocusedChild: false, + onEnterPress: () => {}, + onEnterRelease: () => {}, + onFocus: () => {}, + onBlur: () => {}, + onArrowPress: () => true, + onArrowRelease: () => {}, + onUpdateFocus: () => {}, + onUpdateHasFocusedChild: () => {} + }); +}; + +/** + * Creates offset rows for testing partial overlap + * Row 1: child-1-1 (100, 100), child-1-2 (600, 100), child-1-3 (1100, 100) + * Row 2: child-2-1 (350, 400), child-2-2 (850, 400) (offset by 250px) + */ +export const createOffsetRowsLayout = () => { + createRootNode(); + + // Row 1 container + SpatialNavigation.addFocusable({ + focusKey: 'row-1', + node: { + offsetLeft: 100, + offsetTop: 100, + offsetWidth: 1400, + offsetHeight: 200, + parentElement: { + offsetLeft: 0, + offsetTop: 0, + offsetWidth: 1920, + offsetHeight: 1280 + } as HTMLElement, + offsetParent: { + offsetLeft: 0, + offsetTop: 0, + scrollLeft: 0, + scrollTop: 0, + offsetWidth: 1920, + offsetHeight: 1280, + nodeType: Node.ELEMENT_NODE + } as HTMLElement + } as unknown as HTMLElement, + isFocusBoundary: false, + parentFocusKey: ROOT_FOCUS_KEY, + focusable: false, + trackChildren: false, + forceFocus: false, + autoRestoreFocus: true, + saveLastFocusedChild: false, + closestChildFocusDirections: ['up'], + onEnterPress: () => {}, + onEnterRelease: () => {}, + onFocus: () => {}, + onBlur: () => {}, + onArrowPress: () => true, + onArrowRelease: () => {}, + onUpdateFocus: () => {}, + onUpdateHasFocusedChild: () => {} + }); + + // Row 1 children + SpatialNavigation.addFocusable({ + focusKey: 'child-1-1', + node: { + offsetLeft: 100, + offsetTop: 100, + offsetWidth: 400, + offsetHeight: 200, + parentElement: { + offsetLeft: 0, + offsetTop: 0, + offsetWidth: 1920, + offsetHeight: 1280 + } as HTMLElement, + offsetParent: { + offsetLeft: 0, + offsetTop: 0, + scrollLeft: 0, + scrollTop: 0, + offsetWidth: 1920, + offsetHeight: 1280, + nodeType: Node.ELEMENT_NODE + } as HTMLElement + } as unknown as HTMLElement, + isFocusBoundary: false, + parentFocusKey: 'row-1', + focusable: true, + trackChildren: false, + forceFocus: false, + autoRestoreFocus: true, + saveLastFocusedChild: false, + onEnterPress: () => {}, + onEnterRelease: () => {}, + onFocus: () => {}, + onBlur: () => {}, + onArrowPress: () => true, + onArrowRelease: () => {}, + onUpdateFocus: () => {}, + onUpdateHasFocusedChild: () => {} + }); + + SpatialNavigation.addFocusable({ + focusKey: 'child-1-2', + node: { + offsetLeft: 600, + offsetTop: 100, + offsetWidth: 400, + offsetHeight: 200, + parentElement: { + offsetLeft: 0, + offsetTop: 0, + offsetWidth: 1920, + offsetHeight: 1280 + } as HTMLElement, + offsetParent: { + offsetLeft: 0, + offsetTop: 0, + scrollLeft: 0, + scrollTop: 0, + offsetWidth: 1920, + offsetHeight: 1280, + nodeType: Node.ELEMENT_NODE + } as HTMLElement + } as unknown as HTMLElement, + isFocusBoundary: false, + parentFocusKey: 'row-1', + focusable: true, + trackChildren: false, + forceFocus: false, + autoRestoreFocus: true, + saveLastFocusedChild: false, + onEnterPress: () => {}, + onEnterRelease: () => {}, + onFocus: () => {}, + onBlur: () => {}, + onArrowPress: () => true, + onArrowRelease: () => {}, + onUpdateFocus: () => {}, + onUpdateHasFocusedChild: () => {} + }); + + SpatialNavigation.addFocusable({ + focusKey: 'child-1-3', + node: { + offsetLeft: 1100, + offsetTop: 100, + offsetWidth: 400, + offsetHeight: 200, + parentElement: { + offsetLeft: 0, + offsetTop: 0, + offsetWidth: 1920, + offsetHeight: 1280 + } as HTMLElement, + offsetParent: { + offsetLeft: 0, + offsetTop: 0, + scrollLeft: 0, + scrollTop: 0, + offsetWidth: 1920, + offsetHeight: 1280, + nodeType: Node.ELEMENT_NODE + } as HTMLElement + } as unknown as HTMLElement, + isFocusBoundary: false, + parentFocusKey: 'row-1', + focusable: true, + trackChildren: false, + forceFocus: false, + autoRestoreFocus: true, + saveLastFocusedChild: false, + onEnterPress: () => {}, + onEnterRelease: () => {}, + onFocus: () => {}, + onBlur: () => {}, + onArrowPress: () => true, + onArrowRelease: () => {}, + onUpdateFocus: () => {}, + onUpdateHasFocusedChild: () => {} + }); + + // Row 2 container (offset) + SpatialNavigation.addFocusable({ + focusKey: 'row-2', + node: { + offsetLeft: 350, + offsetTop: 400, + offsetWidth: 900, + offsetHeight: 200, + parentElement: { + offsetLeft: 0, + offsetTop: 0, + offsetWidth: 1920, + offsetHeight: 1280 + } as HTMLElement, + offsetParent: { + offsetLeft: 0, + offsetTop: 0, + scrollLeft: 0, + scrollTop: 0, + offsetWidth: 1920, + offsetHeight: 1280, + nodeType: Node.ELEMENT_NODE + } as HTMLElement + } as unknown as HTMLElement, + isFocusBoundary: false, + parentFocusKey: ROOT_FOCUS_KEY, + focusable: false, + trackChildren: false, + forceFocus: false, + autoRestoreFocus: true, + saveLastFocusedChild: false, + closestChildFocusDirections: ['down'], + onEnterPress: () => {}, + onEnterRelease: () => {}, + onFocus: () => {}, + onBlur: () => {}, + onArrowPress: () => true, + onArrowRelease: () => {}, + onUpdateFocus: () => {}, + onUpdateHasFocusedChild: () => {} + }); + + // Row 2 children (offset) + SpatialNavigation.addFocusable({ + focusKey: 'child-2-1', + node: { + offsetLeft: 350, + offsetTop: 400, + offsetWidth: 400, + offsetHeight: 200, + parentElement: { + offsetLeft: 0, + offsetTop: 0, + offsetWidth: 1920, + offsetHeight: 1280 + } as HTMLElement, + offsetParent: { + offsetLeft: 0, + offsetTop: 0, + scrollLeft: 0, + scrollTop: 0, + offsetWidth: 1920, + offsetHeight: 1280, + nodeType: Node.ELEMENT_NODE + } as HTMLElement + } as unknown as HTMLElement, + isFocusBoundary: false, + parentFocusKey: 'row-2', + focusable: true, + trackChildren: false, + forceFocus: false, + autoRestoreFocus: true, + saveLastFocusedChild: false, + onEnterPress: () => {}, + onEnterRelease: () => {}, + onFocus: () => {}, + onBlur: () => {}, + onArrowPress: () => true, + onArrowRelease: () => {}, + onUpdateFocus: () => {}, + onUpdateHasFocusedChild: () => {} + }); + + SpatialNavigation.addFocusable({ + focusKey: 'child-2-2', + node: { + offsetLeft: 850, + offsetTop: 400, + offsetWidth: 400, + offsetHeight: 200, + parentElement: { + offsetLeft: 0, + offsetTop: 0, + offsetWidth: 1920, + offsetHeight: 1280 + } as HTMLElement, + offsetParent: { + offsetLeft: 0, + offsetTop: 0, + scrollLeft: 0, + scrollTop: 0, + offsetWidth: 1920, + offsetHeight: 1280, + nodeType: Node.ELEMENT_NODE + } as HTMLElement + } as unknown as HTMLElement, + isFocusBoundary: false, + parentFocusKey: 'row-2', + focusable: true, + trackChildren: false, + forceFocus: false, + autoRestoreFocus: true, + saveLastFocusedChild: false, + onEnterPress: () => {}, + onEnterRelease: () => {}, + onFocus: () => {}, + onBlur: () => {}, + onArrowPress: () => true, + onArrowRelease: () => {}, + onUpdateFocus: () => {}, + onUpdateHasFocusedChild: () => {} + }); +}; + +/** + * Creates rows with different configurations for testing mixed behavior + * Row 1: preferredChildFocusKey set, NO closestChildFocusDirections + * Row 2: closestChildFocusDirections set + */ +export const createMixedRowsLayout = () => { + createRootNode(); + + // Row 1 container (traditional behavior with preferredChildFocusKey) + SpatialNavigation.addFocusable({ + focusKey: 'row-1', + node: { + offsetLeft: 100, + offsetTop: 100, + offsetWidth: 900, + offsetHeight: 200, + parentElement: { + offsetLeft: 0, + offsetTop: 0, + offsetWidth: 1920, + offsetHeight: 1280 + } as HTMLElement, + offsetParent: { + offsetLeft: 0, + offsetTop: 0, + scrollLeft: 0, + scrollTop: 0, + offsetWidth: 1920, + offsetHeight: 1280, + nodeType: Node.ELEMENT_NODE + } as HTMLElement + } as unknown as HTMLElement, + isFocusBoundary: false, + parentFocusKey: ROOT_FOCUS_KEY, + focusable: false, + trackChildren: false, + forceFocus: false, + autoRestoreFocus: true, + saveLastFocusedChild: false, + preferredChildFocusKey: 'child-1-1', + onEnterPress: () => {}, + onEnterRelease: () => {}, + onFocus: () => {}, + onBlur: () => {}, + onArrowPress: () => true, + onArrowRelease: () => {}, + onUpdateFocus: () => {}, + onUpdateHasFocusedChild: () => {} + }); + + // Row 1 children + SpatialNavigation.addFocusable({ + focusKey: 'child-1-1', + node: { + offsetLeft: 100, + offsetTop: 100, + offsetWidth: 400, + offsetHeight: 200, + parentElement: { + offsetLeft: 0, + offsetTop: 0, + offsetWidth: 1920, + offsetHeight: 1280 + } as HTMLElement, + offsetParent: { + offsetLeft: 0, + offsetTop: 0, + scrollLeft: 0, + scrollTop: 0, + offsetWidth: 1920, + offsetHeight: 1280, + nodeType: Node.ELEMENT_NODE + } as HTMLElement + } as unknown as HTMLElement, + isFocusBoundary: false, + parentFocusKey: 'row-1', + focusable: true, + trackChildren: false, + forceFocus: false, + autoRestoreFocus: true, + saveLastFocusedChild: false, + onEnterPress: () => {}, + onEnterRelease: () => {}, + onFocus: () => {}, + onBlur: () => {}, + onArrowPress: () => true, + onArrowRelease: () => {}, + onUpdateFocus: () => {}, + onUpdateHasFocusedChild: () => {} + }); + + SpatialNavigation.addFocusable({ + focusKey: 'child-1-2', + node: { + offsetLeft: 600, + offsetTop: 100, + offsetWidth: 400, + offsetHeight: 200, + parentElement: { + offsetLeft: 0, + offsetTop: 0, + offsetWidth: 1920, + offsetHeight: 1280 + } as HTMLElement, + offsetParent: { + offsetLeft: 0, + offsetTop: 0, + scrollLeft: 0, + scrollTop: 0, + offsetWidth: 1920, + offsetHeight: 1280, + nodeType: Node.ELEMENT_NODE + } as HTMLElement + } as unknown as HTMLElement, + isFocusBoundary: false, + parentFocusKey: 'row-1', + focusable: true, + trackChildren: false, + forceFocus: false, + autoRestoreFocus: true, + saveLastFocusedChild: false, + onEnterPress: () => {}, + onEnterRelease: () => {}, + onFocus: () => {}, + onBlur: () => {}, + onArrowPress: () => true, + onArrowRelease: () => {}, + onUpdateFocus: () => {}, + onUpdateHasFocusedChild: () => {} + }); + + // Row 2 container (with closestChildFocusDirections and preferredChildFocusKey) + SpatialNavigation.addFocusable({ + focusKey: 'row-2', + node: { + offsetLeft: 100, + offsetTop: 400, + offsetWidth: 900, + offsetHeight: 200, + parentElement: { + offsetLeft: 0, + offsetTop: 0, + offsetWidth: 1920, + offsetHeight: 1280 + } as HTMLElement, + offsetParent: { + offsetLeft: 0, + offsetTop: 0, + scrollLeft: 0, + scrollTop: 0, + offsetWidth: 1920, + offsetHeight: 1280, + nodeType: Node.ELEMENT_NODE + } as HTMLElement + } as unknown as HTMLElement, + isFocusBoundary: false, + parentFocusKey: ROOT_FOCUS_KEY, + focusable: false, + trackChildren: false, + forceFocus: false, + autoRestoreFocus: true, + saveLastFocusedChild: false, + preferredChildFocusKey: 'child-2-1', + closestChildFocusDirections: ['down'], + onEnterPress: () => {}, + onEnterRelease: () => {}, + onFocus: () => {}, + onBlur: () => {}, + onArrowPress: () => true, + onArrowRelease: () => {}, + onUpdateFocus: () => {}, + onUpdateHasFocusedChild: () => {} + }); + + // Row 2 children + SpatialNavigation.addFocusable({ + focusKey: 'child-2-1', + node: { + offsetLeft: 100, + offsetTop: 400, + offsetWidth: 400, + offsetHeight: 200, + parentElement: { + offsetLeft: 0, + offsetTop: 0, + offsetWidth: 1920, + offsetHeight: 1280 + } as HTMLElement, + offsetParent: { + offsetLeft: 0, + offsetTop: 0, + scrollLeft: 0, + scrollTop: 0, + offsetWidth: 1920, + offsetHeight: 1280, + nodeType: Node.ELEMENT_NODE + } as HTMLElement + } as unknown as HTMLElement, + isFocusBoundary: false, + parentFocusKey: 'row-2', + focusable: true, + trackChildren: false, + forceFocus: false, + autoRestoreFocus: true, + saveLastFocusedChild: false, + onEnterPress: () => {}, + onEnterRelease: () => {}, + onFocus: () => {}, + onBlur: () => {}, + onArrowPress: () => true, + onArrowRelease: () => {}, + onUpdateFocus: () => {}, + onUpdateHasFocusedChild: () => {} + }); + + SpatialNavigation.addFocusable({ + focusKey: 'child-2-2', + node: { + offsetLeft: 600, + offsetTop: 400, + offsetWidth: 400, + offsetHeight: 200, + parentElement: { + offsetLeft: 0, + offsetTop: 0, + offsetWidth: 1920, + offsetHeight: 1280 + } as HTMLElement, + offsetParent: { + offsetLeft: 0, + offsetTop: 0, + scrollLeft: 0, + scrollTop: 0, + offsetWidth: 1920, + offsetHeight: 1280, + nodeType: Node.ELEMENT_NODE + } as HTMLElement + } as unknown as HTMLElement, + isFocusBoundary: false, + parentFocusKey: 'row-2', + focusable: true, + trackChildren: false, + forceFocus: false, + autoRestoreFocus: true, + saveLastFocusedChild: false, + onEnterPress: () => {}, + onEnterRelease: () => {}, + onFocus: () => {}, + onBlur: () => {}, + onArrowPress: () => true, + onArrowRelease: () => {}, + onUpdateFocus: () => {}, + onUpdateHasFocusedChild: () => {} + }); +}; diff --git a/src/useFocusable.ts b/src/useFocusable.ts index a3549fc..70e291b 100644 --- a/src/useFocusable.ts +++ b/src/useFocusable.ts @@ -57,6 +57,13 @@ export interface UseFocusableConfig

{ focusBoundaryDirections?: Direction[]; focusKey?: string; preferredChildFocusKey?: string; + /** + * Directions for which children should be focused based on spatial proximity. + * When navigating in these directions, the spatially closest child will be focused + * instead of using preferredChildFocusKey or lastFocusedChild. + * Useful for grid layouts where you want natural spatial navigation between rows/columns. + */ + closestChildFocusDirections?: Direction[]; onEnterPress?: EnterPressHandler

; onEnterRelease?: EnterReleaseHandler

; onArrowPress?: ArrowPressHandler

; @@ -84,6 +91,7 @@ const useFocusableHook =

({ focusBoundaryDirections, focusKey: propFocusKey, preferredChildFocusKey, + closestChildFocusDirections, onEnterPress = noop, onEnterRelease = noop, onArrowPress = () => true, @@ -157,6 +165,7 @@ const useFocusableHook =

({ node, parentFocusKey, preferredChildFocusKey, + closestChildFocusDirections, onEnterPress: onEnterPressHandler, onEnterRelease: onEnterReleaseHandler, onArrowPress: onArrowPressHandler, @@ -188,6 +197,7 @@ const useFocusableHook =

({ SpatialNavigation.updateFocusable(focusKey, { node, preferredChildFocusKey, + closestChildFocusDirections, focusable, isFocusBoundary, focusBoundaryDirections, @@ -201,6 +211,7 @@ const useFocusableHook =

({ }, [ focusKey, preferredChildFocusKey, + closestChildFocusDirections, focusable, isFocusBoundary, focusBoundaryDirections,