a reactive (signals based) hypermedia web framework (wip)
stormlightlabs.github.io/volt/
hypermedia
frontend
signals
1import { mount } from "$core/binder";
2import { signal } from "$core/signal";
3import { describe, expect, it } from "vitest";
4
5describe("data-volt-model binding", () => {
6 describe("text inputs", () => {
7 it("binds signal to text input value", () => {
8 const container = document.createElement("div");
9 container.innerHTML = `<input type="text" data-volt-model="name" />`;
10
11 const name = signal("Alice");
12 mount(container, { name });
13
14 const input = container.querySelector("input")!;
15 expect(input.value).toBe("Alice");
16 });
17
18 it("updates input when signal changes", () => {
19 const container = document.createElement("div");
20 container.innerHTML = `<input type="text" data-volt-model="message" />`;
21
22 const message = signal("Hello");
23 mount(container, { message });
24
25 const input = container.querySelector("input")!;
26 expect(input.value).toBe("Hello");
27
28 message.set("World");
29 expect(input.value).toBe("World");
30 });
31
32 it("updates signal when input changes", () => {
33 const container = document.createElement("div");
34 container.innerHTML = `<input type="text" data-volt-model="text" />`;
35
36 const text = signal("initial");
37 mount(container, { text });
38
39 const input = container.querySelector("input")!;
40 input.value = "changed";
41 input.dispatchEvent(new Event("input"));
42
43 expect(text.get()).toBe("changed");
44 });
45
46 it("handles bidirectional updates", () => {
47 const container = document.createElement("div");
48 container.innerHTML = `
49 <input data-volt-model="value" />
50 <span data-volt-text="value"></span>
51 `;
52
53 const value = signal("test");
54 mount(container, { value });
55
56 const input = container.querySelector("input")!;
57 const span = container.querySelector("span")!;
58
59 expect(input.value).toBe("test");
60 expect(span.textContent).toBe("test");
61
62 input.value = "updated";
63 input.dispatchEvent(new Event("input"));
64
65 expect(value.get()).toBe("updated");
66 expect(span.textContent).toBe("updated");
67 });
68 });
69
70 describe("number inputs", () => {
71 it("binds signal to number input", () => {
72 const container = document.createElement("div");
73 container.innerHTML = `<input type="number" data-volt-model="count" />`;
74
75 const count = signal(42);
76 mount(container, { count });
77
78 const input = container.querySelector("input")!;
79 expect(input.value).toBe("42");
80 });
81
82 it("updates signal with numeric value", () => {
83 const container = document.createElement("div");
84 container.innerHTML = `<input type="number" data-volt-model="quantity" />`;
85
86 const quantity = signal(0);
87 mount(container, { quantity });
88
89 const input = container.querySelector("input")!;
90 input.value = "10";
91 input.dispatchEvent(new Event("input"));
92
93 expect(quantity.get()).toBe(10);
94 expect(typeof quantity.get()).toBe("number");
95 });
96 });
97
98 describe("checkbox inputs", () => {
99 it("binds signal to checkbox checked state", () => {
100 const container = document.createElement("div");
101 container.innerHTML = `<input type="checkbox" data-volt-model="agreed" />`;
102
103 const agreed = signal(true);
104 mount(container, { agreed });
105
106 const checkbox = container.querySelector("input")!;
107 expect(checkbox.checked).toBe(true);
108 });
109
110 it("updates checkbox when signal changes", () => {
111 const container = document.createElement("div");
112 container.innerHTML = `<input type="checkbox" data-volt-model="enabled" />`;
113
114 const enabled = signal(false);
115 mount(container, { enabled });
116
117 const checkbox = container.querySelector("input")!;
118 expect(checkbox.checked).toBe(false);
119
120 enabled.set(true);
121 expect(checkbox.checked).toBe(true);
122 });
123
124 it("updates signal when checkbox is clicked", () => {
125 const container = document.createElement("div");
126 container.innerHTML = `<input type="checkbox" data-volt-model="checked" />`;
127
128 const checked = signal(false);
129 mount(container, { checked });
130
131 const checkbox = container.querySelector("input")!;
132 checkbox.checked = true;
133 checkbox.dispatchEvent(new Event("change"));
134
135 expect(checked.get()).toBe(true);
136 });
137 });
138
139 describe("radio buttons", () => {
140 it("binds signal to radio button selection", () => {
141 const container = document.createElement("div");
142 container.innerHTML = `
143 <input type="radio" name="choice" value="a" data-volt-model="selected" />
144 <input type="radio" name="choice" value="b" data-volt-model="selected" />
145 `;
146
147 const selected = signal("b");
148 mount(container, { selected });
149
150 const radios = container.querySelectorAll("input");
151 expect(radios[0].checked).toBe(false);
152 expect(radios[1].checked).toBe(true);
153 });
154
155 it("updates signal when radio is selected", () => {
156 const container = document.createElement("div");
157 container.innerHTML = `
158 <input type="radio" name="option" value="x" data-volt-model="choice" />
159 <input type="radio" name="option" value="y" data-volt-model="choice" />
160 `;
161
162 const choice = signal("x");
163 mount(container, { choice });
164
165 const radios = container.querySelectorAll("input");
166 radios[1].checked = true;
167 radios[1].dispatchEvent(new Event("change"));
168
169 expect(choice.get()).toBe("y");
170 });
171 });
172
173 describe("select elements", () => {
174 it("binds signal to select value", () => {
175 const container = document.createElement("div");
176 container.innerHTML = `
177 <select data-volt-model="color">
178 <option value="red">Red</option>
179 <option value="blue">Blue</option>
180 </select>
181 `;
182
183 const color = signal("blue");
184 mount(container, { color });
185
186 const select = container.querySelector("select")!;
187 expect(select.value).toBe("blue");
188 });
189
190 it("updates select when signal changes", () => {
191 const container = document.createElement("div");
192 container.innerHTML = `
193 <select data-volt-model="size">
194 <option value="s">Small</option>
195 <option value="m">Medium</option>
196 <option value="l">Large</option>
197 </select>
198 `;
199
200 const size = signal("s");
201 mount(container, { size });
202
203 const select = container.querySelector("select")!;
204 expect(select.value).toBe("s");
205
206 size.set("l");
207 expect(select.value).toBe("l");
208 });
209
210 it("updates signal when selection changes", () => {
211 const container = document.createElement("div");
212 container.innerHTML = `
213 <select data-volt-model="fruit">
214 <option value="apple">Apple</option>
215 <option value="banana">Banana</option>
216 </select>
217 `;
218
219 const fruit = signal("apple");
220 mount(container, { fruit });
221
222 const select = container.querySelector("select")!;
223 select.value = "banana";
224 select.dispatchEvent(new Event("input"));
225
226 expect(fruit.get()).toBe("banana");
227 });
228 });
229
230 describe("textarea elements", () => {
231 it("binds signal to textarea value", () => {
232 const container = document.createElement("div");
233 container.innerHTML = `<textarea data-volt-model="content"></textarea>`;
234
235 const content = signal("Hello World");
236 mount(container, { content });
237
238 const textarea = container.querySelector("textarea")!;
239 expect(textarea.value).toBe("Hello World");
240 });
241
242 it("updates textarea when signal changes", () => {
243 const container = document.createElement("div");
244 container.innerHTML = `<textarea data-volt-model="notes"></textarea>`;
245
246 const notes = signal("Initial");
247 mount(container, { notes });
248
249 const textarea = container.querySelector("textarea")!;
250 expect(textarea.value).toBe("Initial");
251
252 notes.set("Updated");
253 expect(textarea.value).toBe("Updated");
254 });
255
256 it("updates signal when textarea changes", () => {
257 const container = document.createElement("div");
258 container.innerHTML = `<textarea data-volt-model="message"></textarea>`;
259
260 const message = signal("");
261 mount(container, { message });
262
263 const textarea = container.querySelector("textarea")!;
264 textarea.value = "New content";
265 textarea.dispatchEvent(new Event("input"));
266
267 expect(message.get()).toBe("New content");
268 });
269 });
270});
271
272describe("data-volt-bind:attr binding", () => {
273 describe("boolean attributes", () => {
274 it("binds disabled attribute", () => {
275 const container = document.createElement("div");
276 container.innerHTML = `<button data-volt-bind:disabled="isDisabled">Click</button>`;
277
278 const isDisabled = signal(true);
279 mount(container, { isDisabled });
280
281 const button = container.querySelector("button")!;
282 expect(button.hasAttribute("disabled")).toBe(true);
283
284 isDisabled.set(false);
285 expect(button.hasAttribute("disabled")).toBe(false);
286 });
287
288 it("binds readonly attribute", () => {
289 const container = document.createElement("div");
290 container.innerHTML = `<input data-volt-bind:readonly="locked" />`;
291
292 const locked = signal(false);
293 mount(container, { locked });
294
295 const input = container.querySelector("input")!;
296 expect(input.hasAttribute("readonly")).toBe(false);
297
298 locked.set(true);
299 expect(input.hasAttribute("readonly")).toBe(true);
300 });
301
302 it("binds checked attribute", () => {
303 const container = document.createElement("div");
304 container.innerHTML = `<input type="checkbox" data-volt-bind:checked="isChecked" />`;
305
306 const isChecked = signal(true);
307 mount(container, { isChecked });
308
309 const input = container.querySelector("input")!;
310 expect(input.hasAttribute("checked")).toBe(true);
311 });
312
313 it("binds required attribute", () => {
314 const container = document.createElement("div");
315 container.innerHTML = `<input data-volt-bind:required="mandatory" />`;
316
317 const mandatory = signal(true);
318 mount(container, { mandatory });
319
320 const input = container.querySelector("input")!;
321 expect(input.hasAttribute("required")).toBe(true);
322 });
323 });
324
325 describe("string attributes", () => {
326 it("binds href attribute", () => {
327 const container = document.createElement("div");
328 container.innerHTML = `<a data-volt-bind:href="url">Link</a>`;
329
330 const url = signal("https://example.com");
331 mount(container, { url });
332
333 const link = container.querySelector("a")!;
334 expect(link.getAttribute("href")).toBe("https://example.com");
335
336 url.set("https://volt.js.org");
337 expect(link.getAttribute("href")).toBe("https://volt.js.org");
338 });
339
340 it("binds src attribute", () => {
341 const container = document.createElement("div");
342 container.innerHTML = `<img data-volt-bind:src="image" />`;
343
344 const image = signal("/placeholder.png");
345 mount(container, { image });
346
347 const img = container.querySelector("img")!;
348 expect(img.getAttribute("src")).toBe("/placeholder.png");
349 });
350
351 it("binds title attribute", () => {
352 const container = document.createElement("div");
353 container.innerHTML = `<span data-volt-bind:title="tooltip">Hover me</span>`;
354
355 const tooltip = signal("Help text");
356 mount(container, { tooltip });
357
358 const span = container.querySelector("span")!;
359 expect(span.getAttribute("title")).toBe("Help text");
360 });
361
362 it("binds aria-label attribute", () => {
363 const container = document.createElement("div");
364 container.innerHTML = `<button data-volt-bind:aria-label="label">Icon</button>`;
365
366 const label = signal("Close");
367 mount(container, { label });
368
369 const button = container.querySelector("button")!;
370 expect(button.getAttribute("aria-label")).toBe("Close");
371 });
372 });
373
374 describe("dynamic values", () => {
375 it("updates attribute when expression changes", () => {
376 const container = document.createElement("div");
377 container.innerHTML = `<a data-volt-bind:href="baseUrl">Link</a>`;
378
379 const baseUrl = signal("/page1");
380 mount(container, { baseUrl });
381
382 const link = container.querySelector("a")!;
383 expect(link.getAttribute("href")).toBe("/page1");
384
385 baseUrl.set("/page2");
386 expect(link.getAttribute("href")).toBe("/page2");
387 });
388
389 it("removes attribute when value is null/undefined/false", () => {
390 const container = document.createElement("div");
391 container.innerHTML = `<div data-volt-bind:data-value="value">Content</div>`;
392
393 const value = signal("present");
394 mount(container, { value });
395
396 const div = container.children[0] as HTMLElement;
397 expect(div.dataset.value).toBe("present");
398
399 // @ts-expect-error incorrect type is intentional
400 value.set(null);
401 expect(Object.hasOwn(div.dataset, "value")).toBe(false);
402 });
403
404 it("handles expressions", () => {
405 const container = document.createElement("div");
406 container.innerHTML = `<button data-volt-bind:disabled="count > 5">Submit</button>`;
407
408 const count = signal(3);
409 mount(container, { count });
410
411 const button = container.querySelector("button")!;
412 expect(button.hasAttribute("disabled")).toBe(false);
413
414 count.set(10);
415 expect(button.hasAttribute("disabled")).toBe(true);
416 });
417 });
418});
419
420describe("data-volt-else binding", () => {
421 it("shows else branch when if condition is false", () => {
422 const container = document.createElement("div");
423 container.innerHTML = `
424 <div>
425 <p data-volt-if="show">If content</p>
426 <p data-volt-else>Else content</p>
427 </div>
428 `;
429
430 const show = signal(false);
431 mount(container, { show });
432
433 expect(container.textContent).toContain("Else content");
434 expect(container.textContent).not.toContain("If content");
435 });
436
437 it("shows if branch when condition is true", () => {
438 const container = document.createElement("div");
439 container.innerHTML = `
440 <div>
441 <p data-volt-if="show">If content</p>
442 <p data-volt-else>Else content</p>
443 </div>
444 `;
445
446 const show = signal(true);
447 mount(container, { show });
448
449 expect(container.textContent).toContain("If content");
450 expect(container.textContent).not.toContain("Else content");
451 });
452
453 it("toggles between if and else branches", () => {
454 const container = document.createElement("div");
455 container.innerHTML = `
456 <div>
457 <span data-volt-if="visible">Visible</span>
458 <span data-volt-else>Hidden</span>
459 </div>
460 `;
461
462 const visible = signal(true);
463 mount(container, { visible });
464
465 expect(container.textContent).toContain("Visible");
466 expect(container.textContent).not.toContain("Hidden");
467
468 visible.set(false);
469 expect(container.textContent).not.toContain("Visible");
470 expect(container.textContent).toContain("Hidden");
471
472 visible.set(true);
473 expect(container.textContent).toContain("Visible");
474 expect(container.textContent).not.toContain("Hidden");
475 });
476
477 it("supports bindings in else branch", () => {
478 const container = document.createElement("div");
479 container.innerHTML = `
480 <div>
481 <p data-volt-if="show" data-volt-text="ifMessage"></p>
482 <p data-volt-else data-volt-text="elseMessage"></p>
483 </div>
484 `;
485
486 const show = signal(false);
487 const ifMessage = signal("If text");
488 const elseMessage = signal("Else text");
489
490 mount(container, { show, ifMessage, elseMessage });
491
492 expect(container.querySelector("p")?.textContent).toBe("Else text");
493
494 show.set(true);
495 expect(container.querySelector("p")?.textContent).toBe("If text");
496 });
497
498 it("maintains separate state for each branch", () => {
499 const container = document.createElement("div");
500 container.innerHTML = `
501 <div>
502 <div data-volt-if="mode">
503 <span data-volt-text="ifValue"></span>
504 </div>
505 <div data-volt-else>
506 <span data-volt-text="elseValue"></span>
507 </div>
508 </div>
509 `;
510
511 const mode = signal(true);
512 const ifValue = signal("If");
513 const elseValue = signal("Else");
514
515 mount(container, { mode, ifValue, elseValue });
516
517 expect(container.querySelector("span")?.textContent).toBe("If");
518
519 mode.set(false);
520 expect(container.querySelector("span")?.textContent).toBe("Else");
521
522 ifValue.set("Changed If");
523 mode.set(true);
524 expect(container.querySelector("span")?.textContent).toBe("Changed If");
525 });
526
527 it("handles else without bindings", () => {
528 const container = document.createElement("div");
529 container.innerHTML = `
530 <div>
531 <p data-volt-if="condition">Condition true</p>
532 <p data-volt-else>No condition</p>
533 </div>
534 `;
535
536 const condition = signal(false);
537 mount(container, { condition });
538
539 const paragraphs = container.querySelectorAll("p");
540 expect(paragraphs).toHaveLength(1);
541 expect(paragraphs[0].textContent).toBe("No condition");
542 });
543
544 it("properly cleans up when switching branches", () => {
545 const container = document.createElement("div");
546 container.innerHTML = `
547 <div>
548 <div data-volt-if="branch">
549 <span data-volt-text="message">If</span>
550 </div>
551 <div data-volt-else>
552 <span data-volt-text="message">Else</span>
553 </div>
554 </div>
555 `;
556
557 const branch = signal(true);
558 const message = signal("Test");
559
560 const cleanup = mount(container, { branch, message });
561
562 expect(container.querySelector("span")?.textContent).toBe("Test");
563
564 message.set("Updated");
565 expect(container.querySelector("span")?.textContent).toBe("Updated");
566
567 branch.set(false);
568 expect(container.querySelector("span")?.textContent).toBe("Updated");
569
570 cleanup();
571
572 message.set("After cleanup");
573 expect(container.querySelector("span")?.textContent).toBe("Updated");
574 });
575});
576
577describe("nested property binding in data-volt-model", () => {
578 describe("single-level nesting", () => {
579 it("binds to nested property in signal with object value", () => {
580 const container = document.createElement("div");
581 container.innerHTML = `<input type="text" data-volt-model="user.name" />`;
582
583 const user = signal({ name: "Alice", age: 30 });
584 mount(container, { user });
585
586 const input = container.querySelector("input")!;
587 expect(input.value).toBe("Alice");
588 });
589
590 it("updates input when nested property in signal changes", () => {
591 const container = document.createElement("div");
592 container.innerHTML = `<input type="text" data-volt-model="person.email" />`;
593
594 const person = signal({ email: "alice@example.com", verified: false });
595 mount(container, { person });
596
597 const input = container.querySelector("input")!;
598 expect(input.value).toBe("alice@example.com");
599
600 person.set({ email: "bob@example.com", verified: true });
601 expect(input.value).toBe("bob@example.com");
602 });
603
604 it("updates nested property when input changes", () => {
605 const container = document.createElement("div");
606 container.innerHTML = `<input type="text" data-volt-model="profile.username" />`;
607
608 const profile = signal({ username: "alice123", bio: "Developer" });
609 mount(container, { profile });
610
611 const input = container.querySelector("input")!;
612 input.value = "alice456";
613 input.dispatchEvent(new Event("input"));
614
615 expect(profile.get().username).toBe("alice456");
616 expect(profile.get().bio).toBe("Developer");
617 });
618
619 it("maintains immutability when updating nested properties", () => {
620 const container = document.createElement("div");
621 container.innerHTML = `<input type="text" data-volt-model="data.value" />`;
622
623 const data = signal({ value: "original", other: "unchanged" });
624 const originalObject = data.get();
625 mount(container, { data });
626
627 const input = container.querySelector("input")!;
628 input.value = "modified";
629 input.dispatchEvent(new Event("input"));
630
631 const updatedObject = data.get();
632 expect(updatedObject).not.toBe(originalObject);
633 expect(updatedObject.value).toBe("modified");
634 expect(updatedObject.other).toBe("unchanged");
635 expect(originalObject.value).toBe("original");
636 });
637
638 it("handles bidirectional updates with nested properties", () => {
639 const container = document.createElement("div");
640 container.innerHTML = `
641 <input data-volt-model="form.email" />
642 <span data-volt-text="form.email"></span>
643 `;
644
645 const form = signal({ email: "test@example.com", name: "Test" });
646 mount(container, { form });
647
648 const input = container.querySelector("input")!;
649 const span = container.querySelector("span")!;
650
651 expect(input.value).toBe("test@example.com");
652 expect(span.textContent).toBe("test@example.com");
653
654 input.value = "updated@example.com";
655 input.dispatchEvent(new Event("input"));
656
657 expect(form.get().email).toBe("updated@example.com");
658 expect(span.textContent).toBe("updated@example.com");
659 });
660 });
661
662 describe("multiple nested properties", () => {
663 it("binds multiple inputs to different nested properties", () => {
664 const container = document.createElement("div");
665 container.innerHTML = `
666 <input id="name" type="text" data-volt-model="formData.name" />
667 <input id="email" type="email" data-volt-model="formData.email" />
668 `;
669
670 const formData = signal({ name: "Alice", email: "alice@example.com" });
671 mount(container, { formData });
672
673 const nameInput = container.querySelector("#name")! as HTMLInputElement;
674 const emailInput = container.querySelector("#email")! as HTMLInputElement;
675
676 expect(nameInput.value).toBe("Alice");
677 expect(emailInput.value).toBe("alice@example.com");
678
679 nameInput.value = "Bob";
680 nameInput.dispatchEvent(new Event("input"));
681
682 expect(formData.get().name).toBe("Bob");
683 expect(formData.get().email).toBe("alice@example.com");
684
685 emailInput.value = "bob@example.com";
686 emailInput.dispatchEvent(new Event("input"));
687
688 expect(formData.get().name).toBe("Bob");
689 expect(formData.get().email).toBe("bob@example.com");
690 });
691 });
692
693 describe("different form element types", () => {
694 it("binds checkbox to nested boolean property", () => {
695 const container = document.createElement("div");
696 container.innerHTML = `<input type="checkbox" data-volt-model="settings.enabled" />`;
697
698 const settings = signal({ enabled: true, theme: "dark" });
699 mount(container, { settings });
700
701 const checkbox = container.querySelector("input")!;
702 expect(checkbox.checked).toBe(true);
703
704 checkbox.checked = false;
705 checkbox.dispatchEvent(new Event("change"));
706
707 expect(settings.get().enabled).toBe(false);
708 expect(settings.get().theme).toBe("dark");
709 });
710
711 it("binds select to nested property", () => {
712 const container = document.createElement("div");
713 container.innerHTML = `
714 <select data-volt-model="preferences.color">
715 <option value="red">Red</option>
716 <option value="blue">Blue</option>
717 <option value="green">Green</option>
718 </select>
719 `;
720
721 const preferences = signal({ color: "blue", size: "medium" });
722 mount(container, { preferences });
723
724 const select = container.querySelector("select")!;
725 expect(select.value).toBe("blue");
726
727 select.value = "green";
728 select.dispatchEvent(new Event("input"));
729
730 expect(preferences.get().color).toBe("green");
731 expect(preferences.get().size).toBe("medium");
732 });
733
734 it("binds textarea to nested property", () => {
735 const container = document.createElement("div");
736 container.innerHTML = `<textarea data-volt-model="post.content"></textarea>`;
737
738 const post = signal({ content: "Hello world", published: false });
739 mount(container, { post });
740
741 const textarea = container.querySelector("textarea")!;
742 expect(textarea.value).toBe("Hello world");
743
744 textarea.value = "Updated content";
745 textarea.dispatchEvent(new Event("input"));
746
747 expect(post.get().content).toBe("Updated content");
748 expect(post.get().published).toBe(false);
749 });
750
751 it("binds radio buttons to nested property", () => {
752 const container = document.createElement("div");
753 container.innerHTML = `
754 <input type="radio" name="plan" value="free" data-volt-model="subscription.plan" />
755 <input type="radio" name="plan" value="pro" data-volt-model="subscription.plan" />
756 <input type="radio" name="plan" value="enterprise" data-volt-model="subscription.plan" />
757 `;
758
759 const subscription = signal({ plan: "pro", active: true });
760 mount(container, { subscription });
761
762 const radios = container.querySelectorAll("input");
763 expect(radios[0].checked).toBe(false);
764 expect(radios[1].checked).toBe(true);
765 expect(radios[2].checked).toBe(false);
766
767 radios[2].checked = true;
768 radios[2].dispatchEvent(new Event("change"));
769
770 expect(subscription.get().plan).toBe("enterprise");
771 expect(subscription.get().active).toBe(true);
772 });
773 });
774
775 describe("deep nesting", () => {
776 it("binds to deeply nested properties", () => {
777 const container = document.createElement("div");
778 container.innerHTML = `<input type="text" data-volt-model="app.user.profile.displayName" />`;
779
780 const app = signal({
781 user: { profile: { displayName: "Alice", avatar: "/avatar.png" }, settings: { theme: "dark" } },
782 });
783 mount(container, { app });
784
785 const input = container.querySelector("input")!;
786 expect(input.value).toBe("Alice");
787
788 input.value = "Bob";
789 input.dispatchEvent(new Event("input"));
790
791 expect(app.get().user.profile.displayName).toBe("Bob");
792 expect(app.get().user.profile.avatar).toBe("/avatar.png");
793 expect(app.get().user.settings.theme).toBe("dark");
794 });
795
796 it("maintains immutability with deeply nested updates", () => {
797 const container = document.createElement("div");
798 container.innerHTML = `<input type="text" data-volt-model="state.form.fields.email" />`;
799
800 const state = signal({
801 form: { fields: { email: "test@example.com", name: "Test" }, meta: { submitted: false } },
802 });
803
804 const originalState = state.get();
805 const originalForm = originalState.form;
806 const originalFields = originalState.form.fields;
807
808 mount(container, { state });
809
810 const input = container.querySelector("input")!;
811 input.value = "new@example.com";
812 input.dispatchEvent(new Event("input"));
813
814 const newState = state.get();
815 const newForm = newState.form;
816 const newFields = newState.form.fields;
817
818 expect(newState).not.toBe(originalState);
819 expect(newForm).not.toBe(originalForm);
820 expect(newFields).not.toBe(originalFields);
821
822 expect(newFields.email).toBe("new@example.com");
823 expect(newFields.name).toBe("Test");
824 expect(newForm.meta.submitted).toBe(false);
825
826 expect(originalFields.email).toBe("test@example.com");
827 });
828 });
829
830 describe("edge cases", () => {
831 it("handles undefined nested property gracefully", () => {
832 const container = document.createElement("div");
833 container.innerHTML = `<input type="text" data-volt-model="obj.missing" />`;
834
835 const obj = signal({ present: "value" });
836 mount(container, { obj });
837
838 const input = container.querySelector("input")!;
839 expect(input.value).toBe("");
840 });
841
842 it("creates nested property path when updating undefined property", () => {
843 const container = document.createElement("div");
844 container.innerHTML = `<input type="text" data-volt-model="data.newProp" />`;
845
846 const data = signal({ existingProp: "exists" });
847 mount(container, { data });
848
849 const input = container.querySelector("input")!;
850 input.value = "new value";
851 input.dispatchEvent(new Event("input"));
852
853 // @ts-expect-error updating shape of data
854 expect(data.get().newProp).toBe("new value");
855 expect(data.get().existingProp).toBe("exists");
856 });
857
858 it("works with modifiers on nested properties", () => {
859 const container = document.createElement("div");
860 container.innerHTML = `<input type="text" data-volt-model-trim="form.username" />`;
861
862 const form = signal({ username: "", email: "" });
863 mount(container, { form });
864
865 const input = container.querySelector("input")!;
866 input.value = " alice ";
867 input.dispatchEvent(new Event("input"));
868
869 expect(form.get().username).toBe("alice");
870 });
871
872 it("works with number modifier on nested properties", () => {
873 const container = document.createElement("div");
874 container.innerHTML = `<input type="text" data-volt-model-number="config.port" />`;
875
876 const config = signal({ port: 8080, host: "localhost" });
877 mount(container, { config });
878
879 const input = container.querySelector("input")!;
880 input.value = "3000";
881 input.dispatchEvent(new Event("input"));
882
883 expect(config.get().port).toBe(3000);
884 expect(typeof config.get().port).toBe("number");
885 });
886 });
887});