From 63ddf43550cc14cb318bef8210f4479b61456cb9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Javier=20L=C3=B3pez?= Date: Mon, 27 Apr 2026 12:46:07 +0200 Subject: [PATCH 1/5] feat: add navigationStrategy to FocusableComponent and update SpatialNavigationService for custom navigation handling --- packages/core/src/SpatialNavigation.ts | 158 +++++++++++++++---------- packages/react/src/useFocusable.ts | 8 +- 2 files changed, 105 insertions(+), 61 deletions(-) diff --git a/packages/core/src/SpatialNavigation.ts b/packages/core/src/SpatialNavigation.ts index c3d7fc7..a4d6533 100644 --- a/packages/core/src/SpatialNavigation.ts +++ b/packages/core/src/SpatialNavigation.ts @@ -37,6 +37,12 @@ export type NodeType = NodeTypeOverrides extends { node: infer N } export type Direction = 'up' | 'down' | 'left' | 'right'; +export type NavigationStrategy = ( + direction: Direction, + focusKey: string, + siblings: FocusableComponent[] +) => FocusableComponent | null; + type DistanceCalculationMethod = 'center' | 'edges' | 'corners'; type DistanceCalculationFunction = ( @@ -112,6 +118,7 @@ export interface FocusableComponent { focusBoundaryDirections?: Direction[]; autoRestoreFocus: boolean; forceFocus: boolean; + navigationStrategy?: NavigationStrategy; lastFocusedChildKey?: string; layout?: FocusableComponentLayout; layoutUpdatedAt?: number; @@ -131,6 +138,7 @@ interface FocusableComponentUpdatePayload { onFocus: (layout: FocusableComponentLayout, details: FocusDetails) => void; onBlur: (layout: FocusableComponentLayout, details: FocusDetails) => void; accessibilityLabel?: string; + navigationStrategy?: NavigationStrategy; } interface FocusableComponentRemovePayload { @@ -1133,74 +1141,100 @@ export class SpatialNavigationService { .map((component) => this.updateLayout(component.focusKey)) ); - const siblings = filter(this.focusableComponents, (component) => { - if ( - component.parentFocusKey === parentFocusKey && - component.focusKey !== currentComponent.focusKey && - component.focusable && - component.layout - ) { - const siblingCutoffCoordinate = - SpatialNavigationService.getCutoffCoordinate( - isVerticalDirection, - isIncrementalDirection, - true, - component.layout, - this.writingDirection - ); - - return isVerticalDirection - ? isIncrementalDirection - ? siblingCutoffCoordinate >= currentCutoffCoordinate // vertical next - : siblingCutoffCoordinate <= currentCutoffCoordinate // vertical previous - : this.writingDirection === WritingDirection.LTR - ? isIncrementalDirection - ? siblingCutoffCoordinate >= currentCutoffCoordinate // horizontal LTR next - : siblingCutoffCoordinate <= currentCutoffCoordinate // horizontal LTR previous - : isIncrementalDirection - ? siblingCutoffCoordinate <= currentCutoffCoordinate // horizontal RTL next - : siblingCutoffCoordinate >= currentCutoffCoordinate; // horizontal RTL previous - } + let nextComponent: FocusableComponent | null = null; - return false; - }); + const { navigationStrategy } = + this.focusableComponents[parentFocusKey] ?? {}; - if (this.debug) { - this.log( - 'smartNavigate', - 'currentCutoffCoordinate', - currentCutoffCoordinate + if (navigationStrategy) { + const siblings = filter( + this.focusableComponents, + (component) => + component.parentFocusKey === parentFocusKey && component.focusable ); - this.log( - 'smartNavigate', - 'siblings', - `${siblings.length} elements:`, - siblings.map((sibling) => sibling.focusKey).join(', '), - siblings.map((sibling) => sibling.node), - siblings.map((sibling) => sibling) + nextComponent = navigationStrategy( + direction as Direction, + focusKey, + siblings ); - } - if (this.visualDebugger) { - const refCorners = SpatialNavigationService.getRefCorners( + if (this.debug) { + this.log( + 'smartNavigate', + 'navigation overrided by navigationStrategy', + nextComponent + ); + } + } else { + const siblings = filter(this.focusableComponents, (component) => { + if ( + component.parentFocusKey === parentFocusKey && + component.focusKey !== currentComponent.focusKey && + component.focusable && + component.layout + ) { + const siblingCutoffCoordinate = + SpatialNavigationService.getCutoffCoordinate( + isVerticalDirection, + isIncrementalDirection, + true, + component.layout, + this.writingDirection + ); + + return isVerticalDirection + ? isIncrementalDirection + ? siblingCutoffCoordinate >= currentCutoffCoordinate // vertical next + : siblingCutoffCoordinate <= currentCutoffCoordinate // vertical previous + : this.writingDirection === WritingDirection.LTR + ? isIncrementalDirection + ? siblingCutoffCoordinate >= currentCutoffCoordinate // horizontal LTR next + : siblingCutoffCoordinate <= currentCutoffCoordinate // horizontal LTR previous + : isIncrementalDirection + ? siblingCutoffCoordinate <= currentCutoffCoordinate // horizontal RTL next + : siblingCutoffCoordinate >= currentCutoffCoordinate; // horizontal RTL previous + } + + return false; + }); + + if (this.debug) { + this.log( + 'smartNavigate', + 'currentCutoffCoordinate', + currentCutoffCoordinate + ); + this.log( + 'smartNavigate', + 'siblings', + `${siblings.length} elements:`, + siblings.map((sibling) => sibling.focusKey).join(', '), + siblings.map((sibling) => sibling.node), + siblings.map((sibling) => sibling) + ); + } + + if (this.visualDebugger) { + const refCorners = SpatialNavigationService.getRefCorners( + direction, + false, + layout + ); + + this.visualDebugger.drawPoint(refCorners.a.x, refCorners.a.y); + this.visualDebugger.drawPoint(refCorners.b.x, refCorners.b.y); + } + + const sortedSiblings = this.sortSiblingsByPriority( + siblings, + layout, direction, - false, - layout + focusKey ); - this.visualDebugger.drawPoint(refCorners.a.x, refCorners.a.y); - this.visualDebugger.drawPoint(refCorners.b.x, refCorners.b.y); + nextComponent = first(sortedSiblings); } - const sortedSiblings = this.sortSiblingsByPriority( - siblings, - layout, - direction, - focusKey - ); - - const nextComponent = first(sortedSiblings); - this.log( 'smartNavigate', 'nextComponent', @@ -1405,7 +1439,8 @@ export class SpatialNavigationService { focusable, isFocusBoundary, focusBoundaryDirections, - accessibilityLabel + accessibilityLabel, + navigationStrategy }: FocusableComponent) { this.focusableComponents[focusKey] = { focusKey, @@ -1419,6 +1454,7 @@ export class SpatialNavigationService { onBlur, onUpdateFocus, onUpdateHasFocusedChild, + navigationStrategy, saveLastFocusedChild, trackChildren, preferredChildFocusKey, @@ -1857,7 +1893,8 @@ export class SpatialNavigationService { onArrowPress, onFocus, onBlur, - accessibilityLabel + accessibilityLabel, + navigationStrategy }: FocusableComponentUpdatePayload ) { const component = this.focusableComponents[focusKey]; @@ -1873,6 +1910,7 @@ export class SpatialNavigationService { component.onFocus = onFocus; component.onBlur = onBlur; component.accessibilityLabel = accessibilityLabel; + component.navigationStrategy = navigationStrategy; // Reset layout updated at to force a layout update component.layoutUpdatedAt = 0; diff --git a/packages/react/src/useFocusable.ts b/packages/react/src/useFocusable.ts index 658c6d6..fb0692c 100644 --- a/packages/react/src/useFocusable.ts +++ b/packages/react/src/useFocusable.ts @@ -12,7 +12,8 @@ import { FocusableComponentLayout, FocusDetails, KeyPressDetails, - Direction + Direction, + NavigationStrategy } from '@noriginmedia/norigin-spatial-navigation-core'; import { useFocusContext } from './useFocusContext'; @@ -56,6 +57,7 @@ export interface UseFocusableConfig

{ focusBoundaryDirections?: Direction[]; focusKey?: string; preferredChildFocusKey?: string; + navigationStrategy?: NavigationStrategy; onEnterPress?: EnterPressHandler

; onEnterRelease?: EnterReleaseHandler

; onArrowPress?: ArrowPressHandler

; @@ -90,6 +92,7 @@ const useFocusableHook = ({ focusBoundaryDirections, focusKey: propFocusKey, preferredChildFocusKey, + navigationStrategy, onEnterPress = noop, onEnterRelease = noop, onArrowPress = () => true, @@ -167,6 +170,7 @@ const useFocusableHook = ({ node, parentFocusKey, preferredChildFocusKey, + navigationStrategy, onEnterPress: onEnterPressHandler, onEnterRelease: onEnterReleaseHandler, onArrowPress: onArrowPressHandler, @@ -202,6 +206,7 @@ const useFocusableHook = ({ focusable, isFocusBoundary, focusBoundaryDirections, + navigationStrategy, onEnterPress: onEnterPressHandler, onEnterRelease: onEnterReleaseHandler, onArrowPress: onArrowPressHandler, @@ -216,6 +221,7 @@ const useFocusableHook = ({ focusable, isFocusBoundary, focusBoundaryDirections, + navigationStrategy, onEnterPressHandler, onEnterReleaseHandler, onArrowPressHandler, From 5e775893ccc6078bb87e9bd9eab52c6b1dd6979c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Javier=20L=C3=B3pez?= Date: Tue, 28 Apr 2026 08:35:03 +0200 Subject: [PATCH 2/5] refactor: rename navigationStrategy to nextFocusResolver --- packages/core/src/SpatialNavigation.ts | 22 +++++++++++----------- packages/react/src/useFocusable.ts | 12 ++++++------ 2 files changed, 17 insertions(+), 17 deletions(-) diff --git a/packages/core/src/SpatialNavigation.ts b/packages/core/src/SpatialNavigation.ts index a4d6533..9cc765f 100644 --- a/packages/core/src/SpatialNavigation.ts +++ b/packages/core/src/SpatialNavigation.ts @@ -37,7 +37,7 @@ export type NodeType = NodeTypeOverrides extends { node: infer N } export type Direction = 'up' | 'down' | 'left' | 'right'; -export type NavigationStrategy = ( +export type NextFocusResolver = ( direction: Direction, focusKey: string, siblings: FocusableComponent[] @@ -118,7 +118,7 @@ export interface FocusableComponent { focusBoundaryDirections?: Direction[]; autoRestoreFocus: boolean; forceFocus: boolean; - navigationStrategy?: NavigationStrategy; + nextFocusResolver?: NextFocusResolver; lastFocusedChildKey?: string; layout?: FocusableComponentLayout; layoutUpdatedAt?: number; @@ -138,7 +138,7 @@ interface FocusableComponentUpdatePayload { onFocus: (layout: FocusableComponentLayout, details: FocusDetails) => void; onBlur: (layout: FocusableComponentLayout, details: FocusDetails) => void; accessibilityLabel?: string; - navigationStrategy?: NavigationStrategy; + nextFocusResolver?: NextFocusResolver; } interface FocusableComponentRemovePayload { @@ -1143,16 +1143,16 @@ export class SpatialNavigationService { let nextComponent: FocusableComponent | null = null; - const { navigationStrategy } = + const { nextFocusResolver } = this.focusableComponents[parentFocusKey] ?? {}; - if (navigationStrategy) { + if (nextFocusResolver) { const siblings = filter( this.focusableComponents, (component) => component.parentFocusKey === parentFocusKey && component.focusable ); - nextComponent = navigationStrategy( + nextComponent = nextFocusResolver( direction as Direction, focusKey, siblings @@ -1161,7 +1161,7 @@ export class SpatialNavigationService { if (this.debug) { this.log( 'smartNavigate', - 'navigation overrided by navigationStrategy', + 'navigation overrided by nextFocusResolver', nextComponent ); } @@ -1440,7 +1440,7 @@ export class SpatialNavigationService { isFocusBoundary, focusBoundaryDirections, accessibilityLabel, - navigationStrategy + nextFocusResolver }: FocusableComponent) { this.focusableComponents[focusKey] = { focusKey, @@ -1454,7 +1454,7 @@ export class SpatialNavigationService { onBlur, onUpdateFocus, onUpdateHasFocusedChild, - navigationStrategy, + nextFocusResolver, saveLastFocusedChild, trackChildren, preferredChildFocusKey, @@ -1894,7 +1894,7 @@ export class SpatialNavigationService { onFocus, onBlur, accessibilityLabel, - navigationStrategy + nextFocusResolver }: FocusableComponentUpdatePayload ) { const component = this.focusableComponents[focusKey]; @@ -1910,7 +1910,7 @@ export class SpatialNavigationService { component.onFocus = onFocus; component.onBlur = onBlur; component.accessibilityLabel = accessibilityLabel; - component.navigationStrategy = navigationStrategy; + component.nextFocusResolver = nextFocusResolver; // Reset layout updated at to force a layout update component.layoutUpdatedAt = 0; diff --git a/packages/react/src/useFocusable.ts b/packages/react/src/useFocusable.ts index fb0692c..14e5c3a 100644 --- a/packages/react/src/useFocusable.ts +++ b/packages/react/src/useFocusable.ts @@ -13,7 +13,7 @@ import { FocusDetails, KeyPressDetails, Direction, - NavigationStrategy + NextFocusResolver } from '@noriginmedia/norigin-spatial-navigation-core'; import { useFocusContext } from './useFocusContext'; @@ -57,7 +57,7 @@ export interface UseFocusableConfig

{ focusBoundaryDirections?: Direction[]; focusKey?: string; preferredChildFocusKey?: string; - navigationStrategy?: NavigationStrategy; + nextFocusResolver?: NextFocusResolver; onEnterPress?: EnterPressHandler

; onEnterRelease?: EnterReleaseHandler

; onArrowPress?: ArrowPressHandler

; @@ -92,7 +92,7 @@ const useFocusableHook = ({ focusBoundaryDirections, focusKey: propFocusKey, preferredChildFocusKey, - navigationStrategy, + nextFocusResolver, onEnterPress = noop, onEnterRelease = noop, onArrowPress = () => true, @@ -170,7 +170,7 @@ const useFocusableHook = ({ node, parentFocusKey, preferredChildFocusKey, - navigationStrategy, + nextFocusResolver, onEnterPress: onEnterPressHandler, onEnterRelease: onEnterReleaseHandler, onArrowPress: onArrowPressHandler, @@ -206,7 +206,7 @@ const useFocusableHook = ({ focusable, isFocusBoundary, focusBoundaryDirections, - navigationStrategy, + nextFocusResolver, onEnterPress: onEnterPressHandler, onEnterRelease: onEnterReleaseHandler, onArrowPress: onArrowPressHandler, @@ -221,7 +221,7 @@ const useFocusableHook = ({ focusable, isFocusBoundary, focusBoundaryDirections, - navigationStrategy, + nextFocusResolver, onEnterPressHandler, onEnterReleaseHandler, onArrowPressHandler, From fa74201c1ca1d5ec2446b8d150ffa7a77ccaa68d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Javier=20L=C3=B3pez?= Date: Tue, 28 Apr 2026 08:48:33 +0200 Subject: [PATCH 3/5] Create hot-falcons-begin.md --- .changeset/hot-falcons-begin.md | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 .changeset/hot-falcons-begin.md diff --git a/.changeset/hot-falcons-begin.md b/.changeset/hot-falcons-begin.md new file mode 100644 index 0000000..cc33cd0 --- /dev/null +++ b/.changeset/hot-falcons-begin.md @@ -0,0 +1,6 @@ +--- +"@noriginmedia/norigin-spatial-navigation-core": patch +"@noriginmedia/norigin-spatial-navigation-react": patch +--- + +- Add `nextFocusResolver` to override default behavior From 0b234a1c66aff6a821ce0fa7bb02684962575fec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Javier=20L=C3=B3pez?= Date: Tue, 28 Apr 2026 08:56:55 +0200 Subject: [PATCH 4/5] fix: add warning for invalid component in nextFocusResolver to prevent lost focus --- packages/core/src/SpatialNavigation.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/packages/core/src/SpatialNavigation.ts b/packages/core/src/SpatialNavigation.ts index 9cc765f..5e593d3 100644 --- a/packages/core/src/SpatialNavigation.ts +++ b/packages/core/src/SpatialNavigation.ts @@ -1159,6 +1159,12 @@ export class SpatialNavigationService { ); if (this.debug) { + if (nextComponent != null && !siblings.includes(nextComponent)) { + console.warn( + `nextFocusResolver returned an invalid component. This will result in lost focus. Check the "nextFocusResolver" implementation in component with focusKey: ${parentFocusKey}` + ); + } + this.log( 'smartNavigate', 'navigation overrided by nextFocusResolver', From c6423e5bd3f1155e657504bf82a89413a04029fc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Javier=20L=C3=B3pez?= Date: Tue, 28 Apr 2026 10:05:21 +0200 Subject: [PATCH 5/5] Update packages/core/src/SpatialNavigation.ts Co-authored-by: Dmitriy Bryokhin <2877657+asgvard@users.noreply.github.com> --- packages/core/src/SpatialNavigation.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/core/src/SpatialNavigation.ts b/packages/core/src/SpatialNavigation.ts index 5e593d3..7ee5fc0 100644 --- a/packages/core/src/SpatialNavigation.ts +++ b/packages/core/src/SpatialNavigation.ts @@ -1167,7 +1167,7 @@ export class SpatialNavigationService { this.log( 'smartNavigate', - 'navigation overrided by nextFocusResolver', + 'navigation overridden by nextFocusResolver', nextComponent ); }