@ryupold/vode 1.8.6 → 1.8.8

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.
@@ -0,0 +1,992 @@
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": () => {
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
+ 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
+ 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
+ expect(state.count).toEqual(0);
43
+ 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": () => {
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
+ 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
+ expect(state.todos.filter).toEqual("active");
109
+ expect(state.todos.items.length).toEqual(3);
110
+
111
+ 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
+ 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
+ expect(state.todos.items.length).toEqual(3);
148
+ },
149
+
150
+ "Example 3: Data Fetching - loading/error/success state machine with ternary branches": () => {
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
+ expect(container).toMatch(
174
+ [DIV,
175
+ [P, "Loading..."],
176
+ ]
177
+ );
178
+
179
+ state.patch({ fetch: { status: "success", result: "Fetched data" } });
180
+
181
+ 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
+ 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": () => {
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
+ 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
+ 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
+ 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": () => {
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
+ 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
+ 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@example.com",
353
+ password: "123",
354
+ errors: {
355
+ email: undefined,
356
+ password: "Password must be at least 6 characters"
357
+ },
358
+ },
359
+ });
360
+
361
+ 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
+ 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": () => {
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
+ 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
+ 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
+ expect(state.user.name).toEqual("Bob");
476
+ expect(state.user.role).toEqual("User");
477
+ 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": () => {
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 [
512
+ DIV,
513
+ [SECTION, { class: "panel-a" },
514
+ [H2, ctxA.label.get()],
515
+ [P, `Count: ${s.panelA.count}`],
516
+ [BUTTON, "Increment A"],
517
+ ],
518
+ [SECTION, { class: "panel-b" },
519
+ [H2, ctxB.label.get()],
520
+ [P, `Count: ${s.panelB.count}`],
521
+ [BUTTON, "Increment B"],
522
+ ],
523
+ ];
524
+ });
525
+
526
+ expect(container).toMatch(
527
+ [DIV,
528
+ [SECTION, { class: "panel-a" },
529
+ [H2, "Panel A"],
530
+ [P, "Count: 0"],
531
+ [BUTTON, "Increment A"],
532
+ ],
533
+ [SECTION, { class: "panel-b" },
534
+ [H2, "Panel B"],
535
+ [P, "Count: 0"],
536
+ [BUTTON, "Increment B"],
537
+ ],
538
+ ]
539
+ );
540
+
541
+ const ctxA = context(state).panelA;
542
+ ctxA.count.patch(5);
543
+
544
+ expect(state.panelA.count).toEqual(5);
545
+ expect(state.panelB.count).toEqual(0);
546
+
547
+ expect(container).toMatch(
548
+ [DIV,
549
+ [SECTION, { class: "panel-a" },
550
+ [H2, "Panel A"],
551
+ [P, "Count: 5"],
552
+ [BUTTON, "Increment A"],
553
+ ],
554
+ [SECTION, { class: "panel-b" },
555
+ [H2, "Panel B"],
556
+ [P, "Count: 0"],
557
+ [BUTTON, "Increment B"],
558
+ ],
559
+ ]
560
+ );
561
+
562
+ const ctxB = context(state).panelB;
563
+ ctxB.count.patch(10);
564
+
565
+ expect(state.panelA.count).toEqual(5);
566
+ expect(state.panelB.count).toEqual(10);
567
+
568
+ expect(container).toMatch(
569
+ [DIV,
570
+ [SECTION, { class: "panel-a" },
571
+ [H2, "Panel A"],
572
+ [P, "Count: 5"],
573
+ [BUTTON, "Increment A"],
574
+ ],
575
+ [SECTION, { class: "panel-b" },
576
+ [H2, "Panel B"],
577
+ [P, "Count: 10"],
578
+ [BUTTON, "Increment B"],
579
+ ],
580
+ ]
581
+ );
582
+
583
+ expect(ctxA.label.get()).toEqual("Panel A");
584
+ expect(ctxB.label.get()).toEqual("Panel B");
585
+ },
586
+
587
+ "Example 8: SVG Dynamic - SVG circle with dynamic radius/color": () => {
588
+ const container = setup();
589
+ const state = createState({
590
+ svg: {
591
+ radius: 20,
592
+ color: "red",
593
+ cx: 100,
594
+ cy: 100,
595
+ },
596
+ });
597
+
598
+ app<typeof state>(container, state, (s) => {
599
+ return [DIV,
600
+ [SVG, { xmlns: "http://www.w3.org/2000/svg", width: "200", height: "200" },
601
+ [CIRCLE, {
602
+ cx: s.svg.cx,
603
+ cy: s.svg.cy,
604
+ r: s.svg.radius,
605
+ fill: s.svg.color,
606
+ stroke: "black",
607
+ "stroke-width": "2",
608
+ }],
609
+ ],
610
+ [P, `Radius: ${s.svg.radius}, Color: ${s.svg.color}`],
611
+ ];
612
+ });
613
+
614
+ expect(container).toMatch(
615
+ [DIV,
616
+ [SVG, { xmlns: "http://www.w3.org/2000/svg", width: "200", height: "200" },
617
+ [CIRCLE, {
618
+ cx: 100,
619
+ cy: 100,
620
+ r: 20,
621
+ fill: "red",
622
+ stroke: "black",
623
+ "stroke-width": "2",
624
+ }],
625
+ ],
626
+ [P, "Radius: 20, Color: red"],
627
+ ]
628
+ );
629
+
630
+ const ctx = context(state).svg;
631
+ ctx.radius.patch(30);
632
+ ctx.color.patch("green");
633
+
634
+ expect(state.svg.radius).toEqual(30);
635
+ expect(state.svg.color).toEqual("green");
636
+
637
+ expect(container).toMatch(
638
+ [DIV,
639
+ [SVG, { xmlns: "http://www.w3.org/2000/svg", width: "200", height: "200" },
640
+ [CIRCLE, {
641
+ cx: 100,
642
+ cy: 100,
643
+ r: 30,
644
+ fill: "green",
645
+ stroke: "black",
646
+ "stroke-width": "2",
647
+ }],
648
+ ],
649
+ [P, "Radius: 30, Color: green"],
650
+ ]
651
+ );
652
+
653
+ ctx.radius.patch(50);
654
+ ctx.color.patch("blue");
655
+
656
+ expect(container).toMatch(
657
+ [DIV,
658
+ [SVG, { xmlns: "http://www.w3.org/2000/svg", width: "200", height: "200" },
659
+ [CIRCLE, {
660
+ cx: 100,
661
+ cy: 100,
662
+ r: 50,
663
+ fill: "blue",
664
+ stroke: "black",
665
+ "stroke-width": "2",
666
+ }],
667
+ ],
668
+ [P, "Radius: 50, Color: blue"],
669
+ ]
670
+ );
671
+ },
672
+
673
+ "Example 9: Dynamic Attributes - conditional elements + attribute changes": () => {
674
+ const container = setup();
675
+ const state = createState({
676
+ config: {
677
+ showImage: false,
678
+ imageUrl: "https://example.com/image.png",
679
+ alt: "Example image",
680
+ linkEnabled: true,
681
+ linkUrl: "https://example.com",
682
+ boxWidth: "100px",
683
+ boxColor: "red",
684
+ },
685
+ });
686
+
687
+ app<typeof state>(container, state, (s) => {
688
+ return [
689
+ DIV,
690
+ s.config.showImage && [IMG, {
691
+ src: s.config.imageUrl,
692
+ alt: s.config.alt,
693
+ class: "dynamic-image",
694
+ "data-testid": "image",
695
+ }],
696
+ [BUTTON, s.config.showImage ? "Hide Image" : "Show Image"],
697
+ [A, {
698
+ href: s.config.linkEnabled ? s.config.linkUrl : undefined,
699
+ class: { "link-disabled": !s.config.linkEnabled },
700
+ "data-enabled": String(s.config.linkEnabled),
701
+ }, s.config.linkEnabled ? "Click me" : "Link disabled"],
702
+ [BUTTON, "Toggle Link"],
703
+ [DIV, {
704
+ style: {
705
+ width: s.config.boxWidth,
706
+ backgroundColor: s.config.boxColor,
707
+ },
708
+ class: "dynamic-box",
709
+ }, "Styled Box"],
710
+ [BUTTON, "Change Style"],
711
+ ];
712
+ });
713
+
714
+ expect(container).toMatch(
715
+ [DIV,
716
+ [BUTTON, "Show Image"],
717
+ [A, {
718
+ href: "https://example.com",
719
+ "data-enabled": "true",
720
+ }, "Click me"],
721
+ [BUTTON, "Toggle Link"],
722
+ [DIV, { class: "dynamic-box" }, "Styled Box"],
723
+ [BUTTON, "Change Style"],
724
+ ]
725
+ );
726
+
727
+ state.patch({ config: { showImage: true } });
728
+
729
+ expect(container).toMatch(
730
+ [DIV,
731
+ [IMG, {
732
+ src: "https://example.com/image.png",
733
+ alt: "Example image",
734
+ class: "dynamic-image",
735
+ "data-testid": "image",
736
+ }],
737
+ [BUTTON, "Hide Image"],
738
+ [A, {
739
+ href: "https://example.com",
740
+ "data-enabled": "true",
741
+ }, "Click me"],
742
+ [BUTTON, "Toggle Link"],
743
+ [DIV, { class: "dynamic-box" }, "Styled Box"],
744
+ [BUTTON, "Change Style"],
745
+ ]
746
+ );
747
+
748
+ state.patch({ config: { showImage: false } });
749
+
750
+ expect(container).toMatch(
751
+ [DIV,
752
+ [BUTTON, "Show Image"],
753
+ [A, {
754
+ href: "https://example.com",
755
+ "data-enabled": "true",
756
+ }, "Click me"],
757
+ [BUTTON, "Toggle Link"],
758
+ [DIV, { class: "dynamic-box" }, "Styled Box"],
759
+ [BUTTON, "Change Style"],
760
+ ]
761
+ );
762
+
763
+ state.patch({ config: { linkEnabled: false } });
764
+
765
+ expect(container).toMatch(
766
+ [DIV,
767
+ [BUTTON, "Show Image"],
768
+ [A, {
769
+ "data-enabled": "false",
770
+ }, "Link disabled"],
771
+ [BUTTON, "Toggle Link"],
772
+ [DIV, { class: "dynamic-box" }, "Styled Box"],
773
+ [BUTTON, "Change Style"],
774
+ ]
775
+ );
776
+
777
+ state.patch({ config: { boxWidth: "200px", boxColor: "blue" } });
778
+
779
+ expect(state.config.boxWidth).toEqual("200px");
780
+ expect(state.config.boxColor).toEqual("blue");
781
+ },
782
+
783
+ "Example 10: Nested Vode-App - inner app with isolated state via memo + onMount": () => {
784
+ const container = setup();
785
+
786
+ const outerState = createState({ title: "Outer", visible: true });
787
+ const innerState = createState({ counter: 0 });
788
+
789
+ type Outer = typeof outerState;
790
+ type Inner = typeof innerState;
791
+
792
+ // Helper that wraps an inner app in a memo([]) component so the outer
793
+ // app never re-renders the subtree - the inner app controls itself.
794
+ function IsolatedVodeApp<OuterState, InnerState extends PatchableState>(
795
+ tag: Tag,
796
+ state: InnerState,
797
+ View: (ins: InnerState) => Vode<InnerState>,
798
+ ): ChildVode<OuterState> {
799
+ /**
800
+ * The memo with an empty dependency array prevents further render calls
801
+ * from the outer app so rendering of the subtree inside is controlled
802
+ * by the inner app.
803
+ * Note that the top-level element of the inner app refers
804
+ * to the surrounding element and will change its state accordingly.
805
+ */
806
+ return memo<OuterState>([],
807
+ () => [tag,
808
+ {
809
+ onMount: (s: OuterState, container: Element) => {
810
+ app<InnerState>(container, state, View);
811
+ }
812
+ }
813
+ ]
814
+ );
815
+ }
816
+
817
+ app<Outer>(container, outerState, (s) => [
818
+ DIV,
819
+ [H1, s.title],
820
+ [P, "Outer content"],
821
+ s.visible && [DIV, { class: "inner-wrapper" },
822
+ IsolatedVodeApp<Outer, Inner>(
823
+ DIV,
824
+ innerState,
825
+ (ins) => [DIV,
826
+ [P, `Inner counter: ${ins.counter}`],
827
+ ]
828
+ ),
829
+ ],
830
+ [BUTTON, { onclick: () => ({ title: "Outer Updated" }) }, "Change Title"],
831
+ ]);
832
+
833
+ // initial state
834
+ expect(container).toMatch(
835
+ [DIV,
836
+ [H1, "Outer"],
837
+ [P, "Outer content"],
838
+ [DIV, { class: "inner-wrapper" },
839
+ [DIV,
840
+ [P, "Inner counter: 0"],
841
+ ],
842
+ ],
843
+ [BUTTON, "Change Title"],
844
+ ]
845
+ );
846
+
847
+ // patch inner state independently: inner updates, outer unchanged
848
+ innerState.patch({ counter: 7 });
849
+
850
+ expect(container).toMatch(
851
+ [DIV,
852
+ [H1, "Outer"],
853
+ [P, "Outer content"],
854
+ [DIV, { class: "inner-wrapper" },
855
+ [DIV,
856
+ [P, "Inner counter: 7"],
857
+ ],
858
+ ],
859
+ [BUTTON, "Change Title"],
860
+ ]
861
+ );
862
+
863
+ // patch outer state: inner is NOT re-rendered (memo([]) skips it),
864
+ // so the inner counter stays at 7 (not reset to 0).
865
+ outerState.patch({ title: "Outer Updated" });
866
+
867
+ expect(outerState.title).toEqual("Outer Updated");
868
+ expect(innerState.counter).toEqual(7);
869
+
870
+ expect(container).toMatch(
871
+ [DIV,
872
+ [H1, "Outer Updated"],
873
+ [P, "Outer content"],
874
+ [DIV, { class: "inner-wrapper" },
875
+ [DIV,
876
+ [P, "Inner counter: 7"],
877
+ ],
878
+ ],
879
+ [BUTTON, "Change Title"],
880
+ ]
881
+ );
882
+
883
+ // hiding the outer wrapper removes the inner app entirely
884
+ outerState.patch({ visible: false });
885
+
886
+ expect(container).toMatch(
887
+ [DIV,
888
+ [H1, "Outer Updated"],
889
+ [P, "Outer content"],
890
+ [BUTTON, "Change Title"],
891
+ ]
892
+ );
893
+ },
894
+
895
+ "Example 11: Error Boundary - isolated component crash with catch recovery": () => {
896
+ const container = setup();
897
+ const state = createState({
898
+ users: [
899
+ { id: 1, name: "Alice" },
900
+ { id: 2, name: "Bob" },
901
+ { id: 3, name: "Charlie" },
902
+ ],
903
+ corruptId: 2,
904
+ });
905
+ const broken = (msg: string) => (() => { throw new Error(msg); }) as Component;
906
+
907
+ app<typeof state>(container, state, (s) =>
908
+ <Vode>[DIV,
909
+ [H1, "User List"],
910
+ ...s.users.map(user =>
911
+ [SECTION,
912
+ {
913
+ class: "card",
914
+ key: user.id,
915
+ catch: [P, { class: "error" }, `⚠ Failed to load ${user.name}`],
916
+ },
917
+ user.id === s.corruptId
918
+ ? broken(`crash ${user.id}`)
919
+ : [P, user.name],
920
+ ]
921
+ )
922
+ ]
923
+ );
924
+
925
+ expect(container).toMatch(
926
+ [DIV,
927
+ [H1, "User List"],
928
+ [SECTION, [P, "Alice"]],
929
+ [P, { class: "error" }, "⚠ Failed to load Bob"],
930
+ [SECTION, [P, "Charlie"]],
931
+ ]
932
+ );
933
+
934
+ state.patch({ corruptId: 1 });
935
+
936
+ expect(container).toMatch(
937
+ [DIV,
938
+ [H1, "User List"],
939
+ [P, { class: "error" }, "⚠ Failed to load Alice"],
940
+ [SECTION, [P, "Bob"]],
941
+ [SECTION, [P, "Charlie"]],
942
+ ]
943
+ );
944
+ },
945
+
946
+ "Example 12: State Machine - sequential phase transitions via function patches": () => {
947
+ const container = setup();
948
+ const state = createState({ phase: "idle", count: 0 });
949
+ type State = typeof state;
950
+
951
+ app<State>(container, state, (s) =>
952
+ [DIV,
953
+ [P, `Phase: ${s.phase}`],
954
+ [P, `Count: ${s.count}`],
955
+ ]
956
+ );
957
+
958
+ state.patch((s) => ({ phase: "running", count: 1 }));
959
+ expect(state.phase).toEqual("running");
960
+ expect(state.count).toEqual(1);
961
+
962
+ function step(s: State) {
963
+ const next = s.count < 5
964
+ ? { count: s.count + 1 }
965
+ : { phase: "done", count: s.count };
966
+ return next;
967
+ }
968
+ state.patch(step);
969
+
970
+ expect(state.count).toEqual(2);
971
+
972
+ state.patch(step);
973
+
974
+ expect(container).toMatch(
975
+ [DIV,
976
+ [P, "Phase: running"],
977
+ [P, "Count: 3"],
978
+ ]
979
+ );
980
+
981
+ state.patch(step);
982
+ state.patch(step);
983
+
984
+ expect(state.count).toEqual(5);
985
+ expect(state.phase).toEqual("running");
986
+
987
+ state.patch(step);
988
+
989
+ expect(state.count).toEqual(5);
990
+ expect(state.phase).toEqual("done", "reached done phase");
991
+ },
992
+ };