@roku-sdk/components 0.0.0-stage → 2.18.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.
Files changed (211) hide show
  1. package/BREAKING_CHANGES.md +84 -0
  2. package/LICENSE +21 -0
  3. package/README.md +19 -2
  4. package/core-jsx.d.ts +53 -0
  5. package/eslint.config.mjs +3 -0
  6. package/jsx.d.ts +273 -0
  7. package/package.json +84 -3
  8. package/src/__tests__/container.test.tsx +150 -0
  9. package/src/__tests__/glyphs.test.ts +202 -0
  10. package/src/__tests__/modal-boundary-export.test.ts +12 -0
  11. package/src/__tests__/native-modal.test.tsx +224 -0
  12. package/src/__tests__/tween-release.test.tsx +125 -0
  13. package/src/__tests__/tween-stops-shape.test.tsx +165 -0
  14. package/src/__tests__/tween-without-compat.test.tsx +64 -0
  15. package/src/__tests__/tween.test.tsx +195 -0
  16. package/src/blended-busy-spinner.props.ts +37 -0
  17. package/src/blended-busy-spinner.tsx +13 -0
  18. package/src/bounding-tracker.props.ts +18 -0
  19. package/src/bounding-tracker.tsx +13 -0
  20. package/src/button-row.tsx +126 -0
  21. package/src/channel-store.props.ts +425 -0
  22. package/src/channel-store.tsx +76 -0
  23. package/src/container.tsx +130 -0
  24. package/src/core/jsx-runtime.ts +39 -0
  25. package/src/focusable-group.props.ts +8 -0
  26. package/src/focusable-group.tsx +17 -0
  27. package/src/focusable.props.ts +14 -0
  28. package/src/focusable.tsx +17 -0
  29. package/src/glyphs.ts +116 -0
  30. package/src/helpers/index.tsx +4 -0
  31. package/src/helpers/resolution-context.tsx +68 -0
  32. package/src/helpers/scale-for-device-resolution.ts +31 -0
  33. package/src/index.tsx +70 -0
  34. package/src/jsx-props.ts +87 -0
  35. package/src/kdfs.d.ts +14 -0
  36. package/src/kdfs.ts +15 -0
  37. package/src/key-handler.props.ts +33 -0
  38. package/src/key-handler.tsx +24 -0
  39. package/src/modal-dialog.props.ts +38 -0
  40. package/src/modal-dialog.tsx +41 -0
  41. package/src/retain-asset.ts +24 -0
  42. package/src/sg-intrinsics.json +1153 -0
  43. package/src/sg-nodes/__tests__/__snapshots__/font.test.tsx.snap +13 -0
  44. package/src/sg-nodes/__tests__/__snapshots__/prop-forwarding.test.tsx.snap +4409 -0
  45. package/src/sg-nodes/__tests__/core-props.test.ts +301 -0
  46. package/src/sg-nodes/__tests__/font.test.tsx +141 -0
  47. package/src/sg-nodes/__tests__/imperative-methods.test.ts +268 -0
  48. package/src/sg-nodes/__tests__/prop-forwarding.test.tsx +1049 -0
  49. package/src/sg-nodes/__tests__/solid-integration.test.tsx +719 -0
  50. package/src/sg-nodes/animated-image.props.ts +64 -0
  51. package/src/sg-nodes/animated-image.tsx +23 -0
  52. package/src/sg-nodes/animation.props.ts +292 -0
  53. package/src/sg-nodes/animation.tsx +190 -0
  54. package/src/sg-nodes/array-grid.ts +602 -0
  55. package/src/sg-nodes/audio.props.ts +302 -0
  56. package/src/sg-nodes/audio.tsx +57 -0
  57. package/src/sg-nodes/busy-spinner.props.ts +59 -0
  58. package/src/sg-nodes/busy-spinner.tsx +84 -0
  59. package/src/sg-nodes/button-group.props.ts +141 -0
  60. package/src/sg-nodes/button-group.tsx +30 -0
  61. package/src/sg-nodes/button.props.ts +119 -0
  62. package/src/sg-nodes/button.tsx +31 -0
  63. package/src/sg-nodes/check-list.props.ts +71 -0
  64. package/src/sg-nodes/check-list.tsx +55 -0
  65. package/src/sg-nodes/content-node.props.ts +1316 -0
  66. package/src/sg-nodes/content-node.tsx +153 -0
  67. package/src/sg-nodes/core-props.ts +197 -0
  68. package/src/sg-nodes/custom-keyboard.props.ts +108 -0
  69. package/src/sg-nodes/custom-keyboard.tsx +48 -0
  70. package/src/sg-nodes/dialog.props.ts +159 -0
  71. package/src/sg-nodes/dialog.tsx +36 -0
  72. package/src/sg-nodes/dynamic-key-grid.props.ts +22 -0
  73. package/src/sg-nodes/dynamic-key-grid.tsx +16 -0
  74. package/src/sg-nodes/dynamic-keyboard.props.ts +30 -0
  75. package/src/sg-nodes/dynamic-keyboard.tsx +18 -0
  76. package/src/sg-nodes/dynamic-mini-keyboard.props.ts +22 -0
  77. package/src/sg-nodes/dynamic-mini-keyboard.tsx +18 -0
  78. package/src/sg-nodes/dynamic-pinpad.props.ts +57 -0
  79. package/src/sg-nodes/dynamic-pinpad.tsx +18 -0
  80. package/src/sg-nodes/effect.props.ts +116 -0
  81. package/src/sg-nodes/effect.tsx +39 -0
  82. package/src/sg-nodes/font.props.ts +45 -0
  83. package/src/sg-nodes/font.tsx +80 -0
  84. package/src/sg-nodes/group.props.ts +105 -0
  85. package/src/sg-nodes/group.tsx +30 -0
  86. package/src/sg-nodes/label-list.props.ts +86 -0
  87. package/src/sg-nodes/label-list.tsx +64 -0
  88. package/src/sg-nodes/label.props.ts +207 -0
  89. package/src/sg-nodes/label.tsx +56 -0
  90. package/src/sg-nodes/layout-group.props.ts +69 -0
  91. package/src/sg-nodes/layout-group.tsx +75 -0
  92. package/src/sg-nodes/list-panel.props.ts +104 -0
  93. package/src/sg-nodes/list-panel.tsx +49 -0
  94. package/src/sg-nodes/markup-grid.props.ts +78 -0
  95. package/src/sg-nodes/markup-grid.tsx +38 -0
  96. package/src/sg-nodes/markup-list.props.ts +68 -0
  97. package/src/sg-nodes/markup-list.tsx +38 -0
  98. package/src/sg-nodes/mask-group.props.ts +78 -0
  99. package/src/sg-nodes/mask-group.tsx +59 -0
  100. package/src/sg-nodes/media-playback-methods.ts +20 -0
  101. package/src/sg-nodes/node.ts +77 -0
  102. package/src/sg-nodes/overhang.props.ts +169 -0
  103. package/src/sg-nodes/overhang.tsx +29 -0
  104. package/src/sg-nodes/panel-set.props.ts +71 -0
  105. package/src/sg-nodes/panel-set.tsx +53 -0
  106. package/src/sg-nodes/panel.props.ts +176 -0
  107. package/src/sg-nodes/panel.tsx +56 -0
  108. package/src/sg-nodes/poster-grid.props.ts +277 -0
  109. package/src/sg-nodes/poster-grid.tsx +45 -0
  110. package/src/sg-nodes/poster.props.ts +125 -0
  111. package/src/sg-nodes/poster.tsx +44 -0
  112. package/src/sg-nodes/radio-button-list.props.ts +59 -0
  113. package/src/sg-nodes/radio-button-list.tsx +68 -0
  114. package/src/sg-nodes/rectangle.props.ts +29 -0
  115. package/src/sg-nodes/rectangle.tsx +27 -0
  116. package/src/sg-nodes/row-list.props.ts +288 -0
  117. package/src/sg-nodes/row-list.tsx +47 -0
  118. package/src/sg-nodes/scene.props.ts +65 -0
  119. package/src/sg-nodes/scene.tsx +15 -0
  120. package/src/sg-nodes/scrollable-text.props.ts +133 -0
  121. package/src/sg-nodes/scrollable-text.tsx +64 -0
  122. package/src/sg-nodes/scrolling-label.props.ts +106 -0
  123. package/src/sg-nodes/scrolling-label.tsx +42 -0
  124. package/src/sg-nodes/simple-label.props.ts +156 -0
  125. package/src/sg-nodes/simple-label.tsx +59 -0
  126. package/src/sg-nodes/sound-effect.props.ts +130 -0
  127. package/src/sg-nodes/sound-effect.tsx +65 -0
  128. package/src/sg-nodes/standard-dialog.props.ts +520 -0
  129. package/src/sg-nodes/standard-dialog.tsx +428 -0
  130. package/src/sg-nodes/standard-keyboard-dialog.props.ts +84 -0
  131. package/src/sg-nodes/standard-keyboard-dialog.tsx +59 -0
  132. package/src/sg-nodes/standard-message-dialog.props.ts +56 -0
  133. package/src/sg-nodes/standard-message-dialog.tsx +48 -0
  134. package/src/sg-nodes/standard-pinpad-dialog.props.ts +64 -0
  135. package/src/sg-nodes/standard-pinpad-dialog.tsx +46 -0
  136. package/src/sg-nodes/standard-progress-dialog.props.ts +30 -0
  137. package/src/sg-nodes/standard-progress-dialog.tsx +43 -0
  138. package/src/sg-nodes/target-group.props.ts +278 -0
  139. package/src/sg-nodes/target-group.tsx +63 -0
  140. package/src/sg-nodes/target-list.props.ts +128 -0
  141. package/src/sg-nodes/target-list.tsx +66 -0
  142. package/src/sg-nodes/target-set.props.ts +63 -0
  143. package/src/sg-nodes/target-set.tsx +42 -0
  144. package/src/sg-nodes/time-grid.props.ts +646 -0
  145. package/src/sg-nodes/time-grid.tsx +87 -0
  146. package/src/sg-nodes/tween.props.ts +115 -0
  147. package/src/sg-nodes/types.ts +150 -0
  148. package/src/sg-nodes/video.props.ts +573 -0
  149. package/src/sg-nodes/video.tsx +73 -0
  150. package/src/sg-nodes/voice-text-edit-box.props.ts +91 -0
  151. package/src/sg-nodes/voice-text-edit-box.tsx +15 -0
  152. package/src/sg-nodes/zoom-row-list.props.ts +416 -0
  153. package/src/sg-nodes/zoom-row-list.tsx +42 -0
  154. package/src/standard-dialog-next/__tests__/standard-dialog-next.test.tsx +186 -0
  155. package/src/standard-dialog-next/index.ts +5 -0
  156. package/src/standard-dialog-next/standard-dialog-next.props.ts +80 -0
  157. package/src/standard-dialog-next/standard-dialog-next.tsx +326 -0
  158. package/src/test-helpers/tween-test-setup.ts +78 -0
  159. package/src/tween.tsx +173 -0
  160. package/src/types.ts +8 -0
  161. package/src/xml-components/blended-busy-spinner/BlendedBusySpinner.brs +13 -0
  162. package/src/xml-components/blended-busy-spinner/BlendedBusySpinner.xml +10 -0
  163. package/src/xml-components/blended-busy-spinner/BlendedBusySpinner.xml.d.ts +42 -0
  164. package/src/xml-components/bounding-tracker/BoundingTracker.brs +26 -0
  165. package/src/xml-components/bounding-tracker/BoundingTracker.xml +14 -0
  166. package/src/xml-components/bounding-tracker/BoundingTracker.xml.d.ts +42 -0
  167. package/src/xml-components/channel-store/channelStoreExt.brs +159 -0
  168. package/src/xml-components/channel-store/channelStoreExt.xml +29 -0
  169. package/src/xml-components/channel-store/channelStoreExt.xml.d.ts +57 -0
  170. package/src/xml-components/focus/focusable-group.brs +251 -0
  171. package/src/xml-components/focus/focusable-group.xml +15 -0
  172. package/src/xml-components/focus/focusable-group.xml.d.ts +42 -0
  173. package/src/xml-components/focus/focusable.xml +18 -0
  174. package/src/xml-components/focus/focusable.xml.d.ts +48 -0
  175. package/src/xml-components/keyboards/CustomAddressKDF.keyboard.json +547 -0
  176. package/src/xml-components/keyboards/CustomKeyboard.brs +322 -0
  177. package/src/xml-components/keyboards/CustomKeyboard.xml +45 -0
  178. package/src/xml-components/keyboards/CustomKeyboard.xml.d.ts +73 -0
  179. package/src/xml-components/keyboards/KeyGridExt.xml +4 -0
  180. package/src/xml-components/keyboards/KeyGridExt.xml.d.ts +37 -0
  181. package/src/xml-components/keyboards/KeyboardExt.xml +4 -0
  182. package/src/xml-components/keyboards/KeyboardExt.xml.d.ts +37 -0
  183. package/src/xml-components/keyboards/MOPPinPadCustomKDF.keyboard.json +151 -0
  184. package/src/xml-components/keyboards/MiniKeyboardExt.xml +4 -0
  185. package/src/xml-components/keyboards/MiniKeyboardExt.xml.d.ts +37 -0
  186. package/src/xml-components/keyboards/PhonePadCustomKDF.keyboard.json +42 -0
  187. package/src/xml-components/keyboards/PinPadExt.brs +102 -0
  188. package/src/xml-components/keyboards/PinPadExt.xml +17 -0
  189. package/src/xml-components/keyboards/PinPadExt.xml.d.ts +49 -0
  190. package/src/xml-components/keyboards/WifiCustomKDF.keyboard.json +1242 -0
  191. package/src/xml-components/keyhandler/KeyHandler.brs +30 -0
  192. package/src/xml-components/keyhandler/KeyHandler.xml +16 -0
  193. package/src/xml-components/keyhandler/KeyHandler.xml.d.ts +45 -0
  194. package/src/xml-components/managed-list-panel/ManagedListPanel.brs +137 -0
  195. package/src/xml-components/managed-list-panel/ManagedListPanel.xml +10 -0
  196. package/src/xml-components/managed-list-panel/ManagedListPanel.xml.d.ts +37 -0
  197. package/src/xml-components/modal/Modal.brs +105 -0
  198. package/src/xml-components/modal/Modal.xml +22 -0
  199. package/src/xml-components/modal/Modal.xml.d.ts +54 -0
  200. package/src/xml-components/modal/StandardDialogExt.xml +10 -0
  201. package/src/xml-components/modal/StandardDialogExt.xml.d.ts +37 -0
  202. package/src/xml-components/modal/StandardKeyboardDialogExt.xml +10 -0
  203. package/src/xml-components/modal/StandardKeyboardDialogExt.xml.d.ts +37 -0
  204. package/src/xml-components/modal/StandardMessageDialogExt.xml +10 -0
  205. package/src/xml-components/modal/StandardMessageDialogExt.xml.d.ts +37 -0
  206. package/src/xml-components/modal/StandardPinPadDialogExt.xml +10 -0
  207. package/src/xml-components/modal/StandardPinPadDialogExt.xml.d.ts +37 -0
  208. package/src/xml-components/modal/StandardProgressDialogExt.xml +10 -0
  209. package/src/xml-components/modal/StandardProgressDialogExt.xml.d.ts +37 -0
  210. package/src/xml-components/modal/tests/Modal.test.brs +195 -0
  211. package/tsconfig.json +10 -0
@@ -0,0 +1,125 @@
1
+ // Copyright (c) 2026 Roku, Inc.
2
+ // SPDX-License-Identifier: MIT
3
+ // ---------------------------------------------------------------------------
4
+ // Tween nulls its target ref on dispose.
5
+ //
6
+ // The tween node and the node it animates reference each other on the render thread, so a
7
+ // tween left holding its target keeps a disposed grid alive. Each case disposes one tween and
8
+ // checks the last write to `targetNode` is null, on the native node and on the compat shim.
9
+ // ---------------------------------------------------------------------------
10
+
11
+ import "@roku-sdk/compat";
12
+
13
+ import { afterEach, beforeEach, describe, expect, jest, test } from "@jest/globals";
14
+ import type { DomNode } from "@roku-sdk/interfaces";
15
+ import { hasFeature } from "@roku-sdk/runtime/has-feature";
16
+ import { createSignal, type JSX, Show, tmTransaction } from "@roku-sdk/solid";
17
+
18
+ import { commitEarly, createAnchorNode, installSolidTestRenderer } from "../test-helpers/tween-test-setup";
19
+ import { Tween } from "../tween";
20
+
21
+ jest.mock("@roku-sdk/runtime/has-feature", () => ({
22
+ // The side-effect compat import loads this factory before the imported jest binding.
23
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
24
+ hasFeature: require("@jest/globals").jest.fn()
25
+ }));
26
+
27
+ type Case = [name: string, render: (target: () => DomNode | undefined) => JSX.Element];
28
+
29
+ const cases: Case[] = [
30
+ ["float field", target => <Tween targetRef={target} field="width" targetValue={100} />],
31
+ ["vector field", target => <Tween targetNode={target} axis="y" targetValue={100} />]
32
+ ];
33
+
34
+ const modes: [mode: string, native: boolean, nodeType: string][] = [
35
+ ["compat shim", false, "RsgTweenShim"],
36
+ ["native", true, "tween"]
37
+ ];
38
+
39
+ describe("Tween releases its target ref", () => {
40
+ let render: ReturnType<typeof installSolidTestRenderer>;
41
+ let setProperty: jest.SpiedFunction<typeof tmTransaction.setProperty>;
42
+ let createElement: jest.SpiedFunction<typeof tmTransaction.createElement>;
43
+
44
+ beforeEach(() => {
45
+ render = installSolidTestRenderer();
46
+ setProperty = jest.spyOn(tmTransaction, "setProperty");
47
+ createElement = jest.spyOn(tmTransaction, "createElement");
48
+ });
49
+
50
+ afterEach(() => {
51
+ jest.mocked(hasFeature).mockReset();
52
+ setProperty.mockRestore();
53
+ createElement.mockRestore();
54
+ });
55
+
56
+ const created = (nodeType: string): DomNode[] =>
57
+ createElement.mock.results.map(r => r.value as DomNode).filter(node => node.t === nodeType);
58
+ const lastWrite = (node: DomNode, name: string) => {
59
+ const writes = setProperty.mock.calls.filter(([n, field]) => n === node && field === name);
60
+ return writes[writes.length - 1];
61
+ };
62
+
63
+ describe.each(modes)("%s", (_mode, native, nodeType) => {
64
+ test.each(cases)("%s: nulls targetNode on dispose", (_name, renderTween) => {
65
+ jest.mocked(hasFeature).mockReturnValue(native);
66
+ let target: DomNode | undefined;
67
+ const dispose = render(
68
+ () => (
69
+ <group>
70
+ <rectangle id="target" ref={(el: unknown) => (target = el as DomNode)} />
71
+ {renderTween(() => target)}
72
+ </group>
73
+ ),
74
+ createAnchorNode()
75
+ );
76
+ commitEarly();
77
+
78
+ const [tween] = created(nodeType);
79
+ expect(created(nodeType)).toHaveLength(1);
80
+ expect(lastWrite(tween, "targetNode")?.[2]).toBe(target);
81
+
82
+ dispose();
83
+ commitEarly();
84
+
85
+ expect(lastWrite(tween, "targetNode")).toStrictEqual([tween, "targetNode", null, undefined]);
86
+ });
87
+ });
88
+
89
+ // The shim releases its Animation when targetNode is cleared, so the clear has to be issued
90
+ // before the tween node is taken out of the tree, not after.
91
+ test("clears targetNode before the unmounted tween is removed", () => {
92
+ jest.mocked(hasFeature).mockReturnValue(false);
93
+ const removeNode = jest.spyOn(tmTransaction, "removeNode");
94
+ const [shown, setShown] = createSignal(true);
95
+ let target: DomNode | undefined;
96
+ render(
97
+ () => (
98
+ <group>
99
+ <rectangle id="target" ref={(el: unknown) => (target = el as DomNode)} />
100
+ <Show when={shown()}>
101
+ <Tween targetNode={() => target} field="width" targetValue={100} />
102
+ </Show>
103
+ </group>
104
+ ),
105
+ createAnchorNode()
106
+ );
107
+ commitEarly();
108
+ const [tween] = created("RsgTweenShim");
109
+ expect(lastWrite(tween, "targetNode")?.[2]).toBe(target);
110
+
111
+ setShown(false);
112
+ commitEarly();
113
+
114
+ const clearAt = setProperty.mock.calls.findIndex(
115
+ ([n, field, value]) => n === tween && field === "targetNode" && value === null
116
+ );
117
+ expect(clearAt).toBeGreaterThanOrEqual(0);
118
+ const removed = removeNode.mock.calls.findIndex(([, child]) => child === tween);
119
+ expect(removed).toBeGreaterThanOrEqual(0);
120
+ expect(setProperty.mock.invocationCallOrder[clearAt]).toBeLessThan(
121
+ removeNode.mock.invocationCallOrder[removed]
122
+ );
123
+ removeNode.mockRestore();
124
+ });
125
+ });
@@ -0,0 +1,165 @@
1
+ // Copyright (c) 2026 Roku, Inc.
2
+ // SPDX-License-Identifier: MIT
3
+ // ---------------------------------------------------------------------------
4
+ // Tween warns in dev builds when proposedStops does not have the shape its field and axis call
5
+ // for: number[] on a float field or on a vector field under axis "x" / "y", [x, y][] under axis
6
+ // "all" (the default) on a vector field, and no axis on a float field. Each distinct problem is
7
+ // reported once per instance; unknown fields are not checked; nothing is reported without __DEV__.
8
+ // ---------------------------------------------------------------------------
9
+
10
+ import "@roku-sdk/compat";
11
+
12
+ import { afterEach, beforeEach, describe, expect, jest, test } from "@jest/globals";
13
+ import type { DomNode } from "@roku-sdk/interfaces";
14
+ import { hasFeature } from "@roku-sdk/runtime/has-feature";
15
+ import { createSignal, type JSX } from "@roku-sdk/solid";
16
+
17
+ import { commitEarly, createAnchorNode, installSolidTestRenderer } from "../test-helpers/tween-test-setup";
18
+ import { Tween } from "../tween";
19
+
20
+ jest.mock("@roku-sdk/runtime/has-feature", () => ({
21
+ // The side-effect compat import loads this factory before the imported jest binding.
22
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
23
+ hasFeature: require("@jest/globals").jest.fn()
24
+ }));
25
+
26
+ type Stops = number[] | [number, number][];
27
+
28
+ describe("Tween proposedStops shape warning (dev only)", () => {
29
+ let render: ReturnType<typeof installSolidTestRenderer>;
30
+ let warn: jest.SpiedFunction<typeof console.warn>;
31
+ let realDev: boolean | undefined;
32
+ let dispose: (() => void) | undefined;
33
+
34
+ beforeEach(() => {
35
+ realDev = (globalThis as { __DEV__?: boolean }).__DEV__;
36
+ (globalThis as { __DEV__?: boolean }).__DEV__ = true;
37
+ jest.mocked(hasFeature).mockReturnValue(true);
38
+ render = installSolidTestRenderer();
39
+ warn = jest.spyOn(console, "warn").mockImplementation(() => {});
40
+ });
41
+
42
+ afterEach(() => {
43
+ dispose?.();
44
+ dispose = undefined;
45
+ (globalThis as { __DEV__?: boolean }).__DEV__ = realDev;
46
+ jest.mocked(hasFeature).mockReset();
47
+ warn.mockRestore();
48
+ });
49
+
50
+ const mount = (renderTween: (target: () => DomNode | undefined) => JSX.Element): void => {
51
+ let target: DomNode | undefined;
52
+ dispose = render(
53
+ () => (
54
+ <group>
55
+ <rectangle ref={(el: unknown) => (target = el as DomNode)} />
56
+ {renderTween(() => target)}
57
+ </group>
58
+ ),
59
+ createAnchorNode()
60
+ );
61
+ commitEarly();
62
+ };
63
+
64
+ const tweenWarnings = (): string[] =>
65
+ warn.mock.calls.map(c => String(c[0])).filter(message => message.startsWith("Tween:"));
66
+
67
+ test("warns once when a vector field under axis x / y gets an array stop", () => {
68
+ mount(target => (
69
+ <Tween
70
+ targetRef={target}
71
+ axis="x"
72
+ targetValue={100}
73
+ proposedStops={[[300, 0], 200] as unknown as number[]}
74
+ />
75
+ ));
76
+ expect(tweenWarnings()).toEqual([
77
+ 'Tween: proposedStops on "translation" with axis "x" must be scalars (the x coordinate); got an array. Array entries are skipped.'
78
+ ]);
79
+ });
80
+
81
+ test("warns when a vector field under axis all gets a scalar stop", () => {
82
+ mount(target => <Tween targetRef={target} axis="all" targetVector={[100, 100]} proposedStops={[400, 300]} />);
83
+ expect(tweenWarnings()).toEqual([
84
+ 'Tween: proposedStops on "translation" with axis "all" must be [x, y] pairs; got a scalar. Scalar entries are skipped.'
85
+ ]);
86
+ });
87
+
88
+ test("treats an unset axis on a vector field as all, and checks pair length", () => {
89
+ mount(target => (
90
+ <Tween
91
+ targetRef={target}
92
+ field="scale"
93
+ targetVector={[1, 1]}
94
+ proposedStops={[[1, 2, 3]] as unknown as [number, number][]}
95
+ />
96
+ ));
97
+ expect(tweenWarnings()).toEqual([
98
+ 'Tween: proposedStops on "scale" with axis "all" must be [x, y] pairs; got an array of length 3. Entries that are not pairs are skipped.'
99
+ ]);
100
+ });
101
+
102
+ test("warns when a float field is given an axis", () => {
103
+ mount(target => <Tween targetRef={target} field="opacity" axis="x" targetValue={0.5} proposedStops={[0.2]} />);
104
+ expect(tweenWarnings()).toEqual(['Tween: axis "x" is ignored on float field "opacity"; remove it.']);
105
+ });
106
+
107
+ test("warns when a float field gets an array stop", () => {
108
+ mount(target => (
109
+ <Tween
110
+ targetRef={target}
111
+ field="width"
112
+ targetValue={100}
113
+ proposedStops={[[50, 50]] as [number, number][]}
114
+ />
115
+ ));
116
+ expect(tweenWarnings()).toEqual([
117
+ 'Tween: proposedStops on float field "width" must be numbers; got an array. Array entries are skipped.'
118
+ ]);
119
+ });
120
+
121
+ test("reports each distinct problem once per instance, across stop updates", () => {
122
+ const [stops, setStops] = createSignal<Stops>([400, 300]);
123
+ mount(target => <Tween targetRef={target} targetVector={[100, 100]} proposedStops={stops()} />);
124
+ setStops([500]);
125
+ commitEarly();
126
+ setStops([[1, 2, 3]] as unknown as [number, number][]);
127
+ commitEarly();
128
+ setStops([600]);
129
+ commitEarly();
130
+ expect(tweenWarnings()).toHaveLength(2);
131
+ expect(tweenWarnings()[0]).toContain("got a scalar");
132
+ expect(tweenWarnings()[1]).toContain("got an array of length 3");
133
+ });
134
+
135
+ test("does not warn when every stop has the right shape", () => {
136
+ mount(target => (
137
+ <>
138
+ <Tween targetRef={target} axis="y" targetValue={100} proposedStops={[100, 200]} />
139
+ <Tween targetRef={target} targetVector={[1, 2]} proposedStops={[[1, 2]]} />
140
+ <Tween targetRef={target} field="rotation" targetValue={1} proposedStops={[0.5, 1]} />
141
+ <Tween targetRef={target} field="opacity" targetValue={1} />
142
+ </>
143
+ ));
144
+ expect(tweenWarnings()).toEqual([]);
145
+ });
146
+
147
+ test("does not check a field it does not know", () => {
148
+ mount(target => (
149
+ <Tween
150
+ targetRef={target}
151
+ field="customField"
152
+ axis="all"
153
+ targetValue={1}
154
+ proposedStops={[1, [1, 2, 3]] as unknown as number[]}
155
+ />
156
+ ));
157
+ expect(tweenWarnings()).toEqual([]);
158
+ });
159
+
160
+ test("is silent without __DEV__", () => {
161
+ (globalThis as { __DEV__?: boolean }).__DEV__ = false;
162
+ mount(target => <Tween targetRef={target} axis="all" targetVector={[100, 100]} proposedStops={[400, 300]} />);
163
+ expect(tweenWarnings()).toEqual([]);
164
+ });
165
+ });
@@ -0,0 +1,64 @@
1
+ // Copyright (c) 2026 Roku, Inc.
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ // ---------------------------------------------------------------------------
5
+ // Without `@roku-sdk/compat`, a Tween on firmware with no native node refuses loudly instead of
6
+ // asking SceneGraph for a node type it does not have. This file deliberately never imports
7
+ // compat, so only the refusal alias is registered.
8
+ // ---------------------------------------------------------------------------
9
+
10
+ import "@roku-sdk/compat/refuse-gated-tags";
11
+
12
+ import { afterEach, beforeEach, describe, expect, jest, test } from "@jest/globals";
13
+ import type { DomNode } from "@roku-sdk/interfaces";
14
+ import { hasFeature } from "@roku-sdk/runtime/has-feature";
15
+
16
+ import { commitEarly, createAnchorNode, installSolidTestRenderer } from "../test-helpers/tween-test-setup";
17
+ import { Tween } from "../tween";
18
+
19
+ jest.mock("@roku-sdk/runtime/has-feature", () => ({
20
+ // The side-effect compat import loads this factory before the imported jest binding.
21
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
22
+ hasFeature: require("@jest/globals").jest.fn()
23
+ }));
24
+
25
+ describe("Tween without @roku-sdk/compat", () => {
26
+ let render: ReturnType<typeof installSolidTestRenderer>;
27
+ let dispose: (() => void) | undefined;
28
+
29
+ beforeEach(() => {
30
+ render = installSolidTestRenderer();
31
+ });
32
+
33
+ afterEach(() => {
34
+ dispose?.();
35
+ dispose = undefined;
36
+ jest.mocked(hasFeature).mockReset();
37
+ });
38
+
39
+ const mount = (): void => {
40
+ let target: DomNode | undefined;
41
+ dispose = render(
42
+ () => (
43
+ <group>
44
+ <rectangle ref={(el: unknown) => (target = el as DomNode)} />
45
+ <Tween targetRef={() => target} axis="x" targetValue={100} />
46
+ </group>
47
+ ),
48
+ createAnchorNode()
49
+ );
50
+ commitEarly();
51
+ };
52
+
53
+ test("throws naming the missing firmware feature and compat when the firmware lacks the node", () => {
54
+ jest.mocked(hasFeature).mockReturnValue(false);
55
+
56
+ expect(mount).toThrow('<tween> requires the "tween" firmware feature or @roku-sdk/compat');
57
+ });
58
+
59
+ test("creates the native node when the firmware has it", () => {
60
+ jest.mocked(hasFeature).mockReturnValue(true);
61
+
62
+ expect(mount).not.toThrow();
63
+ });
64
+ });
@@ -0,0 +1,195 @@
1
+ // Copyright (c) 2026 Roku, Inc.
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ // ---------------------------------------------------------------------------
5
+ // Tween renders one `<tween>`: the native node when firmware reports the "tween" feature,
6
+ // otherwise the compat shim. It sends the native node only the fields it has.
7
+ // ---------------------------------------------------------------------------
8
+
9
+ import "@roku-sdk/compat";
10
+
11
+ import { afterEach, beforeEach, describe, expect, jest, test } from "@jest/globals";
12
+ import type { AnimationState, Vector2D } from "@roku-sdk/components";
13
+ import type { DomNode } from "@roku-sdk/interfaces";
14
+ import { hasFeature } from "@roku-sdk/runtime/has-feature";
15
+ import { batch, createSignal, type JSX, tmTransaction } from "@roku-sdk/solid";
16
+
17
+ import { commitEarly, createAnchorNode, installSolidTestRenderer } from "../test-helpers/tween-test-setup";
18
+ import { Tween } from "../tween";
19
+
20
+ jest.mock("@roku-sdk/runtime/has-feature", () => ({
21
+ // The side-effect compat import loads this factory before the imported jest binding.
22
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
23
+ hasFeature: require("@jest/globals").jest.fn()
24
+ }));
25
+
26
+ describe("Tween", () => {
27
+ let render: ReturnType<typeof installSolidTestRenderer>;
28
+ let setProperty: jest.SpiedFunction<typeof tmTransaction.setProperty>;
29
+ let createElement: jest.SpiedFunction<typeof tmTransaction.createElement>;
30
+ let dispose: (() => void) | undefined;
31
+
32
+ beforeEach(() => {
33
+ render = installSolidTestRenderer();
34
+ setProperty = jest.spyOn(tmTransaction, "setProperty");
35
+ createElement = jest.spyOn(tmTransaction, "createElement");
36
+ });
37
+
38
+ afterEach(() => {
39
+ dispose?.();
40
+ dispose = undefined;
41
+ jest.mocked(hasFeature).mockReset();
42
+ setProperty.mockRestore();
43
+ createElement.mockRestore();
44
+ });
45
+
46
+ const createdTypes = (): string[] => createElement.mock.results.map(r => (r.value as DomNode).t);
47
+
48
+ const mount = (native: boolean, renderTween: (target: () => DomNode | undefined) => JSX.Element): DomNode => {
49
+ jest.mocked(hasFeature).mockReturnValue(native);
50
+ let target: DomNode | undefined;
51
+ dispose = render(
52
+ () => (
53
+ <group>
54
+ <rectangle ref={(el: unknown) => (target = el as DomNode)} />
55
+ {renderTween(() => target)}
56
+ </group>
57
+ ),
58
+ createAnchorNode()
59
+ );
60
+ commitEarly();
61
+ const index = createElement.mock.calls.findIndex(([tag]) => tag === "tween");
62
+ return createElement.mock.results[index].value as DomNode;
63
+ };
64
+ const writes = (node: DomNode, name: string): unknown[] =>
65
+ setProperty.mock.calls.filter(([n, field]) => n === node && field === name).map(([, , value]) => value);
66
+
67
+ describe("node selection", () => {
68
+ test("creates the native node alone when the firmware reports the feature", () => {
69
+ const tween = mount(true, target => <Tween targetRef={target} axis="x" targetValue={100} />);
70
+
71
+ expect(tween.t).toBe("tween");
72
+ expect(createdTypes()).toEqual(["group", "rectangle", "tween"]);
73
+ });
74
+
75
+ test("creates the compat shim alone when the firmware does not report the feature", () => {
76
+ const tween = mount(false, target => <Tween targetRef={target} axis="x" targetValue={100} />);
77
+
78
+ expect(tween.t).toBe("RsgTweenShim");
79
+ expect(createdTypes()).toEqual(["group", "rectangle", "RsgTweenShim"]);
80
+ });
81
+ });
82
+
83
+ describe("native field contract", () => {
84
+ test('sends an axis="all" targetVector as given and writes no targetValue', () => {
85
+ const tween = mount(true, target => <Tween targetRef={target} axis="all" targetVector={[40, 90]} />);
86
+
87
+ expect(writes(tween, "targetVector")).toContainEqual([40, 90]);
88
+ expect(writes(tween, "targetValue").filter(v => v !== undefined)).toHaveLength(0);
89
+ });
90
+
91
+ test("sends the target immediately", () => {
92
+ const tween = mount(true, target => <Tween targetRef={target} axis="x" targetValue={100} />);
93
+
94
+ expect(writes(tween, "targetValue")).toContain(100);
95
+ });
96
+
97
+ test.each([true, false])("omits the state observer without a callback (native=%s)", native => {
98
+ const tween = mount(native, target => <Tween targetRef={target} targetValue={100} />);
99
+
100
+ expect(writes(tween, "onAnimationState").filter(value => value !== undefined)).toHaveLength(0);
101
+ });
102
+
103
+ test("reactively installs, replaces and removes the state callback", () => {
104
+ type Handler = NonNullable<Parameters<typeof Tween>[0]["onAnimationState"]>;
105
+ const [callback, setCallback] = createSignal<Handler | undefined>();
106
+ const first = jest.fn<Handler>();
107
+ const second = jest.fn<Handler>();
108
+ const tween = mount(true, target => <Tween targetRef={target} onAnimationState={callback()} />);
109
+ expect(writes(tween, "onAnimationState").filter(value => value !== undefined)).toHaveLength(0);
110
+
111
+ setCallback(() => first);
112
+ commitEarly();
113
+ const adapter = writes(tween, "onAnimationState").at(-1) as (t: [AnimationState, Vector2D]) => void;
114
+ expect(typeof adapter).toBe("function");
115
+ adapter(["running", [10, 20]]);
116
+ expect(first).toHaveBeenCalledWith("running", [10, 20]);
117
+
118
+ setCallback(() => second);
119
+ commitEarly();
120
+ adapter(["stopped", [30, 40]]);
121
+ expect(second).toHaveBeenCalledWith("stopped", [30, 40]);
122
+ expect(first).toHaveBeenCalledTimes(1);
123
+
124
+ setCallback(undefined);
125
+ commitEarly();
126
+ expect(writes(tween, "onAnimationState").at(-1)).toBeUndefined();
127
+ });
128
+
129
+ test("forwards onAnimationState as (state, value)", () => {
130
+ const onAnimationState = jest.fn();
131
+ const tween = mount(true, target => (
132
+ <Tween targetRef={target} axis="x" targetValue={100} onAnimationState={onAnimationState} />
133
+ ));
134
+
135
+ const handler = writes(tween, "onAnimationState")[0] as (t: [AnimationState, Vector2D]) => void;
136
+ handler(["running", [10, 20]]);
137
+
138
+ expect(onAnimationState).toHaveBeenCalledWith("running", [10, 20]);
139
+ });
140
+ });
141
+
142
+ test("targetNode takes precedence over the deprecated targetRef alias", () => {
143
+ const legacy = { t: "rectangle", r: { id: 999 }, c: [] } as unknown as DomNode;
144
+ const tween = mount(true, target => <Tween targetNode={target} targetRef={legacy} targetValue={100} />);
145
+ const targets = writes(tween, "targetNode");
146
+ expect(targets).toHaveLength(1);
147
+ expect(targets[0]).not.toBe(legacy);
148
+ });
149
+
150
+ describe("target switch", () => {
151
+ test.each([
152
+ ["value first", true],
153
+ ["target first", false]
154
+ ])("writes the new targetNode before the trigger that goes with it (%s)", (_, valueFirst) => {
155
+ jest.mocked(hasFeature).mockReturnValue(true);
156
+ const nodes: Record<string, DomNode> = {};
157
+ const [onB, setOnB] = createSignal(false);
158
+ const [to, setTo] = createSignal(100);
159
+ dispose = render(
160
+ () => (
161
+ <group>
162
+ <rectangle ref={(el: unknown) => (nodes.a = el as DomNode)} />
163
+ <rectangle ref={(el: unknown) => (nodes.b = el as DomNode)} />
164
+ <Tween targetNode={() => (onB() ? nodes.b : nodes.a)} axis="x" targetValue={to()} />
165
+ </group>
166
+ ),
167
+ createAnchorNode()
168
+ );
169
+ commitEarly();
170
+ const tween = createElement.mock.results[createElement.mock.calls.findIndex(([t]) => t === "tween")]
171
+ .value as DomNode;
172
+ setProperty.mockClear();
173
+
174
+ batch(() => {
175
+ if (valueFirst) setTo(300);
176
+ setOnB(true);
177
+ if (!valueFirst) setTo(300);
178
+ });
179
+ commitEarly();
180
+
181
+ const order = setProperty.mock.calls
182
+ .filter(([n, field]) => n === tween && (field === "targetNode" || field === "targetValue"))
183
+ .map(([, field, value]) => (field === "targetNode" ? (value === nodes.b ? "B" : "?") : value));
184
+ expect(order).toEqual(["B", 300]);
185
+ });
186
+ });
187
+
188
+ describe("shim field contract", () => {
189
+ test("writes no animationNode", () => {
190
+ const tween = mount(false, target => <Tween targetRef={target} axis="x" targetValue={100} />);
191
+
192
+ expect(writes(tween, "animationNode")).toHaveLength(0);
193
+ });
194
+ });
195
+ });
@@ -0,0 +1,37 @@
1
+ // Copyright (c) 2026 Roku, Inc.
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import type { IBusySpinnerProps } from "./sg-nodes/busy-spinner.props";
5
+
6
+ /**
7
+ * Properties for the BlendedBusySpinner node.
8
+ *
9
+ * BlendedBusySpinner extends BusySpinner with blend color support, displaying an animated
10
+ * spinning indicator to show that content is loading or an operation is in progress.
11
+ *
12
+ * The spinner inherits all BusySpinner properties (control, clockwise, spinInterval, uri)
13
+ * and adds blend color customization.
14
+ *
15
+ * @see {@link https://developer.roku.com/dev/docs/busyspinner}
16
+ *
17
+ * @example
18
+ * // Basic usage with default appearance
19
+ * <BlendedBusySpinner control="start" translation={[100, 100]} uri="pkg:/images/loader.png" />
20
+ *
21
+ * @example
22
+ * // Custom spinner with speed control and blend color
23
+ * <BlendedBusySpinner
24
+ * control="start"
25
+ * clockwise={false}
26
+ * spinInterval={1}
27
+ * uri="pkg:/images/custom-spinner.png"
28
+ * blendColor="0xFF0000FF"
29
+ * />
30
+ */
31
+ export interface IBlendedBusySpinnerProps extends IBusySpinnerProps {
32
+ /**
33
+ * Specifies the blend color for the spinner image.
34
+ * The blend color is applied to the spinner bitmap to modify its appearance.
35
+ */
36
+ blendColor?: string;
37
+ }
@@ -0,0 +1,13 @@
1
+ // Copyright (c) 2026 Roku, Inc.
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import type { IBlendedBusySpinnerProps } from "./blended-busy-spinner.props";
5
+ import { retainAsset } from "./retain-asset";
6
+ import { Component } from "./sg-nodes/types";
7
+ import BlendedBusySpinnerXML from "./xml-components/blended-busy-spinner/BlendedBusySpinner.xml";
8
+ export type { IBlendedBusySpinnerProps } from "./blended-busy-spinner.props";
9
+
10
+ export const BlendedBusySpinner: Component<IBlendedBusySpinnerProps> = props => {
11
+ retainAsset(BlendedBusySpinnerXML);
12
+ return <blendedBusySpinner {...props}></blendedBusySpinner>;
13
+ };
@@ -0,0 +1,18 @@
1
+ // Copyright (c) 2026 Roku, Inc.
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import type { IGroupProps } from "./sg-nodes/group.props";
5
+ import type { EventHandler, JSX } from "./sg-nodes/types";
6
+
7
+ export interface IBoundingRect {
8
+ x: number;
9
+ y: number;
10
+ width: number;
11
+ height: number;
12
+ }
13
+
14
+ export interface IBoundingTrackerProps extends IGroupProps {
15
+ doMeasure?: boolean;
16
+ onBoundsChange?: EventHandler<IBoundingRect>;
17
+ children?: JSX.Element;
18
+ }
@@ -0,0 +1,13 @@
1
+ // Copyright (c) 2026 Roku, Inc.
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import type { IBoundingTrackerProps } from "./bounding-tracker.props";
5
+ import { Component } from "./index";
6
+ import { retainAsset } from "./retain-asset";
7
+ import BoundingTrackerXML from "./xml-components/bounding-tracker/BoundingTracker.xml";
8
+ export type { IBoundingRect, IBoundingTrackerProps } from "./bounding-tracker.props";
9
+
10
+ export const BoundingTracker: Component<IBoundingTrackerProps> = props => {
11
+ retainAsset(BoundingTrackerXML);
12
+ return <bounding-tracker {...props}>{props.children}</bounding-tracker>;
13
+ };