a reactive (signals based) hypermedia web framework (wip) stormlightlabs.github.io/volt/
hypermedia frontend signals
at main 15 kB view raw
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});