Focus & navigation
focusable, on:key, and the cross-component LRUD focus system.
An automatic, cross-component geometric (up/down/left/right) focus system — no hand-written
SetFocus()/onKeyEvent chasing required for
ordinary directional navigation between components you didn't even write together.
focusable and on:key
focusable="true" (or a dynamic {expr}) registers
an element with the app-wide focus registry — it needs an id.
on:key[Key1,Key2,...]={call} binds a handler using Roku's own raw key
strings (a * wildcard matches any key), with bubbling simulated up through
nested on:key ancestors exactly like real DOM event bubbling.
default-focus="true" marks a component's own natural entry point (at most one
per component, must pair with a static focusable="true").
<Rectangle id="prompt" focusable="true" default-focus="true"
color="0x3A3A3AFF" width="{promptWidth}" height="{promptHeight}"
on:key[OK]="{goToSchedule()}">
<Label id="promptLabel" text="{welcomeText}" />
</Rectangle><Rectangle id="tile" focusable="true" on:key[OK]="{selectTile(tile)}">
<Label id="tileLabel" text="{tile.label}" />
</Rectangle>Cross-component LRUD navigation
Arrow-key navigation searches the current component's own focusable content first, then widens to the
whole app-wide registry — geometric, spatial-navigation-style scoring (genuine axis overlap, not a cone/angle
guess). Holding a direction key accelerates repeat navigation automatically. A component declaring both
scrollOffsetX/scrollOffsetY fields gets
automatic scroll-into-view when focus moves to off-screen content within it:
field scrollOffsetX: float = 0
field scrollOffsetY: float = 0
<component>
<Rectangle id="viewport" clippingRect="{[0, 0, 1000, 500]}">
<Group id="track" translation="{[-scrollOffsetX, -scrollOffsetY]}">
{#each tiles as tile (tile.id)}
<Rectangle id="tile" focusable="true" translation="{[tile.x, tile.y]}" .../>
{/each}
</Group>
</Rectangle>
</component>RowList-style multi-item jump — jumpFocus
jumpFocus(<direction>, <count>, <press>) moves focus several
registered candidates at once in one direction, instead of navigate()'s own single step — the same
"up"/"down"/"left"/"right"
vocabulary, repeated up to <count> times and stopping early at a real boundary
(landing on the last reachable item instead of overshooting) — and, unlike an ordinary arrow-key press, a jump
never crosses into a different component partway through: it stays confined to whichever component's own
registered content the press started in, even once it runs out of further candidates there. It's deliberately
not automatic the way arrow-key LRUD is — every chapter app in this
project already reserves FAST-FORWARD/REWIND for chapter switching precisely because
navigate() never touches those keys, so an author wires
jumpFocus onto an on:key[...] binding instead —
on the focusable element itself, or once on a plain wrapping container, since on:key
bubbles from wherever focus currently sits up through its ancestor chain (no focusable
needed on that container). <press> must be
forwarded through unconditionally (never guarded by the caller's own
if (press)) — a press jumps focus and arms the same hold-to-repeat timing arrow keys
already use, a release stops it:
<Group id="list" on:key[fastforward]="{jumpDown()}" on:key[rewind]="{jumpUp()}">
{#each rows as row (row.id)}
<Rectangle id="row" focusable="true">
<Label id="rowLabel" text="{row.label}" />
</Rectangle>
{/each}
</Group>
private function jumpDown(key: string, press: boolean) {
jumpFocus("down", 5, press) ' <press> forwarded unconditionally — see note below
}
private function jumpUp(key: string, press: boolean) {
jumpFocus("up", 5, press)
}The vacuum rule
Automatically-chosen focus (on router mount, or after the focused node is destroyed) is applied
only when nothing currently holds focus — it never takes focus away from
somewhere it already is. This is what makes a persistent side menu keep focus across navigations instead of
getting yanked into newly-mounted content every time. An explicit focus(<id>)
deliberately overrides the rule, since the author asked for it by name.
focus("searchBox") ' jump focus to one of THIS component's own descendants, by idisFocused / isInFocusChain
Reserved, reactive read-only fields — synthesized only for a component that actually reads them (no cost for
everyone else). isFocused means this component owns the focused element;
isInFocusChain means the focused element is anywhere in its subtree (nested
child components included) — a single-writer design makes two components reporting isFocused = true
at once unrepresentable, not merely disallowed.
derived menuHighlight: string = pickHighlight(isFocused)
private function pickHighlight(focused: boolean): string {
if (focused) {
return "0x0057FFFF"
}
return "0x2A2A2AFF"
}<Rectangle id="menu" color="{menuHighlight}">
{#if isInFocusChain}
<Label id="activeBadge" text="active section" />
{/if}
</Rectangle>Reference implementation — apps/focus-demo
Every mechanism on this page has a router-mounted, scaled
chapter in apps/focus-demo — 7 chapters
(/focusable-basics through /jump-focus),
reachable with REWIND/FAST-FORWARD once compiled and sideloaded. One chapter,
CrossSiblingRelayDemo, is deliberately kept hand-written
(not compiled from .thr) — the project's one worked
example of a hand-authored component used directly as a router route's own
component:, composing three real
.thr siblings and hand-wiring the cross-sibling
focus(<id>)/relay pattern this page describes above.
See findings/focus-demo-app.md for what each chapter
covers and findings/demo-app-conventions.md for the
app-structure convention it follows.
⚠️ Not (yet) supported
- ○ Two elements at any nesting depth both statically
focusable="true"— rejected at compile time as a provable ambiguity. - ○ A router-free app needs an explicit boot-time claim (a hand-written
setup()calling into the focus manager) — nothing applies a proposed default focus into a vacuum on its own outside the router's automatic hand-off. - ○ A
{#if:destroy}block whose newly-mounted content is a nested custom component still needs an explicitclaimFocusIfVacantcall to hand focus into it — unregistering the OUTGOING content is now automatic (generated teardown asks the focus manager to walk its own registry by ownership, not by what this component's own template scan can see), but nothing claims focus for the incoming content on its own. - ○ No DSL-exposed timing configuration for
jumpFocus's own hold-to-repeat — it always reuses the fixedrepeatTuning()constants arrow-key repeat already uses.
Exact grammar: GRAMMAR.md. Full feature status: docs/features.md.