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 diff --git a/packages/core/src/SpatialNavigation.ts b/packages/core/src/SpatialNavigation.ts index c3d7fc7..7ee5fc0 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 NextFocusResolver = ( + 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; + nextFocusResolver?: NextFocusResolver; 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; + nextFocusResolver?: NextFocusResolver; } interface FocusableComponentRemovePayload { @@ -1133,74 +1141,106 @@ 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 + let nextComponent: FocusableComponent | null = null; + + const { nextFocusResolver } = + this.focusableComponents[parentFocusKey] ?? {}; + + if (nextFocusResolver) { + const siblings = filter( + this.focusableComponents, + (component) => + component.parentFocusKey === parentFocusKey && component.focusable + ); + nextComponent = nextFocusResolver( + direction as Direction, + focusKey, + siblings + ); + + 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}` ); + } - 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 + this.log( + 'smartNavigate', + 'navigation overridden by nextFocusResolver', + 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 false; - }); + 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.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( + 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 +1445,8 @@ export class SpatialNavigationService { focusable, isFocusBoundary, focusBoundaryDirections, - accessibilityLabel + accessibilityLabel, + nextFocusResolver }: FocusableComponent) { this.focusableComponents[focusKey] = { focusKey, @@ -1419,6 +1460,7 @@ export class SpatialNavigationService { onBlur, onUpdateFocus, onUpdateHasFocusedChild, + nextFocusResolver, saveLastFocusedChild, trackChildren, preferredChildFocusKey, @@ -1857,7 +1899,8 @@ export class SpatialNavigationService { onArrowPress, onFocus, onBlur, - accessibilityLabel + accessibilityLabel, + nextFocusResolver }: FocusableComponentUpdatePayload ) { const component = this.focusableComponents[focusKey]; @@ -1873,6 +1916,7 @@ export class SpatialNavigationService { component.onFocus = onFocus; component.onBlur = onBlur; component.accessibilityLabel = accessibilityLabel; + 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 658c6d6..14e5c3a 100644 --- a/packages/react/src/useFocusable.ts +++ b/packages/react/src/useFocusable.ts @@ -12,7 +12,8 @@ import { FocusableComponentLayout, FocusDetails, KeyPressDetails, - Direction + Direction, + NextFocusResolver } from '@noriginmedia/norigin-spatial-navigation-core'; import { useFocusContext } from './useFocusContext'; @@ -56,6 +57,7 @@ export interface UseFocusableConfig

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

; onEnterRelease?: EnterReleaseHandler

; onArrowPress?: ArrowPressHandler

; @@ -90,6 +92,7 @@ const useFocusableHook = ({ focusBoundaryDirections, focusKey: propFocusKey, preferredChildFocusKey, + nextFocusResolver, onEnterPress = noop, onEnterRelease = noop, onArrowPress = () => true, @@ -167,6 +170,7 @@ const useFocusableHook = ({ node, parentFocusKey, preferredChildFocusKey, + nextFocusResolver, onEnterPress: onEnterPressHandler, onEnterRelease: onEnterReleaseHandler, onArrowPress: onArrowPressHandler, @@ -202,6 +206,7 @@ const useFocusableHook = ({ focusable, isFocusBoundary, focusBoundaryDirections, + nextFocusResolver, onEnterPress: onEnterPressHandler, onEnterRelease: onEnterReleaseHandler, onArrowPress: onArrowPressHandler, @@ -216,6 +221,7 @@ const useFocusableHook = ({ focusable, isFocusBoundary, focusBoundaryDirections, + nextFocusResolver, onEnterPressHandler, onEnterReleaseHandler, onArrowPressHandler,