@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,150 @@
1
+ // Copyright (c) 2026 Roku, Inc.
2
+ // SPDX-License-Identifier: MIT
3
+ /**
4
+ * Transaction semantics of the imperative `ContainerRef` API.
5
+ *
6
+ * `insertNode` / `removeNode` are called from outside the Solid renderer, so
7
+ * unlike every renderer-driven mutation they have to open the transaction
8
+ * themselves. The mocks below record native tree-manipulation calls and
9
+ * transaction boundaries into one ordered log so a call that escapes its
10
+ * transaction is visible.
11
+ *
12
+ * Imports use relative module paths (not the @roku-sdk/components barrel) so
13
+ * Jest only transforms this feature's dependency subtree, per the monorepo's
14
+ * OOM-avoidance guidance.
15
+ */
16
+ import { beforeEach, describe, expect, jest, test } from "@jest/globals";
17
+ import { registerInternStringApi } from "@roku-sdk/common/string-interner";
18
+ import { registerTransactionApi, transactionManager } from "@roku-sdk/common/transaction";
19
+ import { DomNode, InternStringApi, StructuredDataApi, TmNodeRef, TreeManipulationApi } from "@roku-sdk/interfaces";
20
+ import { makeSolidRenderer, Renderer } from "@roku-sdk/solid";
21
+
22
+ import { Container, ContainerRef } from "../container";
23
+
24
+ describe("Container imperative API", () => {
25
+ const commitEarly = transactionManager.commitEarly;
26
+
27
+ let render: Renderer<DomNode>["render"];
28
+ let mockTmApi: TreeManipulationApi;
29
+ /** Ordered log of transaction boundaries and native tree mutations. */
30
+ let calls: string[];
31
+
32
+ beforeEach(() => {
33
+ calls = [];
34
+ let nodeRef = 0;
35
+ let internRef = 0;
36
+
37
+ mockTmApi = {
38
+ createNode: jest.fn().mockImplementation((): object => ({ id: ++nodeRef })),
39
+ appendNode: jest.fn().mockImplementation(() => {
40
+ calls.push("appendNode");
41
+ }),
42
+ insertNodeBefore: jest.fn(),
43
+ removeNode: jest.fn().mockImplementation(() => {
44
+ calls.push("removeNode");
45
+ }),
46
+ setNodePropNull: jest.fn(),
47
+ setNodePropBool: jest.fn(),
48
+ setNodePropInt: jest.fn(),
49
+ setNodePropFloat: jest.fn(),
50
+ setNodePropString: jest.fn(),
51
+ setNodePropObject: jest.fn(),
52
+ setNodePropArray: jest.fn(),
53
+ setNodePropNodeRef: jest.fn(),
54
+ registerFieldChangeHandler: jest.fn(),
55
+ unregisterFieldHandler: jest.fn(),
56
+ setFocus: jest.fn()
57
+ } as unknown as TreeManipulationApi;
58
+
59
+ const internMap = new Map<string, object>();
60
+ const mockIntern: InternStringApi = {
61
+ internString: (str: string): object => {
62
+ const interned = internMap.get(str);
63
+ if (interned) {
64
+ return interned;
65
+ }
66
+ const ref = { id: ++internRef };
67
+ internMap.set(str, ref);
68
+ return ref;
69
+ }
70
+ };
71
+ registerInternStringApi(mockIntern);
72
+
73
+ registerTransactionApi({
74
+ start: () => calls.push("tx:start"),
75
+ end: () => calls.push("tx:end")
76
+ });
77
+
78
+ const mockStructuredDataApi = {
79
+ createArray: jest.fn().mockImplementation(() => ({})),
80
+ appendInt: jest.fn(),
81
+ createObject: jest.fn().mockImplementation(() => ({})),
82
+ insertInt: jest.fn(),
83
+ insertArray: jest.fn()
84
+ } as unknown as StructuredDataApi;
85
+
86
+ const solidRenderer = makeSolidRenderer(mockTmApi, mockStructuredDataApi, mockStructuredDataApi);
87
+ ({ render } = solidRenderer.renderer);
88
+ });
89
+
90
+ /** Renders a Container holding one child and returns handles to both. */
91
+ function renderContainer(): { containerRef: ContainerRef; child: DomNode; dispose: () => void } {
92
+ let containerRef!: ContainerRef;
93
+ let child!: DomNode;
94
+
95
+ const dispose = render(
96
+ () => (
97
+ <Container
98
+ ref={(el: ContainerRef) => {
99
+ containerRef = el;
100
+ }}
101
+ >
102
+ <group
103
+ ref={(el: DomNode) => {
104
+ child = el;
105
+ }}
106
+ />
107
+ </Container>
108
+ ),
109
+ { r: {} as TmNodeRef, c: [] }
110
+ );
111
+ commitEarly();
112
+ calls.length = 0;
113
+
114
+ return { containerRef, child, dispose };
115
+ }
116
+
117
+ test("removeNode detaches the child inside a transaction", () => {
118
+ const { containerRef, child, dispose } = renderContainer();
119
+
120
+ containerRef.removeNode(child);
121
+ // The detach is queued, not applied — it lands in the pre-commit hook,
122
+ // which only runs because removeNode opened a transaction.
123
+ expect(calls).toEqual(["tx:start"]);
124
+
125
+ commitEarly();
126
+ expect(calls).toEqual(["tx:start", "removeNode", "tx:end"]);
127
+ expect(child.parent).toBeUndefined();
128
+
129
+ dispose();
130
+ });
131
+
132
+ test("insertNode re-attaches the child inside a transaction", () => {
133
+ const { containerRef, child, dispose } = renderContainer();
134
+
135
+ containerRef.removeNode(child);
136
+ commitEarly();
137
+ calls.length = 0;
138
+
139
+ containerRef.insertNode(child);
140
+ // appendNode reaches native synchronously, so the transaction has to be
141
+ // open before it — on firmware 15.2.x an append outside a transaction
142
+ // raises a render-thread exception and the child is never attached.
143
+ expect(calls).toEqual(["tx:start", "appendNode"]);
144
+
145
+ commitEarly();
146
+ expect(calls).toEqual(["tx:start", "appendNode", "tx:end"]);
147
+
148
+ dispose();
149
+ });
150
+ });
@@ -0,0 +1,202 @@
1
+ // Copyright (c) 2026 Roku, Inc.
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import { describe, expect, test } from "@jest/globals";
5
+ import { LEFT_BUTTON_GLYPH as SubpathGlyphSmoke } from "@roku-sdk/components/glyphs";
6
+
7
+ import {
8
+ BACK_BUTTON_GLYPH,
9
+ BALLOT_X,
10
+ BLACK_CIRCLE,
11
+ BLACK_STAR,
12
+ BULLET,
13
+ CHECK_MARK,
14
+ CLOSED_CAPTIONS_GLYPH,
15
+ DOT_GLYPH,
16
+ DOWN_ARROW,
17
+ DOWN_BUTTON_GLYPH,
18
+ ELLIPSIS,
19
+ EM_DASH,
20
+ EN_DASH,
21
+ FAST_FORWARD_GLYPH,
22
+ GENERAL_APP_GLYPH,
23
+ HOME_BUTTON_GLYPH,
24
+ HOME_GLYPH,
25
+ KIDS_PROFILE_GLYPH,
26
+ LEFT_ARROW,
27
+ LEFT_BUTTON_GLYPH,
28
+ LIVE_TV_GLYPH,
29
+ MICROPHONE_BUTTON_GLYPH,
30
+ MICROPHONE_GLYPH,
31
+ OK_BUTTON_GLYPH,
32
+ OPTIONS_BUTTON_GLYPH,
33
+ OPTIONS_GLYPH,
34
+ PLAY_PAUSE_BUTTON_GLYPH,
35
+ PLAY_PAUSE_GLYPH,
36
+ POWER_BUTTON_GLYPH,
37
+ QUICK_LAUNCH_BUTTON_GLYPH,
38
+ REMOTE_GLYPH,
39
+ REPLAY_GLYPH,
40
+ REWIND_GLYPH,
41
+ RIGHT_ARROW,
42
+ RIGHT_BUTTON_GLYPH,
43
+ SEARCH_BUTTON_GLYPH,
44
+ SEARCH_GLYPH,
45
+ SHORTCUT_ONE_BUTTON_GLYPH,
46
+ SHORTCUT_TWO_BUTTON_GLYPH,
47
+ SLEEP_TIMER_GLYPH,
48
+ THUMBS_DOWN_GLYPH,
49
+ THUMBS_UP_GLYPH,
50
+ UP_ARROW,
51
+ UP_BUTTON_GLYPH,
52
+ WHITE_STAR
53
+ } from "../glyphs";
54
+
55
+ describe("RokuFont01 glyph constants", () => {
56
+ test("exports expected codepoints", () => {
57
+ const expectedGlyphs: Record<string, string> = {
58
+ OPTIONS_GLYPH: "\uE000",
59
+ OK_BUTTON_GLYPH: "\uE001",
60
+ OPTIONS_BUTTON_GLYPH: "\uE002",
61
+ MICROPHONE_GLYPH: "\uE003",
62
+ PLAY_PAUSE_BUTTON_GLYPH: "\uE004",
63
+ PLAY_PAUSE_GLYPH: "\uE005",
64
+ DOT_GLYPH: "\uE006",
65
+ HOME_GLYPH: "\uE007",
66
+ SEARCH_GLYPH: "\uE008",
67
+ REMOTE_GLYPH: "\uE009",
68
+ REPLAY_GLYPH: "\uE010",
69
+ MICROPHONE_BUTTON_GLYPH: "\uE011",
70
+ SEARCH_BUTTON_GLYPH: "\uE012",
71
+ FAST_FORWARD_GLYPH: "\uE013",
72
+ REWIND_GLYPH: "\uE014",
73
+ SHORTCUT_ONE_BUTTON_GLYPH: "\uE015",
74
+ SHORTCUT_TWO_BUTTON_GLYPH: "\uE016",
75
+ CLOSED_CAPTIONS_GLYPH: "\uE017",
76
+ LIVE_TV_GLYPH: "\uE018",
77
+ SLEEP_TIMER_GLYPH: "\uE019",
78
+ GENERAL_APP_GLYPH: "\uE020",
79
+ BACK_BUTTON_GLYPH: "\uE021",
80
+ HOME_BUTTON_GLYPH: "\uE022",
81
+ QUICK_LAUNCH_BUTTON_GLYPH: "\uE023",
82
+ DOWN_BUTTON_GLYPH: "\uE024",
83
+ LEFT_BUTTON_GLYPH: "\uE025",
84
+ RIGHT_BUTTON_GLYPH: "\uE026",
85
+ KIDS_PROFILE_GLYPH: "\uE027",
86
+ UP_BUTTON_GLYPH: "\uE028",
87
+ POWER_BUTTON_GLYPH: "\uE029",
88
+ THUMBS_UP_GLYPH: "\uE030",
89
+ THUMBS_DOWN_GLYPH: "\uE031"
90
+ };
91
+
92
+ const actualGlyphs: Record<string, string> = {
93
+ OPTIONS_GLYPH,
94
+ OK_BUTTON_GLYPH,
95
+ OPTIONS_BUTTON_GLYPH,
96
+ MICROPHONE_GLYPH,
97
+ PLAY_PAUSE_BUTTON_GLYPH,
98
+ PLAY_PAUSE_GLYPH,
99
+ DOT_GLYPH,
100
+ HOME_GLYPH,
101
+ SEARCH_GLYPH,
102
+ REMOTE_GLYPH,
103
+ REPLAY_GLYPH,
104
+ MICROPHONE_BUTTON_GLYPH,
105
+ SEARCH_BUTTON_GLYPH,
106
+ FAST_FORWARD_GLYPH,
107
+ REWIND_GLYPH,
108
+ SHORTCUT_ONE_BUTTON_GLYPH,
109
+ SHORTCUT_TWO_BUTTON_GLYPH,
110
+ CLOSED_CAPTIONS_GLYPH,
111
+ LIVE_TV_GLYPH,
112
+ SLEEP_TIMER_GLYPH,
113
+ GENERAL_APP_GLYPH,
114
+ BACK_BUTTON_GLYPH,
115
+ HOME_BUTTON_GLYPH,
116
+ QUICK_LAUNCH_BUTTON_GLYPH,
117
+ DOWN_BUTTON_GLYPH,
118
+ LEFT_BUTTON_GLYPH,
119
+ RIGHT_BUTTON_GLYPH,
120
+ KIDS_PROFILE_GLYPH,
121
+ UP_BUTTON_GLYPH,
122
+ POWER_BUTTON_GLYPH,
123
+ THUMBS_UP_GLYPH,
124
+ THUMBS_DOWN_GLYPH
125
+ };
126
+
127
+ expect(actualGlyphs).toEqual(expectedGlyphs);
128
+ });
129
+
130
+ test("supports importing a single constant from subpath", () => {
131
+ expect(SubpathGlyphSmoke).toBe("\uE025");
132
+ });
133
+ });
134
+
135
+ describe("RokuFont01 standard-Unicode symbol constants", () => {
136
+ test("exports expected codepoints", () => {
137
+ expect({
138
+ BLACK_CIRCLE,
139
+ BLACK_STAR,
140
+ WHITE_STAR
141
+ }).toEqual({
142
+ BLACK_CIRCLE: "\u25CF",
143
+ BLACK_STAR: "\u2605",
144
+ WHITE_STAR: "\u2606"
145
+ });
146
+ });
147
+
148
+ test("DOT_GLYPH and BLACK_CIRCLE are distinct alignment variants of the same dot", () => {
149
+ expect(DOT_GLYPH).not.toBe(BLACK_CIRCLE);
150
+ });
151
+ });
152
+
153
+ describe("RokuUI-Regular symbol constants", () => {
154
+ test("exports expected codepoints", () => {
155
+ expect({
156
+ BULLET,
157
+ ELLIPSIS,
158
+ EN_DASH,
159
+ EM_DASH,
160
+ LEFT_ARROW,
161
+ UP_ARROW,
162
+ RIGHT_ARROW,
163
+ DOWN_ARROW,
164
+ CHECK_MARK,
165
+ BALLOT_X
166
+ }).toEqual({
167
+ BULLET: "\u2022",
168
+ ELLIPSIS: "\u2026",
169
+ EN_DASH: "\u2013",
170
+ EM_DASH: "\u2014",
171
+ LEFT_ARROW: "\u2190",
172
+ UP_ARROW: "\u2191",
173
+ RIGHT_ARROW: "\u2192",
174
+ DOWN_ARROW: "\u2193",
175
+ CHECK_MARK: "\u2713",
176
+ BALLOT_X: "\u2717"
177
+ });
178
+ });
179
+
180
+ test("every exported constant is a single character", () => {
181
+ const all = [
182
+ BULLET,
183
+ ELLIPSIS,
184
+ EN_DASH,
185
+ EM_DASH,
186
+ LEFT_ARROW,
187
+ UP_ARROW,
188
+ RIGHT_ARROW,
189
+ DOWN_ARROW,
190
+ CHECK_MARK,
191
+ BALLOT_X,
192
+ BLACK_CIRCLE,
193
+ BLACK_STAR,
194
+ WHITE_STAR,
195
+ DOT_GLYPH
196
+ ];
197
+ for (const c of all) {
198
+ expect(c).toHaveLength(1);
199
+ }
200
+ expect(new Set(all).size).toBe(all.length);
201
+ });
202
+ });
@@ -0,0 +1,12 @@
1
+ // Copyright (c) 2026 Roku, Inc.
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import { describe, expect, test } from "@jest/globals";
5
+ import { ModalBoundary, useModal } from "@roku-sdk/components";
6
+
7
+ describe("ModalBoundary partner export", () => {
8
+ test("re-exports ModalBoundary and useModal from @roku-sdk/components", () => {
9
+ expect(typeof ModalBoundary).toBe("function");
10
+ expect(typeof useModal).toBe("function");
11
+ });
12
+ });
@@ -0,0 +1,224 @@
1
+ // Copyright (c) 2026 Roku, Inc.
2
+ // SPDX-License-Identifier: MIT
3
+ import { describe, expect, it, jest } from "@jest/globals";
4
+ import { createSignal, Show, tmTransaction } from "@roku-sdk/solid";
5
+ import { render } from "@roku-sdk/testing-library";
6
+
7
+ import { Modal } from "../modal-dialog";
8
+ import { StandardDialog } from "../sg-nodes/standard-dialog";
9
+ import { StandardKeyboardDialog } from "../sg-nodes/standard-keyboard-dialog";
10
+ import { StandardMessageDialog } from "../sg-nodes/standard-message-dialog";
11
+ import { StandardPinPadDialog } from "../sg-nodes/standard-pinpad-dialog";
12
+ import { StandardProgressDialog } from "../sg-nodes/standard-progress-dialog";
13
+
14
+ describe("native modal wrappers", () => {
15
+ it("retires handlers on cached dialog and fallback nodes when the modal unmounts", async () => {
16
+ const [visible, setVisible] = createSignal(true);
17
+ const selected = jest.fn();
18
+ const closed = jest.fn();
19
+ const view = await render(() => (
20
+ <Show when={visible()}>
21
+ <StandardDialog
22
+ id="content"
23
+ onButtonSelected={selected}
24
+ onWasClosed={closed}
25
+ fallback={<standardDialog id="fallback" onButtonSelected={selected} />}
26
+ />
27
+ </Show>
28
+ ));
29
+ try {
30
+ const modal = view.getByTag("modal");
31
+ const content = view.getByProp("id", "content");
32
+ const fallback = view.getByProp("id", "fallback");
33
+ await view.fireEvent(content, "onButtonSelected", 0);
34
+ expect(selected).toHaveBeenCalledTimes(1);
35
+ setVisible(false);
36
+ await view.transaction();
37
+ expect(view.queryByProp("id", "content")).toBeFalsy();
38
+ await view.fireEvent(content, "onButtonSelected", 0);
39
+ await view.fireEvent(fallback, "onButtonSelected", 0);
40
+ expect(selected).toHaveBeenCalledTimes(1);
41
+ await expect(view.fireEvent(modal, "onWasClosed", true)).rejects.toThrow("not registered");
42
+ expect(closed).not.toHaveBeenCalled();
43
+ } finally {
44
+ view.dispose();
45
+ }
46
+ });
47
+
48
+ it("prepares content and fallback in one modal and releases it on disposal", async () => {
49
+ const [readiness, setReadiness] = createSignal(0);
50
+ const view = await render(() => (
51
+ <StandardDialog
52
+ ready={readiness() > 0}
53
+ replaceExisting={false}
54
+ fallback={
55
+ <standardDialog id="loading">
56
+ <stdDlgContentArea>
57
+ <stdDlgProgressItem text="Loading" />
58
+ </stdDlgContentArea>
59
+ </standardDialog>
60
+ }
61
+ >
62
+ <stdDlgContentArea>
63
+ <stdDlgTextItem text="Prepared content" />
64
+ </stdDlgContentArea>
65
+ </StandardDialog>
66
+ ));
67
+ const modal = view.getByTag("modal");
68
+ try {
69
+ expect(view.getAllByTag("modal")).toHaveLength(1);
70
+ expect(modal.props).toMatchObject({ mounted: true, ready: false });
71
+ const dialog = view.getByTag("standardDialogExt");
72
+ const fallback = view.getByTag("standardDialog");
73
+ setReadiness(1);
74
+ await view.transaction();
75
+ expect(modal.props).toMatchObject({ ready: true });
76
+ expect(view.getByTag("standardDialogExt")).toBe(dialog);
77
+ expect(view.getByTag("standardDialog")).toBe(fallback);
78
+ setReadiness(2);
79
+ await view.transaction();
80
+ expect(view.getByTag("standardDialogExt")).toBe(dialog);
81
+ expect(view.getByTag("standardDialog")).toBe(fallback);
82
+ } finally {
83
+ view.dispose();
84
+ }
85
+ await view.transaction();
86
+ expect(modal.props).toMatchObject({ mounted: false });
87
+ });
88
+
89
+ it("updates existing content and still allows an intentional dialog replacement", async () => {
90
+ const [title, setTitle] = createSignal("First");
91
+ const [replacement, setReplacement] = createSignal(false);
92
+ const selected = jest.fn();
93
+ const view = await render(() => (
94
+ <Modal
95
+ dialog={
96
+ replacement() ? (
97
+ <standardDialog id="replacement" onButtonSelected={selected} />
98
+ ) : (
99
+ <standardDialog id="original" title={title()} onButtonSelected={selected} />
100
+ )
101
+ }
102
+ />
103
+ ));
104
+ try {
105
+ const original = view.getByProp("id", "original");
106
+ setTitle("Updated");
107
+ await view.transaction();
108
+ expect(view.getByProp("id", "original")).toBe(original);
109
+ expect(original.props).toMatchObject({ title: "Updated" });
110
+ setReplacement(true);
111
+ await view.transaction();
112
+ expect(view.queryByProp("id", "original")).toBeFalsy();
113
+ expect(view.getByProp("id", "replacement")).not.toBe(original);
114
+ await view.fireEvent(original, "onButtonSelected", 0);
115
+ expect(selected).not.toHaveBeenCalled();
116
+ await view.fireEvent(view.getByProp("id", "replacement"), "onButtonSelected", 0);
117
+ expect(selected).toHaveBeenCalledTimes(1);
118
+ } finally {
119
+ view.dispose();
120
+ }
121
+ });
122
+
123
+ it("replaces the fallback without recreating content or retaining old handlers", async () => {
124
+ const [replacement, setReplacement] = createSignal(false);
125
+ const selected = jest.fn();
126
+ const view = await render(() => (
127
+ <Modal
128
+ dialog={<standardDialog id="content" />}
129
+ fallback={
130
+ replacement() ? (
131
+ <standardDialog id="new-fallback" />
132
+ ) : (
133
+ <standardDialog id="old-fallback" onButtonSelected={selected} />
134
+ )
135
+ }
136
+ />
137
+ ));
138
+ try {
139
+ const content = view.getByProp("id", "content");
140
+ const fallback = view.getByProp("id", "old-fallback");
141
+ setReplacement(true);
142
+ await view.transaction();
143
+ expect(view.getByProp("id", "content")).toBe(content);
144
+ expect(view.queryByProp("id", "old-fallback")).toBeFalsy();
145
+ expect(view.getByProp("id", "new-fallback")).not.toBe(fallback);
146
+ await view.fireEvent(fallback, "onButtonSelected", 0);
147
+ expect(selected).not.toHaveBeenCalled();
148
+ } finally {
149
+ view.dispose();
150
+ }
151
+ });
152
+
153
+ it.each([
154
+ ["standardDialogExt", StandardDialog],
155
+ ["standardMessageDialogExt", StandardMessageDialog],
156
+ ["standardKeyboardDialogExt", StandardKeyboardDialog],
157
+ ["standardPinPadDialogExt", StandardPinPadDialog],
158
+ ["standardProgressDialogExt", StandardProgressDialog]
159
+ ] as const)("routes lifecycle props to Modal for %s", async (tag, Dialog) => {
160
+ const [open, setOpen] = createSignal(false);
161
+ const [close, setClose] = createSignal(false, { equals: false });
162
+ const opened = jest.fn();
163
+ const closed = jest.fn();
164
+ const blocked = jest.fn();
165
+ const view = await render(() => (
166
+ <Dialog
167
+ id="dialog"
168
+ title="Native title"
169
+ width={1154}
170
+ optionsDialog
171
+ open={open()}
172
+ close={close()}
173
+ replaceExisting={false}
174
+ initialFocusId="cancel"
175
+ onOpened={opened}
176
+ onWasClosed={closed}
177
+ onBlocked={blocked}
178
+ />
179
+ ));
180
+ const writes = jest.spyOn(tmTransaction, "setProperty");
181
+ try {
182
+ const modal = view.getByTag("modal");
183
+ const dialog = view.getByTag(tag);
184
+ expect(modal.props).toMatchObject({
185
+ mounted: true,
186
+ open: false,
187
+ replaceExisting: false,
188
+ initialFocusId: "cancel"
189
+ });
190
+ expect(dialog.props).toMatchObject({
191
+ title: "Native title",
192
+ width: 1154,
193
+ optionsDialog: true,
194
+ close: false
195
+ });
196
+ expect(modal.props).not.toHaveProperty("close");
197
+ for (const name of ["open", "replaceExisting", "initialFocusId", "onOpened", "onWasClosed", "onBlocked"])
198
+ expect(dialog.props).not.toHaveProperty(name);
199
+ expect(opened).not.toHaveBeenCalled();
200
+ setOpen(true);
201
+ await view.transaction();
202
+ expect(modal.props).toMatchObject({ open: true });
203
+ expect(view.getByTag(tag)).toBe(dialog);
204
+ writes.mockClear();
205
+ setClose(false);
206
+ await view.transaction();
207
+ expect(writes.mock.calls.filter(([, name]) => name === "close")).toEqual([]);
208
+ setClose(true);
209
+ await view.transaction();
210
+ expect(dialog.props).toMatchObject({ close: true });
211
+ expect(view.getByTag(tag)).toBe(dialog);
212
+ expect(modal.props).not.toHaveProperty("close");
213
+ await view.fireEvent(modal, "onOpened", true);
214
+ await view.fireEvent(modal, "onWasClosed", true);
215
+ await view.fireEvent(modal, "onBlocked", true);
216
+ expect(opened).toHaveBeenCalledTimes(1);
217
+ expect(closed).toHaveBeenCalledTimes(1);
218
+ expect(blocked).toHaveBeenCalledTimes(1);
219
+ } finally {
220
+ writes.mockRestore();
221
+ view.dispose();
222
+ }
223
+ });
224
+ });