@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.
- package/BREAKING_CHANGES.md +84 -0
- package/LICENSE +21 -0
- package/README.md +19 -2
- package/core-jsx.d.ts +53 -0
- package/eslint.config.mjs +3 -0
- package/jsx.d.ts +273 -0
- package/package.json +84 -3
- package/src/__tests__/container.test.tsx +150 -0
- package/src/__tests__/glyphs.test.ts +202 -0
- package/src/__tests__/modal-boundary-export.test.ts +12 -0
- package/src/__tests__/native-modal.test.tsx +224 -0
- package/src/__tests__/tween-release.test.tsx +125 -0
- package/src/__tests__/tween-stops-shape.test.tsx +165 -0
- package/src/__tests__/tween-without-compat.test.tsx +64 -0
- package/src/__tests__/tween.test.tsx +195 -0
- package/src/blended-busy-spinner.props.ts +37 -0
- package/src/blended-busy-spinner.tsx +13 -0
- package/src/bounding-tracker.props.ts +18 -0
- package/src/bounding-tracker.tsx +13 -0
- package/src/button-row.tsx +126 -0
- package/src/channel-store.props.ts +425 -0
- package/src/channel-store.tsx +76 -0
- package/src/container.tsx +130 -0
- package/src/core/jsx-runtime.ts +39 -0
- package/src/focusable-group.props.ts +8 -0
- package/src/focusable-group.tsx +17 -0
- package/src/focusable.props.ts +14 -0
- package/src/focusable.tsx +17 -0
- package/src/glyphs.ts +116 -0
- package/src/helpers/index.tsx +4 -0
- package/src/helpers/resolution-context.tsx +68 -0
- package/src/helpers/scale-for-device-resolution.ts +31 -0
- package/src/index.tsx +70 -0
- package/src/jsx-props.ts +87 -0
- package/src/kdfs.d.ts +14 -0
- package/src/kdfs.ts +15 -0
- package/src/key-handler.props.ts +33 -0
- package/src/key-handler.tsx +24 -0
- package/src/modal-dialog.props.ts +38 -0
- package/src/modal-dialog.tsx +41 -0
- package/src/retain-asset.ts +24 -0
- package/src/sg-intrinsics.json +1153 -0
- package/src/sg-nodes/__tests__/__snapshots__/font.test.tsx.snap +13 -0
- package/src/sg-nodes/__tests__/__snapshots__/prop-forwarding.test.tsx.snap +4409 -0
- package/src/sg-nodes/__tests__/core-props.test.ts +301 -0
- package/src/sg-nodes/__tests__/font.test.tsx +141 -0
- package/src/sg-nodes/__tests__/imperative-methods.test.ts +268 -0
- package/src/sg-nodes/__tests__/prop-forwarding.test.tsx +1049 -0
- package/src/sg-nodes/__tests__/solid-integration.test.tsx +719 -0
- package/src/sg-nodes/animated-image.props.ts +64 -0
- package/src/sg-nodes/animated-image.tsx +23 -0
- package/src/sg-nodes/animation.props.ts +292 -0
- package/src/sg-nodes/animation.tsx +190 -0
- package/src/sg-nodes/array-grid.ts +602 -0
- package/src/sg-nodes/audio.props.ts +302 -0
- package/src/sg-nodes/audio.tsx +57 -0
- package/src/sg-nodes/busy-spinner.props.ts +59 -0
- package/src/sg-nodes/busy-spinner.tsx +84 -0
- package/src/sg-nodes/button-group.props.ts +141 -0
- package/src/sg-nodes/button-group.tsx +30 -0
- package/src/sg-nodes/button.props.ts +119 -0
- package/src/sg-nodes/button.tsx +31 -0
- package/src/sg-nodes/check-list.props.ts +71 -0
- package/src/sg-nodes/check-list.tsx +55 -0
- package/src/sg-nodes/content-node.props.ts +1316 -0
- package/src/sg-nodes/content-node.tsx +153 -0
- package/src/sg-nodes/core-props.ts +197 -0
- package/src/sg-nodes/custom-keyboard.props.ts +108 -0
- package/src/sg-nodes/custom-keyboard.tsx +48 -0
- package/src/sg-nodes/dialog.props.ts +159 -0
- package/src/sg-nodes/dialog.tsx +36 -0
- package/src/sg-nodes/dynamic-key-grid.props.ts +22 -0
- package/src/sg-nodes/dynamic-key-grid.tsx +16 -0
- package/src/sg-nodes/dynamic-keyboard.props.ts +30 -0
- package/src/sg-nodes/dynamic-keyboard.tsx +18 -0
- package/src/sg-nodes/dynamic-mini-keyboard.props.ts +22 -0
- package/src/sg-nodes/dynamic-mini-keyboard.tsx +18 -0
- package/src/sg-nodes/dynamic-pinpad.props.ts +57 -0
- package/src/sg-nodes/dynamic-pinpad.tsx +18 -0
- package/src/sg-nodes/effect.props.ts +116 -0
- package/src/sg-nodes/effect.tsx +39 -0
- package/src/sg-nodes/font.props.ts +45 -0
- package/src/sg-nodes/font.tsx +80 -0
- package/src/sg-nodes/group.props.ts +105 -0
- package/src/sg-nodes/group.tsx +30 -0
- package/src/sg-nodes/label-list.props.ts +86 -0
- package/src/sg-nodes/label-list.tsx +64 -0
- package/src/sg-nodes/label.props.ts +207 -0
- package/src/sg-nodes/label.tsx +56 -0
- package/src/sg-nodes/layout-group.props.ts +69 -0
- package/src/sg-nodes/layout-group.tsx +75 -0
- package/src/sg-nodes/list-panel.props.ts +104 -0
- package/src/sg-nodes/list-panel.tsx +49 -0
- package/src/sg-nodes/markup-grid.props.ts +78 -0
- package/src/sg-nodes/markup-grid.tsx +38 -0
- package/src/sg-nodes/markup-list.props.ts +68 -0
- package/src/sg-nodes/markup-list.tsx +38 -0
- package/src/sg-nodes/mask-group.props.ts +78 -0
- package/src/sg-nodes/mask-group.tsx +59 -0
- package/src/sg-nodes/media-playback-methods.ts +20 -0
- package/src/sg-nodes/node.ts +77 -0
- package/src/sg-nodes/overhang.props.ts +169 -0
- package/src/sg-nodes/overhang.tsx +29 -0
- package/src/sg-nodes/panel-set.props.ts +71 -0
- package/src/sg-nodes/panel-set.tsx +53 -0
- package/src/sg-nodes/panel.props.ts +176 -0
- package/src/sg-nodes/panel.tsx +56 -0
- package/src/sg-nodes/poster-grid.props.ts +277 -0
- package/src/sg-nodes/poster-grid.tsx +45 -0
- package/src/sg-nodes/poster.props.ts +125 -0
- package/src/sg-nodes/poster.tsx +44 -0
- package/src/sg-nodes/radio-button-list.props.ts +59 -0
- package/src/sg-nodes/radio-button-list.tsx +68 -0
- package/src/sg-nodes/rectangle.props.ts +29 -0
- package/src/sg-nodes/rectangle.tsx +27 -0
- package/src/sg-nodes/row-list.props.ts +288 -0
- package/src/sg-nodes/row-list.tsx +47 -0
- package/src/sg-nodes/scene.props.ts +65 -0
- package/src/sg-nodes/scene.tsx +15 -0
- package/src/sg-nodes/scrollable-text.props.ts +133 -0
- package/src/sg-nodes/scrollable-text.tsx +64 -0
- package/src/sg-nodes/scrolling-label.props.ts +106 -0
- package/src/sg-nodes/scrolling-label.tsx +42 -0
- package/src/sg-nodes/simple-label.props.ts +156 -0
- package/src/sg-nodes/simple-label.tsx +59 -0
- package/src/sg-nodes/sound-effect.props.ts +130 -0
- package/src/sg-nodes/sound-effect.tsx +65 -0
- package/src/sg-nodes/standard-dialog.props.ts +520 -0
- package/src/sg-nodes/standard-dialog.tsx +428 -0
- package/src/sg-nodes/standard-keyboard-dialog.props.ts +84 -0
- package/src/sg-nodes/standard-keyboard-dialog.tsx +59 -0
- package/src/sg-nodes/standard-message-dialog.props.ts +56 -0
- package/src/sg-nodes/standard-message-dialog.tsx +48 -0
- package/src/sg-nodes/standard-pinpad-dialog.props.ts +64 -0
- package/src/sg-nodes/standard-pinpad-dialog.tsx +46 -0
- package/src/sg-nodes/standard-progress-dialog.props.ts +30 -0
- package/src/sg-nodes/standard-progress-dialog.tsx +43 -0
- package/src/sg-nodes/target-group.props.ts +278 -0
- package/src/sg-nodes/target-group.tsx +63 -0
- package/src/sg-nodes/target-list.props.ts +128 -0
- package/src/sg-nodes/target-list.tsx +66 -0
- package/src/sg-nodes/target-set.props.ts +63 -0
- package/src/sg-nodes/target-set.tsx +42 -0
- package/src/sg-nodes/time-grid.props.ts +646 -0
- package/src/sg-nodes/time-grid.tsx +87 -0
- package/src/sg-nodes/tween.props.ts +115 -0
- package/src/sg-nodes/types.ts +150 -0
- package/src/sg-nodes/video.props.ts +573 -0
- package/src/sg-nodes/video.tsx +73 -0
- package/src/sg-nodes/voice-text-edit-box.props.ts +91 -0
- package/src/sg-nodes/voice-text-edit-box.tsx +15 -0
- package/src/sg-nodes/zoom-row-list.props.ts +416 -0
- package/src/sg-nodes/zoom-row-list.tsx +42 -0
- package/src/standard-dialog-next/__tests__/standard-dialog-next.test.tsx +186 -0
- package/src/standard-dialog-next/index.ts +5 -0
- package/src/standard-dialog-next/standard-dialog-next.props.ts +80 -0
- package/src/standard-dialog-next/standard-dialog-next.tsx +326 -0
- package/src/test-helpers/tween-test-setup.ts +78 -0
- package/src/tween.tsx +173 -0
- package/src/types.ts +8 -0
- package/src/xml-components/blended-busy-spinner/BlendedBusySpinner.brs +13 -0
- package/src/xml-components/blended-busy-spinner/BlendedBusySpinner.xml +10 -0
- package/src/xml-components/blended-busy-spinner/BlendedBusySpinner.xml.d.ts +42 -0
- package/src/xml-components/bounding-tracker/BoundingTracker.brs +26 -0
- package/src/xml-components/bounding-tracker/BoundingTracker.xml +14 -0
- package/src/xml-components/bounding-tracker/BoundingTracker.xml.d.ts +42 -0
- package/src/xml-components/channel-store/channelStoreExt.brs +159 -0
- package/src/xml-components/channel-store/channelStoreExt.xml +29 -0
- package/src/xml-components/channel-store/channelStoreExt.xml.d.ts +57 -0
- package/src/xml-components/focus/focusable-group.brs +251 -0
- package/src/xml-components/focus/focusable-group.xml +15 -0
- package/src/xml-components/focus/focusable-group.xml.d.ts +42 -0
- package/src/xml-components/focus/focusable.xml +18 -0
- package/src/xml-components/focus/focusable.xml.d.ts +48 -0
- package/src/xml-components/keyboards/CustomAddressKDF.keyboard.json +547 -0
- package/src/xml-components/keyboards/CustomKeyboard.brs +322 -0
- package/src/xml-components/keyboards/CustomKeyboard.xml +45 -0
- package/src/xml-components/keyboards/CustomKeyboard.xml.d.ts +73 -0
- package/src/xml-components/keyboards/KeyGridExt.xml +4 -0
- package/src/xml-components/keyboards/KeyGridExt.xml.d.ts +37 -0
- package/src/xml-components/keyboards/KeyboardExt.xml +4 -0
- package/src/xml-components/keyboards/KeyboardExt.xml.d.ts +37 -0
- package/src/xml-components/keyboards/MOPPinPadCustomKDF.keyboard.json +151 -0
- package/src/xml-components/keyboards/MiniKeyboardExt.xml +4 -0
- package/src/xml-components/keyboards/MiniKeyboardExt.xml.d.ts +37 -0
- package/src/xml-components/keyboards/PhonePadCustomKDF.keyboard.json +42 -0
- package/src/xml-components/keyboards/PinPadExt.brs +102 -0
- package/src/xml-components/keyboards/PinPadExt.xml +17 -0
- package/src/xml-components/keyboards/PinPadExt.xml.d.ts +49 -0
- package/src/xml-components/keyboards/WifiCustomKDF.keyboard.json +1242 -0
- package/src/xml-components/keyhandler/KeyHandler.brs +30 -0
- package/src/xml-components/keyhandler/KeyHandler.xml +16 -0
- package/src/xml-components/keyhandler/KeyHandler.xml.d.ts +45 -0
- package/src/xml-components/managed-list-panel/ManagedListPanel.brs +137 -0
- package/src/xml-components/managed-list-panel/ManagedListPanel.xml +10 -0
- package/src/xml-components/managed-list-panel/ManagedListPanel.xml.d.ts +37 -0
- package/src/xml-components/modal/Modal.brs +105 -0
- package/src/xml-components/modal/Modal.xml +22 -0
- package/src/xml-components/modal/Modal.xml.d.ts +54 -0
- package/src/xml-components/modal/StandardDialogExt.xml +10 -0
- package/src/xml-components/modal/StandardDialogExt.xml.d.ts +37 -0
- package/src/xml-components/modal/StandardKeyboardDialogExt.xml +10 -0
- package/src/xml-components/modal/StandardKeyboardDialogExt.xml.d.ts +37 -0
- package/src/xml-components/modal/StandardMessageDialogExt.xml +10 -0
- package/src/xml-components/modal/StandardMessageDialogExt.xml.d.ts +37 -0
- package/src/xml-components/modal/StandardPinPadDialogExt.xml +10 -0
- package/src/xml-components/modal/StandardPinPadDialogExt.xml.d.ts +37 -0
- package/src/xml-components/modal/StandardProgressDialogExt.xml +10 -0
- package/src/xml-components/modal/StandardProgressDialogExt.xml.d.ts +37 -0
- package/src/xml-components/modal/tests/Modal.test.brs +195 -0
- 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,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
|
+
}
|