@roku-sdk/ai 0.0.0-stage → 2.19.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +88 -2
- package/package.json +47 -3
- package/skills/ads-and-raf/SKILL.md +231 -0
- package/skills/ads-and-raf/references/integration-flows.md +332 -0
- package/skills/app-packaging/SKILL.md +107 -0
- package/skills/app-performance/SKILL.md +133 -0
- package/skills/cli-dev-loop/SKILL.md +217 -0
- package/skills/cli-dev-loop/references/logs-and-crashes.md +114 -0
- package/skills/cpu-and-heap-profiling/SKILL.md +108 -0
- package/skills/cpu-and-heap-profiling/references/reading-profiles.md +52 -0
- package/skills/data-entry-keyboards/SKILL.md +160 -0
- package/skills/device-management/SKILL.md +158 -0
- package/skills/dialogs-and-modals/SKILL.md +210 -0
- package/skills/effects/SKILL.md +183 -0
- package/skills/fetch-and-data-loading/SKILL.md +164 -0
- package/skills/fetch-and-data-loading/references/https-and-certificates.md +79 -0
- package/skills/flex-grid/SKILL.md +236 -0
- package/skills/flex-grid/references/focus-integration.md +355 -0
- package/skills/flex-grid/references/plugins.md +201 -0
- package/skills/focus-management/SKILL.md +210 -0
- package/skills/focus-management/references/focus-recipes.md +290 -0
- package/skills/fonts-and-glyphs/SKILL.md +231 -0
- package/skills/fonts-and-glyphs/references/glyph-tables.md +128 -0
- package/skills/logging-and-errors/SKILL.md +227 -0
- package/skills/logging-and-errors/references/uncaught-exceptions.md +137 -0
- package/skills/mcp-ai-dev-loop/SKILL.md +189 -0
- package/skills/mcp-ai-dev-loop/references/crash-and-console.md +68 -0
- package/skills/mcp-ai-dev-loop/references/live-debugging.md +129 -0
- package/skills/mcp-setup/SKILL.md +131 -0
- package/skills/project-setup/SKILL.md +210 -0
- package/skills/project-setup/references/roku-config.md +120 -0
- package/skills/screen-navigation/SKILL.md +166 -0
- package/skills/sdk-components/SKILL.md +213 -0
- package/skills/sdk-components/references/dialogs.md +148 -0
- package/skills/sdk-components/references/scenegraph-node-spelling.md +123 -0
- package/skills/sdk-installation/SKILL.md +129 -0
- package/skills/slideshow/SKILL.md +157 -0
- package/skills/slideshow/references/focus-and-handoff.md +187 -0
- package/skills/solid-on-roku/SKILL.md +181 -0
- package/skills/solid-on-roku/references/hermes-and-exports.md +130 -0
- package/skills/testing/SKILL.md +147 -0
- package/skills/testing/references/async-and-timing.md +97 -0
- package/skills/user-accounts-and-purchases/SKILL.md +164 -0
- package/skills-manifest.json +316 -0
- package/skills-manifest.schema.json +62 -0
- package/src/__tests__/manifest.test.ts +267 -0
- package/src/__tests__/snippets.test.ts +221 -0
- package/src/__tests__/trigger-routing.test.ts +77 -0
- package/src/index.ts +36 -0
- package/src/manifest.ts +215 -0
- package/src/router.ts +147 -0
- package/src/snippets.ts +212 -0
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
# Focus Management
|
|
2
|
+
|
|
3
|
+
## Overview
|
|
4
|
+
|
|
5
|
+
D-pad focus in an RSG SDK TypeScript app is virtual: one SceneGraph node holds real focus
|
|
6
|
+
for the whole screen, and which item is focused inside it is a SolidJS signal, so moving
|
|
7
|
+
focus is a signal write rather than a SceneGraph call. `FocusBoundary` owns that state and
|
|
8
|
+
`useFocusable` registers the items that can hold it, both from `@roku-sdk/navigation`.
|
|
9
|
+
|
|
10
|
+
## Read the recipe first for two of these tasks
|
|
11
|
+
|
|
12
|
+
| If the request is | Read now, before answering |
|
|
13
|
+
|---|---|
|
|
14
|
+
| A dialog or overlay that holds focus while open and gives it back on close | `references/focus-recipes.md` → *Hold focus inside a dialog, and give it back* |
|
|
15
|
+
| Focus moving between regions on a key the layout does not already carry | `references/focus-recipes.md` → *Hand focus between regions of a screen* |
|
|
16
|
+
|
|
17
|
+
The tables below name the API for both, and stop there: the props you pass it, and where the
|
|
18
|
+
call has to sit for it to work at all, are only in the recipe. An answer assembled from a table
|
|
19
|
+
row is an invented one — so read the recipe and quote it, and never hand the pointer to the
|
|
20
|
+
user in place of an answer.
|
|
21
|
+
|
|
22
|
+
## When to Use
|
|
23
|
+
|
|
24
|
+
Use this whenever the question is which item is focused, or what a key press does to it:
|
|
25
|
+
|
|
26
|
+
- a menu, row, list or grid that has to respond to the D-pad
|
|
27
|
+
- highlighting the focused item and dimming the rest
|
|
28
|
+
- an OK press that must act on the focused item
|
|
29
|
+
- handing focus between regions of a screen
|
|
30
|
+
- focus that starts on the wrong item, will not move, or is lost when the data changes
|
|
31
|
+
- an item that must be skipped while it is disabled
|
|
32
|
+
|
|
33
|
+
### When not to use
|
|
34
|
+
|
|
35
|
+
| Situation | Use instead | Because |
|
|
36
|
+
|---|---|---|
|
|
37
|
+
| An overlay you draw yourself out of ordinary nodes | `ModalBoundary` + `useModal` from `@roku-sdk/components`, wired from `references/focus-recipes.md` | It already implements enter on open, dismiss on `back`, and restore on close |
|
|
38
|
+
| A `StandardDialog` | The `sdk-components` skill for what it is made of; nothing here for its focus | It renders a native SceneGraph modal, so focus enters and returns on its own. Point `initialFocusId` at the first button and leave `focusActive` alone — an enclosing `FocusBoundary` deactivates itself while it is up |
|
|
39
|
+
| A region a `FlexGrid` owns — its edges, its cells, its keys | `@roku-sdk/flex-grid`'s own `onEdgeReached` | The grid claims seven keys from the boundary — up, down, left, right, `OK`, `fastforward` and `rewind` — and by default consumes them whether or not they moved anything, so `onUnhandledKey` never sees one and a hand-off written there is dead code. `declineUnhandledNavigation: true` on the grid is the exception: claimed keys that moved nothing then do bubble |
|
|
40
|
+
| BrightScript component interop: `nextFocusUp`, `onFocusedChild`, focus driven on the render thread | `FocusableGroup` / `Focusable` | Those need real SceneGraph focus. It is a legacy path — do not start new work there |
|
|
41
|
+
| Imperative key routing the user asked for by name, or that is already in the file | `KeyHandler` | Every key costs a BrightScript round trip and movement logic ends up per component |
|
|
42
|
+
| Opening and closing a dialog from elsewhere in the app, telling a viewer's choice from a dismissal, or chaining several dialogs | The `dialogs-and-modals` skill | Holding focus while an overlay is up is this skill's job; deciding which overlay is up and what its close means is not |
|
|
43
|
+
| Moving between whole screens | The `screen-navigation` skill | Screen routing is not a focus concern; focus work starts once the screen is up |
|
|
44
|
+
|
|
45
|
+
## Quick Reference
|
|
46
|
+
|
|
47
|
+
| Operation | API |
|
|
48
|
+
|---|---|
|
|
49
|
+
| Wire a screen for focus | `RootFocusBoundary` with `keyEvent`, `focusActive` and `setHandledKeys`, on an anchor node extending `FocusBoundaryBase` |
|
|
50
|
+
| Make an item focusable | `useFocusable()`, then attach the returned `ref` and read `isFocused()` |
|
|
51
|
+
| Act on OK on the focused item | `onKey: { OK: onPress(() => …) }` in `useFocusable` |
|
|
52
|
+
| Name a key without memorising its runtime string | `RemoteKey` from `@roku-sdk/interfaces` — `onKey: { [RemoteKey.Select]: … }`. Four members read differently from the string they carry: `Select` is `"OK"`, `Fwd` is `"fastforward"`, `Rev` is `"rewind"`, `Info` is `"options"`. Bare strings stay valid |
|
|
53
|
+
| Move on up/down, left/right, or all four | `navigation="vertical"`, `"horizontal"`, `"grid"` on the boundary |
|
|
54
|
+
| Route movement in an irregular layout | `navigationMap`: a `Map` from each identity to its neighbour per direction |
|
|
55
|
+
| Add a second region to a screen | A nested `FocusBoundary` with a `focusId` that the parent's `initialFocus` names |
|
|
56
|
+
| Skip an item while it is disabled | `canReceiveFocus: () => !disabled()` |
|
|
57
|
+
| Move focus from app logic | `useFocusActions()` → `setFocus(id)`; `focusedId()` to read it |
|
|
58
|
+
| Act on a key nobody handled, such as `back` | `onUnhandledKey` on the boundary |
|
|
59
|
+
| Reopen a region at its first item | `rememberLastFocus={false}` — the default `true` restores the last item |
|
|
60
|
+
| Mark the item an inactive region will return to — dialog open, or focus in another region | `hasFootprint()` from `useFocusable`; `hasReturnFootprint()` when nested regions or `rememberLastFocus={false}` must mark only where focus will land, and `useFocusActions().hasReturnFootprint()` for the region itself |
|
|
61
|
+
|
|
62
|
+
## Instructions
|
|
63
|
+
|
|
64
|
+
`@roku-sdk/navigation` is not in the set `rk create` installs. Add it to the app's own
|
|
65
|
+
`package.json` first — `workspace:*` inside the SDK's pnpm workspace, otherwise the version the
|
|
66
|
+
app's other `@roku-sdk/*` packages are on — or every import below fails to resolve.
|
|
67
|
+
|
|
68
|
+
1. **Wire the screen once.** Give the screen's anchor node `extends: "FocusBoundaryBase"`,
|
|
69
|
+
`keyEvent` and `focusActive` as input fields and `handledKeys` as an output field, then
|
|
70
|
+
pass all three to `RootFocusBoundary`. Without this no key reaches TypeScript and nothing
|
|
71
|
+
moves. One per anchor node — never give a nested boundary its own `keyEvent`.
|
|
72
|
+
|
|
73
|
+
For TypeScript descriptors, keep `FocusBoundaryBase`: registration selects the native
|
|
74
|
+
component when firmware reports `hasFeature("focusBoundaryBase")`, otherwise
|
|
75
|
+
`FocusBoundaryBaseShim`. XML/BrightScript does not go through that remap: extend or
|
|
76
|
+
create `FocusBoundaryBaseShim` by name to keep working on existing firmware.
|
|
77
|
+
|
|
78
|
+
2. **Wrap each region in a `FocusBoundary`** and set `navigation` to the axis the region
|
|
79
|
+
moves on. Nest a region by giving it a `focusId` that its parent's `initialFocus` or
|
|
80
|
+
`navigationMap` names.
|
|
81
|
+
|
|
82
|
+
3. **Point `initialFocus` at something registered in that same boundary** — a child's
|
|
83
|
+
`useFocusable({ focusId })` identity, or a nested boundary's `focusId`. An identity
|
|
84
|
+
registered one level further down never matches, and focus starts nowhere.
|
|
85
|
+
|
|
86
|
+
4. **Call `useFocusable()` in every item that can hold focus**, attach the returned `ref` to
|
|
87
|
+
the item's root node, and put the item's own keys in `onKey`. Use `onPress` / `onRelease`
|
|
88
|
+
to fire on one edge instead of guarding `press` by hand.
|
|
89
|
+
|
|
90
|
+
5. **Leave directional keys to the boundary.** A leaf handles OK and its own keys; up, down,
|
|
91
|
+
left and right are the boundary's job, so do not add them to a leaf's `onKey`.
|
|
92
|
+
|
|
93
|
+
6. **Read `isFocused()` inside JSX** or another reactive scope, and never destructure
|
|
94
|
+
`props`. Reading either once at the top of a component captures a value that never
|
|
95
|
+
updates again.
|
|
96
|
+
|
|
97
|
+
7. **Iterate with `<For>`**, never `items.map()`. `map` recreates every child when the array
|
|
98
|
+
changes, which unregisters the focused item and drops focus.
|
|
99
|
+
|
|
100
|
+
8. **Create a `focusId` only when something names it** — `initialFocus`, a `navigationMap`
|
|
101
|
+
entry, `setFocus`, a `focusedId()` comparison, or a `FlexGrid` `focusId`. Every other
|
|
102
|
+
item gets one automatically, and linear navigation follows registration order.
|
|
103
|
+
|
|
104
|
+
## Example
|
|
105
|
+
|
|
106
|
+
A screen wired end to end: the anchor node, one vertical region, three rows, per-row OK
|
|
107
|
+
handling, and focus styling driven by `isFocused()`.
|
|
108
|
+
|
|
109
|
+
```tsx rsg-skill=complete
|
|
110
|
+
import { Group, Label, Rectangle } from "@roku-sdk/components";
|
|
111
|
+
import { FocusBoundary, onPress, RootFocusBoundary, useFocusable } from "@roku-sdk/navigation";
|
|
112
|
+
import { type AnchorNodeImplementation, defineAnchorNode, FieldType } from "@roku-sdk/rsg-ts";
|
|
113
|
+
import { For } from "@roku-sdk/solid";
|
|
114
|
+
|
|
115
|
+
const MainSceneDescription = {
|
|
116
|
+
componentName: "MainScene",
|
|
117
|
+
extends: "FocusBoundaryBase" as const,
|
|
118
|
+
inputFields: {
|
|
119
|
+
keyEvent: FieldType.Object(),
|
|
120
|
+
focusActive: FieldType.Boolean(true)
|
|
121
|
+
},
|
|
122
|
+
outputFields: {
|
|
123
|
+
handledKeys: FieldType.Object(),
|
|
124
|
+
exitApp: FieldType.Boolean()
|
|
125
|
+
}
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
// Two identities this screen names: the region the root boundary starts in,
|
|
129
|
+
// and the row that region starts on.
|
|
130
|
+
const menuId: object = {};
|
|
131
|
+
const homeRowId: object = {};
|
|
132
|
+
|
|
133
|
+
interface Item {
|
|
134
|
+
label: string;
|
|
135
|
+
disabled?: boolean;
|
|
136
|
+
focusId?: object;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const ITEMS: Item[] = [
|
|
140
|
+
{ label: "Home", focusId: homeRowId },
|
|
141
|
+
{ label: "Search" },
|
|
142
|
+
{ label: "Settings", disabled: true }
|
|
143
|
+
];
|
|
144
|
+
|
|
145
|
+
function MenuRow(props: { item: Item; y: number; onSelect: (label: string) => void }) {
|
|
146
|
+
const { ref, isFocused } = useFocusable({
|
|
147
|
+
focusId: props.item.focusId,
|
|
148
|
+
onKey: { OK: onPress(() => props.onSelect(props.item.label)) },
|
|
149
|
+
canReceiveFocus: () => !props.item.disabled
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
return (
|
|
153
|
+
<group ref={ref} translation={[0, props.y]}>
|
|
154
|
+
<rectangle width={480} height={72} color={isFocused() ? "0x0055FFFF" : "0x1A1A1AFF"} />
|
|
155
|
+
<label
|
|
156
|
+
text={props.item.label}
|
|
157
|
+
translation={[24, 20]}
|
|
158
|
+
color={isFocused() ? "0xFFFFFFFF" : "0x8A8A8AFF"}
|
|
159
|
+
/>
|
|
160
|
+
</group>
|
|
161
|
+
);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const MainScene: AnchorNodeImplementation<typeof MainSceneDescription> = (inputs, outputs) => (
|
|
165
|
+
<RootFocusBoundary
|
|
166
|
+
keyEvent={inputs.keyEvent as Parameters<typeof RootFocusBoundary>[0]["keyEvent"]}
|
|
167
|
+
focusActive={inputs.focusActive as boolean}
|
|
168
|
+
setHandledKeys={outputs.handledKeys}
|
|
169
|
+
initialFocus={menuId}
|
|
170
|
+
onUnhandledKey={{ back: onPress(() => outputs.exitApp(true)) }}
|
|
171
|
+
>
|
|
172
|
+
<FocusBoundary focusId={menuId} initialFocus={homeRowId} navigation="vertical">
|
|
173
|
+
<For each={ITEMS}>
|
|
174
|
+
{(item, index) => (
|
|
175
|
+
<MenuRow item={item} y={index() * 88} onSelect={label => openScreen(label)} />
|
|
176
|
+
)}
|
|
177
|
+
</For>
|
|
178
|
+
</FocusBoundary>
|
|
179
|
+
</RootFocusBoundary>
|
|
180
|
+
);
|
|
181
|
+
|
|
182
|
+
function openScreen(label: string): void {
|
|
183
|
+
// app logic for the selected row
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
export default defineAnchorNode(MainSceneDescription, MainScene);
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
## Common Mistakes
|
|
190
|
+
|
|
191
|
+
| Symptom | Cause | Fix |
|
|
192
|
+
|---|---|---|
|
|
193
|
+
| Nothing moves and no handler fires | The anchor node is not wired, or the items have no boundary above them | `extends: "FocusBoundaryBase"`, `keyEvent` in, `handledKeys` out |
|
|
194
|
+
| The focus fields were added, but `inputs.keyEvent` and `outputs.handledKeys` come out untyped | The scaffold annotates the descriptor `: AnchorNodeDescriptor`, which widens the field maps, and `AnchorNodeImplementation<typeof …>` derives its types from what `typeof` still knows | Drop the annotation and keep `as const` on `extends`, as the example does — the descriptor should be inferred, not declared |
|
|
195
|
+
| The screen compiles but the app's `.test.tsx` stops compiling the moment the anchor node is wired: `Type '{}' is not assignable`, missing `keyEvent` and `focusActive` | `TestAnchorNode` derives the test component's props from the descriptor's field maps, so the three focus fields are now required props of it — the scaffolded `<TestApp />` passes none | Pass all three: `<TestApp keyEvent={{}} focusActive={true} handledKeys={() => undefined} />` — render `keyEvent` from a signal when the test has to press a key |
|
|
196
|
+
| `'Row' cannot be used as a JSX component. Its return type 'Component' is not a valid JSX element` | A focusable leaf was annotated `function Row(props): Component`. `Component` is the type of the function, not of what it returns | Annotate the value — `const Row: Component<Props> = props => …` — or leave the return type inferred, as the example does |
|
|
197
|
+
| OK works but the arrows do nothing | Wrong axis on `navigation`, or a `navigationMap` with that direction unmapped | Set the axis the region moves on; unmapped directions are deliberately not claimed |
|
|
198
|
+
| Focus drops whenever the list updates | `items.map()` in the JSX | Use `<For>` |
|
|
199
|
+
| The highlight never changes | `isFocused` read outside a reactive scope, or `props` destructured | Read it inside JSX, `createMemo`, `createEffect` or a callback |
|
|
200
|
+
| Focus starts nowhere, and even `onUnhandledKey` is dead | `initialFocus` names an id registered deeper than this boundary, or one nothing registered | Point it at a direct child: a leaf's `focusId`, or a nested boundary's `focusId` |
|
|
201
|
+
| Focus jumps back, or fights the keys | `gainFocus` on a SceneGraph node while the boundary is also moving focus | Inside a boundary, move focus with `setFocus` only |
|
|
202
|
+
| One item is never reachable | Its `canReceiveFocus` returns false | Check the condition — a disabled item is skipped by design |
|
|
203
|
+
| A whole branch looks unfocused — behind an open dialog, or once another region has focus | By design: `isFocused()` is `isActive() && focusedId() === focusId`, so it is false throughout an inactive branch, and false everywhere when `focusActive` is false | Draw the "focus returns here" marker from `hasFootprint()` |
|
|
204
|
+
| Two regions react to one key, or a nested region steals keys | A nested boundary was given `keyEvent` | Only the root boundary gets it |
|
|
205
|
+
| Focus is lost when app state changes | Focus state is held in app state and the subtree is rebuilt from it | Let the boundary own focus; read `isFocused()` |
|
|
206
|
+
| A handler armed something on key-down and the key-up never arrived, so a hold never ends | A release is routed by focus *at key-up time*, and focus moved while the key was held — something now in the path claimed the release, or the key left `handledKeys` and BrightScript filtered it out | Set `captureKeyPress` on the boundary that owns the handler: a consumed press is bound to it and the release is delivered there directly. `FlexGrid` exposes the same flag |
|
|
207
|
+
|
|
208
|
+
Props and options are typed with TSDoc in `@roku-sdk/navigation` — `IRootFocusBoundaryProps`,
|
|
209
|
+
`IFocusBoundaryProps`, `UseFocusableOptions`, `FocusActions`. Read a prop there rather than
|
|
210
|
+
guessing its name.
|
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
# Focus Recipes
|
|
2
|
+
|
|
3
|
+
Two procedures that need more than a row in a table, and that the body of the skill
|
|
4
|
+
therefore does not carry. Each is a whole screen's worth of decisions.
|
|
5
|
+
|
|
6
|
+
## Hand focus between regions of a screen
|
|
7
|
+
|
|
8
|
+
### First: check whether you need any code at all
|
|
9
|
+
|
|
10
|
+
A boundary hands its keys upward when the focused child declines them **and** its own
|
|
11
|
+
directional navigation finds no eligible neighbour. So for a regular layout — a menu on the
|
|
12
|
+
left, content on the right — the hand-off is already implemented: wrap both regions in one
|
|
13
|
+
boundary with `navigation="horizontal"`, and `right` at the menu's edge moves focus into the
|
|
14
|
+
content region with no callback anywhere. Reach for the recipe below only when the hand-off is
|
|
15
|
+
**conditional** (the target region has no data yet) or **non-directional** (`OK` on a menu row
|
|
16
|
+
jumps into the content it describes).
|
|
17
|
+
|
|
18
|
+
One region this recipe does not cover: a `FlexGrid`. It claims seven keys from the boundary — the
|
|
19
|
+
four movement keys, `OK`, `fastforward` and `rewind` — so neither the parent's navigation nor its
|
|
20
|
+
`onUnhandledKey` ever sees them, and the edge it reports arrives through the grid's own
|
|
21
|
+
`onEdgeReached`. Everything below still applies to the *receiving* side — `setFocus` is how focus
|
|
22
|
+
arrives at either kind of region.
|
|
23
|
+
|
|
24
|
+
### Then: capture `setFocus` inside the boundary that owns both regions
|
|
25
|
+
|
|
26
|
+
`useFocusActions()` resolves against the *nearest* boundary, and `setFocus` only reaches
|
|
27
|
+
identities registered with that one. Both region boundaries register with their common parent,
|
|
28
|
+
so the hook has to be called from a component rendered **inside** that parent — which is why
|
|
29
|
+
this pattern exists as a component that renders nothing.
|
|
30
|
+
|
|
31
|
+
```tsx rsg-skill=complete
|
|
32
|
+
import { Group, Label, Rectangle } from "@roku-sdk/components";
|
|
33
|
+
import { FocusBoundary, onPress, useFocusable, useFocusActions } from "@roku-sdk/navigation";
|
|
34
|
+
import { type Component, For, onCleanup } from "@roku-sdk/solid";
|
|
35
|
+
|
|
36
|
+
const menuId: object = {};
|
|
37
|
+
const contentId: object = {};
|
|
38
|
+
const firstMenuItemId: object = {};
|
|
39
|
+
const firstCardId: object = {};
|
|
40
|
+
|
|
41
|
+
const MENU = ["Home", "Movies", "Search"];
|
|
42
|
+
const CARDS = ["A", "B", "C", "D"];
|
|
43
|
+
|
|
44
|
+
function MenuRow(props: { label: string; focusId?: object; y: number; onEnter: () => void }) {
|
|
45
|
+
const { ref, isFocused } = useFocusable({
|
|
46
|
+
focusId: props.focusId,
|
|
47
|
+
onKey: { OK: onPress(() => props.onEnter()) }
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
return (
|
|
51
|
+
<group ref={ref} translation={[0, props.y]}>
|
|
52
|
+
<rectangle width={320} height={64} color={isFocused() ? "0x0055FFFF" : "0x1A1A1AFF"} />
|
|
53
|
+
<label text={props.label} translation={[24, 18]} />
|
|
54
|
+
</group>
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function Card(props: { label: string; focusId?: object; x: number }) {
|
|
59
|
+
const { ref, isFocused } = useFocusable({ focusId: props.focusId });
|
|
60
|
+
|
|
61
|
+
return (
|
|
62
|
+
<group ref={ref} translation={[props.x, 0]}>
|
|
63
|
+
<rectangle width={240} height={135} color={isFocused() ? "0x0055FFFF" : "0x1A1A1AFF"} />
|
|
64
|
+
<label text={props.label} translation={[16, 100]} />
|
|
65
|
+
</group>
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export const TwoRegionScreen: Component<{ focusId?: object; hasContent: () => boolean }> = props => {
|
|
70
|
+
// Written by the capture component below, read by the key handlers. Not a
|
|
71
|
+
// signal: nothing renders from it, and a signal write here would be a
|
|
72
|
+
// render-time update.
|
|
73
|
+
let focusRegion: ((id: object) => void) | undefined;
|
|
74
|
+
|
|
75
|
+
const CaptureRegionFocus: Component = () => {
|
|
76
|
+
const { setFocus } = useFocusActions();
|
|
77
|
+
focusRegion = setFocus;
|
|
78
|
+
onCleanup(() => {
|
|
79
|
+
focusRegion = undefined;
|
|
80
|
+
});
|
|
81
|
+
return null;
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
return (
|
|
85
|
+
<FocusBoundary focusId={props.focusId} initialFocus={menuId} navigation="horizontal">
|
|
86
|
+
<CaptureRegionFocus />
|
|
87
|
+
|
|
88
|
+
<FocusBoundary
|
|
89
|
+
focusId={menuId}
|
|
90
|
+
initialFocus={firstMenuItemId}
|
|
91
|
+
navigation="vertical"
|
|
92
|
+
onUnhandledKey={{
|
|
93
|
+
// Non-directional hand-off: `right` is already handled by the
|
|
94
|
+
// parent's horizontal navigation, but OK is not.
|
|
95
|
+
OK: onPress(() => {
|
|
96
|
+
if (props.hasContent()) focusRegion?.(contentId);
|
|
97
|
+
})
|
|
98
|
+
}}
|
|
99
|
+
>
|
|
100
|
+
<For each={MENU}>
|
|
101
|
+
{(label, index) => (
|
|
102
|
+
<MenuRow
|
|
103
|
+
label={label}
|
|
104
|
+
focusId={index() === 0 ? firstMenuItemId : undefined}
|
|
105
|
+
y={index() * 72}
|
|
106
|
+
onEnter={() => focusRegion?.(contentId)}
|
|
107
|
+
/>
|
|
108
|
+
)}
|
|
109
|
+
</For>
|
|
110
|
+
</FocusBoundary>
|
|
111
|
+
|
|
112
|
+
<group translation={[400, 0]}>
|
|
113
|
+
<FocusBoundary
|
|
114
|
+
focusId={contentId}
|
|
115
|
+
initialFocus={firstCardId}
|
|
116
|
+
navigation="horizontal"
|
|
117
|
+
onUnhandledKey={{ back: onPress(() => focusRegion?.(menuId)) }}
|
|
118
|
+
>
|
|
119
|
+
<For each={CARDS}>
|
|
120
|
+
{(label, index) => (
|
|
121
|
+
<Card
|
|
122
|
+
label={label}
|
|
123
|
+
focusId={index() === 0 ? firstCardId : undefined}
|
|
124
|
+
x={index() * 264}
|
|
125
|
+
/>
|
|
126
|
+
)}
|
|
127
|
+
</For>
|
|
128
|
+
</FocusBoundary>
|
|
129
|
+
</group>
|
|
130
|
+
</FocusBoundary>
|
|
131
|
+
);
|
|
132
|
+
};
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
### Common mistakes
|
|
136
|
+
|
|
137
|
+
| Symptom | Cause | Fix |
|
|
138
|
+
|---|---|---|
|
|
139
|
+
| `setFocus` on a region does nothing | It was called from a hook whose nearest boundary is one of the regions, not their parent | Capture `setFocus` in a component rendered as a direct child of the parent boundary |
|
|
140
|
+
| The hand-off fires but focus lands on the wrong item | The region remembers where it was; that is `rememberLastFocus`, default `true` | Pass `rememberLastFocus={false}` on the region that should reopen at its first item |
|
|
141
|
+
| `back` inside a region exits the screen instead of returning to the menu | The region did not claim `back`, so it bubbled past the parent to the root | Declare `back` in that region's `onUnhandledKey`; a handler that returns nothing consumes the key and stops it there |
|
|
142
|
+
| The whole screen stops navigating after a region is added | The parent boundary has no `navigation` and no `navigationMap`, so it performs no movement at all — there is no default | Set the axis the regions are laid out on |
|
|
143
|
+
|
|
144
|
+
## Hold focus inside a dialog, and give it back
|
|
145
|
+
|
|
146
|
+
`ModalBoundary` and `useModal` from `@roku-sdk/components` implement this: enter on open,
|
|
147
|
+
dismiss on a key you name, restore on close. Do not hand-wire a boundary, a scrim and a
|
|
148
|
+
`Show` — the parts that are easy to get wrong are the ones this already does.
|
|
149
|
+
|
|
150
|
+
Placement is the whole trick. `useModal().open()` calls `setFocus` on the modal's identity, so
|
|
151
|
+
it must be called where the nearest boundary is the **common parent** of the content branch and
|
|
152
|
+
the `ModalBoundary` — the same rule as region hand-off above, for the same reason.
|
|
153
|
+
|
|
154
|
+
```tsx rsg-skill=complete
|
|
155
|
+
import { Group, Label, ModalBoundary, Rectangle, useModal } from "@roku-sdk/components";
|
|
156
|
+
import { FocusBoundary, onPress, useFocusable } from "@roku-sdk/navigation";
|
|
157
|
+
import { type Component, createSignal, For } from "@roku-sdk/solid";
|
|
158
|
+
|
|
159
|
+
const listId: object = {};
|
|
160
|
+
const firstRowId: object = {};
|
|
161
|
+
const confirmId: object = {};
|
|
162
|
+
|
|
163
|
+
const ROWS = ["Play", "Add to list", "Delete"];
|
|
164
|
+
|
|
165
|
+
function Button(props: { label: string; focusId?: object; x: number; onSelect: () => void }) {
|
|
166
|
+
const { ref, isFocused } = useFocusable({
|
|
167
|
+
focusId: props.focusId,
|
|
168
|
+
onKey: { OK: onPress(() => props.onSelect()) }
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
return (
|
|
172
|
+
<group ref={ref} translation={[props.x, 0]}>
|
|
173
|
+
<rectangle width={240} height={64} color={isFocused() ? "0x0055FFFF" : "0x2B2B2BFF"} />
|
|
174
|
+
<label text={props.label} translation={[24, 18]} />
|
|
175
|
+
</group>
|
|
176
|
+
);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Rendered inside the boundary that owns both branches, which is what lets
|
|
181
|
+
* `open()` reach the dialog.
|
|
182
|
+
*/
|
|
183
|
+
const ListWithConfirm: Component = () => {
|
|
184
|
+
const confirm = useModal();
|
|
185
|
+
const [result, setResult] = createSignal("");
|
|
186
|
+
|
|
187
|
+
const finish = (answer: string): void => {
|
|
188
|
+
setResult(answer);
|
|
189
|
+
confirm.close();
|
|
190
|
+
};
|
|
191
|
+
|
|
192
|
+
return (
|
|
193
|
+
<>
|
|
194
|
+
<FocusBoundary focusId={listId} initialFocus={firstRowId} navigation="vertical">
|
|
195
|
+
<For each={ROWS}>
|
|
196
|
+
{(label, index) => (
|
|
197
|
+
<Row
|
|
198
|
+
label={label}
|
|
199
|
+
focusId={index() === 0 ? firstRowId : undefined}
|
|
200
|
+
y={index() * 72}
|
|
201
|
+
onSelect={confirm.open}
|
|
202
|
+
/>
|
|
203
|
+
)}
|
|
204
|
+
</For>
|
|
205
|
+
<label text={result()} translation={[0, 260]} />
|
|
206
|
+
</FocusBoundary>
|
|
207
|
+
|
|
208
|
+
<ModalBoundary
|
|
209
|
+
show={confirm.isOpen()}
|
|
210
|
+
focusId={confirm.focusId}
|
|
211
|
+
initialFocus={confirmId}
|
|
212
|
+
navigation="horizontal"
|
|
213
|
+
onDismiss={confirm.close}
|
|
214
|
+
dismissKeys={["back"]}
|
|
215
|
+
dim="0x000000AA"
|
|
216
|
+
>
|
|
217
|
+
<group translation={[520, 360]}>
|
|
218
|
+
<rectangle width={880} height={240} color="0x14142AFF" />
|
|
219
|
+
<label text="Delete this item?" translation={[48, 36]} />
|
|
220
|
+
<group translation={[48, 140]}>
|
|
221
|
+
<Button label="Delete" focusId={confirmId} x={0} onSelect={() => finish("deleted")} />
|
|
222
|
+
<Button label="Cancel" x={280} onSelect={() => finish("kept")} />
|
|
223
|
+
</group>
|
|
224
|
+
</group>
|
|
225
|
+
</ModalBoundary>
|
|
226
|
+
</>
|
|
227
|
+
);
|
|
228
|
+
};
|
|
229
|
+
|
|
230
|
+
function Row(props: { label: string; focusId?: object; y: number; onSelect: () => void }) {
|
|
231
|
+
const { ref, isFocused, hasFootprint } = useFocusable({
|
|
232
|
+
focusId: props.focusId,
|
|
233
|
+
onKey: { OK: onPress(() => props.onSelect()) }
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
// While the dialog is open every `isFocused()` in this branch is false, so the
|
|
237
|
+
// row the dialog will return to is drawn from `hasFootprint()` instead.
|
|
238
|
+
const color = (): string => {
|
|
239
|
+
if (isFocused()) return "0x0055FFFF";
|
|
240
|
+
return hasFootprint() ? "0x003399FF" : "0x1A1A1AFF";
|
|
241
|
+
};
|
|
242
|
+
|
|
243
|
+
return (
|
|
244
|
+
<group ref={ref} translation={[0, props.y]}>
|
|
245
|
+
<rectangle width={480} height={64} color={color()} />
|
|
246
|
+
<label text={props.label} translation={[24, 18]} />
|
|
247
|
+
</group>
|
|
248
|
+
);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
export const ScreenWithDialog: Component<{ focusId?: object }> = props => (
|
|
252
|
+
<FocusBoundary focusId={props.focusId} initialFocus={listId}>
|
|
253
|
+
<ListWithConfirm />
|
|
254
|
+
</FocusBoundary>
|
|
255
|
+
);
|
|
256
|
+
```
|
|
257
|
+
|
|
258
|
+
### How the restore works, and why you do not write it
|
|
259
|
+
|
|
260
|
+
`close()` only clears `show`. Unmounting the dialog unregisters its boundary from the parent,
|
|
261
|
+
and the parent moves focus to the neighbour it sat next to in registration order — the content
|
|
262
|
+
branch. That branch then restores its own last focused child, because `rememberLastFocus`
|
|
263
|
+
defaults to `true`. Nothing in your code names the item focus returns to, which is why nothing
|
|
264
|
+
in your code can name the wrong one.
|
|
265
|
+
|
|
266
|
+
### The footprint is not dialog-specific
|
|
267
|
+
|
|
268
|
+
`hasFootprint()` is `!isActive() && focusedId() === focusId`, so it is true of any branch that is
|
|
269
|
+
not the active one and still remembers an item. A dialog above it is one cause; focus moving to a
|
|
270
|
+
sibling region is another. A row styled from it therefore keeps its subdued marker while the user
|
|
271
|
+
is over in the menu — usually what you want, but decide it rather than discover it. To mark only
|
|
272
|
+
the dialog case, pass the dialog's `isOpen()` down and gate the marker on it.
|
|
273
|
+
|
|
274
|
+
It is also evaluated against its own boundary alone, so a nested region focus will not pass back
|
|
275
|
+
through keeps marking its last child, and so does a region with `rememberLastFocus={false}` that
|
|
276
|
+
will reopen at `initialFocus`. When only items focus will actually land on should be marked,
|
|
277
|
+
use `hasReturnFootprint()` from `useFocusable` instead; `useFocusActions().hasReturnFootprint()`
|
|
278
|
+
answers the same question for the whole region. Each dormant sibling region under the nearest
|
|
279
|
+
active ancestor still marks its own item — the one focus reaches when the user moves there — so
|
|
280
|
+
the tree shows a single footprint only while the root itself is inactive.
|
|
281
|
+
|
|
282
|
+
### Common mistakes
|
|
283
|
+
|
|
284
|
+
| Symptom | Cause | Fix |
|
|
285
|
+
|---|---|---|
|
|
286
|
+
| The dialog appears but keys still drive the list behind it | `show` is true while focus never moved — `open()` was not used, or `focusId` differs between `useModal` and `ModalBoundary` | Drive both from one `useModal()`: `show={m.isOpen()}` and `focusId={m.focusId}` |
|
|
287
|
+
| `useModal` throws on mount | It was called outside any boundary | Call it from a component rendered inside the parent boundary |
|
|
288
|
+
| The dialog cannot be dismissed | No `dismissKeys` key was pressed, and no child claimed one | `dismissKeys` defaults to `back`; name every key that should close it, and wire `onDismiss` |
|
|
289
|
+
| The dialog opens with nothing focused | `initialFocus` names an identity that no child of the dialog registered | Point it at a button inside the dialog, or omit it entirely and let a hidden focusable be injected |
|
|
290
|
+
| Everything behind the dialog looks unfocused, including the row focus will return to | Correct, and by design: `isFocused()` is false throughout an inactive branch | Draw that indicator from `hasFootprint()` |
|