@ryupold/vode 1.8.7 → 1.8.10
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/README.md +34 -56
- package/dist/vode.cjs.min.js +2 -2
- package/dist/vode.d.ts +10 -10
- package/dist/vode.es5.min.js +7 -7
- package/dist/vode.js +97 -113
- package/dist/vode.min.js +1 -1
- package/dist/vode.min.mjs +1 -1
- package/dist/vode.mjs +97 -113
- package/dist/vode.tests.mjs +5303 -0
- package/package.json +5 -5
- package/src/state-context.ts +6 -4
- package/src/vode.ts +114 -126
- package/test/helper.ts +304 -113
- package/test/index.ts +10 -47
- package/test/mocks.ts +199 -43
- package/test/run-tests.ts +61 -0
- package/test/tests-app.ts +154 -38
- package/test/tests-catch.ts +160 -0
- package/test/tests-children.ts +31 -31
- package/test/tests-createPatch.ts +12 -12
- package/test/tests-createState.ts +11 -11
- package/test/tests-defuse.ts +35 -14
- package/test/tests-examples.ts +991 -0
- package/test/tests-hydrate.ts +59 -25
- package/test/tests-memo.ts +106 -64
- package/test/tests-mergeClass.ts +31 -31
- package/test/tests-mergeProps.ts +19 -19
- package/test/tests-mergeStyle.ts +28 -14
- package/test/tests-mount-unmount.ts +177 -154
- package/test/tests-patch-advanced.ts +86 -0
- package/test/tests-patch-merge.ts +66 -0
- package/test/tests-props.ts +15 -15
- package/test/tests-state-context.ts +56 -25
- package/test/tests-tag.ts +14 -14
- package/test/tests-vode.ts +6 -6
|
@@ -0,0 +1,991 @@
|
|
|
1
|
+
import { expect } from "./helper";
|
|
2
|
+
import { app, createState, context, Component, memo, DIV, SPAN, BUTTON, INPUT, FORM, UL, LI, H1, H2, P, IMG, A, LABEL, SECTION, NAV, HEADER, MAIN, SVG, CIRCLE, Tag, ChildVode, Vode, PatchableState } from "../index";
|
|
3
|
+
|
|
4
|
+
function setup() {
|
|
5
|
+
const root = document.createElement("div");
|
|
6
|
+
const container = document.createElement("div");
|
|
7
|
+
root.appendChild(container);
|
|
8
|
+
return container;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export default {
|
|
12
|
+
"Example 1: Counter - increment/reset buttons, basic state patching": async () => {
|
|
13
|
+
const container = setup();
|
|
14
|
+
const state = createState({ count: 0 });
|
|
15
|
+
|
|
16
|
+
app<typeof state>(container, state, (s) => [DIV,
|
|
17
|
+
[H1, `Count: ${s.count}`],
|
|
18
|
+
[BUTTON, { onclick: () => ({ count: s.count + 1 }) }, "Increment"],
|
|
19
|
+
[BUTTON, { onclick: () => ({ count: 0 }), disabled: s.count === 0 }, "Reset"],
|
|
20
|
+
]);
|
|
21
|
+
|
|
22
|
+
await expect(container).toMatch(
|
|
23
|
+
[DIV,
|
|
24
|
+
[H1, "Count: 0"],
|
|
25
|
+
[BUTTON, "Increment"],
|
|
26
|
+
[BUTTON, "Reset"],
|
|
27
|
+
]
|
|
28
|
+
);
|
|
29
|
+
|
|
30
|
+
state.patch({ count: 1 });
|
|
31
|
+
|
|
32
|
+
await expect(container).toMatch(
|
|
33
|
+
[DIV,
|
|
34
|
+
[H1, "Count: 1"],
|
|
35
|
+
[BUTTON, "Increment"],
|
|
36
|
+
[BUTTON, "Reset"],
|
|
37
|
+
]
|
|
38
|
+
);
|
|
39
|
+
|
|
40
|
+
state.patch({ count: 0 });
|
|
41
|
+
|
|
42
|
+
await expect(state.count).toEqual(0);
|
|
43
|
+
await expect(container).toMatch(
|
|
44
|
+
[DIV,
|
|
45
|
+
[H1, "Count: 0"],
|
|
46
|
+
[BUTTON, "Increment"],
|
|
47
|
+
[BUTTON, "Reset"],
|
|
48
|
+
]
|
|
49
|
+
);
|
|
50
|
+
},
|
|
51
|
+
|
|
52
|
+
"Example 2: Todo List with State Context - nested state via context(), list rendering": async () => {
|
|
53
|
+
const container = setup();
|
|
54
|
+
const state = createState({
|
|
55
|
+
todos: {
|
|
56
|
+
items: [
|
|
57
|
+
{ id: 1, text: "Buy milk", done: false },
|
|
58
|
+
{ id: 2, text: "Walk dog", done: true },
|
|
59
|
+
{ id: 3, text: "Read book", done: false },
|
|
60
|
+
],
|
|
61
|
+
filter: "all" as "all" | "active" | "done",
|
|
62
|
+
newTodo: "",
|
|
63
|
+
},
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
app<typeof state>(container, state, (s) => {
|
|
67
|
+
const filtered = s.todos.items.filter((item) => {
|
|
68
|
+
if (s.todos.filter === "active") return !item.done;
|
|
69
|
+
if (s.todos.filter === "done") return item.done;
|
|
70
|
+
return true;
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
return [DIV,
|
|
74
|
+
[H1, "Todos"],
|
|
75
|
+
[INPUT, { type: "text", value: s.todos.newTodo }],
|
|
76
|
+
[BUTTON, "Add"],
|
|
77
|
+
[NAV,
|
|
78
|
+
[BUTTON, { class: { active: s.todos.filter === "all" } }, "All"],
|
|
79
|
+
[BUTTON, { class: { active: s.todos.filter === "active" } }, "Active"],
|
|
80
|
+
[BUTTON, { class: { active: s.todos.filter === "done" } }, "Done"],
|
|
81
|
+
],
|
|
82
|
+
[UL,
|
|
83
|
+
...filtered.map(item => [LI, item.done ? `[X] ${item.text}` : `[ ] ${item.text}`]),
|
|
84
|
+
],
|
|
85
|
+
];
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
await expect(container).toMatch(
|
|
89
|
+
[DIV,
|
|
90
|
+
[H1, "Todos"],
|
|
91
|
+
[INPUT],
|
|
92
|
+
[BUTTON, "Add"],
|
|
93
|
+
[NAV,
|
|
94
|
+
[BUTTON, "All"],
|
|
95
|
+
[BUTTON, "Active"],
|
|
96
|
+
[BUTTON, "Done"],
|
|
97
|
+
],
|
|
98
|
+
[UL,
|
|
99
|
+
[LI, "[ ] Buy milk"],
|
|
100
|
+
[LI, "[X] Walk dog"],
|
|
101
|
+
[LI, "[ ] Read book"],
|
|
102
|
+
],
|
|
103
|
+
]
|
|
104
|
+
);
|
|
105
|
+
|
|
106
|
+
state.patch({ todos: { filter: "active" } });
|
|
107
|
+
|
|
108
|
+
await expect(state.todos.filter).toEqual("active");
|
|
109
|
+
await expect(state.todos.items.length).toEqual(3);
|
|
110
|
+
|
|
111
|
+
await expect(container).toMatch(
|
|
112
|
+
[DIV,
|
|
113
|
+
[H1, "Todos"],
|
|
114
|
+
[INPUT],
|
|
115
|
+
[BUTTON, "Add"],
|
|
116
|
+
[NAV,
|
|
117
|
+
[BUTTON, "All"],
|
|
118
|
+
[BUTTON, "Active"],
|
|
119
|
+
[BUTTON, "Done"],
|
|
120
|
+
],
|
|
121
|
+
[UL,
|
|
122
|
+
[LI, "[ ] Buy milk"],
|
|
123
|
+
[LI, "[ ] Read book"],
|
|
124
|
+
],
|
|
125
|
+
]
|
|
126
|
+
);
|
|
127
|
+
|
|
128
|
+
state.patch({ todos: { filter: "done" } });
|
|
129
|
+
|
|
130
|
+
await expect(container).toMatch(
|
|
131
|
+
[DIV,
|
|
132
|
+
[H1, "Todos"],
|
|
133
|
+
[INPUT],
|
|
134
|
+
[BUTTON, "Add"],
|
|
135
|
+
[NAV,
|
|
136
|
+
[BUTTON, "All"],
|
|
137
|
+
[BUTTON, "Active"],
|
|
138
|
+
[BUTTON, "Done"],
|
|
139
|
+
],
|
|
140
|
+
[UL,
|
|
141
|
+
[LI, "[X] Walk dog"],
|
|
142
|
+
],
|
|
143
|
+
]
|
|
144
|
+
);
|
|
145
|
+
|
|
146
|
+
state.patch({ todos: { filter: "all" } });
|
|
147
|
+
await expect(state.todos.items.length).toEqual(3);
|
|
148
|
+
},
|
|
149
|
+
|
|
150
|
+
"Example 3: Data Fetching - loading/error/success state machine with ternary branches": async () => {
|
|
151
|
+
const container = setup();
|
|
152
|
+
const state = createState({
|
|
153
|
+
fetch: {
|
|
154
|
+
status: "loading" as "loading" | "error" | "success",
|
|
155
|
+
result: null as string | null,
|
|
156
|
+
error: null as string | null,
|
|
157
|
+
},
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
app<typeof state>(container, state, (s) => {
|
|
161
|
+
return [
|
|
162
|
+
DIV,
|
|
163
|
+
s.fetch.status === "loading"
|
|
164
|
+
? [P, "Loading..."]
|
|
165
|
+
: s.fetch.status === "error"
|
|
166
|
+
? [DIV, { class: "error" }, [P, "Error: ", s.fetch.error]]
|
|
167
|
+
: [DIV, { class: "success" }, [P, "Result: ", s.fetch.result]],
|
|
168
|
+
s.fetch.status !== "loading" && [BUTTON, "Fetch"],
|
|
169
|
+
s.fetch.status === "error" && [BUTTON, "Retry"],
|
|
170
|
+
];
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
await expect(container).toMatch(
|
|
174
|
+
[DIV,
|
|
175
|
+
[P, "Loading..."],
|
|
176
|
+
]
|
|
177
|
+
);
|
|
178
|
+
|
|
179
|
+
state.patch({ fetch: { status: "success", result: "Fetched data" } });
|
|
180
|
+
|
|
181
|
+
await expect(container).toMatch(
|
|
182
|
+
[DIV,
|
|
183
|
+
[DIV, { class: "success" },
|
|
184
|
+
[P, "Result: ", "Fetched data"],
|
|
185
|
+
],
|
|
186
|
+
[BUTTON, "Fetch"],
|
|
187
|
+
]
|
|
188
|
+
);
|
|
189
|
+
|
|
190
|
+
state.patch({ fetch: { status: "error", error: "Network error", result: null } });
|
|
191
|
+
|
|
192
|
+
await expect(container).toMatch(
|
|
193
|
+
[DIV,
|
|
194
|
+
[DIV, { class: "error" },
|
|
195
|
+
[P, "Error: ", "Network error"],
|
|
196
|
+
],
|
|
197
|
+
[BUTTON, "Fetch"],
|
|
198
|
+
[BUTTON, "Retry"],
|
|
199
|
+
]
|
|
200
|
+
);
|
|
201
|
+
},
|
|
202
|
+
|
|
203
|
+
"Example 4: Tabbed Panel - tab switching via conditional rendering": async () => {
|
|
204
|
+
const container = setup();
|
|
205
|
+
const state = createState({
|
|
206
|
+
ui: {
|
|
207
|
+
activeTab: "home" as "home" | "settings" | "profile",
|
|
208
|
+
},
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
app<typeof state>(container, state, (s) => {
|
|
212
|
+
const ctx = context(s).ui;
|
|
213
|
+
return [
|
|
214
|
+
DIV,
|
|
215
|
+
[NAV, { class: "tabs" },
|
|
216
|
+
[BUTTON, { class: { active: s.ui.activeTab === "home" } }, "Home"],
|
|
217
|
+
[BUTTON, { class: { active: s.ui.activeTab === "settings" } }, "Settings"],
|
|
218
|
+
[BUTTON, { class: { active: s.ui.activeTab === "profile" } }, "Profile"],
|
|
219
|
+
],
|
|
220
|
+
[MAIN,
|
|
221
|
+
s.ui.activeTab === "home"
|
|
222
|
+
? [SECTION, { class: "tab-content" }, [H2, "Home"], [P, "Welcome home!"]]
|
|
223
|
+
: s.ui.activeTab === "settings"
|
|
224
|
+
? [SECTION, { class: "tab-content" }, [H2, "Settings"], [P, "Adjust your settings here."]]
|
|
225
|
+
: [SECTION, { class: "tab-content" }, [H2, "Profile"], [P, "Manage your profile."]],
|
|
226
|
+
],
|
|
227
|
+
];
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
await expect(container).toMatch(
|
|
231
|
+
[DIV,
|
|
232
|
+
[NAV, { class: "tabs" },
|
|
233
|
+
[BUTTON, "Home"],
|
|
234
|
+
[BUTTON, "Settings"],
|
|
235
|
+
[BUTTON, "Profile"],
|
|
236
|
+
],
|
|
237
|
+
[MAIN,
|
|
238
|
+
[SECTION, { class: "tab-content" },
|
|
239
|
+
[H2, "Home"],
|
|
240
|
+
[P, "Welcome home!"],
|
|
241
|
+
],
|
|
242
|
+
],
|
|
243
|
+
]
|
|
244
|
+
);
|
|
245
|
+
|
|
246
|
+
const ctx = context(state).ui;
|
|
247
|
+
ctx.activeTab.patch("settings");
|
|
248
|
+
|
|
249
|
+
await expect(container).toMatch(
|
|
250
|
+
[DIV,
|
|
251
|
+
[NAV, { class: "tabs" },
|
|
252
|
+
[BUTTON, "Home"],
|
|
253
|
+
[BUTTON, "Settings"],
|
|
254
|
+
[BUTTON, "Profile"],
|
|
255
|
+
],
|
|
256
|
+
[MAIN,
|
|
257
|
+
[SECTION, { class: "tab-content" },
|
|
258
|
+
[H2, "Settings"],
|
|
259
|
+
[P, "Adjust your settings here."],
|
|
260
|
+
],
|
|
261
|
+
],
|
|
262
|
+
]
|
|
263
|
+
);
|
|
264
|
+
|
|
265
|
+
ctx.activeTab.patch("profile");
|
|
266
|
+
|
|
267
|
+
await expect(container).toMatch(
|
|
268
|
+
[DIV,
|
|
269
|
+
[NAV, { class: "tabs" },
|
|
270
|
+
[BUTTON, "Home"],
|
|
271
|
+
[BUTTON, "Settings"],
|
|
272
|
+
[BUTTON, "Profile"],
|
|
273
|
+
],
|
|
274
|
+
[MAIN,
|
|
275
|
+
[SECTION, { class: "tab-content" },
|
|
276
|
+
[H2, "Profile"],
|
|
277
|
+
[P, "Manage your profile."],
|
|
278
|
+
],
|
|
279
|
+
],
|
|
280
|
+
]
|
|
281
|
+
);
|
|
282
|
+
},
|
|
283
|
+
|
|
284
|
+
"Example 5: Form Validation - live input validation with conditional error display": async () => {
|
|
285
|
+
const container = setup();
|
|
286
|
+
const state = createState({
|
|
287
|
+
form: {
|
|
288
|
+
email: "",
|
|
289
|
+
password: "",
|
|
290
|
+
errors: {} as { email?: string; password?: string },
|
|
291
|
+
submitted: false,
|
|
292
|
+
},
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
app<typeof state>(container, state, (s) => {
|
|
296
|
+
return [
|
|
297
|
+
DIV,
|
|
298
|
+
s.form.submitted
|
|
299
|
+
? [P, { class: "success" }, "Form submitted successfully!"]
|
|
300
|
+
: [FORM,
|
|
301
|
+
[LABEL, "Email:"],
|
|
302
|
+
[INPUT, { type: "email", value: s.form.email }],
|
|
303
|
+
s.form.errors.email && [P, { class: "error" }, s.form.errors.email],
|
|
304
|
+
[LABEL, "Password:"],
|
|
305
|
+
[INPUT, { type: "password", value: s.form.password }],
|
|
306
|
+
s.form.errors.password && [P, { class: "error" }, s.form.errors.password],
|
|
307
|
+
[INPUT, { type: "submit", value: "Submit" }],
|
|
308
|
+
],
|
|
309
|
+
];
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
await expect(container).toMatch(
|
|
313
|
+
[DIV,
|
|
314
|
+
[FORM,
|
|
315
|
+
[LABEL, "Email:"],
|
|
316
|
+
[INPUT],
|
|
317
|
+
[LABEL, "Password:"],
|
|
318
|
+
[INPUT],
|
|
319
|
+
[INPUT],
|
|
320
|
+
],
|
|
321
|
+
],
|
|
322
|
+
|
|
323
|
+
state,
|
|
324
|
+
"failed to create initial form"
|
|
325
|
+
);
|
|
326
|
+
|
|
327
|
+
state.patch({
|
|
328
|
+
form: {
|
|
329
|
+
email: "invalid email",
|
|
330
|
+
errors: { email: "Email must contain @" }
|
|
331
|
+
}
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
await expect(container).toMatch(
|
|
335
|
+
[DIV,
|
|
336
|
+
[FORM,
|
|
337
|
+
[LABEL, "Email:"],
|
|
338
|
+
[INPUT, { type: "email", value: 'invalid email' }],
|
|
339
|
+
[P, { class: "error" }, "Email must contain @"],
|
|
340
|
+
[LABEL, "Password:"],
|
|
341
|
+
[INPUT],
|
|
342
|
+
[INPUT],
|
|
343
|
+
],
|
|
344
|
+
],
|
|
345
|
+
|
|
346
|
+
state,
|
|
347
|
+
"failed to patch invalid email error"
|
|
348
|
+
);
|
|
349
|
+
|
|
350
|
+
state.patch({
|
|
351
|
+
form: {
|
|
352
|
+
email: "user@ryupold.de",
|
|
353
|
+
password: "123",
|
|
354
|
+
errors: {
|
|
355
|
+
email: undefined,
|
|
356
|
+
password: "Password must be at least 6 characters"
|
|
357
|
+
},
|
|
358
|
+
},
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
await expect(container).toMatch(
|
|
362
|
+
[DIV,
|
|
363
|
+
[FORM,
|
|
364
|
+
[LABEL, "Email:"],
|
|
365
|
+
[INPUT],
|
|
366
|
+
[LABEL, "Password:"],
|
|
367
|
+
[INPUT],
|
|
368
|
+
[P, { class: "error" }, "Password must be at least 6 characters"],
|
|
369
|
+
[INPUT],
|
|
370
|
+
],
|
|
371
|
+
],
|
|
372
|
+
|
|
373
|
+
state,
|
|
374
|
+
"failed to patch invalid password error"
|
|
375
|
+
);
|
|
376
|
+
|
|
377
|
+
state.patch({
|
|
378
|
+
form: {
|
|
379
|
+
password: "secure123",
|
|
380
|
+
errors: { password: undefined },
|
|
381
|
+
},
|
|
382
|
+
});
|
|
383
|
+
|
|
384
|
+
await expect(container).toMatch(
|
|
385
|
+
[DIV,
|
|
386
|
+
[FORM,
|
|
387
|
+
[LABEL, "Email:"],
|
|
388
|
+
[INPUT],
|
|
389
|
+
[LABEL, "Password:"],
|
|
390
|
+
[INPUT],
|
|
391
|
+
[INPUT],
|
|
392
|
+
],
|
|
393
|
+
],
|
|
394
|
+
|
|
395
|
+
state,
|
|
396
|
+
"failed to patch valid password and clear error"
|
|
397
|
+
);
|
|
398
|
+
},
|
|
399
|
+
|
|
400
|
+
"Example 6: Component Composition - nested components with dynamic props": async () => {
|
|
401
|
+
const container = setup();
|
|
402
|
+
const state = createState({
|
|
403
|
+
theme: "light" as "light" | "dark",
|
|
404
|
+
user: {
|
|
405
|
+
name: "Alice",
|
|
406
|
+
role: "Admin",
|
|
407
|
+
},
|
|
408
|
+
});
|
|
409
|
+
|
|
410
|
+
type State = typeof state;
|
|
411
|
+
|
|
412
|
+
const Badge: Component<State> = (s) =>
|
|
413
|
+
[SPAN, { class: `badge badge-${s.theme}` }, s.user.name];
|
|
414
|
+
|
|
415
|
+
const Card: Component<State> = (s) =>
|
|
416
|
+
[SECTION, { class: `card card-${s.theme}` },
|
|
417
|
+
[H2, "User Info"],
|
|
418
|
+
[P, `Name: ${s.user.name}`],
|
|
419
|
+
[P, `Role: ${s.user.role}`],
|
|
420
|
+
];
|
|
421
|
+
|
|
422
|
+
const Header: Component<State> = (s) =>
|
|
423
|
+
[HEADER, { class: `header header-${s.theme}` },
|
|
424
|
+
[H1, "App"],
|
|
425
|
+
Badge,
|
|
426
|
+
];
|
|
427
|
+
|
|
428
|
+
app<State>(container, state, (s) => [
|
|
429
|
+
DIV,
|
|
430
|
+
Header,
|
|
431
|
+
[MAIN, Card],
|
|
432
|
+
[BUTTON, {
|
|
433
|
+
onclick: () => ({ theme: s.theme === "light" ? "dark" : "light" }),
|
|
434
|
+
}, "Toggle Theme"],
|
|
435
|
+
]);
|
|
436
|
+
|
|
437
|
+
await expect(container).toMatch(
|
|
438
|
+
[DIV,
|
|
439
|
+
[HEADER, { class: "header header-light" },
|
|
440
|
+
[H1, "App"],
|
|
441
|
+
[SPAN, { class: "badge badge-light" }, "Alice"],
|
|
442
|
+
],
|
|
443
|
+
[MAIN,
|
|
444
|
+
[SECTION, { class: "card card-light" },
|
|
445
|
+
[H2, "User Info"],
|
|
446
|
+
[P, "Name: Alice"],
|
|
447
|
+
[P, "Role: Admin"],
|
|
448
|
+
],
|
|
449
|
+
],
|
|
450
|
+
[BUTTON, "Toggle Theme"],
|
|
451
|
+
]
|
|
452
|
+
);
|
|
453
|
+
|
|
454
|
+
state.patch({ theme: "dark" });
|
|
455
|
+
|
|
456
|
+
await expect(container).toMatch(
|
|
457
|
+
[DIV,
|
|
458
|
+
[HEADER, { class: "header header-dark" },
|
|
459
|
+
[H1, "App"],
|
|
460
|
+
[SPAN, { class: "badge badge-dark" }, "Alice"],
|
|
461
|
+
],
|
|
462
|
+
[MAIN,
|
|
463
|
+
[SECTION, { class: "card card-dark" },
|
|
464
|
+
[H2, "User Info"],
|
|
465
|
+
[P, "Name: Alice"],
|
|
466
|
+
[P, "Role: Admin"],
|
|
467
|
+
],
|
|
468
|
+
],
|
|
469
|
+
[BUTTON, "Toggle Theme"],
|
|
470
|
+
]
|
|
471
|
+
);
|
|
472
|
+
|
|
473
|
+
state.patch({ user: { name: "Bob", role: "User" } });
|
|
474
|
+
|
|
475
|
+
await expect(state.user.name).toEqual("Bob");
|
|
476
|
+
await expect(state.user.role).toEqual("User");
|
|
477
|
+
await expect(container).toMatch(
|
|
478
|
+
[DIV,
|
|
479
|
+
[HEADER, { class: "header header-dark" },
|
|
480
|
+
[H1, "App"],
|
|
481
|
+
[SPAN, { class: "badge badge-dark" }, "Bob"],
|
|
482
|
+
],
|
|
483
|
+
[MAIN,
|
|
484
|
+
[SECTION, { class: "card card-dark" },
|
|
485
|
+
[H2, "User Info"],
|
|
486
|
+
[P, "Name: Bob"],
|
|
487
|
+
[P, "Role: User"],
|
|
488
|
+
],
|
|
489
|
+
],
|
|
490
|
+
[BUTTON, "Toggle Theme"],
|
|
491
|
+
]
|
|
492
|
+
);
|
|
493
|
+
},
|
|
494
|
+
|
|
495
|
+
"Example 7: Multi-Context - multiple independent state contexts": async () => {
|
|
496
|
+
const container = setup();
|
|
497
|
+
const state = createState({
|
|
498
|
+
panelA: {
|
|
499
|
+
count: 0,
|
|
500
|
+
label: "Panel A",
|
|
501
|
+
},
|
|
502
|
+
panelB: {
|
|
503
|
+
count: 0,
|
|
504
|
+
label: "Panel B",
|
|
505
|
+
},
|
|
506
|
+
});
|
|
507
|
+
|
|
508
|
+
app<typeof state>(container, state, (s) => {
|
|
509
|
+
const ctxA = context(s).panelA;
|
|
510
|
+
const ctxB = context(s).panelB;
|
|
511
|
+
return [DIV,
|
|
512
|
+
[SECTION, { class: "panel-a" },
|
|
513
|
+
[H2, ctxA.label.get()],
|
|
514
|
+
[P, `Count: ${s.panelA.count}`],
|
|
515
|
+
[BUTTON, "Increment A"],
|
|
516
|
+
],
|
|
517
|
+
[SECTION, { class: "panel-b" },
|
|
518
|
+
[H2, ctxB.label.get()],
|
|
519
|
+
[P, `Count: ${s.panelB.count}`],
|
|
520
|
+
[BUTTON, "Increment B"],
|
|
521
|
+
],
|
|
522
|
+
];
|
|
523
|
+
});
|
|
524
|
+
|
|
525
|
+
await expect(container).toMatch(
|
|
526
|
+
[DIV,
|
|
527
|
+
[SECTION, { class: "panel-a" },
|
|
528
|
+
[H2, "Panel A"],
|
|
529
|
+
[P, "Count: 0"],
|
|
530
|
+
[BUTTON, "Increment A"],
|
|
531
|
+
],
|
|
532
|
+
[SECTION, { class: "panel-b" },
|
|
533
|
+
[H2, "Panel B"],
|
|
534
|
+
[P, "Count: 0"],
|
|
535
|
+
[BUTTON, "Increment B"],
|
|
536
|
+
],
|
|
537
|
+
]
|
|
538
|
+
);
|
|
539
|
+
|
|
540
|
+
const ctxA = context(state).panelA;
|
|
541
|
+
ctxA.count.patch(5);
|
|
542
|
+
|
|
543
|
+
await expect(state.panelA.count).toEqual(5);
|
|
544
|
+
await expect(state.panelB.count).toEqual(0);
|
|
545
|
+
|
|
546
|
+
await expect(container).toMatch(
|
|
547
|
+
[DIV,
|
|
548
|
+
[SECTION, { class: "panel-a" },
|
|
549
|
+
[H2, "Panel A"],
|
|
550
|
+
[P, "Count: 5"],
|
|
551
|
+
[BUTTON, "Increment A"],
|
|
552
|
+
],
|
|
553
|
+
[SECTION, { class: "panel-b" },
|
|
554
|
+
[H2, "Panel B"],
|
|
555
|
+
[P, "Count: 0"],
|
|
556
|
+
[BUTTON, "Increment B"],
|
|
557
|
+
],
|
|
558
|
+
]
|
|
559
|
+
);
|
|
560
|
+
|
|
561
|
+
const ctxB = context(state).panelB;
|
|
562
|
+
ctxB.count.patch(10);
|
|
563
|
+
|
|
564
|
+
await expect(state.panelA.count).toEqual(5);
|
|
565
|
+
await expect(state.panelB.count).toEqual(10);
|
|
566
|
+
|
|
567
|
+
await expect(container).toMatch(
|
|
568
|
+
[DIV,
|
|
569
|
+
[SECTION, { class: "panel-a" },
|
|
570
|
+
[H2, "Panel A"],
|
|
571
|
+
[P, "Count: 5"],
|
|
572
|
+
[BUTTON, "Increment A"],
|
|
573
|
+
],
|
|
574
|
+
[SECTION, { class: "panel-b" },
|
|
575
|
+
[H2, "Panel B"],
|
|
576
|
+
[P, "Count: 10"],
|
|
577
|
+
[BUTTON, "Increment B"],
|
|
578
|
+
],
|
|
579
|
+
]
|
|
580
|
+
);
|
|
581
|
+
|
|
582
|
+
await expect(ctxA.label.get()).toEqual("Panel A");
|
|
583
|
+
await expect(ctxB.label.get()).toEqual("Panel B");
|
|
584
|
+
},
|
|
585
|
+
|
|
586
|
+
"Example 8: SVG Dynamic - SVG circle with dynamic radius/color": async () => {
|
|
587
|
+
const container = setup();
|
|
588
|
+
const state = createState({
|
|
589
|
+
svg: {
|
|
590
|
+
radius: 20,
|
|
591
|
+
color: "red",
|
|
592
|
+
cx: 100,
|
|
593
|
+
cy: 100,
|
|
594
|
+
},
|
|
595
|
+
});
|
|
596
|
+
|
|
597
|
+
app<typeof state>(container, state, (s) => {
|
|
598
|
+
return [DIV,
|
|
599
|
+
[SVG, { xmlns: "http://www.w3.org/2000/svg", width: "200", height: "200" },
|
|
600
|
+
[CIRCLE, {
|
|
601
|
+
cx: s.svg.cx,
|
|
602
|
+
cy: s.svg.cy,
|
|
603
|
+
r: s.svg.radius,
|
|
604
|
+
fill: s.svg.color,
|
|
605
|
+
stroke: "black",
|
|
606
|
+
"stroke-width": "2",
|
|
607
|
+
}],
|
|
608
|
+
],
|
|
609
|
+
[P, `Radius: ${s.svg.radius}, Color: ${s.svg.color}`],
|
|
610
|
+
];
|
|
611
|
+
});
|
|
612
|
+
|
|
613
|
+
await expect(container).toMatch(
|
|
614
|
+
[DIV,
|
|
615
|
+
[SVG, { xmlns: "http://www.w3.org/2000/svg", width: "200", height: "200" },
|
|
616
|
+
[CIRCLE, {
|
|
617
|
+
cx: 100,
|
|
618
|
+
cy: 100,
|
|
619
|
+
r: 20,
|
|
620
|
+
fill: "red",
|
|
621
|
+
stroke: "black",
|
|
622
|
+
"stroke-width": "2",
|
|
623
|
+
}],
|
|
624
|
+
],
|
|
625
|
+
[P, "Radius: 20, Color: red"],
|
|
626
|
+
]
|
|
627
|
+
);
|
|
628
|
+
|
|
629
|
+
const ctx = context(state).svg;
|
|
630
|
+
ctx.radius.patch(30);
|
|
631
|
+
ctx.color.patch("green");
|
|
632
|
+
|
|
633
|
+
await expect(state.svg.radius).toEqual(30);
|
|
634
|
+
await expect(state.svg.color).toEqual("green");
|
|
635
|
+
|
|
636
|
+
await expect(container).toMatch(
|
|
637
|
+
[DIV,
|
|
638
|
+
[SVG, { xmlns: "http://www.w3.org/2000/svg", width: "200", height: "200" },
|
|
639
|
+
[CIRCLE, {
|
|
640
|
+
cx: 100,
|
|
641
|
+
cy: 100,
|
|
642
|
+
r: 30,
|
|
643
|
+
fill: "green",
|
|
644
|
+
stroke: "black",
|
|
645
|
+
"stroke-width": "2",
|
|
646
|
+
}],
|
|
647
|
+
],
|
|
648
|
+
[P, "Radius: 30, Color: green"],
|
|
649
|
+
]
|
|
650
|
+
);
|
|
651
|
+
|
|
652
|
+
ctx.radius.patch(50);
|
|
653
|
+
ctx.color.patch("blue");
|
|
654
|
+
|
|
655
|
+
await expect(container).toMatch(
|
|
656
|
+
[DIV,
|
|
657
|
+
[SVG, { xmlns: "http://www.w3.org/2000/svg", width: "200", height: "200" },
|
|
658
|
+
[CIRCLE, {
|
|
659
|
+
cx: 100,
|
|
660
|
+
cy: 100,
|
|
661
|
+
r: 50,
|
|
662
|
+
fill: "blue",
|
|
663
|
+
stroke: "black",
|
|
664
|
+
"stroke-width": "2",
|
|
665
|
+
}],
|
|
666
|
+
],
|
|
667
|
+
[P, "Radius: 50, Color: blue"],
|
|
668
|
+
]
|
|
669
|
+
);
|
|
670
|
+
},
|
|
671
|
+
|
|
672
|
+
"Example 9: Dynamic Attributes - conditional elements + attribute changes": async () => {
|
|
673
|
+
const container = setup();
|
|
674
|
+
const state = createState({
|
|
675
|
+
config: {
|
|
676
|
+
showImage: false,
|
|
677
|
+
imageUrl: "https://ryupold.de/main/assets/img/pot.webp",
|
|
678
|
+
alt: "Example image",
|
|
679
|
+
linkEnabled: true,
|
|
680
|
+
linkUrl: "https://ryupold.de",
|
|
681
|
+
boxWidth: "100px",
|
|
682
|
+
boxColor: "red",
|
|
683
|
+
},
|
|
684
|
+
});
|
|
685
|
+
|
|
686
|
+
app<typeof state>(container, state, (s) => {
|
|
687
|
+
return [
|
|
688
|
+
DIV,
|
|
689
|
+
s.config.showImage && [IMG, {
|
|
690
|
+
src: s.config.imageUrl,
|
|
691
|
+
alt: s.config.alt,
|
|
692
|
+
class: "dynamic-image",
|
|
693
|
+
"data-testid": "image",
|
|
694
|
+
}],
|
|
695
|
+
[BUTTON, s.config.showImage ? "Hide Image" : "Show Image"],
|
|
696
|
+
[A, {
|
|
697
|
+
href: s.config.linkEnabled ? s.config.linkUrl : undefined,
|
|
698
|
+
class: { "link-disabled": !s.config.linkEnabled },
|
|
699
|
+
"data-enabled": String(s.config.linkEnabled),
|
|
700
|
+
}, s.config.linkEnabled ? "Click me" : "Link disabled"],
|
|
701
|
+
[BUTTON, "Toggle Link"],
|
|
702
|
+
[DIV, {
|
|
703
|
+
style: {
|
|
704
|
+
width: s.config.boxWidth,
|
|
705
|
+
backgroundColor: s.config.boxColor,
|
|
706
|
+
},
|
|
707
|
+
class: "dynamic-box",
|
|
708
|
+
}, "Styled Box"],
|
|
709
|
+
[BUTTON, "Change Style"],
|
|
710
|
+
];
|
|
711
|
+
});
|
|
712
|
+
|
|
713
|
+
await expect(container).toMatch(
|
|
714
|
+
[DIV,
|
|
715
|
+
[BUTTON, "Show Image"],
|
|
716
|
+
[A, {
|
|
717
|
+
href: "https://ryupold.de",
|
|
718
|
+
"data-enabled": "true",
|
|
719
|
+
}, "Click me"],
|
|
720
|
+
[BUTTON, "Toggle Link"],
|
|
721
|
+
[DIV, { class: "dynamic-box" }, "Styled Box"],
|
|
722
|
+
[BUTTON, "Change Style"],
|
|
723
|
+
]
|
|
724
|
+
);
|
|
725
|
+
|
|
726
|
+
state.patch({ config: { showImage: true } });
|
|
727
|
+
|
|
728
|
+
await expect(container).toMatch(
|
|
729
|
+
[DIV,
|
|
730
|
+
[IMG, {
|
|
731
|
+
src: "https://ryupold.de/main/assets/img/pot.webp",
|
|
732
|
+
alt: "Example image",
|
|
733
|
+
class: "dynamic-image",
|
|
734
|
+
"data-testid": "image",
|
|
735
|
+
}],
|
|
736
|
+
[BUTTON, "Hide Image"],
|
|
737
|
+
[A, {
|
|
738
|
+
href: "https://ryupold.de",
|
|
739
|
+
"data-enabled": "true",
|
|
740
|
+
}, "Click me"],
|
|
741
|
+
[BUTTON, "Toggle Link"],
|
|
742
|
+
[DIV, { class: "dynamic-box" }, "Styled Box"],
|
|
743
|
+
[BUTTON, "Change Style"],
|
|
744
|
+
]
|
|
745
|
+
);
|
|
746
|
+
|
|
747
|
+
state.patch({ config: { showImage: false } });
|
|
748
|
+
|
|
749
|
+
await expect(container).toMatch(
|
|
750
|
+
[DIV,
|
|
751
|
+
[BUTTON, "Show Image"],
|
|
752
|
+
[A, {
|
|
753
|
+
href: "https://ryupold.de",
|
|
754
|
+
"data-enabled": "true",
|
|
755
|
+
}, "Click me"],
|
|
756
|
+
[BUTTON, "Toggle Link"],
|
|
757
|
+
[DIV, { class: "dynamic-box" }, "Styled Box"],
|
|
758
|
+
[BUTTON, "Change Style"],
|
|
759
|
+
]
|
|
760
|
+
);
|
|
761
|
+
|
|
762
|
+
state.patch({ config: { linkEnabled: false } });
|
|
763
|
+
|
|
764
|
+
await expect(container).toMatch(
|
|
765
|
+
[DIV,
|
|
766
|
+
[BUTTON, "Show Image"],
|
|
767
|
+
[A, {
|
|
768
|
+
"data-enabled": "false",
|
|
769
|
+
}, "Link disabled"],
|
|
770
|
+
[BUTTON, "Toggle Link"],
|
|
771
|
+
[DIV, { class: "dynamic-box" }, "Styled Box"],
|
|
772
|
+
[BUTTON, "Change Style"],
|
|
773
|
+
]
|
|
774
|
+
);
|
|
775
|
+
|
|
776
|
+
state.patch({ config: { boxWidth: "200px", boxColor: "blue" } });
|
|
777
|
+
|
|
778
|
+
await expect(state.config.boxWidth).toEqual("200px");
|
|
779
|
+
await expect(state.config.boxColor).toEqual("blue");
|
|
780
|
+
},
|
|
781
|
+
|
|
782
|
+
"Example 10: Nested Vode-App - inner app with isolated state via memo + onMount": async () => {
|
|
783
|
+
const container = setup();
|
|
784
|
+
|
|
785
|
+
const outerState = createState({ title: "Outer", visible: true });
|
|
786
|
+
const innerState = createState({ counter: 0 });
|
|
787
|
+
|
|
788
|
+
type Outer = typeof outerState;
|
|
789
|
+
type Inner = typeof innerState;
|
|
790
|
+
|
|
791
|
+
// Helper that wraps an inner app in a memo([]) component so the outer
|
|
792
|
+
// app never re-renders the subtree - the inner app controls itself.
|
|
793
|
+
function IsolatedVodeApp<OuterState, InnerState extends PatchableState>(
|
|
794
|
+
tag: Tag,
|
|
795
|
+
state: InnerState,
|
|
796
|
+
View: (ins: InnerState) => Vode<InnerState>,
|
|
797
|
+
): ChildVode<OuterState> {
|
|
798
|
+
/**
|
|
799
|
+
* The memo with an empty dependency array prevents further render calls
|
|
800
|
+
* from the outer app so rendering of the subtree inside is controlled
|
|
801
|
+
* by the inner app.
|
|
802
|
+
* Note that the top-level element of the inner app refers
|
|
803
|
+
* to the surrounding element and will change its state accordingly.
|
|
804
|
+
*/
|
|
805
|
+
return memo<OuterState>([],
|
|
806
|
+
() => [tag,
|
|
807
|
+
{
|
|
808
|
+
onMount: (s: OuterState, container: Element) => {
|
|
809
|
+
app<InnerState>(container, state, View);
|
|
810
|
+
}
|
|
811
|
+
}
|
|
812
|
+
]
|
|
813
|
+
);
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
app<Outer>(container, outerState, (s) => [
|
|
817
|
+
DIV,
|
|
818
|
+
[H1, s.title],
|
|
819
|
+
[P, "Outer content"],
|
|
820
|
+
s.visible && [DIV, { class: "inner-wrapper" },
|
|
821
|
+
IsolatedVodeApp<Outer, Inner>(
|
|
822
|
+
DIV,
|
|
823
|
+
innerState,
|
|
824
|
+
(ins) => [DIV,
|
|
825
|
+
[P, `Inner counter: ${ins.counter}`],
|
|
826
|
+
]
|
|
827
|
+
),
|
|
828
|
+
],
|
|
829
|
+
[BUTTON, { onclick: () => ({ title: "Outer Updated" }) }, "Change Title"],
|
|
830
|
+
]);
|
|
831
|
+
|
|
832
|
+
// initial state
|
|
833
|
+
await expect(container).toMatch(
|
|
834
|
+
[DIV,
|
|
835
|
+
[H1, "Outer"],
|
|
836
|
+
[P, "Outer content"],
|
|
837
|
+
[DIV, { class: "inner-wrapper" },
|
|
838
|
+
[DIV,
|
|
839
|
+
[P, "Inner counter: 0"],
|
|
840
|
+
],
|
|
841
|
+
],
|
|
842
|
+
[BUTTON, "Change Title"],
|
|
843
|
+
]
|
|
844
|
+
);
|
|
845
|
+
|
|
846
|
+
// patch inner state independently: inner updates, outer unchanged
|
|
847
|
+
innerState.patch({ counter: 7 });
|
|
848
|
+
|
|
849
|
+
await expect(container).toMatch(
|
|
850
|
+
[DIV,
|
|
851
|
+
[H1, "Outer"],
|
|
852
|
+
[P, "Outer content"],
|
|
853
|
+
[DIV, { class: "inner-wrapper" },
|
|
854
|
+
[DIV,
|
|
855
|
+
[P, "Inner counter: 7"],
|
|
856
|
+
],
|
|
857
|
+
],
|
|
858
|
+
[BUTTON, "Change Title"],
|
|
859
|
+
]
|
|
860
|
+
);
|
|
861
|
+
|
|
862
|
+
// patch outer state: inner is NOT re-rendered (memo([]) skips it),
|
|
863
|
+
// so the inner counter stays at 7 (not reset to 0).
|
|
864
|
+
outerState.patch({ title: "Outer Updated" });
|
|
865
|
+
|
|
866
|
+
await expect(outerState.title).toEqual("Outer Updated");
|
|
867
|
+
await expect(innerState.counter).toEqual(7);
|
|
868
|
+
|
|
869
|
+
await expect(container).toMatch(
|
|
870
|
+
[DIV,
|
|
871
|
+
[H1, "Outer Updated"],
|
|
872
|
+
[P, "Outer content"],
|
|
873
|
+
[DIV, { class: "inner-wrapper" },
|
|
874
|
+
[DIV,
|
|
875
|
+
[P, "Inner counter: 7"],
|
|
876
|
+
],
|
|
877
|
+
],
|
|
878
|
+
[BUTTON, "Change Title"],
|
|
879
|
+
]
|
|
880
|
+
);
|
|
881
|
+
|
|
882
|
+
// hiding the outer wrapper removes the inner app entirely
|
|
883
|
+
outerState.patch({ visible: false });
|
|
884
|
+
|
|
885
|
+
await expect(container).toMatch(
|
|
886
|
+
[DIV,
|
|
887
|
+
[H1, "Outer Updated"],
|
|
888
|
+
[P, "Outer content"],
|
|
889
|
+
[BUTTON, "Change Title"],
|
|
890
|
+
]
|
|
891
|
+
);
|
|
892
|
+
},
|
|
893
|
+
|
|
894
|
+
"Example 11: Error Boundary - isolated component crash with catch recovery": async () => {
|
|
895
|
+
const container = setup();
|
|
896
|
+
const state = createState({
|
|
897
|
+
users: [
|
|
898
|
+
{ id: 1, name: "Alice" },
|
|
899
|
+
{ id: 2, name: "Bob" },
|
|
900
|
+
{ id: 3, name: "Charlie" },
|
|
901
|
+
],
|
|
902
|
+
corruptId: 2,
|
|
903
|
+
});
|
|
904
|
+
const broken = (msg: string) => (() => { throw new Error(msg); }) as Component;
|
|
905
|
+
|
|
906
|
+
app<typeof state>(container, state, (s) =>
|
|
907
|
+
<Vode>[DIV,
|
|
908
|
+
[H1, "User List"],
|
|
909
|
+
...s.users.map(user =>
|
|
910
|
+
[SECTION,
|
|
911
|
+
{
|
|
912
|
+
class: "card",
|
|
913
|
+
key: user.id,
|
|
914
|
+
catch: [P, { class: "error" }, `⚠ Failed to load ${user.name}`],
|
|
915
|
+
},
|
|
916
|
+
user.id === s.corruptId
|
|
917
|
+
? broken(`crash ${user.id}`)
|
|
918
|
+
: [P, user.name],
|
|
919
|
+
]
|
|
920
|
+
)
|
|
921
|
+
]
|
|
922
|
+
);
|
|
923
|
+
|
|
924
|
+
await expect(container).toMatch(
|
|
925
|
+
[DIV,
|
|
926
|
+
[H1, "User List"],
|
|
927
|
+
[SECTION, [P, "Alice"]],
|
|
928
|
+
[P, { class: "error" }, "⚠ Failed to load Bob"],
|
|
929
|
+
[SECTION, [P, "Charlie"]],
|
|
930
|
+
]
|
|
931
|
+
);
|
|
932
|
+
|
|
933
|
+
state.patch({ corruptId: 1 });
|
|
934
|
+
|
|
935
|
+
await expect(container).toMatch(
|
|
936
|
+
[DIV,
|
|
937
|
+
[H1, "User List"],
|
|
938
|
+
[P, { class: "error" }, "⚠ Failed to load Alice"],
|
|
939
|
+
[SECTION, [P, "Bob"]],
|
|
940
|
+
[SECTION, [P, "Charlie"]],
|
|
941
|
+
]
|
|
942
|
+
);
|
|
943
|
+
},
|
|
944
|
+
|
|
945
|
+
"Example 12: State Machine - sequential phase transitions via function patches": async () => {
|
|
946
|
+
const container = setup();
|
|
947
|
+
const state = createState({ phase: "idle", count: 0 });
|
|
948
|
+
type State = typeof state;
|
|
949
|
+
|
|
950
|
+
app<State>(container, state, (s) =>
|
|
951
|
+
[DIV,
|
|
952
|
+
[P, `Phase: ${s.phase}`],
|
|
953
|
+
[P, `Count: ${s.count}`],
|
|
954
|
+
]
|
|
955
|
+
);
|
|
956
|
+
|
|
957
|
+
state.patch((s) => ({ phase: "running", count: 1 }));
|
|
958
|
+
await expect(state.phase).toEqual("running");
|
|
959
|
+
await expect(state.count).toEqual(1);
|
|
960
|
+
|
|
961
|
+
function step(s: State) {
|
|
962
|
+
const next = s.count < 5
|
|
963
|
+
? { count: s.count + 1 }
|
|
964
|
+
: { phase: "done", count: s.count };
|
|
965
|
+
return next;
|
|
966
|
+
}
|
|
967
|
+
state.patch(step);
|
|
968
|
+
|
|
969
|
+
await expect(state.count).toEqual(2);
|
|
970
|
+
|
|
971
|
+
state.patch(step);
|
|
972
|
+
|
|
973
|
+
await expect(container).toMatch(
|
|
974
|
+
[DIV,
|
|
975
|
+
[P, "Phase: running"],
|
|
976
|
+
[P, "Count: 3"],
|
|
977
|
+
]
|
|
978
|
+
);
|
|
979
|
+
|
|
980
|
+
state.patch(step);
|
|
981
|
+
state.patch(step);
|
|
982
|
+
|
|
983
|
+
await expect(state.count).toEqual(5);
|
|
984
|
+
await expect(state.phase).toEqual("running");
|
|
985
|
+
|
|
986
|
+
state.patch(step);
|
|
987
|
+
|
|
988
|
+
await expect(state.count).toEqual(5);
|
|
989
|
+
await expect(state.phase).toEqual("done", "reached done phase");
|
|
990
|
+
},
|
|
991
|
+
};
|