@tailorkit/react 0.1.0-beta.2 → 0.1.0-beta.3
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/package.json +5 -4
- package/src/__tests__/node-store.test.ts +206 -0
- package/src/__tests__/primitives.test.tsx +85 -0
- package/src/__tests__/tailor-kit.test.tsx +424 -0
- package/src/__tests__/tailor-kit.types.tsx +151 -0
- package/src/components/context.ts +21 -0
- package/src/components/index.ts +1 -0
- package/src/components/render.tsx +116 -0
- package/src/components/root.tsx +37 -0
- package/src/hooks/use-apps.ts +47 -0
- package/src/hooks/use-current-screen.ts +81 -0
- package/src/index.ts +8 -0
- package/src/node-store.ts +131 -0
- package/src/primitives.tsx +261 -0
- package/src/remote-context.ts +11 -0
- package/src/remote-view.tsx +232 -0
- package/src/tailor-kit.tsx +501 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tailorkit/react",
|
|
3
|
-
"version": "0.1.0-beta.
|
|
3
|
+
"version": "0.1.0-beta.3",
|
|
4
4
|
"license": "AGPL-3.0-only",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -8,7 +8,8 @@
|
|
|
8
8
|
"directory": "packages/react"
|
|
9
9
|
},
|
|
10
10
|
"files": [
|
|
11
|
-
"dist"
|
|
11
|
+
"dist",
|
|
12
|
+
"src"
|
|
12
13
|
],
|
|
13
14
|
"type": "module",
|
|
14
15
|
"imports": {
|
|
@@ -33,8 +34,8 @@
|
|
|
33
34
|
"dependencies": {
|
|
34
35
|
"@floating-ui/react-dom": "^2.1.9",
|
|
35
36
|
"@standard-schema/spec": "^1.1.0",
|
|
36
|
-
"@tailorkit/core": "0.1.0-beta.
|
|
37
|
-
"@tailorkit/sandbox": "0.1.0-beta.
|
|
37
|
+
"@tailorkit/core": "0.1.0-beta.3",
|
|
38
|
+
"@tailorkit/sandbox": "0.1.0-beta.3"
|
|
38
39
|
},
|
|
39
40
|
"devDependencies": {
|
|
40
41
|
"@tailorkit/config": "0.0.0",
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { NodeStore } from "../node-store";
|
|
3
|
+
import type { RemoteNode } from "@tailorkit/sandbox/protocol";
|
|
4
|
+
|
|
5
|
+
const textNode = (id: string, text: string): RemoteNode => ({ id, kind: "text", text });
|
|
6
|
+
|
|
7
|
+
const elemNode = (
|
|
8
|
+
id: string,
|
|
9
|
+
type: string,
|
|
10
|
+
props: Record<string, unknown> = {},
|
|
11
|
+
children: RemoteNode[] = [],
|
|
12
|
+
): RemoteNode => ({ children, id, kind: "element", props, type });
|
|
13
|
+
|
|
14
|
+
const elementWithCallback = (event: string): RemoteNode => ({
|
|
15
|
+
callbacks: [{ callback: "onSelect", inputCount: 1, event }],
|
|
16
|
+
children: [],
|
|
17
|
+
id: "n",
|
|
18
|
+
kind: "element",
|
|
19
|
+
props: {},
|
|
20
|
+
type: "tailorkit-button",
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
describe("NodeStore", () => {
|
|
24
|
+
describe("setSnapshot", () => {
|
|
25
|
+
it("stores all nodes from the tree", () => {
|
|
26
|
+
const store = new NodeStore();
|
|
27
|
+
const tree = elemNode("root", "div", {}, [textNode("t1", "hello")]);
|
|
28
|
+
store.setSnapshot(tree);
|
|
29
|
+
|
|
30
|
+
expect(store.getNode("root")).toMatchObject({ id: "root", type: "div" });
|
|
31
|
+
expect(store.getNode("t1")).toMatchObject({ id: "t1", text: "hello" });
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("flattens nested children", () => {
|
|
35
|
+
const store = new NodeStore();
|
|
36
|
+
const tree = elemNode("root", "div", {}, [
|
|
37
|
+
elemNode("child", "span", {}, [textNode("text", "hi")]),
|
|
38
|
+
]);
|
|
39
|
+
store.setSnapshot(tree);
|
|
40
|
+
|
|
41
|
+
expect(store.getNode("text")).toMatchObject({ text: "hi" });
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("tracks the root id", () => {
|
|
45
|
+
const store = new NodeStore();
|
|
46
|
+
store.setSnapshot(textNode("root", "hi"));
|
|
47
|
+
expect(store.getRootId()).toBe("root");
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("removes stale nodes when they leave the tree", () => {
|
|
51
|
+
const store = new NodeStore();
|
|
52
|
+
store.setSnapshot(elemNode("root", "div", {}, [textNode("old", "gone")]));
|
|
53
|
+
store.setSnapshot(elemNode("root", "div", {}, []));
|
|
54
|
+
|
|
55
|
+
expect(store.getNode("old")).toBeNull();
|
|
56
|
+
});
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
describe("subscribe", () => {
|
|
60
|
+
it("notifies listener when a node changes", () => {
|
|
61
|
+
const store = new NodeStore();
|
|
62
|
+
store.setSnapshot(textNode("n1", "initial"));
|
|
63
|
+
|
|
64
|
+
const listener = vi.fn();
|
|
65
|
+
store.subscribe("n1", listener);
|
|
66
|
+
|
|
67
|
+
store.setSnapshot(textNode("n1", "updated"));
|
|
68
|
+
expect(listener).toHaveBeenCalledTimes(1);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it("does not notify when node content is unchanged", () => {
|
|
72
|
+
const store = new NodeStore();
|
|
73
|
+
store.setSnapshot(textNode("n1", "same"));
|
|
74
|
+
|
|
75
|
+
const listener = vi.fn();
|
|
76
|
+
store.subscribe("n1", listener);
|
|
77
|
+
|
|
78
|
+
store.setSnapshot(textNode("n1", "same"));
|
|
79
|
+
expect(listener).not.toHaveBeenCalled();
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it("only notifies the subscriber for its specific node", () => {
|
|
83
|
+
const store = new NodeStore();
|
|
84
|
+
store.setSnapshot(elemNode("root", "div", {}, [textNode("n1", "a"), textNode("n2", "b")]));
|
|
85
|
+
|
|
86
|
+
const n1Listener = vi.fn();
|
|
87
|
+
const n2Listener = vi.fn();
|
|
88
|
+
store.subscribe("n1", n1Listener);
|
|
89
|
+
store.subscribe("n2", n2Listener);
|
|
90
|
+
|
|
91
|
+
store.setSnapshot(
|
|
92
|
+
elemNode("root", "div", {}, [textNode("n1", "changed"), textNode("n2", "b")]),
|
|
93
|
+
);
|
|
94
|
+
|
|
95
|
+
expect(n1Listener).toHaveBeenCalledTimes(1);
|
|
96
|
+
expect(n2Listener).not.toHaveBeenCalled();
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it("does not notify parent when only a child's props change", () => {
|
|
100
|
+
const store = new NodeStore();
|
|
101
|
+
store.setSnapshot(elemNode("root", "div", {}, [textNode("child", "initial")]));
|
|
102
|
+
|
|
103
|
+
const rootListener = vi.fn();
|
|
104
|
+
store.subscribe("root", rootListener);
|
|
105
|
+
|
|
106
|
+
store.setSnapshot(elemNode("root", "div", {}, [textNode("child", "updated")]));
|
|
107
|
+
|
|
108
|
+
expect(rootListener).not.toHaveBeenCalled();
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it("notifies parent when children list changes", () => {
|
|
112
|
+
const store = new NodeStore();
|
|
113
|
+
store.setSnapshot(elemNode("root", "div", {}, [textNode("n1", "a")]));
|
|
114
|
+
|
|
115
|
+
const rootListener = vi.fn();
|
|
116
|
+
store.subscribe("root", rootListener);
|
|
117
|
+
|
|
118
|
+
store.setSnapshot(elemNode("root", "div", {}, [textNode("n1", "a"), textNode("n2", "b")]));
|
|
119
|
+
|
|
120
|
+
expect(rootListener).toHaveBeenCalledTimes(1);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it("unsubscribes cleanly", () => {
|
|
124
|
+
const store = new NodeStore();
|
|
125
|
+
store.setSnapshot(textNode("n1", "a"));
|
|
126
|
+
|
|
127
|
+
const listener = vi.fn();
|
|
128
|
+
const unsub = store.subscribe("n1", listener);
|
|
129
|
+
unsub();
|
|
130
|
+
|
|
131
|
+
store.setSnapshot(textNode("n1", "b"));
|
|
132
|
+
expect(listener).not.toHaveBeenCalled();
|
|
133
|
+
});
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
describe("subscribeRoot", () => {
|
|
137
|
+
it("notifies when root id changes", () => {
|
|
138
|
+
const store = new NodeStore();
|
|
139
|
+
store.setSnapshot(textNode("root1", "a"));
|
|
140
|
+
|
|
141
|
+
const listener = vi.fn();
|
|
142
|
+
store.subscribeRoot(listener);
|
|
143
|
+
|
|
144
|
+
store.setSnapshot(textNode("root2", "b"));
|
|
145
|
+
expect(listener).toHaveBeenCalledTimes(1);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it("does not notify when root id stays the same", () => {
|
|
149
|
+
const store = new NodeStore();
|
|
150
|
+
store.setSnapshot(textNode("root", "a"));
|
|
151
|
+
|
|
152
|
+
const listener = vi.fn();
|
|
153
|
+
store.subscribeRoot(listener);
|
|
154
|
+
|
|
155
|
+
store.setSnapshot(textNode("root", "b"));
|
|
156
|
+
expect(listener).not.toHaveBeenCalled();
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it("unsubscribes cleanly", () => {
|
|
160
|
+
const store = new NodeStore();
|
|
161
|
+
store.setSnapshot(textNode("root1", "a"));
|
|
162
|
+
|
|
163
|
+
const listener = vi.fn();
|
|
164
|
+
const unsub = store.subscribeRoot(listener);
|
|
165
|
+
unsub();
|
|
166
|
+
|
|
167
|
+
store.setSnapshot(textNode("root2", "b"));
|
|
168
|
+
expect(listener).not.toHaveBeenCalled();
|
|
169
|
+
});
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
describe("element node change detection", () => {
|
|
173
|
+
it("detects prop changes", () => {
|
|
174
|
+
const store = new NodeStore();
|
|
175
|
+
store.setSnapshot(elemNode("n", "div", { color: "red" }));
|
|
176
|
+
|
|
177
|
+
const listener = vi.fn();
|
|
178
|
+
store.subscribe("n", listener);
|
|
179
|
+
|
|
180
|
+
store.setSnapshot(elemNode("n", "div", { color: "blue" }));
|
|
181
|
+
expect(listener).toHaveBeenCalledTimes(1);
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
it("detects type changes", () => {
|
|
185
|
+
const store = new NodeStore();
|
|
186
|
+
store.setSnapshot(elemNode("n", "div"));
|
|
187
|
+
|
|
188
|
+
const listener = vi.fn();
|
|
189
|
+
store.subscribe("n", listener);
|
|
190
|
+
|
|
191
|
+
store.setSnapshot(elemNode("n", "span"));
|
|
192
|
+
expect(listener).toHaveBeenCalledTimes(1);
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
it("detects callback binding changes", () => {
|
|
196
|
+
const store = new NodeStore();
|
|
197
|
+
|
|
198
|
+
store.setSnapshot(elementWithCallback("tailorkitcallbackonselect"));
|
|
199
|
+
const listener = vi.fn();
|
|
200
|
+
store.subscribe("n", listener);
|
|
201
|
+
|
|
202
|
+
store.setSnapshot(elementWithCallback("tailorkitcallbackonchange"));
|
|
203
|
+
expect(listener).toHaveBeenCalledTimes(1);
|
|
204
|
+
});
|
|
205
|
+
});
|
|
206
|
+
});
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { render, screen } from "@testing-library/react";
|
|
2
|
+
import { createElement } from "react";
|
|
3
|
+
import { describe, expect, it } from "vitest";
|
|
4
|
+
import { buildThemeCss, PrimitiveThemeContext, primitives } from "../primitives";
|
|
5
|
+
|
|
6
|
+
const theme = {
|
|
7
|
+
breakpoints: {
|
|
8
|
+
base: null,
|
|
9
|
+
lg: "1024px",
|
|
10
|
+
sm: "640px",
|
|
11
|
+
},
|
|
12
|
+
tokens: {
|
|
13
|
+
background: {
|
|
14
|
+
surface: "var(--background)",
|
|
15
|
+
},
|
|
16
|
+
borderColor: {
|
|
17
|
+
default: "var(--border)",
|
|
18
|
+
},
|
|
19
|
+
textColor: {
|
|
20
|
+
foreground: "var(--foreground)",
|
|
21
|
+
muted: "var(--muted-foreground)",
|
|
22
|
+
},
|
|
23
|
+
radius: {
|
|
24
|
+
md: "8px",
|
|
25
|
+
},
|
|
26
|
+
space: {
|
|
27
|
+
lg: "16px",
|
|
28
|
+
md: "8px",
|
|
29
|
+
},
|
|
30
|
+
},
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
describe("react primitives", () => {
|
|
34
|
+
it("builds screen-scoped theme variables", () => {
|
|
35
|
+
const css = buildThemeCss("screen-1", theme);
|
|
36
|
+
expect(css).toContain('[data-tailorkit-screen="screen-1"]');
|
|
37
|
+
expect(css).toContain("--tailorkit-border-solid: solid;");
|
|
38
|
+
expect(css).toContain("--tailorkit-space-md: 8px;");
|
|
39
|
+
expect(css).toContain("--tailorkit-background-surface: var(--background);");
|
|
40
|
+
expect(css).toContain("--tailorkit-textColor-muted: var(--muted-foreground);");
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("renders a scoped primitive style with responsive rules", () => {
|
|
44
|
+
render(
|
|
45
|
+
<PrimitiveThemeContext.Provider value={{ screenId: "screen-1", theme }}>
|
|
46
|
+
{primitives.Flex({
|
|
47
|
+
props: {
|
|
48
|
+
direction: { base: "column", lg: "row" },
|
|
49
|
+
border: "solid",
|
|
50
|
+
gap: "md",
|
|
51
|
+
padding: { base: "md", lg: "lg" },
|
|
52
|
+
textColor: { base: "muted", lg: "foreground" },
|
|
53
|
+
},
|
|
54
|
+
slots: { default: createElement("span", null, "Content") },
|
|
55
|
+
})}
|
|
56
|
+
</PrimitiveThemeContext.Provider>,
|
|
57
|
+
);
|
|
58
|
+
|
|
59
|
+
const content = screen.getByText("Content");
|
|
60
|
+
const node = content.parentElement;
|
|
61
|
+
const style = node?.querySelector("style");
|
|
62
|
+
expect(node?.tagName).toBe("DIV");
|
|
63
|
+
expect(style?.textContent).toContain("display: flex;");
|
|
64
|
+
expect(style?.textContent).toContain("border-style: var(--tailorkit-border-solid);");
|
|
65
|
+
expect(style?.textContent).toContain("border-width: 1px;");
|
|
66
|
+
expect(style?.textContent).toContain("padding: var(--tailorkit-space-md);");
|
|
67
|
+
expect(style?.textContent).toContain("color: var(--tailorkit-textColor-muted);");
|
|
68
|
+
expect(style?.textContent).toContain("@media (min-width: 1024px)");
|
|
69
|
+
expect(style?.textContent).toContain("color: var(--tailorkit-textColor-foreground);");
|
|
70
|
+
expect(style?.textContent).toContain("flex-direction: row;");
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("renders inline as a span", () => {
|
|
74
|
+
render(
|
|
75
|
+
<PrimitiveThemeContext.Provider value={{ screenId: "screen-2", theme }}>
|
|
76
|
+
{primitives.Inline({
|
|
77
|
+
props: { padding: "md" },
|
|
78
|
+
slots: { default: "Label" },
|
|
79
|
+
})}
|
|
80
|
+
</PrimitiveThemeContext.Provider>,
|
|
81
|
+
);
|
|
82
|
+
|
|
83
|
+
expect(screen.getByText("Label").tagName).toBe("SPAN");
|
|
84
|
+
});
|
|
85
|
+
});
|