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-for binding", () => {
6 it("renders a list from array signal", () => {
7 const container = document.createElement("div");
8 container.innerHTML = `<ul><li data-volt-for="item in items" data-volt-text="item"></li></ul>`;
9
10 const items = signal(["apple", "banana", "cherry"]);
11 mount(container, { items });
12
13 const ul = container.querySelector("ul")!;
14 const listItems = ul.querySelectorAll("li");
15
16 expect(listItems.length).toBe(3);
17 expect(listItems[0]?.textContent).toBe("apple");
18 expect(listItems[1]?.textContent).toBe("banana");
19 expect(listItems[2]?.textContent).toBe("cherry");
20 });
21
22 it("updates list when signal changes", () => {
23 const container = document.createElement("div");
24 container.innerHTML = `<ul><li data-volt-for="item in items" data-volt-text="item"></li></ul>`;
25
26 const items = signal(["one", "two"]);
27 mount(container, { items });
28
29 const ul = container.querySelector("ul")!;
30 let listItems = ul.querySelectorAll("li");
31
32 expect(listItems.length).toBe(2);
33 expect(listItems[0]?.textContent).toBe("one");
34 expect(listItems[1]?.textContent).toBe("two");
35
36 items.set(["one", "two", "three", "four"]);
37 listItems = ul.querySelectorAll("li");
38
39 expect(listItems.length).toBe(4);
40 expect(listItems[2]?.textContent).toBe("three");
41 expect(listItems[3]?.textContent).toBe("four");
42
43 items.set(["solo"]);
44 listItems = ul.querySelectorAll("li");
45
46 expect(listItems.length).toBe(1);
47 expect(listItems[0]?.textContent).toBe("solo");
48 });
49
50 it("renders list with object properties", () => {
51 const container = document.createElement("div");
52 container.innerHTML = `
53 <ul>
54 <li data-volt-for="user in users">
55 <span data-volt-text="user.name"></span>
56 </li>
57 </ul>
58 `;
59
60 const users = signal([{ name: "Alice", age: 30 }, { name: "Bob", age: 25 }]);
61
62 mount(container, { users });
63
64 const spans = container.querySelectorAll("span");
65 expect(spans.length).toBe(2);
66 expect(spans[0]?.textContent).toBe("Alice");
67 expect(spans[1]?.textContent).toBe("Bob");
68 });
69
70 it("supports index access with (item, index) syntax", () => {
71 const container = document.createElement("div");
72 container.innerHTML = `
73 <ul>
74 <li data-volt-for="(item, i) in items">
75 <span data-volt-text="i"></span>: <span data-volt-text="item"></span>
76 </li>
77 </ul>
78 `;
79
80 const items = signal(["first", "second", "third"]);
81 mount(container, { items });
82
83 const listItems = container.querySelectorAll("li");
84 expect(listItems.length).toBe(3);
85
86 const firstItem = listItems[0]?.querySelectorAll("span");
87 expect(firstItem?.[0]?.textContent).toBe("0");
88 expect(firstItem?.[1]?.textContent).toBe("first");
89
90 const secondItem = listItems[1]?.querySelectorAll("span");
91 expect(secondItem?.[0]?.textContent).toBe("1");
92 expect(secondItem?.[1]?.textContent).toBe("second");
93 });
94
95 it("handles empty arrays", () => {
96 const container = document.createElement("div");
97 container.innerHTML = `<ul><li data-volt-for="item in items" data-volt-text="item"></li></ul>`;
98
99 const items = signal<string[]>([]);
100 mount(container, { items });
101
102 const listItems = container.querySelectorAll("li");
103 expect(listItems.length).toBe(0);
104
105 items.set(["now", "there", "are", "items"]);
106 const updatedItems = container.querySelectorAll("li");
107 expect(updatedItems.length).toBe(4);
108 });
109
110 it("handles static arrays (non-signal)", () => {
111 const container = document.createElement("div");
112 container.innerHTML = `<ul><li data-volt-for="item in items" data-volt-text="item"></li></ul>`;
113
114 mount(container, { items: ["static", "array"] });
115
116 const listItems = container.querySelectorAll("li");
117 expect(listItems.length).toBe(2);
118 expect(listItems[0]?.textContent).toBe("static");
119 expect(listItems[1]?.textContent).toBe("array");
120 });
121
122 it("supports event handlers in list items", () => {
123 const container = document.createElement("div");
124 container.innerHTML = `
125 <ul>
126 <li data-volt-for="item in items">
127 <button data-volt-on-click="handleClick" data-volt-text="item.text"></button>
128 </li>
129 </ul>
130 `;
131
132 let clickedItem = "";
133 const handleClick = (event: Event) => {
134 const button = event.target as HTMLButtonElement;
135 clickedItem = button.textContent || "";
136 };
137
138 const items = signal([{ text: "Click Me" }, { text: "Or Me" }]);
139 mount(container, { items, handleClick });
140
141 const buttons = container.querySelectorAll("button");
142 expect(buttons.length).toBe(2);
143
144 buttons[0]?.click();
145 expect(clickedItem).toBe("Click Me");
146
147 buttons[1]?.click();
148 expect(clickedItem).toBe("Or Me");
149 });
150
151 it("supports nested loops", () => {
152 const container = document.createElement("div");
153 container.innerHTML = `
154 <div data-volt-for="group in groups">
155 <ul>
156 <li data-volt-for="item in group.items" data-volt-text="item"></li>
157 </ul>
158 </div>
159 `;
160
161 const groups = signal([{ items: ["a", "b"] }, { items: ["c", "d", "e"] }]);
162
163 mount(container, { groups });
164
165 const divs = container.querySelectorAll("div");
166 expect(divs.length).toBe(2);
167
168 const firstGroupItems = divs[0]?.querySelectorAll("li");
169 expect(firstGroupItems?.length).toBe(2);
170 expect(firstGroupItems?.[0]?.textContent).toBe("a");
171
172 const secondGroupItems = divs[1]?.querySelectorAll("li");
173 expect(secondGroupItems?.length).toBe(3);
174 expect(secondGroupItems?.[2]?.textContent).toBe("e");
175 });
176
177 it("properly cleans up when unmounting", () => {
178 const container = document.createElement("div");
179 container.innerHTML = `<ul><li data-volt-for="item in items" data-volt-text="item.value"></li></ul>`;
180
181 const items = signal([{ value: signal("A") }, { value: signal("B") }]);
182 const cleanup = mount(container, { items });
183
184 expect(container.querySelectorAll("li").length).toBe(2);
185
186 const listItemsBefore = container.querySelectorAll("li");
187 const textBefore = [listItemsBefore[0]?.textContent, listItemsBefore[1]?.textContent];
188
189 cleanup();
190
191 const itemA = items.get()[0];
192 const itemB = items.get()[1];
193
194 itemA.value.set("Changed A");
195 itemB.value.set("Changed B");
196
197 const listItems = container.querySelectorAll("li");
198 expect(listItems[0]?.textContent).toBe(textBefore[0]);
199 expect(listItems[1]?.textContent).toBe(textBefore[1]);
200 });
201
202 it("handles non-array values gracefully", () => {
203 const container = document.createElement("div");
204 container.innerHTML = `<ul><li data-volt-for="item in notAnArray" data-volt-text="item"></li></ul>`;
205
206 mount(container, { notAnArray: "not an array" });
207
208 const listItems = container.querySelectorAll("li");
209 expect(listItems.length).toBe(0);
210 });
211
212 it("supports reactive properties within list items", () => {
213 const container = document.createElement("div");
214 container.innerHTML = `
215 <ul>
216 <li data-volt-for="todo in todos">
217 <span data-volt-text="todo.title"></span>
218 <span data-volt-class="todo.completed">Done</span>
219 </li>
220 </ul>
221 `;
222
223 const todos = signal([{ title: signal("Buy milk"), completed: signal({ done: false }) }, {
224 title: signal("Walk dog"),
225 completed: signal({ done: true }),
226 }]);
227
228 mount(container, { todos });
229
230 const listItems = container.querySelectorAll("li");
231 expect(listItems.length).toBe(2);
232
233 const firstTodo = listItems[0];
234 const firstTitle = firstTodo?.querySelector("span:first-child");
235 const firstStatus = firstTodo?.querySelector("span:last-child");
236
237 expect(firstTitle?.textContent).toBe("Buy milk");
238 expect(firstStatus?.classList.contains("done")).toBe(false);
239
240 todos.get()[0].title.set("Buy eggs");
241 todos.get()[0].completed.set({ done: true });
242
243 expect(firstTitle?.textContent).toBe("Buy eggs");
244 expect(firstStatus?.classList.contains("done")).toBe(true);
245 });
246});