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 type { Nullable } from "$types/helpers";
4import { describe, expect, it, vi } from "vitest";
5
6describe("binding extensions", () => {
7 describe("data-volt-show", () => {
8 it("shows element when expression is truthy", () => {
9 const element = document.createElement("div");
10 element.dataset.voltShow = "visible";
11
12 const scope = { visible: true };
13 mount(element, scope);
14
15 expect(element.style.display).not.toBe("none");
16 });
17
18 it("hides element when expression is falsy", () => {
19 const element = document.createElement("div");
20 element.dataset.voltShow = "visible";
21
22 const scope = { visible: false };
23 mount(element, scope);
24
25 expect(element.style.display).toBe("none");
26 });
27
28 it("updates visibility when signal changes", () => {
29 const element = document.createElement("div");
30 element.dataset.voltShow = "visible";
31
32 const visible = signal(true);
33 const scope = { visible };
34 mount(element, scope);
35
36 expect(element.style.display).not.toBe("none");
37
38 visible.set(false);
39 expect(element.style.display).toBe("none");
40
41 visible.set(true);
42 expect(element.style.display).not.toBe("none");
43 });
44
45 it("preserves original display value", () => {
46 const element = document.createElement("div");
47 element.style.display = "flex";
48 element.dataset.voltShow = "visible";
49
50 const visible = signal(true);
51 const scope = { visible };
52 mount(element, scope);
53
54 visible.set(false);
55 expect(element.style.display).toBe("none");
56
57 visible.set(true);
58 expect(element.style.display).toBe("flex");
59 });
60
61 it("handles computed display values", () => {
62 const element = document.createElement("span");
63 element.dataset.voltShow = "visible";
64 document.body.append(element);
65
66 const visible = signal(true);
67 const scope = { visible };
68 mount(element, scope);
69
70 visible.set(false);
71 expect(globalThis.getComputedStyle(element).display).toBe("none");
72
73 visible.set(true);
74 expect(globalThis.getComputedStyle(element).display).toBe("inline");
75 expect(element.style.display).toBe("");
76
77 element.remove();
78 });
79
80 it("handles expression with falsy values", () => {
81 const element = document.createElement("div");
82 element.dataset.voltShow = "count";
83
84 const count = signal(0);
85 const scope = { count };
86 mount(element, scope);
87
88 expect(element.style.display).toBe("none");
89
90 count.set(1);
91 expect(element.style.display).not.toBe("none");
92 });
93 });
94
95 describe("data-volt-style", () => {
96 it("applies styles from object notation", () => {
97 const element = document.createElement("div");
98 element.dataset.voltStyle = "styles";
99
100 const scope = { styles: { color: "red", fontSize: "16px" } };
101 mount(element, scope);
102
103 expect(element.style.color).toBe("red");
104 expect(element.style.fontSize).toBe("16px");
105 });
106
107 it("applies styles from string notation", () => {
108 const element = document.createElement("div");
109 element.dataset.voltStyle = "styleString";
110
111 const scope = { styleString: "color: blue; font-size: 20px" };
112 mount(element, scope);
113
114 expect(element.style.color).toBe("blue");
115 expect(element.style.fontSize).toBe("20px");
116 });
117
118 it("updates styles when signal changes", () => {
119 const element = document.createElement("div");
120 element.dataset.voltStyle = "styles";
121
122 const styles = signal({ color: "red" });
123 const scope = { styles };
124 mount(element, scope);
125
126 expect(element.style.color).toBe("red");
127
128 styles.set({ color: "blue" });
129 expect(element.style.color).toBe("blue");
130 });
131
132 it("handles camelCase property names", () => {
133 const element = document.createElement("div");
134 element.dataset.voltStyle = "styles";
135
136 const scope = { styles: { backgroundColor: "yellow", borderRadius: "5px" } };
137 mount(element, scope);
138
139 expect(element.style.backgroundColor).toBe("yellow");
140 expect(element.style.borderRadius).toBe("5px");
141 });
142
143 it("removes styles when value is null", () => {
144 const element = document.createElement("div");
145 element.style.color = "red";
146 element.dataset.voltStyle = "styles";
147
148 const styles = signal<{ color: Nullable<string> }>({ color: "blue" });
149 const scope = { styles };
150 mount(element, scope);
151
152 expect(element.style.color).toBe("blue");
153
154 styles.set({ color: null });
155 expect(element.style.color).toBe("");
156 });
157
158 it("removes styles when value is undefined", () => {
159 const element = document.createElement("div");
160 element.dataset.voltStyle = "styles";
161
162 const styles = signal<{ color?: string; fontSize: string }>({ color: "red", fontSize: "16px" });
163 const scope = { styles };
164 mount(element, scope);
165
166 expect(element.style.color).toBe("red");
167 expect(element.style.fontSize).toBe("16px");
168
169 styles.set({ color: undefined, fontSize: "20px" });
170 expect(element.style.color).toBe("");
171 expect(element.style.fontSize).toBe("20px");
172 });
173
174 it("handles multiple style updates", () => {
175 const element = document.createElement("div");
176 element.dataset.voltStyle = "styles";
177
178 const styles = signal<{ color: string; fontSize: string; fontWeight?: string }>({
179 color: "red",
180 fontSize: "16px",
181 });
182 const scope = { styles };
183 mount(element, scope);
184
185 expect(element.style.color).toBe("red");
186 expect(element.style.fontSize).toBe("16px");
187
188 styles.set({ color: "blue", fontSize: "20px", fontWeight: "bold" });
189 expect(element.style.color).toBe("blue");
190 expect(element.style.fontSize).toBe("20px");
191 expect(element.style.fontWeight).toBe("bold");
192 });
193
194 it("handles string notation updates", () => {
195 const element = document.createElement("div");
196 element.dataset.voltStyle = "styleString";
197
198 const styleString = signal("color: red");
199 const scope = { styleString };
200 mount(element, scope);
201
202 expect(element.style.color).toBe("red");
203
204 styleString.set("color: blue; font-size: 20px");
205 expect(element.style.color).toBe("blue");
206 expect(element.style.fontSize).toBe("20px");
207 });
208
209 it("handles CSS custom properties (CSS variables)", () => {
210 const element = document.createElement("div");
211 element.dataset.voltStyle = "styles";
212
213 const scope = { styles: { "--primary-color": "blue", "--spacing": "16px" } };
214 mount(element, scope);
215
216 expect(element.style.getPropertyValue("--primary-color")).toBe("blue");
217 expect(element.style.getPropertyValue("--spacing")).toBe("16px");
218 });
219
220 it("handles vendor-prefixed properties", () => {
221 const element = document.createElement("div");
222 element.dataset.voltStyle = "styles";
223
224 const scope = { styles: { WebkitTransform: "scale(1.5)", MozUserSelect: "none" } };
225 expect(() => mount(element, scope)).not.toThrow();
226 expect(element.style.getPropertyValue("-webkit-transform")).toBe("scale(1.5)");
227 });
228
229 it("gracefully handles invalid property names", () => {
230 const element = document.createElement("div");
231 element.dataset.voltStyle = "styles";
232
233 const consoleWarnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
234 const scope = { styles: { invalidProp123: "value", color: "red" } };
235 mount(element, scope);
236
237 expect(element.style.color).toBe("red");
238
239 consoleWarnSpy.mockRestore();
240 });
241
242 it("converts numeric values to strings", () => {
243 const element = document.createElement("div");
244 element.dataset.voltStyle = "styles";
245
246 const scope = { styles: { opacity: 0.5, zIndex: 100 } };
247 mount(element, scope);
248
249 expect(element.style.opacity).toBe("0.5");
250 expect(element.style.zIndex).toBe("100");
251 });
252
253 it("handles kebab-case property names directly", () => {
254 const element = document.createElement("div");
255 element.dataset.voltStyle = "styles";
256
257 const scope = { styles: { "font-size": "20px", "background-color": "yellow" } };
258 mount(element, scope);
259
260 expect(element.style.fontSize).toBe("20px");
261 expect(element.style.backgroundColor).toBe("yellow");
262 });
263
264 it("updates CSS variables reactively", () => {
265 const element = document.createElement("div");
266 element.dataset.voltStyle = "styles";
267
268 const styles = signal({ "--theme-color": "blue" });
269 const scope = { styles };
270 mount(element, scope);
271
272 expect(element.style.getPropertyValue("--theme-color")).toBe("blue");
273
274 styles.set({ "--theme-color": "red" });
275 expect(element.style.getPropertyValue("--theme-color")).toBe("red");
276 });
277 });
278
279 describe("data-volt-skip", () => {
280 it("skips element with data-volt-skip", () => {
281 const element = document.createElement("div");
282 element.dataset.voltSkip = "";
283 element.dataset.voltText = "message";
284
285 const scope = { message: "Hello" };
286 mount(element, scope);
287
288 expect(element.textContent).toBe("");
289 });
290
291 it("skips descendants of data-volt-skip element", () => {
292 const parent = document.createElement("div");
293 const child = document.createElement("span");
294 parent.append(child);
295
296 parent.dataset.voltSkip = "";
297 child.dataset.voltText = "message";
298
299 const scope = { message: "Hello" };
300 mount(parent, scope);
301
302 expect(child.textContent).toBe("");
303 });
304
305 it("doesn't affect siblings", () => {
306 const container = document.createElement("div");
307 const skipped = document.createElement("div");
308 const processed = document.createElement("div");
309
310 container.append(skipped);
311 container.append(processed);
312
313 skipped.dataset.voltSkip = "";
314 skipped.dataset.voltText = "skipped";
315 processed.dataset.voltText = "message";
316
317 const scope = { message: "Hello", skipped: "Skipped" };
318 mount(container, scope);
319
320 expect(skipped.textContent).toBe("");
321 expect(processed.textContent).toBe("Hello");
322 });
323
324 it("skips nested descendants multiple levels deep", () => {
325 const container = document.createElement("div");
326 const skipped = document.createElement("div");
327 const child = document.createElement("div");
328 const grandchild = document.createElement("span");
329
330 child.append(grandchild);
331 skipped.append(child);
332 container.append(skipped);
333
334 skipped.dataset.voltSkip = "";
335 grandchild.dataset.voltText = "message";
336
337 const scope = { message: "Hello" };
338 mount(container, scope);
339
340 expect(grandchild.textContent).toBe("");
341 });
342
343 it("allows processing after skipped element", () => {
344 const container = document.createElement("div");
345 const before = document.createElement("div");
346 const skipped = document.createElement("div");
347 const after = document.createElement("div");
348
349 container.append(before);
350 container.append(skipped);
351 container.append(after);
352
353 before.dataset.voltText = "beforeMsg";
354 skipped.dataset.voltSkip = "";
355 skipped.dataset.voltText = "skippedMsg";
356 after.dataset.voltText = "afterMsg";
357
358 const scope = { beforeMsg: "Before", skippedMsg: "Skipped", afterMsg: "After" };
359 mount(container, scope);
360
361 expect(before.textContent).toBe("Before");
362 expect(skipped.textContent).toBe("");
363 expect(after.textContent).toBe("After");
364 });
365 });
366
367 describe("data-volt-cloak", () => {
368 it("removes data-volt-cloak attribute after mount", () => {
369 const element = document.createElement("div");
370 element.dataset.voltCloak = "";
371
372 expect(Object.hasOwn(element.dataset, "voltCloak")).toBe(true);
373
374 mount(element, {});
375
376 expect(Object.hasOwn(element.dataset, "voltCloak")).toBe(false);
377 });
378
379 it("removes from nested elements", () => {
380 const parent = document.createElement("div");
381 const child = document.createElement("div");
382 parent.append(child);
383
384 parent.dataset.voltCloak = "";
385 child.dataset.voltCloak = "";
386
387 expect(Object.hasOwn(parent.dataset, "voltCloak")).toBe(true);
388 expect(Object.hasOwn(child.dataset, "voltCloak")).toBe(true);
389
390 mount(parent, {});
391
392 expect(Object.hasOwn(parent.dataset, "voltCloak")).toBe(false);
393 expect(Object.hasOwn(child.dataset, "voltCloak")).toBe(false);
394 });
395
396 it("works with other bindings", () => {
397 const element = document.createElement("div");
398 element.dataset.voltCloak = "";
399 element.dataset.voltText = "message";
400
401 const scope = { message: "Hello" };
402 mount(element, scope);
403
404 expect(Object.hasOwn(element.dataset, "voltCloak")).toBe(false);
405 expect(element.textContent).toBe("Hello");
406 });
407
408 it("removes from multiple siblings", () => {
409 const container = document.createElement("div");
410 const child1 = document.createElement("div");
411 const child2 = document.createElement("div");
412 const child3 = document.createElement("div");
413
414 container.append(child1);
415 container.append(child2);
416 container.append(child3);
417
418 child1.dataset.voltCloak = "";
419 child2.dataset.voltCloak = "";
420 child3.dataset.voltCloak = "";
421
422 mount(container, {});
423
424 expect(Object.hasOwn(child1.dataset, "voltCloak")).toBe(false);
425 expect(Object.hasOwn(child2.dataset, "voltCloak")).toBe(false);
426 expect(Object.hasOwn(child3.dataset, "voltCloak")).toBe(false);
427 });
428 });
429
430 describe("combined usage", () => {
431 it("combines data-volt-show with data-volt-style", () => {
432 const element = document.createElement("div");
433 element.dataset.voltShow = "visible";
434 element.dataset.voltStyle = "styles";
435
436 const visible = signal(true);
437 const styles = signal({ color: "red" });
438 const scope = { visible, styles };
439 mount(element, scope);
440
441 expect(element.style.display).not.toBe("none");
442 expect(element.style.color).toBe("red");
443
444 visible.set(false);
445 expect(element.style.display).toBe("none");
446 expect(element.style.color).toBe("red");
447 });
448
449 it("data-volt-skip prevents data-volt-cloak removal", () => {
450 const element = document.createElement("div");
451 element.dataset.voltSkip = "";
452 element.dataset.voltCloak = "";
453
454 mount(element, {});
455 expect(Object.hasOwn(element.dataset, "voltCloak")).toBe(true);
456 });
457
458 it("data-volt-cloak removed before bindings execute", () => {
459 const element = document.createElement("div");
460 element.dataset.voltCloak = "";
461 element.dataset.voltText = "message";
462
463 const scope = { message: "Hello" };
464 mount(element, scope);
465
466 expect(Object.hasOwn(element.dataset, "voltCloak")).toBe(false);
467 expect(element.textContent).toBe("Hello");
468 });
469 });
470});