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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/measure-children-layout.md
Original file line number Diff line number Diff line change
@@ -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.
42 changes: 22 additions & 20 deletions docs/api-reference/useFocusable.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<P>` | no-op | Called when the Enter key is pressed while this component is focused. |
| `onEnterRelease` | `EnterReleaseHandler<P>` | no-op | Called when the Enter key is released. |
| `onArrowPress` | `ArrowPressHandler<P>` | `() => true` | Called when an arrow key is pressed. Return `true` to allow default navigation, `false` to prevent it. |
| `onArrowRelease` | `ArrowReleaseHandler<P>` | no-op | Called when an arrow key is released. |
| `onFocus` | `FocusHandler<P>` | no-op | Called when this component gains focus. |
| `onBlur` | `BlurHandler<P>` | 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<P>` | no-op | Called when the Enter key is pressed while this component is focused. |
| `onEnterRelease` | `EnterReleaseHandler<P>` | no-op | Called when the Enter key is released. |
| `onArrowPress` | `ArrowPressHandler<P>` | `() => true` | Called when an arrow key is pressed. Return `true` to allow default navigation, `false` to prevent it. |
| `onArrowRelease` | `ArrowReleaseHandler<P>` | no-op | Called when an arrow key is released. |
| `onFocus` | `FocusHandler<P>` | no-op | Called when this component gains focus. |
| `onBlur` | `BlurHandler<P>` | 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. |

---

Expand Down Expand Up @@ -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.
2 changes: 1 addition & 1 deletion docs/guides/accessibility-labels.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
---
sidebar_position: 14
sidebar_position: 16
---

# Accessibility Labels
Expand Down
2 changes: 1 addition & 1 deletion docs/guides/debugging.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
---
sidebar_position: 12
sidebar_position: 13
---

# Debugging
Expand Down
9 changes: 9 additions & 0 deletions docs/guides/distance-calculation.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
2 changes: 1 addition & 1 deletion docs/guides/key-mapping.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
---
sidebar_position: 9
sidebar_position: 10
---

# Key Mapping
Expand Down
108 changes: 108 additions & 0 deletions docs/guides/next-focus-resolver.md
Original file line number Diff line number Diff line change
@@ -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.
29 changes: 28 additions & 1 deletion docs/guides/performance.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
---
sidebar_position: 11
sidebar_position: 12
---

# Performance Tuning
Expand Down Expand Up @@ -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)).
Expand Down
2 changes: 1 addition & 1 deletion docs/guides/react-native-tv.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
---
sidebar_position: 13
sidebar_position: 14
---

# React Native TV
Expand Down
2 changes: 1 addition & 1 deletion docs/guides/recipes.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
---
sidebar_position: 14
sidebar_position: 15
---

# Recipes & Patterns
Expand Down
2 changes: 1 addition & 1 deletion docs/guides/rtl-support.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
---
sidebar_position: 10
sidebar_position: 11
---

# RTL Support
Expand Down
Loading
Loading