Extends example (focus and key handling with anchor nodes)

This RSG 2.0 SDK example demonstrates how to implement focus and key handling on TypeScript Anchor nodes by extending BrightScript components.

Background

In Roku's SceneGraph, focus management and key event handling are native capabilities that you must implement on the render thread (the BrightScript domain). TypeScript components (anchor nodes) run asynchronously off the render thread; as a result, they cannot directly do the following:

  • Receive focus state changes
  • Handle key events (OK, Back, directional keys, and so on)
  • Set themselves as focusable
  • Participate in focus management

Solution

With the RSG 2.0 SDK, TypeScript components can extend any BrightScript component. This capability enables the following architectural pattern:

  1. Create a BrightScript base component (for example, ButtonBase) that handles render thread concerns:

    • Focus state management (focusable, onFocusChanged)
    • Key event handling (onKeyEvent)
    • Communication fields (input/output) for TypeScript integration
  2. Extend the base component from TypeScript (for example, TsCustomButton extends ButtonBase) to add:

    • Reactive UI using TypeScript and JSX
    • Business logic and state management
    • Component patterns (SolidJS reactivity)
  3. Communicate between domains using SceneGraph fields:

    • Input fields (BrightScript to TypeScript): focused, key array
    • Output fields (TypeScript to BrightScript): keys array (allowed keys), onClick counter

Summary

This section describes the architecture, components, and communication flow of the example.

Architecture overview

The following diagram shows how the scene, the buttons, and the base component relate.

BaseAppScene manages four buttons. Button1 and Button2 are XML components. Button3 and Button4 are TypeScript components that extend ButtonBase.

Components

The example uses the following three components:

  1. ButtonBase (BrightScript and XML)
  2. TsCustomButton (TypeScript and JSX)
  3. BaseAppScene (BrightScript and XML)

ButtonBase (BrightScript/XML)

Location: components/ButtonBase.xml and components/ButtonBase.brs

Purpose: The base component that provides focus and key handling infrastructure.

The following code shows the base component:

sub init()
    m.top.focusable = true
    m.top.observeField("focusedChild", "onFocusChanged")
end sub

sub onFocusChanged()
    m.top.focused = (m.top.hasFocus() or m.top.isInFocusChain())
end sub

function onKeyEvent(key as String, press as Boolean) as Boolean
    if press and m.top.keys.count() > 0
        ' Check if key is in allowed keys array
        for each allowedKey in m.top.keys
            if allowedKey = key
                m.top.key = [key, m.keyPressCount]
                m.keyPressCount++
                return true
            end if
        end for
    end if
    return false
end function

The base component has these interface fields:

  • focused (bool): Current focus state
  • key (array): Most recent key press as [keyName, index]
  • keys (array): Allowed keys (set by TypeScript component)
  • onClick (integer): Click counter (set by TypeScript component)

TsCustomButton (TypeScript/JSX)

Location: src/components/ts-custom-button.tsx

Purpose: The TypeScript component that extends ButtonBase and provides reactive UI.

The following code shows the component descriptor:

export const TsCustomButtonDescription = {
    componentName: "TsCustomButton",
    extends: "ButtonBase" as const, // ← Extends ButtonBase, not Group!
    inputFields: {
        text: FieldType.String(),
        width: FieldType.Number(),
        height: FieldType.Number(),
        focused: FieldType.Boolean(false), // From ButtonBase
        key: FieldType.Array() // From ButtonBase
    },
    outputFields: {
        keys: FieldType.Array(), // Tell ButtonBase which keys to handle
        onClick: FieldType.Number() // Notify scene of clicks
    }
};

The following code shows the reactive behavior:

export const TsCustomButton: TsComponent<typeof TsCustomButtonDescription> = (inputs, outputs) => {
    let clickCount = 0;

    // React to focus changes
    createEffect(() => {
        if (inputs.focused) {
            outputs.keys(["OK"]);  // Accept OK key when focused
        } else {
            outputs.keys([]);      // Accept no keys when unfocused
        }
    });

    // React to key presses
    createEffect(() => {
        if (inputs.key && inputs.key[0] === "OK") {
            clickCount++;
            outputs.onClick(clickCount);
        }
    });

    // Reactive UI
    return (
        <group>
            <rectangle
                color={inputs.focused ? "0x0080FFFF" : "0x404040FF"}
                width={inputs.width}
                height={inputs.height}
            />
            <label
                text={inputs.text}
                color="0xFFFFFFFF"
                width={inputs.width}
                height={inputs.height}
            />
        </group>
    );
};

BaseAppScene (BrightScript/XML)

Location: components/BaseAppScene.xml and components/BaseAppScene.brs

Purpose: The main scene that manages the button collection and routing.

The following code shows the scene:

sub init()
    m.buttons = [
        m.top.findNode("button1"),
        m.top.findNode("button2"),
        m.top.findNode("button3"),
        m.top.findNode("button4")
    ]

    ' Set up observers for all buttons
    for i = 0 to m.buttons.count() - 1
        m.buttons[i].observeField("onClick", "onButtonClicked")
    next i

    m.buttons[0].setFocus(true)
end sub

sub onButtonClicked(event as Object)
    buttonNode = event.getRoSGNode()
    ' Find which button was clicked
    for i = 0 to m.buttons.count() - 1
        if m.buttons[i].isSameNode(buttonNode)
            print "[Scene] Button " + Str(i + 1) + " clicked!"
            exit for
        end if
    next i
end sub

Communication flow

The following sequences show how a focus change and a key press move between BrightScript and TypeScript.

  1. Focus change:

    User navigates → ButtonBase.onFocusChanged()
    → Sets ButtonBase.focused field
    → TsCustomButton.inputs.focused updates
    → createEffect triggers
    → Background color changes (blue/gray)
    → outputs.keys() tells ButtonBase which keys to accept
  2. Key press:

    User presses OK → ButtonBase.onKeyEvent()
    → Checks if "OK" in m.top.keys array
    → Sets ButtonBase.key = ["OK", count]
    → TsCustomButton.inputs.key updates
    → createEffect triggers
    → Increments clickCount
    → outputs.onClick() notifies scene
    → BaseAppScene.onButtonClicked() handles event

Key patterns

This example relies on three patterns:

  1. Field communication with alwaysNotify
  2. Allowed keys pattern
  3. Field naming that avoids type-mismatch collisions

Field communication with alwaysNotify

For edge detection (the same key pressed multiple times), use the alwaysNotify attribute:

<field id="key" type="array" alwaysNotify="true" />

The array [keyName, index] makes each key press trigger observers, even when you press the same key repeatedly.

Allowed keys pattern

The TypeScript component controls which keys ButtonBase handles:

  • Focused: outputs.keys(["OK"]) accepts the OK key
  • Unfocused: outputs.keys([]) accepts no keys

Field naming that avoids type-mismatch collisions

When an anchor node extends a custom base component, its inputFields and outputFields share one field namespace with the base's <interface> fields. This example reuses base field names (focused, key) with the same type, which is safe. A field with the same name and a different type is not safe:

  • A field with the same name and the same type (as here) round-trips on both sides.
  • You can use distinct names. If you need to reach a base field, you can bridge to it under a different name and forward it inside the base BrightScript.
  • You must not declare a field whose name matches a base field but whose type differs (for example, a string input over an array base field). This causes a failure on the device: the node is rejected at creation (Field <name> type mismatch during creation), and the app exits. The SDK does not have any build-time guard for this case.

See AnchorNode and base-component field-name collisions for more information about the full behavior matrix.

Running the example

To run the example, use these commands:

# Install dependencies
pnpm install

# Build and deploy the app
npx rk dev

# Run tests
npm test

# Run with coverage
npm run coverage

Did this page help you?