Files

607 lines
27 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Navigation
Focus moves by itself, the way a browser or a TV platform moves it: mark what
is focusable, and the D-pad picks the nearest element in the pressed direction
from the elements' real positions on screen. Cross presses, Circle goes back.
Apps do not track focus indices.
```jsx
<FocusScope autoFocus>
{games.map(game => (
<View key={game.id} focusable onPress={() => open(game)}
className="rounded-xl bg-slate-800 transition focused:bg-sky-700 focused:scale-105">
<Text className="text-white">{game.title}</Text>
</View>
))}
</FocusScope>
```
## Quick start
```jsx
import {useState} from 'react';
import {AppRegistry, View, Text, FocusScope} from '@ps5-react/core';
const items = ['Play', 'Settings', 'About'];
function Menu() {
const [selected, setSelected] = useState(null);
const clear = () => {
setSelected(null);
return true;
};
return (
<FocusScope autoFocus wrap onBack={clear}>
<View className="w-screen h-screen bg-slate-950 items-center justify-center gap-8">
<View className="flex-row gap-4">
{items.map(item => (
<View key={item} onPress={() => setSelected(item)}
className="w-56 h-24 rounded-xl border-4 border-slate-700 bg-slate-800
items-center justify-center focused:border-sky-400 focused:scale-105">
<Text className="text-xl text-white">{item}</Text>
</View>
))}
</View>
<Text className="text-base text-sky-300">{selected ?? 'Nothing selected'}</Text>
</View>
</FocusScope>
);
}
AppRegistry.registerComponent('menu', () => Menu);
```
1. Give each item `onPress` (or `focusable` when it does nothing on Cross).
2. Style the focused state with `focused:` classes, `whileFocus`, or a `style`
function. The element's own focus drives them; there is no `focused` prop to pass.
3. Wrap the screen in `FocusScope autoFocus` so something has focus at start.
4. Handle Circle with `onBack`.
`npm run create` scaffolds this menu; `apps/starter`, `apps/tailwind-gallery`,
`apps/system-explorer`, and `apps/motion-lab` use the patterns below.
## Host input (input protocol v5)
The input protocol is versioned on its own; it is not the native API's ABI
version ([NATIVE-API.md](NATIVE-API.md#contract)).
`globalThis.__ps5ReactDispatch(action)` receives `up`, `down`, `left`, `right`
(the D-pad and the left stick, with the platform's hold-to-repeat), `confirm`,
`back`, and, since input protocol v3, `l1`, `r1`, `l2`, `r2`, `triangle`, `square`, and `options`
(one action per press, no repeat). For input protocol v1 compatibility the runtime also
delivers `previous` (after `up`/`left`) and `next` (after `down`/`right`) to
`useController` handlers only. Both hosts and input scripts take the names from
`native/shared/actions.hpp`.
Options (O on desktop) is an action like the buttons above; the PS button (Escape on
desktop) is how the user leaves. A media app uses Options for its settings, as the
system apps do.
Since input protocol v5, hosts also report the touchpad's first finger: `__ps5ReactTouch(x, y)`
with its position 0..1 across and down the pad each frame it touches, and `__ps5ReactTouch(null)`
once when it lifts (the PS5 reads the DualSense; the desktop, a controller's touchpad through SDL).
See [Touchpad](#touchpad).
The focus manager does not move on these buttons: they go to the enclosing
`FocusScope.onAction` handlers (tabs on L1/R1, a filter on Triangle, settings on Options)
and to `useController`.
Once per frame, after the pump and before the commit, hosts also call
`globalThis.__ps5ReactFrame(elapsedMs)` with the time the frame advances (on the
PS5 a whole number of 60 Hz vblanks). Focus scrolling steps there, so it moves on
every presented frame; a timer drifts against the display and skips or doubles
frames.
| Action | Desktop | PS5 |
| --- | --- | --- |
| `up`, `down`, `left`, `right` | Arrow keys | D-pad, left stick |
| `confirm` | Enter | Cross (X) |
| `back` | Backspace | Circle |
| `l1`, `r1` | Q, E | L1, R1 |
| `l2`, `r2` | Z, C | L2, R2 (past half travel on the desktop) |
| `triangle`, `square` | T, F | Triangle, Square |
| `options` | O | Options (Start) |
| Exit (handled by the host) | Escape | (the PS button) |
| None: `touchpad` in `Controller.getState()` and [controller events](NATIVE-API.md#controller-events) only | P | Touchpad click |
The keys also hold their buttons in `Controller.getState()`, as a controller's
would. Z and C are L2 and R2 at full travel; with Shift down they are a light
press, 0.4 of the travel, short of the half-travel press, so a key held while
Shift goes down and up eases the trigger off and back in. On the desktop the
`l2` and `r2` actions fire as the travel crosses half, from a key or a
controller, and re-arm below a quarter.
## Touchpad
A finger sliding on the touchpad moves focus as the D-pad would, one step per stretch of travel:
about a ninth of the pad's width across, a fifth of its height down. A slow swipe walks card by card
and a long one runs further; going back over the ground steps back, like a dial. A swipe settles on
an axis after a short distance and keeps to it, and a tap moves nothing. Each step reaches
`useNavigationEvents` as a `move` or `blocked` event with `source: 'touchpad'`, so an app can tick
for swipes alone:
```js
useNavigationEvents(event => {
if (event.type === 'move' && event.source === 'touchpad') Controller.vibrate(0.25, 25);
});
```
Pressing the touchpad stays the `touchpad` button of `useGamepad`.
## Focusable elements
`View`, `Image`, and `Pressable` from `@ps5-react/core` accept:
| Prop | Meaning |
| --- | --- |
| `focusable` | Takes part in D-pad navigation (`Pressable` and any element with `onPress` are focusable by default) |
| `onPress` | Called on Cross while focused |
| `onFocus`, `onBlur` | Focus changes |
| `autoFocus` | Takes focus when mounted if nothing in its scope has it |
| `focusKey` | Stable id for `focus(key)` and `nextFocus*` |
| `nextFocusUp`, `nextFocusDown`, `nextFocusLeft`, `nextFocusRight` | `focusKey` that overrides the spatial choice for that direction |
| `style` | May be a function `({focused, pressed}) => style` |
`pressed` is true for about 120 ms after Cross (for press feedback).
`Text` is not focusable; wrap it in a `View`.
```jsx
<View onPress={save}
style={({focused, pressed}) => ({
padding: 16,
backgroundColor: pressed ? '#0369a1' : focused ? '#0ea5e9' : '#1e293b',
})} />
```
## Spatial algorithm
Candidates are the focusable elements of the active scope whose rectangle lies
in the pressed direction from the focused one. The winner minimises
`primary distance + 2 × orthogonal offset` between the rectangles' nearest edges
(ties: the one with more overlap on the orthogonal axis, then document order).
Rectangles are the elements' laid-out screen rectangles (pre-transform), kept
up to date as layout and scrolling change. If nothing qualifies in the scope,
the search continues in the parent scope unless the scope traps. A `wrap`
scope first wraps within the same row or column; with nothing there, the search
continues outward as usual. When no scope has a candidate, focus stays where it
is.
An element inside a `ScrollView` that the focused element is not inside is a
candidate only while its rectangle overlaps that `ScrollView`'s viewport, at
least in part. Entering a scroll view from outside (Right from the last header
item, Down into a rail) therefore reaches only what it shows, never an item
scrolled out of view; moves inside a scroll view, including down a scrolling
page past its viewport, reach any of its elements as before. Nested scroll
views apply this per level.
Because rectangles are pre-transform, `focused:scale-105`, `whileFocus` lifts,
and animated translations never change where focus goes next. Let the layout
express the order you want, and use `nextFocus*` only for exceptions.
## `FocusScope`
| Prop | Meaning |
| --- | --- |
| `autoFocus` | Focus the scope's first (or remembered) element on mount |
| `trap` | Focus cannot leave the scope (modals, dialogs) |
| `wrap` | Moving past the last element in a direction wraps to the first in that row/column; with nothing in that row/column (Down from a tab row), focus leaves the scope as usual unless it traps |
| `row` | A horizontal row (a shelf of cards): Left and Right stop at its ends instead of moving into the rows around it (the move reports `blocked`). Entered for the first time it focuses its `selected` element, or else its first; after that, the one it had, as `restoreFocus` does |
| `restoreFocus` | Re-entering the scope focuses the element focused when it was left (default `true`) |
| `onBack` | Handles Circle while focus is inside; return `true` to consume |
| `onAction` | `(action) => boolean`: handles `l1`, `r1`, `l2`, `r2`, `triangle`, `square`, and `options` while focus is inside, innermost scope first; return `true` to consume |
| `focusKey` | Name for `focus(key)` on the scope (focuses its remembered element) |
| `inert` | Takes the scope's subtree out of navigation while true: no candidates, no `autoFocus`, no `focus(key)`; focus inside moves out after the commit |
Scopes nest. The active scope is the innermost scope containing the focused
element. Unmounting the focused element moves focus to the nearest focusable in
the same scope (or the scope's remembered/first element).
Use `inert` for a layer that stays mounted while hidden (`display: none`, or
covered by another screen). The engine stops laying out a hidden subtree, so its
elements keep their last rectangles and would otherwise remain reachable. Wrap
it in `ReleaseImages` too, so its images do not hold memory the visible screen
needs ([IMAGES.md](IMAGES.md)).
`FocusScope` renders no element of its own, so it does not affect layout.
## Hooks and modules
- `useFocusable({focusKey, onPress, autoFocus, ...})` → `{focused, pressed, focusProps}` for
custom components; spread `focusProps` on a host element.
- `useFocus()` → `{focusedKey, focus(key), blur(), focusedRect()}`. `focusedRect()` is where the
focused element is on screen, `{x, y, width, height}` in render pixels with the scrolling around
it applied (transforms are not), or `null`: to place a menu or tooltip next to it.
- `useFocusActions()` → `{focus(key), blur(), focusedRect()}`, the same functions without
`focusedKey`: it never re-renders its component.
- `useIsFocused()` inside a focusable subtree → boolean of the nearest focusable ancestor.
- `BackHandler.addEventListener('hardwareBackPress', fn)` → `{remove()}`; `fn`
returning `true` consumes Circle. Order: each enclosing `FocusScope.onBack`,
innermost first, until one returns `true`; then listeners (last added first).
Unconsumed Circle does nothing. `BackHandler.exitApp()` is unchanged.
- `useController(handler)` keeps receiving every action (raw input for games).
- `useNavigationEvents(listener)` reports what the focus manager did with each
input; see [Navigation events](#navigation-events).
`useFocus()` re-renders its component whenever the focused key changes; call it
in the component that needs `focusedKey`, not in every list item. A component that only
moves focus (an app root, a page) takes `useFocusActions()` instead: with `useFocus()`
every D-pad move would re-render it and everything it renders. `focus(key)`
returns whether something took focus, so it can be returned from `onBack`.
## Navigation events
`useNavigationEvents(listener)` calls `listener` after the focus manager has
handled each input, for feedback such as sounds ([NATIVE-API.md](NATIVE-API.md#sound)).
It never re-renders its component, so call it once near the root.
| Event | When |
| --- | --- |
| `{type: 'move', direction, source?}` | Focus moved (spatially, through `nextFocus*`, or onto the first element when nothing had focus); `source` is `'touchpad'` for a step of a swipe |
| `{type: 'blocked', direction, source?}` | Nothing to move to: an edge, a trap, a row's end, or no focusable element |
| `{type: 'press', handled}` | Cross on the focused element; `handled` when it is enabled and has `onPress` (no event without focus) |
| `{type: 'back', handled}` | Circle; `handled` when an `onBack` or `BackHandler` listener consumed it |
| `{type: 'action', action, handled}` | L1, R1, L2, R2, Triangle, Square or Options; `handled` when an `onAction` returned `true` |
Held directions repeat, so `move` arrives on every repeat. `FocusManager.move`
and `press` report the same outcomes (`true`/`false`, `null` for a press
without focus) to code that drives the manager directly.
## ScrollView
A `ScrollView` scrolls its focused descendant into view (minimal movement with a
margin) through the native scroll offset, stepped once per presented frame in whole
pixels: it speeds up over four frames to at most 40 logical px per frame and
brakes to stop exactly on the target, with no slow tail. A held key scrolls at that
steady speed, and a new target mid-scroll keeps the current speed. When focus
moves faster than that (a held key over tall rows) and the target gets more than
a third of the viewport ahead, the cap rises by a quarter of the excess, so the
screen follows focus instead of falling ever further behind.
```jsx
<ScrollView className="flex-1 gap-1">
{entries.map(entry => (
<View key={entry.path} onPress={() => open(entry)}
className="px-3 py-1 rounded-md focused:bg-slate-700">
<Text className="text-sm text-white">{entry.name}</Text>
</View>
))}
</ScrollView>
```
Render the whole list and let the `ScrollView` follow focus instead of slicing
a window of rows around a focus index. Give it a bounded height (`flex-1` in a
sized parent, or an explicit `h-*`).
For lists of more than a few dozen items, or any list that loads more as it
scrolls, put a [`VirtualList`](LISTS.md) in the `ScrollView`: it mounts only
the rows near the viewport and near focus, and calls `onEndReached` near the end.
### Section anchors
Minimal movement reveals only the focused element: focusing a card in a
horizontal rail below a section title can leave the title cut off. Mark the
section with `scrollAnchor` (on a `View` or `Pressable`; keep it constant for
the element's lifetime) and a vertical `ScrollView` aligns the section's top to
the viewport top plus the margin instead, whichever element inside it takes focus:
```jsx
<ScrollView className="flex-1" onScrollTarget={({y}) => setHeaderHidden(y > heroHeight)}>
<View scrollAnchor className="h-[420px]">…hero with a Play button…</View>
{sections.map(section => (
<View key={section.id} scrollAnchor className="pt-10 gap-4">
<Text>{section.title}</Text>
<ScrollView horizontal className="h-[260px]">…cards…</ScrollView>
</View>
))}
</ScrollView>
```
- Each `ScrollView` uses the nearest `scrollAnchor` ancestor of the focused
element that lies directly inside it (not inside a nested `ScrollView`). In
the example, the horizontal rail still moves the least to show the card, and
the page aligns the section.
- The offset is clamped to the content, so an anchor within the margin of the
content's top (the hero) scrolls all the way back to 0.
- When the aligned offset would not show the focused element (an anchor taller
than the viewport), the `ScrollView` falls back to minimal movement for it.
- Only the vertical axis of a vertical `ScrollView` aligns; horizontal
`ScrollView`s and the x axis always move the least.
- An element can be its own anchor (`<View scrollAnchor focusable>`).
`onScrollTarget({x, y})` on a `ScrollView` reports the offset it scrolls to on
focus, once per new target when the scroll starts (and again if the content
shrank and the scroll settled short of it). Touch scrolling does not call it;
use `onScroll` for the live offset. Offsets are render pixels, like layout
rectangles.
## Tailwind and motion integration
- On an element that is focusable (`focusable`, `onPress`, or `Pressable`) and
has no explicit `focused`/`pressed` prop, the `focused:` and `pressed:`
variants read the element's own focus state: the compiler emits a `style`
function instead of requiring the prop. An explicit `focused={...}` prop keeps
today's behavior.
- `motion.*` elements that are focusable use their own focus state for
`whileFocus` and `whilePress`; animation classes with `transition` work the same.
The compiler decides from the JSX attributes it sees: `focusable` or `onPress`
written on the element, or `Pressable` imported from `@ps5-react/core`. A
spread such as `{...focusProps}` does not count, so pass `focused` explicitly
there (see [custom components](#custom-focusable-components)).
Animated focus classes (`transition … focused:scale-105`) must not sit inside a
conditional class expression; choose between two complete elements instead.
See [TAILWIND.md](TAILWIND.md#focus-state).
## Recipes
### Grid
A grid needs nothing beyond focusable cells: Up and Down find the cell in the
neighbouring row from the real layout.
```jsx
<FocusScope autoFocus>
<View className="flex-row flex-wrap gap-6 w-[1096px]">
{apps.map(app => (
<motion.View key={app.id} onPress={() => launch(app)}
whileFocus={{y: -6, scale: 1.06}} transition={transitions.focus}
className="w-[200px] h-[140px] rounded-2xl bg-slate-800 focused:bg-slate-700" />
))}
</View>
</FocusScope>
```
When something outside the cell needs to know which one is focused (a gliding
halo, a details panel), mirror it from `onFocus`/`onBlur`. The state only
describes focus; it never moves it. `apps/motion-lab/pages/focus.jsx` places its
halo this way.
```jsx
const [current, setCurrent] = useState(-1);
// on each cell:
onFocus={() => setCurrent(index)} onBlur={() => setCurrent(-1)}
```
### List
A list is a column of focusable rows. Put long lists in a
[`ScrollView`](#scrollview). Add `wrap` to its scope to loop from the last row
back to the first.
### Tabs
Tabs are one scope and the page another. `wrap` loops Left and Right along the
tab row while Down still leaves it. Focusing a tab shows its page; Cross or Down
enters the page; Circle in the page returns to the tab row, which
restores the active tab because scopes remember their last element.
```jsx
function App() {
const [page, setPage] = useState(0);
const {focusedKey, focus} = useFocus();
const inTabs = focusedKey?.startsWith('tab:') ?? true;
const {Page} = PAGES[page];
return (
<View className="w-screen h-screen gap-6">
<FocusScope focusKey="tabs" autoFocus wrap>
<View className="flex-row gap-2">
{PAGES.map(({label}, index) => (
<View key={label} focusKey={`tab:${label}`} selected={index === page}
onFocus={() => setPage(index)} onPress={() => focus('content')}
className="px-5 py-2 rounded-full border-2 border-transparent
selected:bg-slate-700 focused:border-sky-400">
<Text className="text-white">{label}</Text>
</View>
))}
</View>
</FocusScope>
<FocusScope focusKey="content" onBack={() => focus('tabs')}>
<Page key={page} />
</FocusScope>
<Text className="text-sm text-slate-400">
{inTabs ? 'X / Down: enter the page' : 'Circle: back to tabs'}
</Text>
</View>
);
}
```
`key={page}` remounts the page, so entering a new page starts at its first
element. `focusedKey` with a `tab:` prefix tells the app which region has focus
for hints and highlights, without an index.
### Tabs that keep their pages
Remounting rebuilds a whole page on every tab switch, tens of milliseconds of
React work on the PS5 for a page of posters. `Screens` keeps each page it has
shown mounted instead, and brings it back with its state, scroll and focus:
```jsx
import {Screen, Screens, useIsScreenActive} from '@ps5-react/core';
<Screens active={page} focusKey="content" onBack={() => focus('tabs')} preload>
<Screen id={0}><HomePage /></Screen>
<Screen id={1}><MoviesPage /></Screen>
<Screen id={2}><SearchPage /></Screen>
</Screens>
```
Switching works like `AnimatePresence mode="wait"`: the page on stage leaves,
then the next one enters, with the `variants` labels `hidden`, `shown` and
`gone` (a short slide by default; ANIMATION.md, "Page transitions"). A page off
stage is `display: 'none'`, out of navigation (its `FocusScope` is `inert`),
holds no image cache entries (`ReleaseImages`), and does not re-render when the
component rendering `Screens` does; context changes still reach it. Pages
passed over while another one leaves are not mounted. `focusKey` and `onBack`
go to the scope of the page on stage, and a `Screen`'s own `onBack` wins.
`preload` mounts pages not shown yet, one at a time, after the stage has rested
for a second, so their first visit does not build them either.
Hidden pages keep their engine nodes and their timers. Pause polling, clocks
and ambient animation with `useIsScreenActive()`, which is false while the
page is off stage or leaving and true outside `Screens`.
### Modal
A modal traps focus and closes on Circle. When it unmounts, focus returns to the
element that had it before the modal opened, or the nearest one left in the
enclosing scope.
```jsx
{confirming && (
<FocusScope trap autoFocus onBack={() => { setConfirming(false); return true; }}>
<View className="absolute inset-0 bg-black/60 items-center justify-center">
<View className="w-[520px] p-8 gap-6 rounded-2xl bg-slate-800">
<Text className="text-xl text-white">Delete save data?</Text>
<View className="flex-row gap-4">
<View onPress={() => setConfirming(false)}
className="px-6 py-3 rounded-xl bg-slate-700 focused:bg-slate-500">
<Text className="text-white">Cancel</Text>
</View>
<View onPress={deleteSave}
className="px-6 py-3 rounded-xl bg-slate-700 focused:bg-rose-600">
<Text className="text-white">Delete</Text>
</View>
</View>
</View>
</View>
</FocusScope>
)}
```
To send focus somewhere specific after closing, give that element a `focusKey`
and call `focus(key)`.
## Back handling
Circle goes to the enclosing scopes' `onBack`, innermost first, until one returns
`true`, then to `BackHandler` listeners, last added first. An unconsumed Circle
does nothing; it never exits the app.
A nested scope can handle Circle only in some states and let the rest bubble up.
The System Explorer Files page climbs folders and, at `/`, leaves Circle to the
page scope, which returns to the tabs:
```jsx
const up = () => {
if (dir === '/') return false;
open(parent(dir));
return true;
};
<FocusScope onBack={up}>…</FocusScope>
```
Use `onBack` for behavior tied to a region of the screen (close the modal, leave
the page, go up a folder). Use a listener for screen-wide behavior that does not
depend on where focus is, or when nothing is focusable:
```jsx
useEffect(() => {
const subscription = BackHandler.addEventListener('hardwareBackPress', () => {
if (!playing) return false;
pause();
return true;
});
return () => subscription.remove();
}, [playing]);
```
`BackHandler.exitApp()` quits explicitly; the PS button (Escape on desktop) leaves through the host.
## Custom focusable components
The simplest custom control is a focusable `View` whose children read its focus
with `useIsFocused()`. `Text` styles are not inherited, so this is also how a
label changes with focus:
```jsx
function Label({children}) {
const focused = useIsFocused();
return <Text focused={focused} className="text-sm text-slate-300 focused:text-white">{children}</Text>;
}
export function Button({label, onPress}) {
return (
<View onPress={onPress} className="px-5 py-2 rounded-full bg-slate-800 focused:bg-sky-700">
<Label>{label}</Label>
</View>
);
}
```
When the component itself needs the state, use `useFocusable` and spread
`focusProps` on the element whose rectangle should be navigable. `focusProps`
already contains `focusable={false}`, so the `View` does not register a second
time; pass `focused` and `pressed` explicitly for the variants:
```jsx
import {View, Text, useFocusable} from '@ps5-react/core';
export function Toggle({label, on, onChange, focusKey}) {
const {focused, pressed, focusProps} = useFocusable({focusKey, onPress: () => onChange(!on)});
return (
<View {...focusProps} focused={focused} pressed={pressed} selected={on}
className="flex-row gap-3 px-4 py-2 rounded-xl bg-slate-800
focused:bg-slate-700 pressed:scale-95 selected:border-2 selected:border-sky-400">
<Text focused={focused} className="text-slate-300 focused:text-white">{label}</Text>
<Text className="text-sky-300">{on ? 'On' : 'Off'}</Text>
</View>
);
}
```
`useIsFocused()` below an element that uses `useFocusable` does not see it;
pass `focused` down instead.
## Migrating from `useController`
| Before | After |
| --- | --- |
| `const [focus, setFocus] = useState(0)` plus `next`/`previous` arithmetic | `onPress` or `focusable` on each item inside a `FocusScope` |
| `focused={focus === i}` | nothing: `focused:` classes and `whileFocus` read the element's own state |
| `if (action === 'confirm') run(items[focus])` | `onPress={() => run(item)}` |
| `if (action === 'back') …` | `FocusScope onBack`, or `BackHandler.addEventListener` |
| `% length` wrapping | `FocusScope wrap` |
| A "zone" flag for tabs versus content | Two scopes and `focus('tabs')` / `focus('content')` |
| Slicing visible rows around the focus index | `ScrollView`, which follows focus; `VirtualList` for long lists |
| `focused` passed down to child `Text` | `useIsFocused()` in the child |
| A focus index needed for display | Mirror it with `onFocus`/`onBlur`, read-only |
A list screen before and after:
```jsx
// Before
const [focus, setFocus] = useState(0);
useController(action => {
if (action === 'next') setFocus(i => (i + 1) % items.length);
if (action === 'previous') setFocus(i => (i + items.length - 1) % items.length);
if (action === 'confirm') open(items[focus]);
});
// …
<View focused={focus === i} className="… focused:bg-sky-700" />
// After
<FocusScope autoFocus wrap>
{items.map(item => (
<View key={item.id} onPress={() => open(item)} className="… focused:bg-sky-700" />
))}
</FocusScope>
```
Keep `useController` for raw input that is not navigation: a game loop, a
custom gesture, or a debug overlay. It receives every action alongside focus
navigation, so do not also move focus from it.
## Limits
- Only `View`, `Image`, `Pressable`, and their `motion.*` versions can be
focusable. `Text` cannot.
- The search uses pre-transform rectangles; transforms never steer focus.
- `Text` does not follow its parent's focus automatically; use `useIsFocused()`.
- While `AnimatePresence` plays an exit, the leaving elements remain focusable
until they unmount.
- There is no pointer, touch, or text-input focus, and the runtime draws no
focus ring: the app styles the focused state.
- Focus is JavaScript state and resets when the preview restarts.