a reactive (signals based) hypermedia web framework (wip) stormlightlabs.github.io/volt/
hypermedia frontend signals
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: extended reactive attr bindings

+775 -108
+25 -72
ROADMAP.md
··· 26 26 27 27 **Goal:** Establish project structure, tooling, and base reactivity primitives. 28 28 **Outcome:** A bootable TypeScript project with working reactivity primitives and test coverage. 29 - **Deliverables:** 30 - - ✓ Project scaffolding 31 - - ✓ `signal()` implementation with subscribe/set/get 32 - - ✓ Initial tests (signals, reactivity basics) 29 + **Summary:** A TypeScript project scaffold with implemented signal() (subscribe/set/get) and foundational reactivity tests establishes the base system. 33 30 34 31 ### Reactivity & Bindings 35 32 36 33 **Goal:** Connect signals to DOM via declarative `data-x-*` bindings. 37 34 **Outcome:** Reactive text/attribute binding with signals → DOM synchronization. 38 - **Deliverables:** 39 - - ✓ `data-x-text`, `data-x-html`, `data-x-class` binding parser 40 - - ✓ Expression evaluator (safe, minimal subset) 41 - - ✓ DOM mutation batching & cleanup 42 - - ✓ Internal test harness for bindings 43 - - ✓ DOM Testing Library integration tests 35 + **Summary:** Reactive DOM bindings (`data-x-text`, `data-x-html`, `data-x-class`) with a safe expression evaluator, mutation batching, and DOM testing ensure synchronized updates between signals and UI. 44 36 45 37 ### Actions & Effects 46 38 47 39 **Goal:** Add event-driven behavior and derived reactivity. 48 40 **Outcome:** Fully functional reactive UI layer with event bindings and computed updates. 49 - **Deliverables:** 50 - - ✓ Event binding system (`data-volt-on-*`) 51 - - ✓ `$el` and `$event` scoped references 52 - - ✓ Derived signals (`computed`, `effect`) 53 - - ✓ Async effects (e.g., fetch triggers) 41 + **Summary:** An event binding system with `$el`, `$event`, and derived signals (computed, effect, async effects) delivers a complete reactive event-driven UI layer. 54 42 55 43 ### Plugins Framework 56 44 57 45 **Goal:** Build a modular plugin architecture with dynamic registration. 58 46 **Outcome:** Stable plugin API enabling community-driven extensions. 59 - **Deliverables:** 60 - - ✓ `registerPlugin(name, fn)` API 61 - - ✓ Context and lifecycle hooks 62 - - ✓ Built-ins: 63 - - ✓ `data-volt-persist` 64 - - ✓ `data-volt-scroll` 65 - - ✓ `data-volt-url` 66 - - ✓ Registry 47 + **Summary:** A modular plugin API with lifecycle hooks and built-in extensions (persist, scroll, url) enables dynamic feature registration and community contributions. 67 48 68 49 ### Backend Integration & HTTP Actions 69 50 70 51 **Goal:** Provide backend integration with declarative HTTP requests and responses. 71 52 **Outcome:** Volt.js can make backend requests and update the DOM 72 - **Deliverables:** 73 - - ✓ HTTP action system (`data-volt-get`, `data-volt-post`, `data-volt-put`, `data-volt-patch`, `data-volt-delete`) 74 - - ✓ Request configuration (`data-volt-trigger`, `data-volt-target`, `data-volt-swap`) 75 - - ✓ Swap strategies (innerHTML, outerHTML, beforebegin, afterbegin, beforeend, afterend, delete, none) 76 - - ✓ Loading states and indicators (`data-volt-indicator`) 77 - - ✓ Error handling and retry logic 78 - - ✓ Form serialization and submission 79 - - ✓ Request/response headers customization 53 + **Summary:** Declarative HTTP directives (data-volt-get|post|put|patch|delete) with swap strategies, loading indicators, error handling, and form serialization integrate Volt.js seamlessly with backend APIs. 80 54 81 55 ### Markup Based Reactivity 82 56 83 57 **Goal:** Allow Volt apps to declare state, bindings, and behavior entirely in HTML markup 84 58 **Outcome:** Authors can ship examples without companion JavaScript bundles 85 - **Deliverables:** 86 - - ✓ Auto-bootstrapping loader (`volt.min.js`) that detects `data-volt` roots and hydrates one scope per root. 87 - - ✓ Declarative state primitives (`data-volt-state`, `data-volt-computed:*`) aligned with `docs/reactivity-spec.md`. 88 - - ✓ Binding directives for text, attributes, classes, styles, and two-way form controls (`data-volt-[bind|text|model|class:*]`). 89 - - ✓ Control-flow directives (`data-volt-for`, `data-volt-if`, `data-volt-else`) with lifecycle-safe teardown. 90 - - ✓ Declarative event system (`data-volt-on:*`) with helper surface for list mutations and plugin hooks. 91 - - ✓ SSR compatibility helpers 92 - - ✓ Sandboxed expression evaluator 59 + **Summary:** Declarative HTML state, binding, control-flow, and event directives with SSR support and a sandboxed evaluator enable Volt apps to run without separate JavaScript bundles. 93 60 94 61 ### Proxy-Based Reactivity Enhancements 95 62 96 63 **Goal:** Use JavaScript Proxies to improve reactivity ergonomics and automatic dependency tracking. 97 64 **Outcome:** More intuitive API with automatic dependency tracking and optional deep reactivity for objects/arrays. 98 - **Deliverables:** 99 - - ✓ Automatic dependency tracking for `computed()` 100 - - ✓ Eliminate manual dependency arrays via proxy-based tracking 101 - - ✓ Auto-detect signal access during computation 102 - - ✓ Track nested property access for fine-grained updates 103 - - ✓ `reactive()` primitive for deep object reactivity (optional, alongside `signal()`) 104 - - ✓ Nested property changes trigger updates automatically 105 - - ✓ Proxy-wrapped objects with transparent reactivity 106 - - ✓ Array reactivity improvements 107 - - ✓ Reactive array methods (push, pop, shift, unshift, splice, etc.) 108 - - ✓ Automatic updates on array mutations 109 - - ✓ Efficient tracking of index-based changes 110 - - ✓ Lazy signal initialization 111 - - ✓ Create signals on-demand when properties are accessed 112 - - ✓ Expose debugging utilities 65 + **Summary:** Proxy-driven automatic dependency tracking, deep reactive() objects, reactive arrays, lazy signal creation, and debugging utilities improve reactivity ergonomics and performance. 113 66 **Notes:** 114 67 - Separate reactive() function for objects/arrays to gives users choice 115 68 - Keep .get()/.set() - explicitness is valuable for understanding reactivity (include in docs) ··· 121 74 **Goal:** Extend Volt.js with expressive attribute patterns and event options for fine-grained control. 122 75 **Outcome:** Volt.js supports rich declarative behaviors and event semantics built entirely on standard DOM APIs. 123 76 **Deliverables:** 124 - - `data-volt-show` - toggles element visibility via CSS rather than DOM removal (complements `data-volt-if`) 125 - - `data-volt-style` - binds inline styles to reactive expressions 126 - - `data-volt-skip` - marks elements or subtrees to exclude from Volt’s reactive parsing 127 - - `data-volt-cloak` - hides content until the Volt runtime initializes 77 + - ✓ `data-volt-show` - toggles element visibility via CSS rather than DOM removal (complements `data-volt-if`) 78 + - ✓ `data-volt-style` - binds inline styles to reactive expressions 79 + - ✓ `data-volt-skip` - marks elements or subtrees to exclude from Volt’s reactive parsing 80 + - ✓ `data-volt-cloak` - hides content until the Volt runtime initializes 128 81 - Event options for `data-volt-on-*` attributes: 129 82 - `.prevent` - calls `preventDefault()` on the event 130 83 - `.stop` - stops propagation ··· 146 99 **Goal:** Implement store/context pattern 147 100 **Outcome:** Volt.js provides intuitive global state management 148 101 **Deliverables:** 149 - - `$refs` - Scoped element references via data-volt-ref="name". Provides an object mapping ref names to DOM nodes. 150 - - Example: `data-volt-on-click="$refs.username.focus()"` 151 - - `$next()` - Defers execution to the next microtask tick after DOM updates. 152 - - Example: `data-volt-on-click="$count++; $next(() => console.log('updated'))"` 153 - - `$watch(expr, fn)` - Imperatively observes a reactive signal or expression within the current scope. 154 - - Example: `data-volt-init="$watch('count', v => console.log(v))"` 155 - - `$emit(event, detail?)` - Dispatches a native CustomEvent from the current element. 156 - - Example: `data-volt-on-click="$emit('user:save', { id })"` 102 + - `$origin` - Reference to the root element of the active reactive scope. 103 + - `$scope` - Reference to the current reactive scope object (signals + context). 104 + - `$pulse()` - Defers execution to the next microtask tick after DOM updates. 105 + - Example: `data-volt-on-click="$count++; $pulse(() => console.log('updated'))"` 157 106 - `$store` - Accesses global reactive state registered with Volt’s global store. 158 107 - Example: `data-volt-text="$store.theme"` 159 108 - `$uid(name?)` - Generates a unique, deterministic ID string within the current scope. 160 109 - Example: `data-volt-id="$uid('field')"` 161 - - `$root` - Reference to the root element of the active reactive scope. 162 - - `$scope` - Reference to the current reactive scope object (signals + context). 110 + - `$probe(expr, fn)` - Imperatively observes a reactive signal or expression within the current scope. 111 + - Example: `data-volt-init="$probe('count', v => console.log(v))"` 112 + - `$pins` - Scoped element references via `data-volt-pin="name"`. Provides an object mapping ref/pin names to DOM nodes. 113 + - Example: `data-volt-on-click="$pins.username.focus()"` 114 + - `$arc(event, detail?)` - Dispatches a native CustomEvent from the current element. 115 + - Example: `data-volt-on-click="$arc('user:save', { id })"` 163 116 164 117 ### Animation & Transitions 165 118 166 119 **Goal:** Add animation primitives for smooth UI transitions with Alpine/Datastar parity. 167 120 **Outcome:** Volt.js enables declarative animations and view transitions alongside reactivity. 168 121 **Deliverables:** 169 - - `data-volt-transition` directive with enter/leave transitions 122 + - `data-volt-surge` directive with enter/leave transitions 170 123 - Transition modifiers (duration, delay, opacity, scale, etc.) 171 124 - View Transitions API integration (when available) 172 125 - CSS-based transition helpers 173 - - `data-volt-animate` plugin for keyframe animations 126 + - `data-volt-shift` plugin for keyframe animations 174 127 - Timing utilities and easing functions 175 128 - Integration with `data-volt-if` and `data-volt-show` for automatic transitions 176 129 ··· 180 133 **Outcome:** Volt.js can receive and apply live updates from the server 181 134 **Deliverables:** 182 135 - Server-Sent Events (SSE) integration 183 - - `data-volt-stream` attribute for SSE endpoints 136 + - `data-volt-flow` attribute for SSE endpoints 184 137 - Signal patching from backend (`data-signals-*` merge system) 185 - - Backend action system with `$$action()` syntax (TBD on final syntax decision) 138 + - Backend action system with `$$spark()` syntax 186 139 - JSON Patch parser and DOM morphing engine 187 140 - WebSocket as alternative to SSE 188 141 - `data-volt-ignore-morph` for selective patch exclusion
+102 -11
lib/src/core/binder.ts
··· 9 9 import { bindDelete, bindGet, bindPatch, bindPost, bindPut } from "./http"; 10 10 import { execGlobalHooks, notifyBindingCreated, notifyElementMounted, notifyElementUnmounted } from "./lifecycle"; 11 11 import { getPlugin } from "./plugin"; 12 - import { findScopedSignal } from "./shared"; 12 + import { findScopedSignal, isNil } from "./shared"; 13 13 14 14 /** 15 15 * Mount Volt.js on a root element and its descendants and binds all data-volt-* attributes to the provided scope. ··· 21 21 export function mount(root: Element, scope: Scope): CleanupFunction { 22 22 execGlobalHooks("beforeMount", root, scope); 23 23 24 - const elements = walkDOM(root); 24 + const allElements = walkDOM(root); 25 + 26 + const elements = allElements.filter((element) => { 27 + let current: Element | null = element; 28 + while (current) { 29 + if (Object.hasOwn((current as HTMLElement).dataset, "voltSkip")) { 30 + return false; 31 + } 32 + if (current === root) break; 33 + current = current.parentElement; 34 + } 35 + return true; 36 + }); 37 + 25 38 const allCleanups: CleanupFunction[] = []; 26 39 const mountedElements: Element[] = []; 27 40 28 - for (const element of elements) { 29 - const attributes = getVoltAttrs(element); 30 - const context: BindingContext = { element, scope, cleanups: [] }; 41 + for (const el of elements) { 42 + if (Object.hasOwn((el as HTMLElement).dataset, "voltCloak")) { 43 + delete (el as HTMLElement).dataset.voltCloak; 44 + } 45 + 46 + const attributes = getVoltAttrs(el); 47 + const context: BindingContext = { element: el, scope, cleanups: [] }; 31 48 32 49 if (attributes.has("for")) { 33 50 const forExpression = attributes.get("for")!; 34 51 bindFor(context, forExpression); 35 - notifyBindingCreated(element, "for"); 52 + notifyBindingCreated(el, "for"); 36 53 } else if (attributes.has("if")) { 37 54 const ifExpression = attributes.get("if")!; 38 55 bindIf(context, ifExpression); 39 - notifyBindingCreated(element, "if"); 56 + notifyBindingCreated(el, "if"); 40 57 } else { 41 58 for (const [name, value] of attributes) { 42 59 bindAttribute(context, name, value); 43 - notifyBindingCreated(element, name); 60 + notifyBindingCreated(el, name); 44 61 } 45 62 } 46 63 47 - notifyElementMounted(element); 48 - mountedElements.push(element); 64 + notifyElementMounted(el); 65 + mountedElements.push(el); 49 66 allCleanups.push(...context.cleanups); 50 67 } 51 68 ··· 98 115 } 99 116 case "class": { 100 117 bindClass(ctx, value); 118 + break; 119 + } 120 + case "show": { 121 + bindShow(ctx, value); 122 + break; 123 + } 124 + case "style": { 125 + bindStyle(ctx, value); 101 126 break; 102 127 } 103 128 case "model": { ··· 216 241 } 217 242 218 243 /** 244 + * Bind data-volt-show to toggle element visibility via CSS display property. 245 + * Unlike data-volt-if, this keeps the element in the DOM and toggles display: none. 246 + */ 247 + function bindShow(ctx: BindingContext, expr: string): void { 248 + const el = ctx.element as HTMLElement; 249 + const originalInlineDisplay = el.style.display; 250 + 251 + const update = () => { 252 + const value = evaluate(expr, ctx.scope); 253 + const shouldShow = Boolean(value); 254 + 255 + if (shouldShow) { 256 + el.style.display = originalInlineDisplay; 257 + } else { 258 + el.style.display = "none"; 259 + } 260 + }; 261 + 262 + update(); 263 + 264 + const deps = extractDeps(expr, ctx.scope); 265 + for (const dep of deps) { 266 + const unsubscribe = dep.subscribe(update); 267 + ctx.cleanups.push(unsubscribe); 268 + } 269 + } 270 + 271 + /** 272 + * Bind data-volt-style to reactively apply inline styles. 273 + * Supports object notation {color: 'red', fontSize: '16px'} or string notation 'color: red; font-size: 16px'. 274 + */ 275 + function bindStyle(ctx: BindingContext, expr: string): void { 276 + const element = ctx.element as HTMLElement; 277 + 278 + const update = () => { 279 + const value = evaluate(expr, ctx.scope); 280 + 281 + if (typeof value === "object" && value !== null) { 282 + for (const [key, val] of Object.entries(value)) { 283 + const cssKey = key.replaceAll(/[A-Z]/g, (m) => `-${m.toLowerCase()}`); 284 + 285 + if (isNil(val)) { 286 + element.style.removeProperty(cssKey); 287 + } else { 288 + try { 289 + element.style.setProperty(cssKey, String(val)); 290 + } catch (error) { 291 + console.warn(`[Volt] Failed to set style property "${cssKey}":`, error); 292 + } 293 + } 294 + } 295 + } else if (typeof value === "string") { 296 + element.style.cssText = value; 297 + } 298 + }; 299 + 300 + update(); 301 + 302 + const deps = extractDeps(expr, ctx.scope); 303 + for (const dep of deps) { 304 + const unsubscribe = dep.subscribe(update); 305 + ctx.cleanups.push(unsubscribe); 306 + } 307 + } 308 + 309 + /** 219 310 * Bind data-volt-on-* to attach event listeners. 220 311 * Provides $el and $event in the scope for the event handler. 221 312 */ ··· 357 448 ctx.element.removeAttribute(attrName); 358 449 } 359 450 } else { 360 - if (value === null || value === undefined || value === false) { 451 + if (isNil(value) || value === false) { 361 452 ctx.element.removeAttribute(attrName); 362 453 } else { 363 454 ctx.element.setAttribute(attrName, String(value));
+2 -2
lib/src/core/charge.ts
··· 7 7 import type { ChargedRoot, ChargeResult, Scope } from "$types/volt"; 8 8 import { mount } from "./binder"; 9 9 import { evaluate } from "./evaluator"; 10 - import { getComputedAttributes } from "./shared"; 10 + import { getComputedAttributes, isNil } from "./shared"; 11 11 import { computed, signal } from "./signal"; 12 12 13 13 /** ··· 70 70 try { 71 71 const stateData = JSON.parse(stateAttr); 72 72 73 - if (typeof stateData !== "object" || stateData === null || Array.isArray(stateData)) { 73 + if (typeof stateData !== "object" || isNil(stateData) || Array.isArray(stateData)) { 74 74 console.error(`data-volt-state must be a JSON object, got ${typeof stateData}:`, el); 75 75 } else { 76 76 for (const [key, value] of Object.entries(stateData)) {
+3 -3
lib/src/core/evaluator.ts
··· 6 6 */ 7 7 8 8 import type { Dep, Scope } from "$types/volt"; 9 - import { findScopedSignal, isSignal } from "./shared"; 9 + import { findScopedSignal, isNil, isSignal } from "./shared"; 10 10 11 11 const DANGEROUS_PROPERTIES = new Set(["__proto__", "prototype", "constructor"]); 12 12 ··· 547 547 } 548 548 549 549 private callMethod(object: unknown, methodName: string, args: unknown[]): unknown { 550 - if (object === null || object === undefined) { 550 + if (isNil(object)) { 551 551 throw new Error(`Cannot call method '${methodName}' on ${object}`); 552 552 } 553 553 ··· 794 794 } 795 795 796 796 private getMember(object: unknown, key: unknown): unknown { 797 - if (object === null || object === undefined) { 797 + if (isNil(object)) { 798 798 return undefined; 799 799 } 800 800
+10 -3
lib/src/core/shared.ts
··· 1 - import type { Optional } from "$types/helpers"; 1 + import type { None, Optional } from "$types/helpers"; 2 2 import type { Dep, Scope, Signal } from "$types/volt"; 3 3 4 4 export function kebabToCamel(str: string): string { 5 5 return str.replaceAll(/-([a-z])/g, (_, letter) => letter.toUpperCase()); 6 + } 7 + 8 + /** 9 + * Check if a value is null or undefined ({@link None}). 10 + */ 11 + export function isNil(value: unknown): value is None { 12 + return value === null || value === undefined; 6 13 } 7 14 8 15 export function isSignal(value: unknown): value is Dep { ··· 20 27 let current: unknown = scope; 21 28 22 29 for (const part of parts) { 23 - if (current === null || current === undefined) { 30 + if (isNil(current)) { 24 31 return undefined; 25 32 } 26 33 ··· 57 64 } 58 65 59 66 /** 60 - * Sleep for a specified duration 67 + * Sleep for a specified duration in ms 61 68 */ 62 69 export function sleep(ms: number): Promise<void> { 63 70 return new Promise((resolve) => setTimeout(resolve, ms));
+2 -2
lib/src/core/ssr.ts
··· 13 13 import { computed, signal } from "./signal"; 14 14 15 15 /** 16 - * Serialize a scope object to JSON for embedding in server-rendered HTML 16 + * Serialize a {@link Scope} object to JSON for embedding in server-rendered HTML 17 17 * 18 18 * Converts all signals in the scope to their plain values. 19 19 * Computed signals are serialized as their current values. ··· 161 161 try { 162 162 const stateData = JSON.parse(stateAttr); 163 163 164 - if (typeof stateData !== "object" || stateData === null || Array.isArray(stateData)) { 164 + if (typeof stateData !== "object" || isSignal(stateData) || Array.isArray(stateData)) { 165 165 console.error(`data-volt-state must be a JSON object, got ${typeof stateData}:`, el); 166 166 } else { 167 167 for (const [key, value] of Object.entries(stateData)) {
+6 -8
lib/src/plugins/persist.ts
··· 4 4 * Supports localStorage, sessionStorage, IndexedDB, and custom adapters 5 5 */ 6 6 7 + import { isNil } from "$core/shared"; 7 8 import type { Optional } from "$types/helpers"; 8 9 import type { PluginContext, Signal, StorageAdapter } from "$types/volt"; 9 10 10 - /** 11 - * Registry of custom storage adapters 12 - */ 13 - const storageAdapters = new Map<string, StorageAdapter>(); 11 + const storageAdapterRegistry = new Map<string, StorageAdapter>(); 14 12 15 13 /** 16 14 * Register a custom storage adapter. ··· 19 17 * @param adapter - Storage adapter implementation 20 18 */ 21 19 export function registerStorageAdapter(name: string, adapter: StorageAdapter): void { 22 - storageAdapters.set(name, adapter); 20 + storageAdapterRegistry.set(name, adapter); 23 21 } 24 22 25 23 const localStorageAdapter = { 26 24 get(key: string) { 27 25 const value = localStorage.getItem(key); 28 - if (value === null) return void 0; 26 + if (isNil(value)) return void 0; 29 27 try { 30 28 return JSON.parse(value); 31 29 } catch { ··· 43 41 const sessionStorageAdapter = { 44 42 get(key: string) { 45 43 const value = sessionStorage.getItem(key); 46 - if (value === null) return void 0; 44 + if (isNil(value)) return void 0; 47 45 try { 48 46 return JSON.parse(value); 49 47 } catch { ··· 148 146 return idbAdapter; 149 147 } 150 148 default: { 151 - return storageAdapters.get(type); 149 + return storageAdapterRegistry.get(type); 152 150 } 153 151 } 154 152 }
+8 -7
lib/src/plugins/url.ts
··· 3 3 * Supports one-way read, bidirectional sync, and hash-based routing 4 4 */ 5 5 6 + import { isNil } from "$core/shared"; 6 7 import type { Optional } from "$types/helpers"; 7 8 import type { PluginContext, Signal } from "$types/volt"; 8 9 ··· 27 28 28 29 switch (mode) { 29 30 case "read": { 30 - handleUrlRead(ctx, signalPath); 31 + handleReadURL(ctx, signalPath); 31 32 break; 32 33 } 33 34 case "sync": { 34 - handleUrlSync(ctx, signalPath); 35 + handleSyncURL(ctx, signalPath); 35 36 break; 36 37 } 37 38 case "hash": { ··· 48 49 * Read URL parameter into signal on mount (one-way). 49 50 * Signal changes do not update URL. 50 51 */ 51 - function handleUrlRead(ctx: PluginContext, signalPath: string): void { 52 + function handleReadURL(ctx: PluginContext, signalPath: string): void { 52 53 const signal = ctx.findSignal(signalPath); 53 54 if (!signal) { 54 55 console.error(`Signal "${signalPath}" not found for url read`); ··· 67 68 * Bidirectional sync between signal and URL parameter. 68 69 * Changes to either the signal or URL update the other. 69 70 */ 70 - function handleUrlSync(ctx: PluginContext, signalPath: string): void { 71 + function handleSyncURL(ctx: PluginContext, signalPath: string): void { 71 72 const signal = ctx.findSignal(signalPath); 72 73 if (!signal) { 73 74 console.error(`Signal "${signalPath}" not found for url sync`); ··· 94 95 const params = new URLSearchParams(globalThis.location.search); 95 96 const serialized = serializeValue(value); 96 97 97 - if (serialized === null || serialized === "") { 98 + if (isNil(serialized) || serialized === "") { 98 99 params.delete(signalPath); 99 100 } else { 100 101 params.set(signalPath, serialized); ··· 112 113 const params = new URLSearchParams(globalThis.location.search); 113 114 const paramValue = params.get(signalPath); 114 115 115 - if (paramValue === null) { 116 + if (isNil(paramValue)) { 116 117 (signal as Signal<unknown>).set(""); 117 118 } else { 118 119 (signal as Signal<unknown>).set(deserializeValue(paramValue)); ··· 182 183 * Handles strings, numbers, booleans, and No Value (null/undefined). 183 184 */ 184 185 function serializeValue(value: unknown): string { 185 - if (value === null || value === undefined) { 186 + if (isNil(value)) { 186 187 return ""; 187 188 } 188 189 if (typeof value === "string") {
+2
lib/src/types/helpers.ts
··· 3 3 export type Nullable<T> = T | null; 4 4 5 5 export type Timer = ReturnType<typeof setTimeout>; 6 + 7 + export type None = null | undefined;
+470
lib/test/core/binding-extensions.test.ts
··· 1 + import { mount } from "$core/binder"; 2 + import { signal } from "$core/signal"; 3 + import type { Nullable } from "$types/helpers"; 4 + import { describe, expect, it, vi } from "vitest"; 5 + 6 + describe("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 + });
+144
lib/test/core/shared.test.ts
··· 1 + import { findScopedSignal, getComputedAttributes, isNil, isSignal, kebabToCamel } from "$core/shared"; 2 + import { signal } from "$core/signal"; 3 + import { describe, expect, it } from "vitest"; 4 + 5 + describe("shared utilities", () => { 6 + describe("isNil", () => { 7 + it.each([ 8 + { value: null, expected: true, label: "null" }, 9 + { value: undefined, expected: true, label: "undefined" }, 10 + { value: 0, expected: false, label: "0" }, 11 + { value: "", expected: false, label: "empty string" }, 12 + { value: false, expected: false, label: "false" }, 13 + { value: {}, expected: false, label: "empty object" }, 14 + { value: [], expected: false, label: "empty array" }, 15 + { value: Number.NaN, expected: false, label: "NaN" }, 16 + { value: "test", expected: false, label: "string" }, 17 + { value: 42, expected: false, label: "number" }, 18 + { value: true, expected: false, label: "true" }, 19 + { value: { a: 1 }, expected: false, label: "object" }, 20 + { value: [1, 2], expected: false, label: "array" }, 21 + ])("returns $expected for $label", ({ value, expected }) => { 22 + expect(isNil(value)).toBe(expected); 23 + }); 24 + }); 25 + 26 + describe("kebabToCamel", () => { 27 + it.each([ 28 + { input: "hello-world", expected: "helloWorld" }, 29 + { input: "font-size", expected: "fontSize" }, 30 + { input: "background-color", expected: "backgroundColor" }, 31 + { input: "data-volt-text", expected: "dataVoltText" }, 32 + { input: "simple", expected: "simple" }, 33 + { input: "a-b-c-d", expected: "aBCD" }, 34 + { input: "", expected: "" }, 35 + ])("converts '$input' to '$expected'", ({ input, expected }) => { 36 + expect(kebabToCamel(input)).toBe(expected); 37 + }); 38 + }); 39 + 40 + describe("isSignal", () => { 41 + it("returns true for signals", () => { 42 + const sig = signal(0); 43 + expect(isSignal(sig)).toBe(true); 44 + }); 45 + 46 + it.each([ 47 + { value: null, label: "null" }, 48 + { value: undefined, label: "undefined" }, 49 + { value: 42, label: "number" }, 50 + { value: "test", label: "string" }, 51 + { value: {}, label: "empty object" }, 52 + { value: { get: "not a function" }, label: "object with get property" }, 53 + { value: { get: () => {} }, label: "object with only get method" }, 54 + { value: { subscribe: () => {} }, label: "object with only subscribe method" }, 55 + ])("returns false for $label", ({ value }) => { 56 + expect(isSignal(value)).toBe(false); 57 + }); 58 + }); 59 + 60 + describe("findScopedSignal", () => { 61 + it("finds signal at top level", () => { 62 + const count = signal(5); 63 + const scope = { count }; 64 + 65 + const found = findScopedSignal(scope, "count"); 66 + expect(found).toBe(count); 67 + }); 68 + 69 + it("finds signal in nested path", () => { 70 + const count = signal(5); 71 + const scope = { user: { stats: { count } } }; 72 + 73 + const found = findScopedSignal(scope, "user.stats.count"); 74 + expect(found).toBe(count); 75 + }); 76 + 77 + it("returns undefined for non-existent path", () => { 78 + const scope = { count: signal(5) }; 79 + 80 + const found = findScopedSignal(scope, "missing"); 81 + expect(found).toBeUndefined(); 82 + }); 83 + 84 + it("returns undefined for non-signal values", () => { 85 + const scope = { value: 42 }; 86 + 87 + const found = findScopedSignal(scope, "value"); 88 + expect(found).toBeUndefined(); 89 + }); 90 + 91 + it("returns undefined when traversing through null", () => { 92 + const scope = { user: null }; 93 + const found = findScopedSignal(scope, "user.name"); 94 + expect(found).toBeUndefined(); 95 + }); 96 + 97 + it("handles whitespace in path", () => { 98 + const count = signal(5); 99 + const scope = { count }; 100 + const found = findScopedSignal(scope, " count "); 101 + expect(found).toBe(count); 102 + }); 103 + }); 104 + 105 + describe("getComputedAttributes", () => { 106 + it("extracts computed attributes from element", () => { 107 + const element = document.createElement("div"); 108 + element.dataset["voltComputed:doubled"] = "count * 2"; 109 + element.dataset["voltComputed:tripled"] = "count * 3"; 110 + 111 + const computed = getComputedAttributes(element); 112 + expect(computed.size).toBe(2); 113 + expect(computed.get("doubled")).toBe("count * 2"); 114 + expect(computed.get("tripled")).toBe("count * 3"); 115 + }); 116 + 117 + it("converts kebab-case names to camelCase", () => { 118 + const element = document.createElement("div"); 119 + element.dataset["voltComputed:fullName"] = "firstName + ' ' + lastName"; 120 + 121 + const computed = getComputedAttributes(element); 122 + expect(computed.get("fullName")).toBe("firstName + ' ' + lastName"); 123 + }); 124 + 125 + it("returns empty map when no computed attributes", () => { 126 + const element = document.createElement("div"); 127 + element.dataset.voltText = "message"; 128 + 129 + const computed = getComputedAttributes(element); 130 + expect(computed.size).toBe(0); 131 + }); 132 + 133 + it("ignores non-computed data-volt attributes", () => { 134 + const element = document.createElement("div"); 135 + element.dataset.voltText = "message"; 136 + element.dataset["voltComputed:value"] = "count * 2"; 137 + element.dataset.voltShow = "visible"; 138 + 139 + const computed = getComputedAttributes(element); 140 + expect(computed.size).toBe(1); 141 + expect(computed.get("value")).toBe("count * 2"); 142 + }); 143 + }); 144 + });
+1
lib/vite.config.ts
··· 29 29 "$core": path.resolve(__dirname, "./src/core"), 30 30 "$plugins": path.resolve(__dirname, "./src/plugins"), 31 31 "$debug": path.resolve(__dirname, "./src/debug"), 32 + "$vebug": path.resolve(__dirname, "./src/debug.ts"), 32 33 }, 33 34 }, 34 35 build: mode === "lib"