@roku-sdk/components 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.
Files changed (212) 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 +85 -3
  8. package/src/__tests__/container.test.tsx +150 -0
  9. package/src/__tests__/effect-gradient-validation.test.tsx +133 -0
  10. package/src/__tests__/glyphs.test.ts +202 -0
  11. package/src/__tests__/modal-boundary-export.test.ts +12 -0
  12. package/src/__tests__/native-modal.test.tsx +224 -0
  13. package/src/__tests__/tween-release.test.tsx +125 -0
  14. package/src/__tests__/tween-stops-shape.test.tsx +165 -0
  15. package/src/__tests__/tween-without-compat.test.tsx +64 -0
  16. package/src/__tests__/tween.test.tsx +195 -0
  17. package/src/blended-busy-spinner.props.ts +37 -0
  18. package/src/blended-busy-spinner.tsx +13 -0
  19. package/src/bounding-tracker.props.ts +18 -0
  20. package/src/bounding-tracker.tsx +13 -0
  21. package/src/button-row.tsx +126 -0
  22. package/src/channel-store.props.ts +425 -0
  23. package/src/channel-store.tsx +76 -0
  24. package/src/container.tsx +130 -0
  25. package/src/core/jsx-runtime.ts +39 -0
  26. package/src/focusable-group.props.ts +8 -0
  27. package/src/focusable-group.tsx +17 -0
  28. package/src/focusable.props.ts +14 -0
  29. package/src/focusable.tsx +17 -0
  30. package/src/glyphs.ts +116 -0
  31. package/src/helpers/index.tsx +4 -0
  32. package/src/helpers/resolution-context.tsx +68 -0
  33. package/src/helpers/scale-for-device-resolution.ts +31 -0
  34. package/src/index.tsx +70 -0
  35. package/src/jsx-props.ts +87 -0
  36. package/src/kdfs.d.ts +14 -0
  37. package/src/kdfs.ts +15 -0
  38. package/src/key-handler.props.ts +33 -0
  39. package/src/key-handler.tsx +24 -0
  40. package/src/modal-dialog.props.ts +38 -0
  41. package/src/modal-dialog.tsx +41 -0
  42. package/src/retain-asset.ts +24 -0
  43. package/src/sg-intrinsics.json +1153 -0
  44. package/src/sg-nodes/__tests__/__snapshots__/font.test.tsx.snap +13 -0
  45. package/src/sg-nodes/__tests__/__snapshots__/prop-forwarding.test.tsx.snap +4409 -0
  46. package/src/sg-nodes/__tests__/core-props.test.ts +301 -0
  47. package/src/sg-nodes/__tests__/font.test.tsx +141 -0
  48. package/src/sg-nodes/__tests__/imperative-methods.test.ts +276 -0
  49. package/src/sg-nodes/__tests__/prop-forwarding.test.tsx +1049 -0
  50. package/src/sg-nodes/__tests__/solid-integration.test.tsx +719 -0
  51. package/src/sg-nodes/animated-image.props.ts +64 -0
  52. package/src/sg-nodes/animated-image.tsx +23 -0
  53. package/src/sg-nodes/animation.props.ts +292 -0
  54. package/src/sg-nodes/animation.tsx +190 -0
  55. package/src/sg-nodes/array-grid.ts +602 -0
  56. package/src/sg-nodes/audio.props.ts +302 -0
  57. package/src/sg-nodes/audio.tsx +57 -0
  58. package/src/sg-nodes/busy-spinner.props.ts +59 -0
  59. package/src/sg-nodes/busy-spinner.tsx +84 -0
  60. package/src/sg-nodes/button-group.props.ts +141 -0
  61. package/src/sg-nodes/button-group.tsx +30 -0
  62. package/src/sg-nodes/button.props.ts +119 -0
  63. package/src/sg-nodes/button.tsx +31 -0
  64. package/src/sg-nodes/check-list.props.ts +71 -0
  65. package/src/sg-nodes/check-list.tsx +55 -0
  66. package/src/sg-nodes/content-node.props.ts +1316 -0
  67. package/src/sg-nodes/content-node.tsx +153 -0
  68. package/src/sg-nodes/core-props.ts +197 -0
  69. package/src/sg-nodes/custom-keyboard.props.ts +108 -0
  70. package/src/sg-nodes/custom-keyboard.tsx +48 -0
  71. package/src/sg-nodes/dialog.props.ts +159 -0
  72. package/src/sg-nodes/dialog.tsx +36 -0
  73. package/src/sg-nodes/dynamic-key-grid.props.ts +22 -0
  74. package/src/sg-nodes/dynamic-key-grid.tsx +16 -0
  75. package/src/sg-nodes/dynamic-keyboard.props.ts +30 -0
  76. package/src/sg-nodes/dynamic-keyboard.tsx +18 -0
  77. package/src/sg-nodes/dynamic-mini-keyboard.props.ts +22 -0
  78. package/src/sg-nodes/dynamic-mini-keyboard.tsx +18 -0
  79. package/src/sg-nodes/dynamic-pinpad.props.ts +57 -0
  80. package/src/sg-nodes/dynamic-pinpad.tsx +18 -0
  81. package/src/sg-nodes/effect.props.ts +119 -0
  82. package/src/sg-nodes/effect.tsx +167 -0
  83. package/src/sg-nodes/font.props.ts +45 -0
  84. package/src/sg-nodes/font.tsx +80 -0
  85. package/src/sg-nodes/group.props.ts +105 -0
  86. package/src/sg-nodes/group.tsx +30 -0
  87. package/src/sg-nodes/label-list.props.ts +86 -0
  88. package/src/sg-nodes/label-list.tsx +64 -0
  89. package/src/sg-nodes/label.props.ts +207 -0
  90. package/src/sg-nodes/label.tsx +56 -0
  91. package/src/sg-nodes/layout-group.props.ts +69 -0
  92. package/src/sg-nodes/layout-group.tsx +75 -0
  93. package/src/sg-nodes/list-panel.props.ts +103 -0
  94. package/src/sg-nodes/list-panel.tsx +49 -0
  95. package/src/sg-nodes/markup-grid.props.ts +78 -0
  96. package/src/sg-nodes/markup-grid.tsx +38 -0
  97. package/src/sg-nodes/markup-list.props.ts +66 -0
  98. package/src/sg-nodes/markup-list.tsx +38 -0
  99. package/src/sg-nodes/mask-group.props.ts +78 -0
  100. package/src/sg-nodes/mask-group.tsx +59 -0
  101. package/src/sg-nodes/media-playback-methods.ts +20 -0
  102. package/src/sg-nodes/node.ts +77 -0
  103. package/src/sg-nodes/overhang.props.ts +169 -0
  104. package/src/sg-nodes/overhang.tsx +29 -0
  105. package/src/sg-nodes/panel-set.props.ts +71 -0
  106. package/src/sg-nodes/panel-set.tsx +53 -0
  107. package/src/sg-nodes/panel.props.ts +176 -0
  108. package/src/sg-nodes/panel.tsx +56 -0
  109. package/src/sg-nodes/poster-grid.props.ts +277 -0
  110. package/src/sg-nodes/poster-grid.tsx +45 -0
  111. package/src/sg-nodes/poster.props.ts +125 -0
  112. package/src/sg-nodes/poster.tsx +44 -0
  113. package/src/sg-nodes/radio-button-list.props.ts +59 -0
  114. package/src/sg-nodes/radio-button-list.tsx +68 -0
  115. package/src/sg-nodes/rectangle.props.ts +29 -0
  116. package/src/sg-nodes/rectangle.tsx +27 -0
  117. package/src/sg-nodes/row-list.props.ts +288 -0
  118. package/src/sg-nodes/row-list.tsx +47 -0
  119. package/src/sg-nodes/scene.props.ts +65 -0
  120. package/src/sg-nodes/scene.tsx +15 -0
  121. package/src/sg-nodes/scrollable-text.props.ts +133 -0
  122. package/src/sg-nodes/scrollable-text.tsx +64 -0
  123. package/src/sg-nodes/scrolling-label.props.ts +106 -0
  124. package/src/sg-nodes/scrolling-label.tsx +42 -0
  125. package/src/sg-nodes/simple-label.props.ts +156 -0
  126. package/src/sg-nodes/simple-label.tsx +59 -0
  127. package/src/sg-nodes/sound-effect.props.ts +130 -0
  128. package/src/sg-nodes/sound-effect.tsx +65 -0
  129. package/src/sg-nodes/standard-dialog.props.ts +520 -0
  130. package/src/sg-nodes/standard-dialog.tsx +428 -0
  131. package/src/sg-nodes/standard-keyboard-dialog.props.ts +84 -0
  132. package/src/sg-nodes/standard-keyboard-dialog.tsx +58 -0
  133. package/src/sg-nodes/standard-message-dialog.props.ts +56 -0
  134. package/src/sg-nodes/standard-message-dialog.tsx +47 -0
  135. package/src/sg-nodes/standard-pinpad-dialog.props.ts +64 -0
  136. package/src/sg-nodes/standard-pinpad-dialog.tsx +44 -0
  137. package/src/sg-nodes/standard-progress-dialog.props.ts +30 -0
  138. package/src/sg-nodes/standard-progress-dialog.tsx +40 -0
  139. package/src/sg-nodes/target-group.props.ts +278 -0
  140. package/src/sg-nodes/target-group.tsx +63 -0
  141. package/src/sg-nodes/target-list.props.ts +128 -0
  142. package/src/sg-nodes/target-list.tsx +66 -0
  143. package/src/sg-nodes/target-set.props.ts +63 -0
  144. package/src/sg-nodes/target-set.tsx +42 -0
  145. package/src/sg-nodes/time-grid.props.ts +646 -0
  146. package/src/sg-nodes/time-grid.tsx +87 -0
  147. package/src/sg-nodes/tween.props.ts +115 -0
  148. package/src/sg-nodes/types.ts +150 -0
  149. package/src/sg-nodes/video.props.ts +642 -0
  150. package/src/sg-nodes/video.tsx +75 -0
  151. package/src/sg-nodes/voice-text-edit-box.props.ts +91 -0
  152. package/src/sg-nodes/voice-text-edit-box.tsx +15 -0
  153. package/src/sg-nodes/zoom-row-list.props.ts +416 -0
  154. package/src/sg-nodes/zoom-row-list.tsx +42 -0
  155. package/src/standard-dialog-next/__tests__/standard-dialog-next.test.tsx +186 -0
  156. package/src/standard-dialog-next/index.ts +5 -0
  157. package/src/standard-dialog-next/standard-dialog-next.props.ts +80 -0
  158. package/src/standard-dialog-next/standard-dialog-next.tsx +326 -0
  159. package/src/test-helpers/tween-test-setup.ts +78 -0
  160. package/src/tween.tsx +173 -0
  161. package/src/types.ts +8 -0
  162. package/src/xml-components/blended-busy-spinner/BlendedBusySpinner.brs +13 -0
  163. package/src/xml-components/blended-busy-spinner/BlendedBusySpinner.xml +10 -0
  164. package/src/xml-components/blended-busy-spinner/BlendedBusySpinner.xml.d.ts +42 -0
  165. package/src/xml-components/bounding-tracker/BoundingTracker.brs +26 -0
  166. package/src/xml-components/bounding-tracker/BoundingTracker.xml +14 -0
  167. package/src/xml-components/bounding-tracker/BoundingTracker.xml.d.ts +42 -0
  168. package/src/xml-components/channel-store/channelStoreExt.brs +159 -0
  169. package/src/xml-components/channel-store/channelStoreExt.xml +29 -0
  170. package/src/xml-components/channel-store/channelStoreExt.xml.d.ts +57 -0
  171. package/src/xml-components/focus/focusable-group.brs +251 -0
  172. package/src/xml-components/focus/focusable-group.xml +15 -0
  173. package/src/xml-components/focus/focusable-group.xml.d.ts +42 -0
  174. package/src/xml-components/focus/focusable.xml +18 -0
  175. package/src/xml-components/focus/focusable.xml.d.ts +48 -0
  176. package/src/xml-components/keyboards/CustomAddressKDF.keyboard.json +547 -0
  177. package/src/xml-components/keyboards/CustomKeyboard.brs +322 -0
  178. package/src/xml-components/keyboards/CustomKeyboard.xml +45 -0
  179. package/src/xml-components/keyboards/CustomKeyboard.xml.d.ts +73 -0
  180. package/src/xml-components/keyboards/KeyGridExt.xml +4 -0
  181. package/src/xml-components/keyboards/KeyGridExt.xml.d.ts +37 -0
  182. package/src/xml-components/keyboards/KeyboardExt.xml +4 -0
  183. package/src/xml-components/keyboards/KeyboardExt.xml.d.ts +37 -0
  184. package/src/xml-components/keyboards/MOPPinPadCustomKDF.keyboard.json +151 -0
  185. package/src/xml-components/keyboards/MiniKeyboardExt.xml +4 -0
  186. package/src/xml-components/keyboards/MiniKeyboardExt.xml.d.ts +37 -0
  187. package/src/xml-components/keyboards/PhonePadCustomKDF.keyboard.json +42 -0
  188. package/src/xml-components/keyboards/PinPadExt.brs +102 -0
  189. package/src/xml-components/keyboards/PinPadExt.xml +17 -0
  190. package/src/xml-components/keyboards/PinPadExt.xml.d.ts +49 -0
  191. package/src/xml-components/keyboards/WifiCustomKDF.keyboard.json +1242 -0
  192. package/src/xml-components/keyhandler/KeyHandler.brs +30 -0
  193. package/src/xml-components/keyhandler/KeyHandler.xml +16 -0
  194. package/src/xml-components/keyhandler/KeyHandler.xml.d.ts +45 -0
  195. package/src/xml-components/managed-list-panel/ManagedListPanel.brs +137 -0
  196. package/src/xml-components/managed-list-panel/ManagedListPanel.xml +10 -0
  197. package/src/xml-components/managed-list-panel/ManagedListPanel.xml.d.ts +37 -0
  198. package/src/xml-components/modal/Modal.brs +105 -0
  199. package/src/xml-components/modal/Modal.xml +22 -0
  200. package/src/xml-components/modal/Modal.xml.d.ts +54 -0
  201. package/src/xml-components/modal/StandardDialogExt.xml +10 -0
  202. package/src/xml-components/modal/StandardDialogExt.xml.d.ts +37 -0
  203. package/src/xml-components/modal/StandardKeyboardDialogExt.xml +10 -0
  204. package/src/xml-components/modal/StandardKeyboardDialogExt.xml.d.ts +37 -0
  205. package/src/xml-components/modal/StandardMessageDialogExt.xml +10 -0
  206. package/src/xml-components/modal/StandardMessageDialogExt.xml.d.ts +37 -0
  207. package/src/xml-components/modal/StandardPinPadDialogExt.xml +10 -0
  208. package/src/xml-components/modal/StandardPinPadDialogExt.xml.d.ts +37 -0
  209. package/src/xml-components/modal/StandardProgressDialogExt.xml +10 -0
  210. package/src/xml-components/modal/StandardProgressDialogExt.xml.d.ts +37 -0
  211. package/src/xml-components/modal/tests/Modal.test.brs +195 -0
  212. package/tsconfig.json +10 -0
@@ -0,0 +1,186 @@
1
+ // Copyright (c) 2026 Roku, Inc.
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import { afterEach, describe, expect, jest, test } from "@jest/globals";
5
+ import { type RemoteKeyEvent } from "@roku-sdk/interfaces";
6
+ import { RootFocusBoundary, useModal } from "@roku-sdk/navigation";
7
+ import { Component, createSignal } from "@roku-sdk/solid";
8
+ import { render } from "@roku-sdk/testing-library";
9
+
10
+ import { StandardDialogNext } from "../standard-dialog-next";
11
+
12
+ type KeyEvent = RemoteKeyEvent | null;
13
+
14
+ describe("StandardDialogNext", () => {
15
+ let cleanup: (() => Promise<void> | void) | undefined;
16
+
17
+ afterEach(async () => {
18
+ await cleanup?.();
19
+ cleanup = undefined;
20
+ });
21
+
22
+ test("OK on the default button calls onButtonSelected and onClosed once", async () => {
23
+ const onButtonSelected = jest.fn();
24
+ const onClosed = jest.fn();
25
+ const [keyEvent, setKeyEvent] = createSignal<KeyEvent>(null);
26
+
27
+ let openModal!: ReturnType<typeof useModal>;
28
+ const Opener: Component = () => {
29
+ openModal = useModal();
30
+ return (
31
+ <>
32
+ <group />
33
+ <StandardDialogNext
34
+ show={openModal.isOpen()}
35
+ focusId={openModal.focusId}
36
+ title="Title"
37
+ text="Body"
38
+ buttons={[{ text: "OK" }]}
39
+ onButtonSelected={onButtonSelected}
40
+ onClosed={onClosed}
41
+ onDismiss={openModal.close}
42
+ />
43
+ </>
44
+ );
45
+ };
46
+
47
+ const r = await render(() => (
48
+ <RootFocusBoundary keyEvent={keyEvent()} initialFocus={{}} focusActive={true}>
49
+ <Opener />
50
+ </RootFocusBoundary>
51
+ ));
52
+ cleanup = r.dispose;
53
+
54
+ openModal.open();
55
+ await r.transaction();
56
+
57
+ setKeyEvent({ key: "OK", press: true });
58
+ await r.transaction();
59
+ setKeyEvent({ key: "OK", press: false });
60
+ await r.transaction();
61
+
62
+ expect(onButtonSelected).toHaveBeenCalledWith(0);
63
+ expect(onClosed).toHaveBeenCalledTimes(1);
64
+ });
65
+
66
+ test("two dialogs open at once keep their buttons apart", async () => {
67
+ const firstSelected = jest.fn();
68
+ const secondSelected = jest.fn();
69
+ const [keyEvent, setKeyEvent] = createSignal<KeyEvent>(null);
70
+ const firstFocusId: object = {};
71
+ const secondFocusId: object = {};
72
+
73
+ // Chaining one dialog onto another (terms, then a confirmation over it) is the case that
74
+ // makes per-instance button identity necessary. Each dialog's ModalBoundary scopes key
75
+ // routing to its own subtree, which is what keeps the presses apart; this pins that, so a
76
+ // change to how button focus ids are allocated cannot quietly start crossing the streams.
77
+ const r = await render(() => (
78
+ <RootFocusBoundary keyEvent={keyEvent()} initialFocus={firstFocusId} focusActive={true}>
79
+ <StandardDialogNext
80
+ show={true}
81
+ focusId={firstFocusId}
82
+ title="Terms"
83
+ buttons={[{ text: "Accept" }]}
84
+ onButtonSelected={firstSelected}
85
+ />
86
+ <StandardDialogNext
87
+ show={true}
88
+ focusId={secondFocusId}
89
+ title="Confirm"
90
+ buttons={[{ text: "Yes" }]}
91
+ onButtonSelected={secondSelected}
92
+ />
93
+ </RootFocusBoundary>
94
+ ));
95
+ cleanup = r.dispose;
96
+
97
+ setKeyEvent({ key: "OK", press: true });
98
+ await r.transaction();
99
+ setKeyEvent({ key: "OK", press: false });
100
+ await r.transaction();
101
+
102
+ expect(firstSelected).toHaveBeenCalledWith(0);
103
+ expect(secondSelected).not.toHaveBeenCalled();
104
+ });
105
+
106
+ test("a button past the old eight-entry focus-id table is still focusable", async () => {
107
+ const onButtonSelected = jest.fn();
108
+ const [keyEvent, setKeyEvent] = createSignal<KeyEvent>(null);
109
+ const focusId: object = {};
110
+ const buttons = Array.from({ length: 12 }, (_, index) => ({ text: `Option ${index}` }));
111
+
112
+ // Index 9 had no entry in the fixed eight-slot table, so the button got `focusId:
113
+ // undefined` and `initialButtonIndex` could not reach it.
114
+ const r = await render(() => (
115
+ <RootFocusBoundary keyEvent={keyEvent()} initialFocus={focusId} focusActive={true}>
116
+ <StandardDialogNext
117
+ show={true}
118
+ focusId={focusId}
119
+ title="Pick one"
120
+ buttons={buttons}
121
+ initialButtonIndex={9}
122
+ onButtonSelected={onButtonSelected}
123
+ />
124
+ </RootFocusBoundary>
125
+ ));
126
+ cleanup = r.dispose;
127
+ await r.transaction();
128
+
129
+ setKeyEvent({ key: "OK", press: true });
130
+ await r.transaction();
131
+ setKeyEvent({ key: "OK", press: false });
132
+ await r.transaction();
133
+
134
+ expect(onButtonSelected).toHaveBeenCalledWith(9);
135
+ });
136
+
137
+ test("driving show to false fires onClosed, with no button and no Back", async () => {
138
+ const onClosed = jest.fn();
139
+ const [open, setOpen] = createSignal(true);
140
+
141
+ const r = await render(() => (
142
+ <RootFocusBoundary keyEvent={null} initialFocus={{}} focusActive={true}>
143
+ <StandardDialogNext show={open()} title="Loading" buttons={[{ text: "OK" }]} onClosed={onClosed} />
144
+ </RootFocusBoundary>
145
+ ));
146
+ cleanup = r.dispose;
147
+ await r.transaction();
148
+ expect(onClosed).not.toHaveBeenCalled();
149
+
150
+ setOpen(false);
151
+ await r.transaction();
152
+
153
+ expect(onClosed).toHaveBeenCalledTimes(1);
154
+ });
155
+
156
+ test("Back dismisses and fires onClosed once", async () => {
157
+ const onClosed = jest.fn();
158
+ const onDismiss = jest.fn();
159
+ const [keyEvent, setKeyEvent] = createSignal<KeyEvent>(null);
160
+ const [open, setOpen] = createSignal(true);
161
+ const focusId: object = {};
162
+
163
+ const r = await render(() => (
164
+ <RootFocusBoundary keyEvent={keyEvent()} initialFocus={focusId} focusActive={true}>
165
+ <StandardDialogNext
166
+ show={open()}
167
+ focusId={focusId}
168
+ title="Exit"
169
+ buttons={[{ text: "OK" }]}
170
+ onDismiss={() => {
171
+ onDismiss();
172
+ setOpen(false);
173
+ }}
174
+ onClosed={onClosed}
175
+ />
176
+ </RootFocusBoundary>
177
+ ));
178
+ cleanup = r.dispose;
179
+
180
+ setKeyEvent({ key: "back", press: true });
181
+ await r.transaction();
182
+
183
+ expect(onDismiss).toHaveBeenCalledTimes(1);
184
+ expect(onClosed).toHaveBeenCalledTimes(1);
185
+ });
186
+ });
@@ -0,0 +1,5 @@
1
+ // Copyright (c) 2026 Roku, Inc.
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ export * from "./standard-dialog-next";
5
+ export * from "./standard-dialog-next.props";
@@ -0,0 +1,80 @@
1
+ // Copyright (c) 2026 Roku, Inc.
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import type { JSX } from "@roku-sdk/solid";
5
+
6
+ /**
7
+ * Colour bag matching the native StandardDialog palette fields that StandardDialogNext chrome draws.
8
+ *
9
+ * Only the fields this component actually reads are here. StandardDialog's `dialogItemColor` and
10
+ * `dialogFootprintColor` have no counterpart in the chrome composed from core-contract primitives,
11
+ * and declaring them would invite a caller to set a colour that silently does nothing.
12
+ */
13
+ export interface IStandardDialogNextPaletteColors {
14
+ /** Panel fill. */
15
+ dialogBackgroundColor?: string;
16
+ /** Title text, and the label on an unfocused button. */
17
+ dialogTextColor?: string;
18
+ /** Fill of the focused button. */
19
+ dialogFocusColor?: string;
20
+ /** Label on the focused button. */
21
+ dialogFocusItemColor?: string;
22
+ /** Body text. */
23
+ dialogSecondaryTextColor?: string;
24
+ /** Fill of an unfocused button, and the rule under the title. */
25
+ dialogSecondaryItemColor?: string;
26
+ }
27
+
28
+ export interface IStandardDialogNextButton {
29
+ text: string;
30
+ }
31
+
32
+ /**
33
+ * Portable dialog built from core-contract primitives on {@link ModalBoundary}.
34
+ * Prefer this over {@link StandardDialog} when using the portable core contract.
35
+ *
36
+ * The intended successor to {@link StandardDialog}, hence the name: it is the same kind of
37
+ * component -- a themed dialog with a title, a body and a row of buttons -- but composed from the
38
+ * frozen primitive subset instead of the native SceneGraph dialog node, so it renders on every
39
+ * target rather than on device only. The two ship side by side while apps migrate; `Next` collapses
40
+ * into the base name once the SceneGraph implementation is retired.
41
+ *
42
+ * The props are deliberately close to `IStandardDialogProps` but not identical -- see
43
+ * {@link IStandardDialogNextPaletteColors} for the palette fields that have no counterpart in
44
+ * primitive-composed chrome.
45
+ *
46
+ * @see rsg-sdk/external/documentation/guides/focus-management/modal-boundary.md
47
+ */
48
+ export interface IStandardDialogNextProps {
49
+ /** Whether the dialog is visible. Wire to `useModal().isOpen()` or app state. */
50
+ show: boolean;
51
+ /**
52
+ * Stable focus identity shared with `useModal().focusId` when opening via `open()`.
53
+ * Required when the dialog participates in virtual-focus modality.
54
+ */
55
+ focusId?: object;
56
+ title?: string;
57
+ /** Plain-text body when `children` is not provided. Ignored when `children` is. */
58
+ text?: string;
59
+ /**
60
+ * Custom body, replacing `text`. Rendered in the panel's content region, so its own
61
+ * coordinates start at the left margin below the title rule rather than at the panel corner.
62
+ *
63
+ * Nothing here can measure a subtree, so pass {@link contentHeight} as well — without it the
64
+ * content region is zero-height and the buttons are laid out over the top of the children.
65
+ */
66
+ children?: JSX.Element;
67
+ /** Height reserved for `children`, in FHD units. Ignored when `children` is absent. */
68
+ contentHeight?: number;
69
+ buttons?: IStandardDialogNextButton[];
70
+ /** Which button receives initial focus. @default 0 */
71
+ initialButtonIndex?: number;
72
+ /** Panel width in FHD space. @default 960 */
73
+ width?: number;
74
+ palette?: IStandardDialogNextPaletteColors;
75
+ onButtonSelected?: (index: number) => void;
76
+ /** Back / dismissKeys path before `onClosed`. */
77
+ onDismiss?: () => void;
78
+ /** Fires at most once per open cycle (button, Back, or programmatic close). */
79
+ onClosed?: () => void;
80
+ }
@@ -0,0 +1,326 @@
1
+ // Copyright (c) 2026 Roku, Inc.
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import { ModalBoundary, onPress, useFocusable, type UseFocusableOptions } from "@roku-sdk/navigation";
5
+ import { batch, Component, createEffect, createMemo, For, Show } from "@roku-sdk/solid";
6
+
7
+ import { SystemFont } from "../sg-nodes/types";
8
+ import type { IStandardDialogNextPaletteColors, IStandardDialogNextProps } from "./standard-dialog-next.props";
9
+
10
+ // ---------------------------------------------------------------------------
11
+ // Layout constants.
12
+ //
13
+ // Every value here is taken from a `weeland tree -v` dump of the `StandardDialog` this
14
+ // component replaces, converted from the device's 720p render space to the FHD coordinate
15
+ // space the app authors in (HD x 1.5). Keeping the two visually identical is the whole point
16
+ // of the component, so prefer re-measuring over adjusting these by eye.
17
+ // ---------------------------------------------------------------------------
18
+
19
+ const SCREEN_WIDTH = 1920;
20
+ const SCREEN_HEIGHT = 1080;
21
+ const DEFAULT_WIDTH = 960;
22
+ const H_MARGIN = 90;
23
+ const TITLE_TOP = 58;
24
+ const SEPARATOR_TOP = 124;
25
+ const SEPARATOR_HEIGHT = 3;
26
+ const BODY_TOP = 156;
27
+ const BODY_LINE_HEIGHT = 47;
28
+ const BODY_MAX_LINES = 12;
29
+ const BODY_BUTTON_GAP = 48;
30
+ const BUTTON_HEIGHT = 70;
31
+ const BUTTON_GAP = 16;
32
+ const BUTTON_TEXT_INSET = 24;
33
+ const BOTTOM_MARGIN = 60;
34
+
35
+ // StandardDialog draws its panel and buttons as 9-patch bitmaps, which the frozen property
36
+ // subset has no equivalent for. `effect borderRadius` is the portable way to get the same rounded corners.
37
+ const PANEL_RADIUS = 12;
38
+ const BUTTON_RADIUS = 8;
39
+
40
+ /** Scrim color StandardDialog draws behind itself (`0,0,0` is visibly wrong against it). */
41
+ const DIM_COLOR = "0x0B001BCC";
42
+
43
+ /**
44
+ * Average advance width of the body font at FHD, used to estimate how many lines the body will
45
+ * wrap to. Nothing here can measure text, and the panel has to be tall enough to keep the body
46
+ * clear of the buttons, so the estimate is deliberately generous.
47
+ */
48
+ const BODY_AVG_CHAR_WIDTH = 16;
49
+
50
+ // StandardDialog's type ramp, matched by rasterized size rather than by name: its title is
51
+ // RokuUI-Regular at 30 (720p), its body RokuUI-Regular at 22, its button labels RokuUI-Medium
52
+ // at 22. The `*SystemFont` names are used rather than an explicit `<font>` node because `font`
53
+ // is not a portable core-contract element.
54
+ const TITLE_FONT = SystemFont.Large;
55
+ const BODY_FONT = SystemFont.Small;
56
+ const BUTTON_FONT = SystemFont.SmallBold;
57
+
58
+ function estimateLineCount(text: string, width: number): number {
59
+ const perLine = Math.max(1, Math.floor(width / BODY_AVG_CHAR_WIDTH));
60
+ const lines = text.split("\n").reduce((total, line) => total + Math.max(1, Math.ceil(line.length / perLine)), 0);
61
+ return Math.min(lines, BODY_MAX_LINES);
62
+ }
63
+
64
+ const DEFAULT_PALETTE: IStandardDialogNextPaletteColors = {
65
+ dialogBackgroundColor: "#e5e5e5",
66
+ dialogTextColor: "#121212ff",
67
+ dialogFocusColor: "#7540c4ff",
68
+ dialogFocusItemColor: "#EFEFEF",
69
+ dialogSecondaryTextColor: "#121212ff",
70
+ dialogSecondaryItemColor: "#0000001A"
71
+ };
72
+
73
+ /** Map `#RRGGBB` / `#RRGGBBAA` palette strings to SceneGraph `0xRRGGBBAA`. */
74
+ function toSceneGraphColor(color: string): string {
75
+ if (color.startsWith("0x") || color.startsWith("0X")) {
76
+ return color;
77
+ }
78
+ if (!color.startsWith("#")) {
79
+ return color;
80
+ }
81
+ const hex = color.slice(1);
82
+ if (hex.length === 6) {
83
+ return `0x${hex}FF`;
84
+ }
85
+ if (hex.length === 8) {
86
+ return `0x${hex}`;
87
+ }
88
+ return color;
89
+ }
90
+
91
+ function resolveColor(
92
+ palette: IStandardDialogNextPaletteColors,
93
+ key: keyof IStandardDialogNextPaletteColors,
94
+ fallback: string
95
+ ): string {
96
+ const raw = palette[key] ?? DEFAULT_PALETTE[key] ?? fallback;
97
+ return toSceneGraphColor(raw!);
98
+ }
99
+
100
+ interface DialogButtonProps {
101
+ label: string;
102
+ focusId: object;
103
+ palette: IStandardDialogNextPaletteColors;
104
+ x: number;
105
+ width: number;
106
+ y: number;
107
+ onSelect: () => void;
108
+ }
109
+
110
+ const DialogButton: Component<DialogButtonProps> = props => {
111
+ // Built here rather than at module scope: JSX outside a component body runs at import time,
112
+ // before a renderer exists.
113
+ const roundedButtonEffect = <effect borderRadius={BUTTON_RADIUS} />;
114
+ const { isFocused, ref } = useFocusable({
115
+ focusId: props.focusId,
116
+ onKey: {
117
+ OK: onPress(() => props.onSelect())
118
+ }
119
+ } satisfies UseFocusableOptions);
120
+
121
+ const focusFill = () => resolveColor(props.palette, "dialogFocusColor", DEFAULT_PALETTE.dialogFocusColor!);
122
+ const idleFill = () =>
123
+ resolveColor(props.palette, "dialogSecondaryItemColor", DEFAULT_PALETTE.dialogSecondaryItemColor!);
124
+ const idleText = () => resolveColor(props.palette, "dialogTextColor", DEFAULT_PALETTE.dialogTextColor!);
125
+ const focusText = () => resolveColor(props.palette, "dialogFocusItemColor", DEFAULT_PALETTE.dialogFocusItemColor!);
126
+
127
+ return (
128
+ <group ref={ref} translation={[props.x, props.y]}>
129
+ <rectangle
130
+ width={props.width}
131
+ height={BUTTON_HEIGHT}
132
+ color={isFocused() ? focusFill() : idleFill()}
133
+ effect={roundedButtonEffect}
134
+ />
135
+ <label
136
+ text={props.label}
137
+ color={isFocused() ? focusText() : idleText()}
138
+ font={BUTTON_FONT}
139
+ width={props.width - BUTTON_TEXT_INSET * 2}
140
+ height={BUTTON_HEIGHT}
141
+ horizAlign="left"
142
+ vertAlign="center"
143
+ translation={[BUTTON_TEXT_INSET, 0]}
144
+ />
145
+ </group>
146
+ );
147
+ };
148
+
149
+ /**
150
+ * Portable dialog chrome composed from core-contract primitives on {@link ModalBoundary}.
151
+ * Use instead of {@link StandardDialog} when the app must stay within the portable core-contract elements.
152
+ */
153
+ export const StandardDialogNext: Component<IStandardDialogNextProps> = props => {
154
+ const width = () => props.width ?? DEFAULT_WIDTH;
155
+ const palette = (): IStandardDialogNextPaletteColors => ({ ...DEFAULT_PALETTE, ...props.palette });
156
+ const buttons = () => props.buttons ?? [{ text: "OK" }];
157
+ const initialIndex = () => props.initialButtonIndex ?? 0;
158
+
159
+ /**
160
+ * Focus identity per button position, owned by this dialog instance.
161
+ *
162
+ * Grown on demand, which is the part that matters: the fixed eight-entry table this replaces
163
+ * handed `focusId: undefined` to every button past the eighth, so a ninth button could not be
164
+ * reached by `initialButtonIndex` at all.
165
+ *
166
+ * Per-instance rather than module-level for the case of two dialogs open at once — a
167
+ * confirmation over the terms dialog that raised it. Each dialog's `ModalBoundary` scopes key
168
+ * routing to its own subtree, so sharing the objects did not misroute presses, but two live
169
+ * registrations of one identity is not a property worth depending on.
170
+ *
171
+ * Keyed by position rather than by button object so a caller that rebuilds its `buttons` array
172
+ * on every render does not churn focus.
173
+ */
174
+ const buttonFocusIds = new Map<number, object>();
175
+ const focusIdFor = (index: number): object => {
176
+ let id = buttonFocusIds.get(index);
177
+ if (!id) {
178
+ id = {};
179
+ buttonFocusIds.set(index, id);
180
+ }
181
+ return id;
182
+ };
183
+
184
+ const initialFocusId = createMemo(() => focusIdFor(initialIndex()));
185
+
186
+ const panelX = createMemo(() => (SCREEN_WIDTH - width()) / 2);
187
+ const innerWidth = createMemo(() => width() - H_MARGIN * 2);
188
+
189
+ const bodyLines = createMemo(() => (props.text ? estimateLineCount(props.text, innerWidth()) : 0));
190
+ const bodyHeight = createMemo(() => bodyLines() * BODY_LINE_HEIGHT);
191
+
192
+ const contentBottom = createMemo(() => {
193
+ // `children` replaces the text body, and cannot be measured — the caller's `contentHeight`
194
+ // is the only thing standing between it and the buttons laid out on top of it.
195
+ if (props.children) {
196
+ return BODY_TOP + (props.contentHeight ?? 0);
197
+ }
198
+ if (props.text) {
199
+ return BODY_TOP + bodyHeight();
200
+ }
201
+ return props.title ? SEPARATOR_TOP + SEPARATOR_HEIGHT : H_MARGIN;
202
+ });
203
+
204
+ // StandardDialog stacks its buttons vertically, each spanning the panel's inner width.
205
+ const buttonTop = createMemo(() => contentBottom() + BODY_BUTTON_GAP);
206
+ const buttonY = (index: number) => buttonTop() + index * (BUTTON_HEIGHT + BUTTON_GAP);
207
+
208
+ const panelHeight = createMemo(() => {
209
+ const n = buttons().length;
210
+ return buttonTop() + n * BUTTON_HEIGHT + (n - 1) * BUTTON_GAP + BOTTOM_MARGIN;
211
+ });
212
+ // Floor rather than round: StandardDialog's panel lands one 720p pixel higher than rounding
213
+ // gives, and every line of body text inherits the offset.
214
+ const panelY = createMemo(() => Math.floor((SCREEN_HEIGHT - panelHeight()) / 2));
215
+
216
+ let closedLatch = false;
217
+
218
+ const fireClosedOnce = () => {
219
+ if (closedLatch) {
220
+ return;
221
+ }
222
+ closedLatch = true;
223
+ props.onClosed?.();
224
+ };
225
+
226
+ // `onClosed` is documented as firing once per open cycle however the dialog closed, so a
227
+ // caller driving `show` back to false directly — no button, no Back — has to reach it too.
228
+ // The latch is armed on the rising edge and fired on the falling one, which keeps a
229
+ // button-initiated close (where the handler has already fired it) to a single call.
230
+ let wasShown = false;
231
+ createEffect(() => {
232
+ const shown = props.show;
233
+ if (shown && !wasShown) {
234
+ closedLatch = false;
235
+ } else if (!shown && wasShown) {
236
+ fireClosedOnce();
237
+ }
238
+ wasShown = shown;
239
+ });
240
+
241
+ const handleDismiss = () => {
242
+ batch(() => {
243
+ props.onDismiss?.();
244
+ fireClosedOnce();
245
+ });
246
+ };
247
+
248
+ const handleButton = (index: number) => {
249
+ batch(() => {
250
+ props.onButtonSelected?.(index);
251
+ fireClosedOnce();
252
+ });
253
+ };
254
+
255
+ const panelBg = () => resolveColor(palette(), "dialogBackgroundColor", DEFAULT_PALETTE.dialogBackgroundColor!);
256
+ const titleColor = () => resolveColor(palette(), "dialogTextColor", DEFAULT_PALETTE.dialogTextColor!);
257
+ const bodyColor = () =>
258
+ resolveColor(palette(), "dialogSecondaryTextColor", DEFAULT_PALETTE.dialogSecondaryTextColor!);
259
+ const ruleColor = () =>
260
+ resolveColor(palette(), "dialogSecondaryItemColor", DEFAULT_PALETTE.dialogSecondaryItemColor!);
261
+
262
+ const roundedPanelEffect = <effect borderRadius={PANEL_RADIUS} />;
263
+
264
+ return (
265
+ <ModalBoundary
266
+ show={props.show}
267
+ focusId={props.focusId}
268
+ dim={DIM_COLOR}
269
+ initialFocus={initialFocusId()}
270
+ navigation="vertical"
271
+ onDismiss={handleDismiss}
272
+ >
273
+ <group translation={[panelX(), panelY()]}>
274
+ <rectangle width={width()} height={panelHeight()} color={panelBg()} effect={roundedPanelEffect} />
275
+ <Show when={props.title}>
276
+ <label
277
+ text={props.title!}
278
+ color={titleColor()}
279
+ font={TITLE_FONT}
280
+ width={innerWidth()}
281
+ horizAlign="left"
282
+ vertAlign="top"
283
+ maxLines={1}
284
+ translation={[H_MARGIN, TITLE_TOP]}
285
+ />
286
+ <rectangle
287
+ width={innerWidth()}
288
+ height={SEPARATOR_HEIGHT}
289
+ color={ruleColor()}
290
+ translation={[H_MARGIN, SEPARATOR_TOP]}
291
+ />
292
+ </Show>
293
+ <Show when={props.text && !props.children}>
294
+ <label
295
+ text={props.text!}
296
+ color={bodyColor()}
297
+ font={BODY_FONT}
298
+ width={innerWidth()}
299
+ height={bodyHeight()}
300
+ horizAlign="left"
301
+ vertAlign="top"
302
+ maxLines={bodyLines()}
303
+ translation={[H_MARGIN, BODY_TOP]}
304
+ wrap={true}
305
+ />
306
+ </Show>
307
+ <Show when={props.children}>
308
+ <group translation={[H_MARGIN, BODY_TOP]}>{props.children}</group>
309
+ </Show>
310
+ <For each={buttons()}>
311
+ {(button, index) => (
312
+ <DialogButton
313
+ label={button.text}
314
+ focusId={focusIdFor(index())}
315
+ palette={palette()}
316
+ width={innerWidth()}
317
+ x={H_MARGIN}
318
+ y={buttonY(index())}
319
+ onSelect={() => handleButton(index())}
320
+ />
321
+ )}
322
+ </For>
323
+ </group>
324
+ </ModalBoundary>
325
+ );
326
+ };
@@ -0,0 +1,78 @@
1
+ // Copyright (c) 2026 Roku, Inc.
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import { jest } from "@jest/globals";
5
+ import { registerInternStringApi } from "@roku-sdk/common/string-interner";
6
+ import { transactionManager } from "@roku-sdk/common/transaction";
7
+ import type { InternStringApi, StructuredDataApi, TreeManipulationApi } from "@roku-sdk/interfaces";
8
+ import { AnchorNode, type DomNode } from "@roku-sdk/interfaces";
9
+ import { makeSolidRenderer, type Renderer } from "@roku-sdk/solid";
10
+
11
+ const anchorNameRef = { id: 0 };
12
+
13
+ export const commitEarly = transactionManager.commitEarly;
14
+
15
+ /** Install mocked Solid renderer APIs. Call from beforeEach in tween tests. */
16
+ export function installSolidTestRenderer(): Renderer<DomNode>["render"] {
17
+ let nodeRef = 0;
18
+ let internRef = 0;
19
+ const mockTmApi: TreeManipulationApi = {
20
+ createNode: jest.fn().mockImplementation(() => ({ id: ++nodeRef })),
21
+ appendNode: jest.fn(),
22
+ insertNodeBefore: jest.fn(),
23
+ removeNode: jest.fn(),
24
+ setNodePropNull: jest.fn(),
25
+ setNodePropBool: jest.fn(),
26
+ setNodePropInt: jest.fn(),
27
+ setNodePropFloat: jest.fn(),
28
+ setNodePropString: jest.fn(),
29
+ setNodePropObject: jest.fn(),
30
+ setNodePropArray: jest.fn(),
31
+ setNodePropNodeRef: jest.fn(),
32
+ registerFieldChangeHandler: jest.fn(),
33
+ unregisterFieldHandler: jest.fn(),
34
+ setFocus: jest.fn()
35
+ };
36
+
37
+ const internMap = new Map<string, object>();
38
+ const mockIntern: InternStringApi = {
39
+ internString: (str: string): object => {
40
+ const existing = internMap.get(str);
41
+ if (existing) return existing;
42
+ const ref = { id: ++internRef };
43
+ internMap.set(str, ref);
44
+ return ref;
45
+ }
46
+ };
47
+ registerInternStringApi(mockIntern);
48
+
49
+ const mockStructuredDataApi: StructuredDataApi = {
50
+ createArray: jest.fn(),
51
+ appendInt: jest.fn(),
52
+ appendBool: jest.fn(),
53
+ appendNull: jest.fn(),
54
+ appendFloat: jest.fn(),
55
+ appendString: jest.fn(),
56
+ appendObject: jest.fn(),
57
+ appendArray: jest.fn(),
58
+ appendNodeRef: jest.fn(),
59
+ createObject: jest.fn(),
60
+ insertInt: jest.fn(),
61
+ insertBool: jest.fn(),
62
+ insertNull: jest.fn(),
63
+ insertFloat: jest.fn(),
64
+ insertString: jest.fn(),
65
+ insertObject: jest.fn(),
66
+ insertArray: jest.fn(),
67
+ insertNodeRef: jest.fn(),
68
+ getArray: jest.fn(),
69
+ getObject: jest.fn()
70
+ };
71
+
72
+ const solidRenderer = makeSolidRenderer(mockTmApi, mockStructuredDataApi, mockStructuredDataApi);
73
+ return solidRenderer.renderer.render;
74
+ }
75
+
76
+ export function createAnchorNode(): AnchorNode {
77
+ return { r: { id: 0 }, c: [], n: anchorNameRef } as AnchorNode;
78
+ }