a reactive (signals based) hypermedia web framework (wip)
stormlightlabs.github.io/volt/
hypermedia
frontend
signals
1import { computed, mount, signal } from "$volt";
2import { describe, expect, it } from "vitest";
3
4describe("integration: list rendering", () => {
5 it("creates a reactive todo list", () => {
6 const container = document.createElement("div");
7 container.innerHTML = `
8 <div>
9 <input id="new-todo" type="text" />
10 <button id="add-btn" data-volt-on-click="addTodo">Add</button>
11
12 <ul id="todo-list">
13 <li data-volt-for="todo in todos">
14 <input type="checkbox" data-volt-on-click="toggleTodo" />
15 <span data-volt-text="todo.text"></span>
16 <button data-volt-on-click="deleteTodo">Delete</button>
17 </li>
18 </ul>
19
20 <div data-volt-text="remaining">0</div>
21 </div>
22 `;
23
24 type Todo = { id: number; text: string; completed: boolean };
25 let nextId = 1;
26
27 const todos = signal<Todo[]>([{ id: nextId++, text: "Learn Volt.js", completed: false }, {
28 id: nextId++,
29 text: "Build an app",
30 completed: false,
31 }]);
32
33 const remaining = computed(() => {
34 return todos.get().filter((t) => !t.completed).length;
35 });
36
37 const addTodo = () => {
38 const input = container.querySelector("#new-todo") as HTMLInputElement;
39 if (input.value.trim()) {
40 todos.set([...todos.get(), { id: nextId++, text: input.value, completed: false }]);
41 input.value = "";
42 }
43 };
44
45 const toggleTodo = (event: Event) => {
46 const checkbox = event.target as HTMLInputElement;
47 const li = checkbox.closest("li");
48 const index = [...(li?.parentElement?.children || [])].indexOf(li!);
49
50 const updated = todos.get().map((todo, i) => (i === index ? { ...todo, completed: checkbox.checked } : todo));
51
52 todos.set(updated);
53 };
54
55 const deleteTodo = (event: Event) => {
56 const button = event.target as HTMLButtonElement;
57 const li = button.closest("li");
58 const index = [...(li?.parentElement?.children || [])].indexOf(li!);
59
60 todos.set(todos.get().filter((_, i) => i !== index));
61 };
62
63 mount(container, { todos, remaining, addTodo, toggleTodo, deleteTodo });
64
65 const listItems = container.querySelectorAll("#todo-list li");
66 expect(listItems.length).toBe(2);
67 expect(listItems[0]?.querySelector("span")?.textContent).toBe("Learn Volt.js");
68 expect(listItems[1]?.querySelector("span")?.textContent).toBe("Build an app");
69
70 const remainingDiv = container.querySelector("div[data-volt-text='remaining']");
71 expect(remainingDiv?.textContent).toBe("2");
72
73 const checkboxes = container.querySelectorAll("input[type='checkbox']");
74 (checkboxes[0] as HTMLInputElement).checked = true;
75 checkboxes[0]?.dispatchEvent(new Event("click", { bubbles: true }));
76
77 expect(remainingDiv?.textContent).toBe("1");
78
79 const deleteButtons = container.querySelectorAll("button[data-volt-on-click='deleteTodo']");
80 deleteButtons[1]?.dispatchEvent(new Event("click", { bubbles: true }));
81
82 const updatedListItems = container.querySelectorAll("#todo-list li");
83 expect(updatedListItems.length).toBe(1);
84 expect(updatedListItems[0]?.querySelector("span")?.textContent).toBe("Learn Volt.js");
85 });
86
87 it("renders filtered lists with computed signals", () => {
88 const container = document.createElement("div");
89 container.innerHTML = `
90 <div>
91 <div id="all-items">
92 <h3>All Items</h3>
93 <div data-volt-for="item in allItems" data-volt-text="item.name"></div>
94 </div>
95
96 <div id="active-items">
97 <h3>Active Items</h3>
98 <div data-volt-for="item in activeItems" data-volt-text="item.name"></div>
99 </div>
100 </div>
101 `;
102
103 const items = signal([{ name: "Item 1", active: true }, { name: "Item 2", active: false }, {
104 name: "Item 3",
105 active: true,
106 }]);
107
108 const activeItems = computed(() => items.get().filter((item) => item.active));
109
110 mount(container, { allItems: items, activeItems });
111
112 const allItemDivs = container.querySelectorAll("#all-items > div[data-volt-for]");
113 const activeItemDivs = container.querySelectorAll("#active-items > div[data-volt-for]");
114
115 expect(allItemDivs.length).toBe(0);
116 expect(activeItemDivs.length).toBe(0);
117
118 const renderedAll = container.querySelectorAll("#all-items div[data-volt-text]");
119 const renderedActive = container.querySelectorAll("#active-items div[data-volt-text]");
120
121 expect(renderedAll.length).toBe(3);
122 expect(renderedActive.length).toBe(2);
123 expect(renderedActive[0]?.textContent).toBe("Item 1");
124 expect(renderedActive[1]?.textContent).toBe("Item 3");
125
126 items.set([{ name: "Item 1", active: false }, { name: "Item 2", active: true }, { name: "Item 3", active: true }]);
127
128 const updatedActive = container.querySelectorAll("#active-items div[data-volt-text]");
129 expect(updatedActive.length).toBe(2);
130 expect(updatedActive[0]?.textContent).toBe("Item 2");
131 expect(updatedActive[1]?.textContent).toBe("Item 3");
132 });
133
134 it("handles complex nested data structures", () => {
135 const container = document.createElement("div");
136 container.innerHTML = `
137 <div>
138 <div data-volt-for="category in categories">
139 <h2 data-volt-text="category.name"></h2>
140 <ul>
141 <li data-volt-for="product in category.products">
142 <span data-volt-text="product.name"></span>: $<span data-volt-text="product.price"></span>
143 </li>
144 </ul>
145 </div>
146 </div>
147 `;
148
149 const categories = signal([{
150 name: "Electronics",
151 products: [{ name: "Laptop", price: 999 }, { name: "Phone", price: 699 }],
152 }, { name: "Books", products: [{ name: "JavaScript Guide", price: 29 }, { name: "CSS Mastery", price: 35 }] }]);
153
154 mount(container, { categories });
155
156 const categoryDivs = container.querySelectorAll("div > div > h2");
157 expect(categoryDivs.length).toBe(2);
158 expect(categoryDivs[0]?.textContent).toBe("Electronics");
159 expect(categoryDivs[1]?.textContent).toBe("Books");
160
161 const categoryDivElements = container.querySelectorAll("div > div > div");
162 expect(categoryDivElements.length).toBe(2);
163
164 const electronicsProducts = categoryDivElements[0]?.querySelectorAll("ul li");
165 expect(electronicsProducts?.length).toBe(2);
166 expect(electronicsProducts?.[0]?.textContent).toContain("Laptop");
167 expect(electronicsProducts?.[0]?.textContent).toContain("999");
168
169 const booksProducts = categoryDivElements[1]?.querySelectorAll("ul li");
170 expect(booksProducts?.length).toBe(2);
171 expect(booksProducts?.[1]?.textContent).toContain("CSS Mastery");
172 expect(booksProducts?.[1]?.textContent).toContain("35");
173 });
174});