@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.
- 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 +85 -3
- package/src/__tests__/container.test.tsx +150 -0
- package/src/__tests__/effect-gradient-validation.test.tsx +133 -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 +276 -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 +119 -0
- package/src/sg-nodes/effect.tsx +167 -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 +103 -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 +66 -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 +58 -0
- package/src/sg-nodes/standard-message-dialog.props.ts +56 -0
- package/src/sg-nodes/standard-message-dialog.tsx +47 -0
- package/src/sg-nodes/standard-pinpad-dialog.props.ts +64 -0
- package/src/sg-nodes/standard-pinpad-dialog.tsx +44 -0
- package/src/sg-nodes/standard-progress-dialog.props.ts +30 -0
- package/src/sg-nodes/standard-progress-dialog.tsx +40 -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 +642 -0
- package/src/sg-nodes/video.tsx +75 -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,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
|
+
});
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
// Copyright (c) 2026 Roku, Inc.
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
// ---------------------------------------------------------------------------
|
|
4
|
+
// Tween nulls its target ref on dispose.
|
|
5
|
+
//
|
|
6
|
+
// The tween node and the node it animates reference each other on the render thread, so a
|
|
7
|
+
// tween left holding its target keeps a disposed grid alive. Each case disposes one tween and
|
|
8
|
+
// checks the last write to `targetNode` is null, on the native node and on the compat shim.
|
|
9
|
+
// ---------------------------------------------------------------------------
|
|
10
|
+
|
|
11
|
+
import "@roku-sdk/compat";
|
|
12
|
+
|
|
13
|
+
import { afterEach, beforeEach, describe, expect, jest, test } from "@jest/globals";
|
|
14
|
+
import type { DomNode } from "@roku-sdk/interfaces";
|
|
15
|
+
import { hasFeature } from "@roku-sdk/runtime/has-feature";
|
|
16
|
+
import { createSignal, type JSX, Show, tmTransaction } from "@roku-sdk/solid";
|
|
17
|
+
|
|
18
|
+
import { commitEarly, createAnchorNode, installSolidTestRenderer } from "../test-helpers/tween-test-setup";
|
|
19
|
+
import { Tween } from "../tween";
|
|
20
|
+
|
|
21
|
+
jest.mock("@roku-sdk/runtime/has-feature", () => ({
|
|
22
|
+
// The side-effect compat import loads this factory before the imported jest binding.
|
|
23
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
24
|
+
hasFeature: require("@jest/globals").jest.fn()
|
|
25
|
+
}));
|
|
26
|
+
|
|
27
|
+
type Case = [name: string, render: (target: () => DomNode | undefined) => JSX.Element];
|
|
28
|
+
|
|
29
|
+
const cases: Case[] = [
|
|
30
|
+
["float field", target => <Tween targetRef={target} field="width" targetValue={100} />],
|
|
31
|
+
["vector field", target => <Tween targetNode={target} axis="y" targetValue={100} />]
|
|
32
|
+
];
|
|
33
|
+
|
|
34
|
+
const modes: [mode: string, native: boolean, nodeType: string][] = [
|
|
35
|
+
["compat shim", false, "RsgTweenShim"],
|
|
36
|
+
["native", true, "tween"]
|
|
37
|
+
];
|
|
38
|
+
|
|
39
|
+
describe("Tween releases its target ref", () => {
|
|
40
|
+
let render: ReturnType<typeof installSolidTestRenderer>;
|
|
41
|
+
let setProperty: jest.SpiedFunction<typeof tmTransaction.setProperty>;
|
|
42
|
+
let createElement: jest.SpiedFunction<typeof tmTransaction.createElement>;
|
|
43
|
+
|
|
44
|
+
beforeEach(() => {
|
|
45
|
+
render = installSolidTestRenderer();
|
|
46
|
+
setProperty = jest.spyOn(tmTransaction, "setProperty");
|
|
47
|
+
createElement = jest.spyOn(tmTransaction, "createElement");
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
afterEach(() => {
|
|
51
|
+
jest.mocked(hasFeature).mockReset();
|
|
52
|
+
setProperty.mockRestore();
|
|
53
|
+
createElement.mockRestore();
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
const created = (nodeType: string): DomNode[] =>
|
|
57
|
+
createElement.mock.results.map(r => r.value as DomNode).filter(node => node.t === nodeType);
|
|
58
|
+
const lastWrite = (node: DomNode, name: string) => {
|
|
59
|
+
const writes = setProperty.mock.calls.filter(([n, field]) => n === node && field === name);
|
|
60
|
+
return writes[writes.length - 1];
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
describe.each(modes)("%s", (_mode, native, nodeType) => {
|
|
64
|
+
test.each(cases)("%s: nulls targetNode on dispose", (_name, renderTween) => {
|
|
65
|
+
jest.mocked(hasFeature).mockReturnValue(native);
|
|
66
|
+
let target: DomNode | undefined;
|
|
67
|
+
const dispose = render(
|
|
68
|
+
() => (
|
|
69
|
+
<group>
|
|
70
|
+
<rectangle id="target" ref={(el: unknown) => (target = el as DomNode)} />
|
|
71
|
+
{renderTween(() => target)}
|
|
72
|
+
</group>
|
|
73
|
+
),
|
|
74
|
+
createAnchorNode()
|
|
75
|
+
);
|
|
76
|
+
commitEarly();
|
|
77
|
+
|
|
78
|
+
const [tween] = created(nodeType);
|
|
79
|
+
expect(created(nodeType)).toHaveLength(1);
|
|
80
|
+
expect(lastWrite(tween, "targetNode")?.[2]).toBe(target);
|
|
81
|
+
|
|
82
|
+
dispose();
|
|
83
|
+
commitEarly();
|
|
84
|
+
|
|
85
|
+
expect(lastWrite(tween, "targetNode")).toStrictEqual([tween, "targetNode", null, undefined]);
|
|
86
|
+
});
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
// The shim releases its Animation when targetNode is cleared, so the clear has to be issued
|
|
90
|
+
// before the tween node is taken out of the tree, not after.
|
|
91
|
+
test("clears targetNode before the unmounted tween is removed", () => {
|
|
92
|
+
jest.mocked(hasFeature).mockReturnValue(false);
|
|
93
|
+
const removeNode = jest.spyOn(tmTransaction, "removeNode");
|
|
94
|
+
const [shown, setShown] = createSignal(true);
|
|
95
|
+
let target: DomNode | undefined;
|
|
96
|
+
render(
|
|
97
|
+
() => (
|
|
98
|
+
<group>
|
|
99
|
+
<rectangle id="target" ref={(el: unknown) => (target = el as DomNode)} />
|
|
100
|
+
<Show when={shown()}>
|
|
101
|
+
<Tween targetNode={() => target} field="width" targetValue={100} />
|
|
102
|
+
</Show>
|
|
103
|
+
</group>
|
|
104
|
+
),
|
|
105
|
+
createAnchorNode()
|
|
106
|
+
);
|
|
107
|
+
commitEarly();
|
|
108
|
+
const [tween] = created("RsgTweenShim");
|
|
109
|
+
expect(lastWrite(tween, "targetNode")?.[2]).toBe(target);
|
|
110
|
+
|
|
111
|
+
setShown(false);
|
|
112
|
+
commitEarly();
|
|
113
|
+
|
|
114
|
+
const clearAt = setProperty.mock.calls.findIndex(
|
|
115
|
+
([n, field, value]) => n === tween && field === "targetNode" && value === null
|
|
116
|
+
);
|
|
117
|
+
expect(clearAt).toBeGreaterThanOrEqual(0);
|
|
118
|
+
const removed = removeNode.mock.calls.findIndex(([, child]) => child === tween);
|
|
119
|
+
expect(removed).toBeGreaterThanOrEqual(0);
|
|
120
|
+
expect(setProperty.mock.invocationCallOrder[clearAt]).toBeLessThan(
|
|
121
|
+
removeNode.mock.invocationCallOrder[removed]
|
|
122
|
+
);
|
|
123
|
+
removeNode.mockRestore();
|
|
124
|
+
});
|
|
125
|
+
});
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
// Copyright (c) 2026 Roku, Inc.
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
// ---------------------------------------------------------------------------
|
|
4
|
+
// Tween warns in dev builds when proposedStops does not have the shape its field and axis call
|
|
5
|
+
// for: number[] on a float field or on a vector field under axis "x" / "y", [x, y][] under axis
|
|
6
|
+
// "all" (the default) on a vector field, and no axis on a float field. Each distinct problem is
|
|
7
|
+
// reported once per instance; unknown fields are not checked; nothing is reported without __DEV__.
|
|
8
|
+
// ---------------------------------------------------------------------------
|
|
9
|
+
|
|
10
|
+
import "@roku-sdk/compat";
|
|
11
|
+
|
|
12
|
+
import { afterEach, beforeEach, describe, expect, jest, test } from "@jest/globals";
|
|
13
|
+
import type { DomNode } from "@roku-sdk/interfaces";
|
|
14
|
+
import { hasFeature } from "@roku-sdk/runtime/has-feature";
|
|
15
|
+
import { createSignal, type JSX } from "@roku-sdk/solid";
|
|
16
|
+
|
|
17
|
+
import { commitEarly, createAnchorNode, installSolidTestRenderer } from "../test-helpers/tween-test-setup";
|
|
18
|
+
import { Tween } from "../tween";
|
|
19
|
+
|
|
20
|
+
jest.mock("@roku-sdk/runtime/has-feature", () => ({
|
|
21
|
+
// The side-effect compat import loads this factory before the imported jest binding.
|
|
22
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
23
|
+
hasFeature: require("@jest/globals").jest.fn()
|
|
24
|
+
}));
|
|
25
|
+
|
|
26
|
+
type Stops = number[] | [number, number][];
|
|
27
|
+
|
|
28
|
+
describe("Tween proposedStops shape warning (dev only)", () => {
|
|
29
|
+
let render: ReturnType<typeof installSolidTestRenderer>;
|
|
30
|
+
let warn: jest.SpiedFunction<typeof console.warn>;
|
|
31
|
+
let realDev: boolean | undefined;
|
|
32
|
+
let dispose: (() => void) | undefined;
|
|
33
|
+
|
|
34
|
+
beforeEach(() => {
|
|
35
|
+
realDev = (globalThis as { __DEV__?: boolean }).__DEV__;
|
|
36
|
+
(globalThis as { __DEV__?: boolean }).__DEV__ = true;
|
|
37
|
+
jest.mocked(hasFeature).mockReturnValue(true);
|
|
38
|
+
render = installSolidTestRenderer();
|
|
39
|
+
warn = jest.spyOn(console, "warn").mockImplementation(() => {});
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
afterEach(() => {
|
|
43
|
+
dispose?.();
|
|
44
|
+
dispose = undefined;
|
|
45
|
+
(globalThis as { __DEV__?: boolean }).__DEV__ = realDev;
|
|
46
|
+
jest.mocked(hasFeature).mockReset();
|
|
47
|
+
warn.mockRestore();
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
const mount = (renderTween: (target: () => DomNode | undefined) => JSX.Element): void => {
|
|
51
|
+
let target: DomNode | undefined;
|
|
52
|
+
dispose = render(
|
|
53
|
+
() => (
|
|
54
|
+
<group>
|
|
55
|
+
<rectangle ref={(el: unknown) => (target = el as DomNode)} />
|
|
56
|
+
{renderTween(() => target)}
|
|
57
|
+
</group>
|
|
58
|
+
),
|
|
59
|
+
createAnchorNode()
|
|
60
|
+
);
|
|
61
|
+
commitEarly();
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
const tweenWarnings = (): string[] =>
|
|
65
|
+
warn.mock.calls.map(c => String(c[0])).filter(message => message.startsWith("Tween:"));
|
|
66
|
+
|
|
67
|
+
test("warns once when a vector field under axis x / y gets an array stop", () => {
|
|
68
|
+
mount(target => (
|
|
69
|
+
<Tween
|
|
70
|
+
targetRef={target}
|
|
71
|
+
axis="x"
|
|
72
|
+
targetValue={100}
|
|
73
|
+
proposedStops={[[300, 0], 200] as unknown as number[]}
|
|
74
|
+
/>
|
|
75
|
+
));
|
|
76
|
+
expect(tweenWarnings()).toEqual([
|
|
77
|
+
'Tween: proposedStops on "translation" with axis "x" must be scalars (the x coordinate); got an array. Array entries are skipped.'
|
|
78
|
+
]);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test("warns when a vector field under axis all gets a scalar stop", () => {
|
|
82
|
+
mount(target => <Tween targetRef={target} axis="all" targetVector={[100, 100]} proposedStops={[400, 300]} />);
|
|
83
|
+
expect(tweenWarnings()).toEqual([
|
|
84
|
+
'Tween: proposedStops on "translation" with axis "all" must be [x, y] pairs; got a scalar. Scalar entries are skipped.'
|
|
85
|
+
]);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test("treats an unset axis on a vector field as all, and checks pair length", () => {
|
|
89
|
+
mount(target => (
|
|
90
|
+
<Tween
|
|
91
|
+
targetRef={target}
|
|
92
|
+
field="scale"
|
|
93
|
+
targetVector={[1, 1]}
|
|
94
|
+
proposedStops={[[1, 2, 3]] as unknown as [number, number][]}
|
|
95
|
+
/>
|
|
96
|
+
));
|
|
97
|
+
expect(tweenWarnings()).toEqual([
|
|
98
|
+
'Tween: proposedStops on "scale" with axis "all" must be [x, y] pairs; got an array of length 3. Entries that are not pairs are skipped.'
|
|
99
|
+
]);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
test("warns when a float field is given an axis", () => {
|
|
103
|
+
mount(target => <Tween targetRef={target} field="opacity" axis="x" targetValue={0.5} proposedStops={[0.2]} />);
|
|
104
|
+
expect(tweenWarnings()).toEqual(['Tween: axis "x" is ignored on float field "opacity"; remove it.']);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test("warns when a float field gets an array stop", () => {
|
|
108
|
+
mount(target => (
|
|
109
|
+
<Tween
|
|
110
|
+
targetRef={target}
|
|
111
|
+
field="width"
|
|
112
|
+
targetValue={100}
|
|
113
|
+
proposedStops={[[50, 50]] as [number, number][]}
|
|
114
|
+
/>
|
|
115
|
+
));
|
|
116
|
+
expect(tweenWarnings()).toEqual([
|
|
117
|
+
'Tween: proposedStops on float field "width" must be numbers; got an array. Array entries are skipped.'
|
|
118
|
+
]);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
test("reports each distinct problem once per instance, across stop updates", () => {
|
|
122
|
+
const [stops, setStops] = createSignal<Stops>([400, 300]);
|
|
123
|
+
mount(target => <Tween targetRef={target} targetVector={[100, 100]} proposedStops={stops()} />);
|
|
124
|
+
setStops([500]);
|
|
125
|
+
commitEarly();
|
|
126
|
+
setStops([[1, 2, 3]] as unknown as [number, number][]);
|
|
127
|
+
commitEarly();
|
|
128
|
+
setStops([600]);
|
|
129
|
+
commitEarly();
|
|
130
|
+
expect(tweenWarnings()).toHaveLength(2);
|
|
131
|
+
expect(tweenWarnings()[0]).toContain("got a scalar");
|
|
132
|
+
expect(tweenWarnings()[1]).toContain("got an array of length 3");
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
test("does not warn when every stop has the right shape", () => {
|
|
136
|
+
mount(target => (
|
|
137
|
+
<>
|
|
138
|
+
<Tween targetRef={target} axis="y" targetValue={100} proposedStops={[100, 200]} />
|
|
139
|
+
<Tween targetRef={target} targetVector={[1, 2]} proposedStops={[[1, 2]]} />
|
|
140
|
+
<Tween targetRef={target} field="rotation" targetValue={1} proposedStops={[0.5, 1]} />
|
|
141
|
+
<Tween targetRef={target} field="opacity" targetValue={1} />
|
|
142
|
+
</>
|
|
143
|
+
));
|
|
144
|
+
expect(tweenWarnings()).toEqual([]);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
test("does not check a field it does not know", () => {
|
|
148
|
+
mount(target => (
|
|
149
|
+
<Tween
|
|
150
|
+
targetRef={target}
|
|
151
|
+
field="customField"
|
|
152
|
+
axis="all"
|
|
153
|
+
targetValue={1}
|
|
154
|
+
proposedStops={[1, [1, 2, 3]] as unknown as number[]}
|
|
155
|
+
/>
|
|
156
|
+
));
|
|
157
|
+
expect(tweenWarnings()).toEqual([]);
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
test("is silent without __DEV__", () => {
|
|
161
|
+
(globalThis as { __DEV__?: boolean }).__DEV__ = false;
|
|
162
|
+
mount(target => <Tween targetRef={target} axis="all" targetVector={[100, 100]} proposedStops={[400, 300]} />);
|
|
163
|
+
expect(tweenWarnings()).toEqual([]);
|
|
164
|
+
});
|
|
165
|
+
});
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
// Copyright (c) 2026 Roku, Inc.
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
// ---------------------------------------------------------------------------
|
|
5
|
+
// Without `@roku-sdk/compat`, a Tween on firmware with no native node refuses loudly instead of
|
|
6
|
+
// asking SceneGraph for a node type it does not have. This file deliberately never imports
|
|
7
|
+
// compat, so only the refusal alias is registered.
|
|
8
|
+
// ---------------------------------------------------------------------------
|
|
9
|
+
|
|
10
|
+
import "@roku-sdk/compat/refuse-gated-tags";
|
|
11
|
+
|
|
12
|
+
import { afterEach, beforeEach, describe, expect, jest, test } from "@jest/globals";
|
|
13
|
+
import type { DomNode } from "@roku-sdk/interfaces";
|
|
14
|
+
import { hasFeature } from "@roku-sdk/runtime/has-feature";
|
|
15
|
+
|
|
16
|
+
import { commitEarly, createAnchorNode, installSolidTestRenderer } from "../test-helpers/tween-test-setup";
|
|
17
|
+
import { Tween } from "../tween";
|
|
18
|
+
|
|
19
|
+
jest.mock("@roku-sdk/runtime/has-feature", () => ({
|
|
20
|
+
// The side-effect compat import loads this factory before the imported jest binding.
|
|
21
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
22
|
+
hasFeature: require("@jest/globals").jest.fn()
|
|
23
|
+
}));
|
|
24
|
+
|
|
25
|
+
describe("Tween without @roku-sdk/compat", () => {
|
|
26
|
+
let render: ReturnType<typeof installSolidTestRenderer>;
|
|
27
|
+
let dispose: (() => void) | undefined;
|
|
28
|
+
|
|
29
|
+
beforeEach(() => {
|
|
30
|
+
render = installSolidTestRenderer();
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
afterEach(() => {
|
|
34
|
+
dispose?.();
|
|
35
|
+
dispose = undefined;
|
|
36
|
+
jest.mocked(hasFeature).mockReset();
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
const mount = (): void => {
|
|
40
|
+
let target: DomNode | undefined;
|
|
41
|
+
dispose = render(
|
|
42
|
+
() => (
|
|
43
|
+
<group>
|
|
44
|
+
<rectangle ref={(el: unknown) => (target = el as DomNode)} />
|
|
45
|
+
<Tween targetRef={() => target} axis="x" targetValue={100} />
|
|
46
|
+
</group>
|
|
47
|
+
),
|
|
48
|
+
createAnchorNode()
|
|
49
|
+
);
|
|
50
|
+
commitEarly();
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
test("throws naming the missing firmware feature and compat when the firmware lacks the node", () => {
|
|
54
|
+
jest.mocked(hasFeature).mockReturnValue(false);
|
|
55
|
+
|
|
56
|
+
expect(mount).toThrow('<tween> requires the "tween" firmware feature or @roku-sdk/compat');
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test("creates the native node when the firmware has it", () => {
|
|
60
|
+
jest.mocked(hasFeature).mockReturnValue(true);
|
|
61
|
+
|
|
62
|
+
expect(mount).not.toThrow();
|
|
63
|
+
});
|
|
64
|
+
});
|