diff --git a/.changeset/measure-children-layout.md b/.changeset/measure-children-layout.md new file mode 100644 index 0000000..b0290a2 --- /dev/null +++ b/.changeset/measure-children-layout.md @@ -0,0 +1,7 @@ +--- +'@noriginmedia/norigin-spatial-navigation-core': minor +'@noriginmedia/norigin-spatial-navigation-react': minor +'@noriginmedia/norigin-spatial-navigation': minor +--- + +Add `measureChildrenLayout` option to `useFocusable` (default `true`) to control whether a container's direct children are measured during navigation and `updateAllLayouts`. Set it to `false` on containers driven by their own `nextFocusResolver` to skip layout measurement that the resolver doesn't need. diff --git a/docs/api-reference/useFocusable.md b/docs/api-reference/useFocusable.md index 8a850c1..8f05bc6 100644 --- a/docs/api-reference/useFocusable.md +++ b/docs/api-reference/useFocusable.md @@ -22,25 +22,27 @@ The generic parameter `P` is the type of `extraProps`. The parameter `E` is the All options are optional. -| Option | Type | Default | Description | -| ------------------------- | ------------------------ | -------------- | -------------------------------------------------------------------------------------------------------------------------------------- | -| `focusable` | `boolean` | `true` | Whether this component can receive focus. Set to `false` to temporarily disable a component without unmounting it. | -| `saveLastFocusedChild` | `boolean` | `true` | When focus returns to this container, restore focus to the last focused child instead of the first. | -| `trackChildren` | `boolean` | `false` | Update `hasFocusedChild` when any descendant gains or loses focus. Must be `true` to use `hasFocusedChild` for styling. | -| `autoRestoreFocus` | `boolean` | `true` | If this component is focused when it unmounts, automatically restore focus to the nearest other component. | -| `forceFocus` | `boolean` | `false` | Mark this component as the preferred fallback target when focus is lost and no other candidate exists. | -| `isFocusBoundary` | `boolean` | `false` | Prevent focus from leaving this container in any direction. See [Focus Boundaries](../guides/focus-boundaries.md). | -| `focusBoundaryDirections` | `Direction[]` | `undefined` | Limit boundary behavior to specific directions only (e.g., `['up', 'left']`). Only used when `isFocusBoundary` is `true`. | -| `focusKey` | `string` | auto-generated | A stable, unique identifier for this component. Required for programmatic focus via `setFocus`. | -| `preferredChildFocusKey` | `string` | `undefined` | Focus key of the child that should receive focus when this container is first entered. | -| `onEnterPress` | `EnterPressHandler

` | no-op | Called when the Enter key is pressed while this component is focused. | -| `onEnterRelease` | `EnterReleaseHandler

` | no-op | Called when the Enter key is released. | -| `onArrowPress` | `ArrowPressHandler

` | `() => true` | Called when an arrow key is pressed. Return `true` to allow default navigation, `false` to prevent it. | -| `onArrowRelease` | `ArrowReleaseHandler

` | no-op | Called when an arrow key is released. | -| `onFocus` | `FocusHandler

` | no-op | Called when this component gains focus. | -| `onBlur` | `BlurHandler

` | no-op | Called when this component loses focus. | -| `extraProps` | `P` | `undefined` | Arbitrary data passed as the first argument to all event callbacks. Use this to avoid closure stale-state issues. | -| `accessibilityLabel` | `string` | `undefined` | Text uttered by the global `onUtterText` callback when this component is focused. See [accessibilityLabel](#accessibilitylabel) below. | +| Option | Type | Default | Description | +| ------------------------- | ------------------------ | -------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `focusable` | `boolean` | `true` | Whether this component can receive focus. Set to `false` to temporarily disable a component without unmounting it. | +| `saveLastFocusedChild` | `boolean` | `true` | When focus returns to this container, restore focus to the last focused child instead of the first. | +| `trackChildren` | `boolean` | `false` | Update `hasFocusedChild` when any descendant gains or loses focus. Must be `true` to use `hasFocusedChild` for styling. | +| `autoRestoreFocus` | `boolean` | `true` | If this component is focused when it unmounts, automatically restore focus to the nearest other component. | +| `forceFocus` | `boolean` | `false` | Mark this component as the preferred fallback target when focus is lost and no other candidate exists. | +| `isFocusBoundary` | `boolean` | `false` | Prevent focus from leaving this container in any direction. See [Focus Boundaries](../guides/focus-boundaries.md). | +| `focusBoundaryDirections` | `Direction[]` | `undefined` | Limit boundary behavior to specific directions only (e.g., `['up', 'left']`). Only used when `isFocusBoundary` is `true`. | +| `focusKey` | `string` | auto-generated | A stable, unique identifier for this component. Required for programmatic focus via `setFocus`. | +| `preferredChildFocusKey` | `string` | `undefined` | Focus key of the child that should receive focus when this container is first entered. | +| `nextFocusResolver` | `NextFocusResolver` | `undefined` | Override default coordinate-based navigation for this container's direct children. See [Next Focus Resolver](../guides/next-focus-resolver.md). | +| `measureChildrenLayout` | `boolean` | `true` | When `false`, this container's direct children are not measured during navigation or `updateAllLayouts`. See [Next Focus Resolver](../guides/next-focus-resolver.md#measurechildrenlayout). | +| `onEnterPress` | `EnterPressHandler

` | no-op | Called when the Enter key is pressed while this component is focused. | +| `onEnterRelease` | `EnterReleaseHandler

` | no-op | Called when the Enter key is released. | +| `onArrowPress` | `ArrowPressHandler

` | `() => true` | Called when an arrow key is pressed. Return `true` to allow default navigation, `false` to prevent it. | +| `onArrowRelease` | `ArrowReleaseHandler

` | no-op | Called when an arrow key is released. | +| `onFocus` | `FocusHandler

` | no-op | Called when this component gains focus. | +| `onBlur` | `BlurHandler

` | no-op | Called when this component loses focus. | +| `extraProps` | `P` | `undefined` | Arbitrary data passed as the first argument to all event callbacks. Use this to avoid closure stale-state issues. | +| `accessibilityLabel` | `string` | `undefined` | Text uttered by the global `onUtterText` callback when this component is focused. See [accessibilityLabel](#accessibilitylabel) below. | --- @@ -292,5 +294,5 @@ With the tree above, landing focus on `Inception` utters `"Movies, Inception"`. ## Notes - The `ref` must be attached to a DOM element that has non-zero width and height when the component mounts. If the element is zero-sized, the library cannot measure its position and navigation to/from it will not work correctly. -- Config options other than `focusKey`, `focusable`, `isFocusBoundary`, `focusBoundaryDirections`, `preferredChildFocusKey`, and `accessibilityLabel` are **not** reactive after mount. Callbacks are updated via a separate effect, but structural options like `saveLastFocusedChild`, `trackChildren`, `autoRestoreFocus`, and `forceFocus` are only read at registration time. +- Config options other than `focusKey`, `focusable`, `isFocusBoundary`, `focusBoundaryDirections`, `preferredChildFocusKey`, `nextFocusResolver`, `measureChildrenLayout`, and `accessibilityLabel` are **not** reactive after mount. Callbacks are updated via a separate effect, but structural options like `saveLastFocusedChild`, `trackChildren`, `autoRestoreFocus`, and `forceFocus` are only read at registration time. - Use `extraProps` to pass data to callbacks instead of relying on closure variables. This avoids stale closure issues with callbacks that reference component props. diff --git a/docs/guides/accessibility-labels.md b/docs/guides/accessibility-labels.md index a43971e..3d342fe 100644 --- a/docs/guides/accessibility-labels.md +++ b/docs/guides/accessibility-labels.md @@ -1,5 +1,5 @@ --- -sidebar_position: 14 +sidebar_position: 16 --- # Accessibility Labels diff --git a/docs/guides/debugging.md b/docs/guides/debugging.md index 6d0182a..cd02585 100644 --- a/docs/guides/debugging.md +++ b/docs/guides/debugging.md @@ -1,5 +1,5 @@ --- -sidebar_position: 12 +sidebar_position: 13 --- # Debugging diff --git a/docs/guides/distance-calculation.md b/docs/guides/distance-calculation.md index e131d34..d22706e 100644 --- a/docs/guides/distance-calculation.md +++ b/docs/guides/distance-calculation.md @@ -156,3 +156,12 @@ init({ ``` This makes it easy to understand why a particular element wins or loses the distance calculation. See [Debugging](./debugging.md) for more. + +--- + +## Overriding the Algorithm + +When distance-based scoring isn't the right fit for a container — for example, a carousel that +should always move by exactly one item — a `nextFocusResolver` lets that container supply its own +navigation logic entirely, bypassing this algorithm. See +[Next Focus Resolver](./next-focus-resolver.md). diff --git a/docs/guides/key-mapping.md b/docs/guides/key-mapping.md index bdf776d..7b86a15 100644 --- a/docs/guides/key-mapping.md +++ b/docs/guides/key-mapping.md @@ -1,5 +1,5 @@ --- -sidebar_position: 9 +sidebar_position: 10 --- # Key Mapping diff --git a/docs/guides/next-focus-resolver.md b/docs/guides/next-focus-resolver.md new file mode 100644 index 0000000..1e03a30 --- /dev/null +++ b/docs/guides/next-focus-resolver.md @@ -0,0 +1,108 @@ +--- +sidebar_position: 9 +--- + +# Next Focus Resolver + +[Distance Calculation](./distance-calculation.md) explains how the library picks the next +sibling by default: it scores every candidate and focuses the lowest-scoring one. `nextFocusResolver` +lets a container replace that entirely with your own logic. + +## `nextFocusResolver` + +Set on the **container**, `nextFocusResolver` is called whenever an arrow key would move focus +between its direct children. Its return value decides which child (if any) gets focused — +default coordinate-based navigation for that container is bypassed completely. + +```typescript +import { useFocusable } from '@noriginmedia/norigin-spatial-navigation-react'; + +const resolver: NextFocusResolver = (direction, currentFocusKey, siblings) => { + const currentIndex = siblings.findIndex( + (sibling) => sibling.focusKey === currentFocusKey + ); + + if (direction === 'right') { + return siblings[currentIndex + 1] ?? null; + } + + if (direction === 'left') { + return siblings[currentIndex - 1] ?? null; + } + + return null; // fall through to the parent container for up/down +}; + +function Carousel() { + const { ref, focusKey } = useFocusable({ nextFocusResolver: resolver }); + + // ... +} +``` + +### Signature + +```typescript +type NextFocusResolver = ( + direction: Direction, + currentFocusKey: string, + siblings: FocusableComponent[] +) => FocusableComponent | null; +``` + +- `direction` — the arrow key that was pressed. +- `currentFocusKey` — the focus key of the currently focused child. +- `siblings` — every **focusable** direct child of the container the resolver is set on (not + grandchildren, and not children filtered by direction or position — that filtering is now your + responsibility). + +### Return value + +- Return one of the objects from `siblings` to focus it. +- Return `null` to decline the navigation for this direction. The library then falls through to + the container's own parent, exactly as it would if no sibling qualified under default + navigation. + +Returning an object that isn't a member of the `siblings` array you were given results in lost +focus. In development (`debug: true`), the library warns to the console when this happens, +naming the container's `focusKey`. + +--- + +## `measureChildrenLayout` + +By default, every registered component's on-screen position is re-measured as needed to support +coordinate-based navigation and the [distance calculation](./distance-calculation.md) algorithm. +Once a container supplies its own `nextFocusResolver`, that measurement is often unnecessary — +your resolver already knows what the next component should be without consulting layout. + +Set `measureChildrenLayout: false` on the same container to skip measuring its **direct +children** during navigation and `updateAllLayouts()`. See +[Performance Tuning](./performance.md#skipping-layout-measurement) for the perf motivation and +exactly which measurements are skipped. + +```typescript +const { ref, focusKey } = useFocusable({ + nextFocusResolver: resolver, + measureChildrenLayout: false +}); +``` + +### The staleness caveat + +`measureChildrenLayout: false` does not clear the `layout` already stored on each child — it +leaves it as whatever was last measured. If your `nextFocusResolver` reads `sibling.layout` (for +example, to pick the visually nearest child), those coordinates will silently go stale as the app +scrolls or re-renders, since nothing is refreshing them anymore. + +Only opt out of measurement when your resolver decides purely from `direction`, `currentFocusKey`, +and the shape of `siblings` (order, count, custom data you attach elsewhere) — not from +`sibling.layout`. + +Because default navigation is coordinate-based, `measureChildrenLayout: false` is only meaningful +paired with a `nextFocusResolver` on the same container. In development (`debug: true`), the +library warns to the console if a container has the flag set, no resolver, and more than one +focusable child — that combination reliably breaks arrow-key navigation between its children. + +`measureChildrenLayout` only affects **direct** children. A nested container underneath one that +sets it to `false` still measures its own children normally unless it also opts out. diff --git a/docs/guides/performance.md b/docs/guides/performance.md index 00a4143..5893216 100644 --- a/docs/guides/performance.md +++ b/docs/guides/performance.md @@ -1,5 +1,5 @@ --- -sidebar_position: 11 +sidebar_position: 12 --- # Performance Tuning @@ -82,6 +82,33 @@ Use viewport-relative layout when: `getBoundingClientRect` is slightly slower because it triggers a layout reflow, but it accounts for CSS transforms that `offsetLeft/Top` ignores. +### Skipping Layout Measurement + +Coordinate-based navigation needs a fresh position for every candidate on each keypress, which is +the most expensive part of layout measurement — on React Native TV in particular, each measurement +is a bridge round-trip. A container driven by its own [`nextFocusResolver`](./next-focus-resolver.md) +already knows the next component without consulting coordinates, so that measurement is pure +overhead. + +Set `measureChildrenLayout: false` on such a container to skip measuring its **direct children** +in two places: + +- The per-keypress sibling measurement inside navigation. +- The bulk `updateAllLayouts()` call. + +Children are still measured once on mount, and still measured on focus/blur (so `onFocus`/`onBlur` +continue to receive accurate layout) — only the navigation-time re-measurement is skipped. + +```typescript +const { ref, focusKey } = useFocusable({ + nextFocusResolver: resolver, + measureChildrenLayout: false +}); +``` + +See [Next Focus Resolver](./next-focus-resolver.md#measurechildrenlayout) for the full contract, +including the staleness caveat for resolvers that read `sibling.layout`. + ### Async work ordering The core engine runs navigation- and layout-related async work through an internal **scheduler**, so operations are serialized instead of interleaving arbitrarily. When you need logic to run after focus or navigation completes, use **`await setFocus`** / **`await navigateByDirection`** (see [Programmatic focus](./programmatic-focus.md)). diff --git a/docs/guides/react-native-tv.md b/docs/guides/react-native-tv.md index 75f42dc..1562ae8 100644 --- a/docs/guides/react-native-tv.md +++ b/docs/guides/react-native-tv.md @@ -1,5 +1,5 @@ --- -sidebar_position: 13 +sidebar_position: 14 --- # React Native TV diff --git a/docs/guides/recipes.md b/docs/guides/recipes.md index b4f040e..3cbb827 100644 --- a/docs/guides/recipes.md +++ b/docs/guides/recipes.md @@ -1,5 +1,5 @@ --- -sidebar_position: 14 +sidebar_position: 15 --- # Recipes & Patterns diff --git a/docs/guides/rtl-support.md b/docs/guides/rtl-support.md index cacec48..a2ba6a8 100644 --- a/docs/guides/rtl-support.md +++ b/docs/guides/rtl-support.md @@ -1,5 +1,5 @@ --- -sidebar_position: 10 +sidebar_position: 11 --- # RTL Support diff --git a/packages/core/src/SpatialNavigation.ts b/packages/core/src/SpatialNavigation.ts index dded3c1..112a2be 100644 --- a/packages/core/src/SpatialNavigation.ts +++ b/packages/core/src/SpatialNavigation.ts @@ -119,6 +119,13 @@ export interface FocusableComponent { autoRestoreFocus: boolean; forceFocus: boolean; nextFocusResolver?: NextFocusResolver; + /** + * When false, direct children of this component are not measured during + * navigation (`smartNavigate`) or `updateAllLayouts`. Only meaningful + * alongside `nextFocusResolver`, since default coordinate-based navigation + * requires fresh child layouts. Defaults to true when undefined. + */ + measureChildrenLayout?: boolean; lastFocusedChildKey?: string; layout?: FocusableComponentLayout; layoutUpdatedAt?: number; @@ -139,6 +146,7 @@ interface FocusableComponentUpdatePayload { onBlur: (layout: FocusableComponentLayout, details: FocusDetails) => void; accessibilityLabel?: string; nextFocusResolver?: NextFocusResolver; + measureChildrenLayout?: boolean; } interface FocusableComponentRemovePayload { @@ -1126,8 +1134,17 @@ export class SpatialNavigationService { ); if (currentComponent) { - await this.updateLayout(currentComponent.focusKey); - const { parentFocusKey, focusKey, layout } = currentComponent; + const { parentFocusKey, focusKey } = currentComponent; + + const currentParentComponent = this.focusableComponents[parentFocusKey]; + const { nextFocusResolver, measureChildrenLayout = true } = + currentParentComponent ?? {}; + + if (measureChildrenLayout) { + await this.updateLayout(currentComponent.focusKey); + } + + const { layout } = currentComponent; const currentCutoffCoordinate = SpatialNavigationService.getCutoffCoordinate( @@ -1138,26 +1155,25 @@ export class SpatialNavigationService { this.writingDirection ); - /** - * Get only the siblings with the coords on the way of our moving direction - */ - const threshold = Date.now() - LAYOUT_STALE_TIME; - await Promise.all( - Object.values(this.focusableComponents) - .filter( - (component) => - component.parentFocusKey === parentFocusKey && - component.focusable && - component.layoutUpdatedAt <= threshold - ) - .map((component) => this.updateLayout(component.focusKey)) - ); + if (measureChildrenLayout) { + /** + * Get only the siblings with the coords on the way of our moving direction + */ + const threshold = Date.now() - LAYOUT_STALE_TIME; + await Promise.all( + Object.values(this.focusableComponents) + .filter( + (component) => + component.parentFocusKey === parentFocusKey && + component.focusable && + component.layoutUpdatedAt <= threshold + ) + .map((component) => this.updateLayout(component.focusKey)) + ); + } let nextComponent: FocusableComponent | null = null; - const { nextFocusResolver } = - this.focusableComponents[parentFocusKey] ?? {}; - if (nextFocusResolver) { const siblings = filter( this.focusableComponents, @@ -1184,6 +1200,20 @@ export class SpatialNavigationService { ); } } else { + if (this.debug && !measureChildrenLayout) { + const focusableSiblingCount = filter( + this.focusableComponents, + (component) => + component.parentFocusKey === parentFocusKey && component.focusable + ).length; + + if (focusableSiblingCount >= 2) { + console.warn( + `measureChildrenLayout is false but no nextFocusResolver is set for component with focusKey: ${parentFocusKey}. Default coordinate-based navigation requires fresh layout data and may behave incorrectly.` + ); + } + } + const siblings = filter(this.focusableComponents, (component) => { if ( component.parentFocusKey === parentFocusKey && @@ -1458,7 +1488,8 @@ export class SpatialNavigationService { isFocusBoundary, focusBoundaryDirections, accessibilityLabel, - nextFocusResolver + nextFocusResolver, + measureChildrenLayout }: FocusableComponent) { this.focusableComponents[focusKey] = { focusKey, @@ -1473,6 +1504,7 @@ export class SpatialNavigationService { onUpdateFocus, onUpdateHasFocusedChild, nextFocusResolver, + measureChildrenLayout, saveLastFocusedChild, trackChildren, preferredChildFocusKey, @@ -1889,9 +1921,13 @@ export class SpatialNavigationService { } await Promise.all( - Object.keys(this.focusableComponents).map((focusKey) => - this.updateLayout(focusKey) - ) + Object.values(this.focusableComponents) + .filter( + (component) => + this.focusableComponents[component.parentFocusKey] + ?.measureChildrenLayout !== false + ) + .map((component) => this.updateLayout(component.focusKey)) ); } @@ -1920,7 +1956,8 @@ export class SpatialNavigationService { onFocus, onBlur, accessibilityLabel, - nextFocusResolver + nextFocusResolver, + measureChildrenLayout }: FocusableComponentUpdatePayload ) { const component = this.focusableComponents[focusKey]; @@ -1937,6 +1974,7 @@ export class SpatialNavigationService { component.onBlur = onBlur; component.accessibilityLabel = accessibilityLabel; component.nextFocusResolver = nextFocusResolver; + component.measureChildrenLayout = measureChildrenLayout; // Reset layout updated at to force a layout update component.layoutUpdatedAt = 0; diff --git a/packages/core/src/__tests__/SpatialNavigation.test.ts b/packages/core/src/__tests__/SpatialNavigation.test.ts index 9c252db..9a8c26b 100644 --- a/packages/core/src/__tests__/SpatialNavigation.test.ts +++ b/packages/core/src/__tests__/SpatialNavigation.test.ts @@ -2,10 +2,13 @@ import { ROOT_FOCUS_KEY, SpatialNavigation, destroy, - init + init, + type NextFocusResolver } from '../SpatialNavigation'; +import { measureLayout } from '../measureLayout'; import { createHorizontalLayout, + createHorizontalLayoutWithResolver, createRootNode, createVerticalLayout } from './domNodes'; @@ -15,6 +18,28 @@ const settle = () => setTimeout(resolve, 0); }); +const wait = (ms: number) => + new Promise((resolve) => { + setTimeout(resolve, ms); + }); + +const createMeasureLayoutSpy = () => { + const measuredFocusKeys: string[] = []; + + const measureLayoutSpy = jest.fn( + (component: { focusKey: string; node: HTMLElement }) => { + measuredFocusKeys.push(component.focusKey); + + return Promise.resolve({ + ...measureLayout(component.node), + node: component.node + }); + } + ); + + return { measureLayoutSpy, measuredFocusKeys }; +}; + describe('SpatialNavigation', () => { beforeEach(() => { window.innerWidth = 1920; @@ -248,4 +273,55 @@ describe('SpatialNavigation', () => { expect(onUpdateFocus).not.toHaveBeenCalled(); }); }); + + describe('measureChildrenLayout', () => { + it('measures sibling layouts by default when navigating', async () => { + const { measureLayoutSpy, measuredFocusKeys } = createMeasureLayoutSpy(); + + destroy(); + init({ layoutAdapter: { measureLayout: measureLayoutSpy } }); + createHorizontalLayout(); + + SpatialNavigation.setFocus(ROOT_FOCUS_KEY); + await settle(); + // Let the initial layouts go stale so the sibling loop is forced to re-measure them + await wait(20); + measuredFocusKeys.length = 0; + + SpatialNavigation.navigateByDirection('right', {}); + await settle(); + + // The uninvolved sibling was still re-measured as part of the default coordinate-based navigation + expect(measuredFocusKeys).toContain('child-3'); + expect(SpatialNavigation.getCurrentFocusKey()).toBe('child-2'); + }); + + it('does not measure sibling layouts when false and a nextFocusResolver is set', async () => { + const { measureLayoutSpy, measuredFocusKeys } = createMeasureLayoutSpy(); + const nextFocusResolver: NextFocusResolver = ( + _direction, + _focusKey, + siblings + ) => siblings.find((sibling) => sibling.focusKey === 'child-2') ?? null; + + destroy(); + init({ layoutAdapter: { measureLayout: measureLayoutSpy } }); + createHorizontalLayoutWithResolver({ + nextFocusResolver, + measureChildrenLayout: false + }); + + SpatialNavigation.setFocus(ROOT_FOCUS_KEY); + await settle(); + await wait(20); + measuredFocusKeys.length = 0; + + SpatialNavigation.navigateByDirection('right', {}); + await settle(); + + // The uninvolved sibling was left untouched: measurement was skipped entirely + expect(measuredFocusKeys).not.toContain('child-3'); + expect(SpatialNavigation.getCurrentFocusKey()).toBe('child-2'); + }); + }); }); diff --git a/packages/core/src/__tests__/domNodes.ts b/packages/core/src/__tests__/domNodes.ts index 32c854b..ab5a289 100644 --- a/packages/core/src/__tests__/domNodes.ts +++ b/packages/core/src/__tests__/domNodes.ts @@ -1,6 +1,16 @@ -import { SpatialNavigation, ROOT_FOCUS_KEY } from '../SpatialNavigation'; +import { + SpatialNavigation, + ROOT_FOCUS_KEY, + type NextFocusResolver +} from '../SpatialNavigation'; -export const createRootNode = () => { +export const createRootNode = ({ + nextFocusResolver, + measureChildrenLayout +}: { + nextFocusResolver?: NextFocusResolver; + measureChildrenLayout?: boolean; +} = {}) => { SpatialNavigation.addFocusable({ focusKey: ROOT_FOCUS_KEY, node: { @@ -31,6 +41,8 @@ export const createRootNode = () => { forceFocus: true, autoRestoreFocus: true, saveLastFocusedChild: false, + nextFocusResolver, + measureChildrenLayout, onEnterPress: () => {}, onEnterRelease: () => {}, onFocus: () => {}, @@ -166,6 +178,136 @@ export const createHorizontalLayout = () => { }); }; +export const createHorizontalLayoutWithResolver = ({ + nextFocusResolver, + measureChildrenLayout +}: { + nextFocusResolver: NextFocusResolver; + measureChildrenLayout?: boolean; +}) => { + createRootNode({ nextFocusResolver, measureChildrenLayout }); + + SpatialNavigation.addFocusable({ + focusKey: 'child-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: ROOT_FOCUS_KEY, + focusable: true, + trackChildren: false, + forceFocus: false, + autoRestoreFocus: true, + saveLastFocusedChild: false, + onEnterPress: () => {}, + onEnterRelease: () => {}, + onFocus: () => {}, + onBlur: () => {}, + onArrowPress: () => true, + onArrowRelease: () => {}, + onUpdateFocus: () => {}, + onUpdateHasFocusedChild: () => {} + }); + + SpatialNavigation.addFocusable({ + focusKey: 'child-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: ROOT_FOCUS_KEY, + focusable: true, + trackChildren: false, + forceFocus: false, + autoRestoreFocus: true, + saveLastFocusedChild: false, + onEnterPress: () => {}, + onEnterRelease: () => {}, + onFocus: () => {}, + onBlur: () => {}, + onArrowPress: () => true, + onArrowRelease: () => {}, + onUpdateFocus: () => {}, + onUpdateHasFocusedChild: () => {} + }); + + SpatialNavigation.addFocusable({ + focusKey: 'child-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: ROOT_FOCUS_KEY, + focusable: true, + trackChildren: false, + forceFocus: false, + autoRestoreFocus: true, + saveLastFocusedChild: false, + onEnterPress: () => {}, + onEnterRelease: () => {}, + onFocus: () => {}, + onBlur: () => {}, + onArrowPress: () => true, + onArrowRelease: () => {}, + onUpdateFocus: () => {}, + onUpdateHasFocusedChild: () => {} + }); +}; + export const createVerticalLayout = () => { createRootNode(); diff --git a/packages/react/src/useFocusable.ts b/packages/react/src/useFocusable.ts index 14e5c3a..fbf8fe9 100644 --- a/packages/react/src/useFocusable.ts +++ b/packages/react/src/useFocusable.ts @@ -58,6 +58,13 @@ export interface UseFocusableConfig

{ focusKey?: string; preferredChildFocusKey?: string; nextFocusResolver?: NextFocusResolver; + /** + * When false, direct children of this component are not measured during + * navigation or `updateAllLayouts`. Only meaningful together with + * `nextFocusResolver`, since default coordinate-based navigation requires + * fresh child layouts. + */ + measureChildrenLayout?: boolean; onEnterPress?: EnterPressHandler

; onEnterRelease?: EnterReleaseHandler

; onArrowPress?: ArrowPressHandler

; @@ -93,6 +100,7 @@ const useFocusableHook = ({ focusKey: propFocusKey, preferredChildFocusKey, nextFocusResolver, + measureChildrenLayout = true, onEnterPress = noop, onEnterRelease = noop, onArrowPress = () => true, @@ -171,6 +179,7 @@ const useFocusableHook = ({ parentFocusKey, preferredChildFocusKey, nextFocusResolver, + measureChildrenLayout, onEnterPress: onEnterPressHandler, onEnterRelease: onEnterReleaseHandler, onArrowPress: onArrowPressHandler, @@ -207,6 +216,7 @@ const useFocusableHook = ({ isFocusBoundary, focusBoundaryDirections, nextFocusResolver, + measureChildrenLayout, onEnterPress: onEnterPressHandler, onEnterRelease: onEnterReleaseHandler, onArrowPress: onArrowPressHandler, @@ -222,6 +232,7 @@ const useFocusableHook = ({ isFocusBoundary, focusBoundaryDirections, nextFocusResolver, + measureChildrenLayout, onEnterPressHandler, onEnterReleaseHandler, onArrowPressHandler,