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: markup reactivity refactor: data-volt migration

+2527 -430
+223 -53
src/core/binder.ts
··· 4 4 5 5 import type { BindingContext, CleanupFunction, PluginContext, Scope, Signal } from "../types/volt"; 6 6 import { getVoltAttributes, parseClassBinding, setHTML, setText, toggleClass, walkDOM } from "./dom"; 7 - import { evaluate } from "./evaluator"; 7 + import { evaluate, extractDependencies, isSignal } from "./evaluator"; 8 8 import { getPlugin } from "./plugin"; 9 9 10 10 /** 11 - * Mount Volt.js on a root element and its descendants and binds all data-x-* attributes to the provided scope. 11 + * Mount Volt.js on a root element and its descendants and binds all data-volt-* attributes to the provided scope. 12 12 * Returns a cleanup function to unmount and dispose all bindings. 13 13 * 14 14 * @param root - Root element to mount on ··· 50 50 } 51 51 52 52 /** 53 - * Bind a single data-x-* attribute to an element. 53 + * Bind a single data-volt-* attribute to an element. 54 54 * Routes to the appropriate binding handler. 55 55 * 56 56 * @param context - Binding context 57 - * @param name - Attribute name (without data-x- prefix) 57 + * @param name - Attribute name (without data-volt- prefix) 58 58 * @param value - Attribute value (expression) 59 59 */ 60 60 function bindAttribute(context: BindingContext, name: string, value: string): void { ··· 64 64 return; 65 65 } 66 66 67 + if (name.startsWith("bind:")) { 68 + const attrName = name.slice(5); 69 + bindAttr(context, attrName, value); 70 + return; 71 + } 72 + 67 73 switch (name) { 68 74 case "text": { 69 75 bindText(context, value); ··· 75 81 } 76 82 case "class": { 77 83 bindClass(context, value); 84 + break; 85 + } 86 + case "model": { 87 + bindModel(context, value); 78 88 break; 79 89 } 80 90 case "for": { ··· 91 101 console.error(`Error in plugin "${name}":`, error); 92 102 } 93 103 } else { 94 - console.warn(`Unknown binding: data-x-${name}`); 104 + console.warn(`Unknown binding: data-volt-${name}`); 95 105 } 96 106 } 97 107 } 98 108 } 99 109 100 110 /** 101 - * Bind data-x-text to update element's text content. 111 + * Bind data-volt-text to update element's text content. 102 112 * Subscribes to signals in the expression and updates on change. 103 113 * 104 114 * @param context - Binding context ··· 120 130 } 121 131 122 132 /** 123 - * Bind data-x-html to update element's HTML content. 133 + * Bind data-volt-html to update element's HTML content. 134 + * 124 135 * Subscribes to signals in the expression and updates on change. 125 - * 126 - * @param context - Binding context 127 - * @param expression - Expression to evaluate 128 136 */ 129 137 function bindHTML(context: BindingContext, expression: string): void { 130 138 const update = () => { ··· 142 150 } 143 151 144 152 /** 145 - * Bind data-x-class to toggle CSS classes. 153 + * Bind data-volt-class to toggle CSS classes. 146 154 * Supports both string and object notation. 147 155 * Subscribes to signals in the expression and updates on change. 148 156 * ··· 179 187 } 180 188 181 189 /** 182 - * Bind data-x-on-* to attach event listeners. 190 + * Bind data-volt-on-* to attach event listeners. 183 191 * Provides $el and $event in the scope for the event handler. 184 192 * 185 193 * @param context - Binding context ··· 208 216 } 209 217 210 218 /** 211 - * Find a signal in the scope by resolving a simple property path. 212 - * Returns the signal if found, otherwise undefined. 219 + * Bind data-volt-model for two-way data binding on form elements. 220 + * Syncs the signal value with the input value bidirectionally. 213 221 * 214 - * @param scope - Scope object 215 - * @param path - Property path (e.g., "count" or "user.name") 216 - * @returns Signal if found, undefined otherwise 222 + * @param context - Binding context 223 + * @param signalPath - Path to the signal in scope 224 + */ 225 + function bindModel(context: BindingContext, signalPath: string): void { 226 + const signal = findSignalInScope(context.scope, signalPath); 227 + if (!signal) { 228 + console.error(`Signal "${signalPath}" not found for data-volt-model`); 229 + return; 230 + } 231 + 232 + const element = context.element as HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement; 233 + const type = element instanceof HTMLInputElement ? element.type : null; 234 + const initialValue = signal.get(); 235 + setElementValue(element, initialValue, type); 236 + 237 + const unsubscribe = signal.subscribe(() => { 238 + const value = signal.get(); 239 + setElementValue(element, value, type); 240 + }); 241 + context.cleanups.push(unsubscribe); 242 + 243 + const eventName = type === "checkbox" || type === "radio" ? "change" : "input"; 244 + 245 + const handler = () => { 246 + const value = getElementValue(element, type); 247 + (signal as Signal<unknown>).set(value); 248 + }; 249 + 250 + element.addEventListener(eventName, handler); 251 + context.cleanups.push(() => { 252 + element.removeEventListener(eventName, handler); 253 + }); 254 + } 255 + 256 + /** 257 + * Set element value based on type 258 + */ 259 + function setElementValue( 260 + element: HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement, 261 + value: unknown, 262 + type: string | null, 263 + ): void { 264 + if (element instanceof HTMLInputElement) { 265 + switch (type) { 266 + case "checkbox": { 267 + element.checked = Boolean(value); 268 + 269 + break; 270 + } 271 + case "radio": { 272 + element.checked = element.value === String(value); 273 + break; 274 + } 275 + case "number": { 276 + element.value = String(value ?? ""); 277 + break; 278 + } 279 + default: { 280 + element.value = String(value ?? ""); 281 + } 282 + } 283 + } else if (element instanceof HTMLSelectElement) { 284 + element.value = String(value ?? ""); 285 + } else if (element instanceof HTMLTextAreaElement) { 286 + element.value = String(value ?? ""); 287 + } 288 + } 289 + 290 + /** 291 + * Get element value based on type 292 + */ 293 + function getElementValue( 294 + element: HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement, 295 + type: string | null, 296 + ): unknown { 297 + if (element instanceof HTMLInputElement) { 298 + if (type === "checkbox") { 299 + return element.checked; 300 + } 301 + if (type === "number") { 302 + return element.valueAsNumber; 303 + } 304 + return element.value; 305 + } 306 + 307 + if (element instanceof HTMLSelectElement) { 308 + return element.value; 309 + } 310 + 311 + if (element instanceof HTMLTextAreaElement) { 312 + return element.value; 313 + } 314 + 315 + return ""; 316 + } 317 + 318 + /** 319 + * Bind data-volt-bind:attr for generic attribute binding. 320 + * 321 + * Updates any HTML attribute reactively based on expression value. 322 + */ 323 + function bindAttr(context: BindingContext, attrName: string, expression: string): void { 324 + const update = () => { 325 + const value = evaluate(expression, context.scope); 326 + 327 + const booleanAttrs = new Set([ 328 + "disabled", 329 + "checked", 330 + "selected", 331 + "readonly", 332 + "required", 333 + "multiple", 334 + "autofocus", 335 + "autoplay", 336 + "controls", 337 + "loop", 338 + "muted", 339 + ]); 340 + 341 + if (booleanAttrs.has(attrName)) { 342 + if (value) { 343 + context.element.setAttribute(attrName, ""); 344 + } else { 345 + context.element.removeAttribute(attrName); 346 + } 347 + } else { 348 + if (value === null || value === undefined || value === false) { 349 + context.element.removeAttribute(attrName); 350 + } else { 351 + context.element.setAttribute(attrName, String(value)); 352 + } 353 + } 354 + }; 355 + 356 + update(); 357 + 358 + const dependencies = extractDependencies(expression, context.scope); 359 + for (const dependency of dependencies) { 360 + const unsubscribe = dependency.subscribe(update); 361 + context.cleanups.push(unsubscribe); 362 + } 363 + } 364 + 365 + /** 366 + * Find a signal in the scope by resolving a simple property path. 217 367 */ 218 368 function findSignalInScope(scope: Scope, path: string): Signal<unknown> | undefined { 219 369 const trimmed = path.trim(); ··· 232 382 } 233 383 } 234 384 235 - if ( 236 - typeof current === "object" 237 - && current !== null 238 - && "get" in current 239 - && "subscribe" in current 240 - && typeof (current as { get: unknown }).get === "function" 241 - && typeof (current as { subscribe: unknown }).subscribe === "function" 242 - ) { 385 + if (isSignal(current)) { 243 386 return current as Signal<unknown>; 244 387 } 245 388 ··· 247 390 } 248 391 249 392 /** 250 - * Bind data-x-for to render a list of items. 393 + * Bind data-volt-for to render a list of items. 251 394 * Subscribes to array signal and re-renders when array changes. 252 395 * 253 396 * @param context - Binding context ··· 256 399 function bindFor(context: BindingContext, expression: string): void { 257 400 const parsed = parseForExpression(expression); 258 401 if (!parsed) { 259 - console.error(`Invalid data-x-for expression: "${expression}"`); 402 + console.error(`Invalid data-volt-for expression: "${expression}"`); 260 403 return; 261 404 } 262 405 ··· 265 408 const parent = template.parentElement; 266 409 267 410 if (!parent) { 268 - console.error("data-x-for element must have a parent"); 411 + console.error("data-volt-for element must have a parent"); 269 412 return; 270 413 } 271 414 ··· 294 437 295 438 for (const [index, item] of arrayValue.entries()) { 296 439 const clone = template.cloneNode(true) as Element; 297 - delete (clone as HTMLElement).dataset.xFor; 440 + delete (clone as HTMLElement).dataset.voltFor; 298 441 299 442 const itemScope: Scope = { ...context.scope, [itemName]: item }; 300 443 if (indexName) { ··· 325 468 } 326 469 327 470 /** 328 - * Bind data-x-if to conditionally render an element. 329 - * Subscribes to condition signal and shows/hides element when condition changes. 471 + * Bind data-volt-if to conditionally render an element. 472 + * Supports data-volt-else on the next sibling element. 473 + * Subscribes to condition signal and shows/hides elements when condition changes. 330 474 * 331 475 * @param context - Binding context 332 476 * @param expression - Expression to evaluate as condition 333 477 */ 334 478 function bindIf(context: BindingContext, expression: string): void { 335 - const template = context.element as HTMLElement; 336 - const parent = template.parentElement; 479 + const ifTemplate = context.element as HTMLElement; 480 + const parent = ifTemplate.parentElement; 337 481 338 482 if (!parent) { 339 - console.error("data-x-if element must have a parent"); 483 + console.error("data-volt-if element must have a parent"); 340 484 return; 341 485 } 342 486 487 + let elseTemplate: HTMLElement | undefined; 488 + let nextSibling = ifTemplate.nextElementSibling; 489 + 490 + while (nextSibling && nextSibling.nodeType !== 1) { 491 + nextSibling = nextSibling.nextElementSibling; 492 + } 493 + 494 + if (nextSibling && Object.hasOwn((nextSibling as HTMLElement).dataset, "voltElse")) { 495 + elseTemplate = nextSibling as HTMLElement; 496 + elseTemplate.remove(); 497 + } 498 + 343 499 const placeholder = document.createComment(`if: ${expression}`); 344 - template.before(placeholder); 345 - template.remove(); 500 + ifTemplate.before(placeholder); 501 + ifTemplate.remove(); 346 502 347 503 let currentElement: Element | undefined; 348 504 let currentCleanup: CleanupFunction | undefined; 505 + let currentBranch: "if" | "else" | undefined; 349 506 350 507 const render = () => { 351 508 const condition = evaluate(expression, context.scope); 352 509 const shouldShow = Boolean(condition); 353 510 354 - if (shouldShow && !currentElement) { 355 - currentElement = template.cloneNode(true) as Element; 356 - delete (currentElement as HTMLElement).dataset.xIf; 511 + const targetBranch = shouldShow ? "if" : (elseTemplate ? "else" : undefined); 512 + 513 + if (targetBranch === currentBranch) { 514 + return; 515 + } 516 + 517 + if (currentCleanup) { 518 + currentCleanup(); 519 + currentCleanup = undefined; 520 + } 521 + if (currentElement) { 522 + currentElement.remove(); 523 + currentElement = undefined; 524 + } 525 + 526 + if (targetBranch === "if") { 527 + currentElement = ifTemplate.cloneNode(true) as Element; 528 + delete (currentElement as HTMLElement).dataset.voltIf; 357 529 currentCleanup = mount(currentElement, context.scope); 358 530 placeholder.before(currentElement); 359 - } else if (!shouldShow && currentElement) { 360 - if (currentCleanup) { 361 - currentCleanup(); 362 - } 363 - currentElement.remove(); 364 - currentElement = undefined; 365 - currentCleanup = undefined; 531 + currentBranch = "if"; 532 + } else if (targetBranch === "else" && elseTemplate) { 533 + currentElement = elseTemplate.cloneNode(true) as Element; 534 + delete (currentElement as HTMLElement).dataset.voltElse; 535 + currentCleanup = mount(currentElement, context.scope); 536 + placeholder.before(currentElement); 537 + currentBranch = "else"; 538 + } else { 539 + currentBranch = undefined; 366 540 } 367 541 }; 368 542 ··· 382 556 } 383 557 384 558 /** 385 - * Parse a data-x-for expression. 559 + * Parse a data-volt-for expression 560 + * 386 561 * Supports: "item in items" or "(item, index) in items" 387 - * 388 - * @param expr - The for expression 389 - * @returns Parsed parts or undefined if invalid 390 562 */ 391 563 function parseForExpression(expr: string): { itemName: string; indexName?: string; arrayPath: string } | undefined { 392 564 const trimmed = expr.trim(); ··· 406 578 407 579 /** 408 580 * Create a plugin context from a binding context. 409 - * Provides the plugin with access to utilities and cleanup registration. 410 581 * 411 - * @param bindingContext - Internal binding context 412 - * @returns PluginContext for the plugin handler 582 + * Provides the plugin with access to utilities and cleanup registration. 413 583 */ 414 584 function createPluginContext(bindingContext: BindingContext): PluginContext { 415 585 return {
+123
src/core/charge.ts
··· 1 + /** 2 + * Charge system (bootstrap) for auto-discovery and initialization of Volt roots 3 + * 4 + * Handles declarative state initialization via data-volt-state and data-volt-computed 5 + */ 6 + 7 + import type { ChargedRoot, ChargeResult, Scope } from "../types/volt"; 8 + import { mount } from "./binder"; 9 + import { evaluate, extractDependencies } from "./evaluator"; 10 + import { computed, signal } from "./signal"; 11 + 12 + /** 13 + * Discover and mount all Volt roots in the document. 14 + * Parses data-volt-state for initial state and data-volt-computed for derived values. 15 + * 16 + * @param rootSelector - Selector for root elements (default: "[data-volt]") 17 + * @returns ChargeResult containing mounted roots and cleanup function 18 + * 19 + * @example 20 + * ```html 21 + * <div data-volt data-volt-state='{"count": 0}' data-volt-computed:double="count * 2"> 22 + * <p data-volt-text="count"></p> 23 + * <p data-volt-text="double"></p> 24 + * </div> 25 + * ``` 26 + * 27 + * ```ts 28 + * const { cleanup } = charge(); 29 + * // Later: cleanup() to unmount all 30 + * ``` 31 + */ 32 + export function charge(rootSelector = "[data-volt]"): ChargeResult { 33 + const elements = document.querySelectorAll(rootSelector); 34 + const chargedRoots: ChargedRoot[] = []; 35 + 36 + for (const element of elements) { 37 + try { 38 + const scope = createScopeFromElement(element); 39 + const cleanup = mount(element, scope); 40 + 41 + chargedRoots.push({ element, scope, cleanup }); 42 + } catch (error) { 43 + console.error("Error charging Volt root:", element, error); 44 + } 45 + } 46 + 47 + return { 48 + roots: chargedRoots, 49 + cleanup: () => { 50 + for (const root of chargedRoots) { 51 + try { 52 + root.cleanup(); 53 + } catch (error) { 54 + console.error("Error cleaning up Volt root:", root.element, error); 55 + } 56 + } 57 + }, 58 + }; 59 + } 60 + 61 + /** 62 + * Create a reactive scope from element's data-volt-state and data-volt-computed attributes 63 + * 64 + * @param element - The root element 65 + * @returns Reactive scope object with signals 66 + */ 67 + function createScopeFromElement(element: Element): Scope { 68 + const scope: Scope = {}; 69 + 70 + const stateAttr = (element as HTMLElement).dataset.voltState; 71 + if (stateAttr) { 72 + try { 73 + const stateData = JSON.parse(stateAttr); 74 + 75 + if (typeof stateData !== "object" || stateData === null || Array.isArray(stateData)) { 76 + console.error(`data-volt-state must be a JSON object, got ${typeof stateData}:`, element); 77 + } else { 78 + for (const [key, value] of Object.entries(stateData)) { 79 + scope[key] = signal(value); 80 + } 81 + } 82 + } catch (error) { 83 + console.error("Failed to parse data-volt-state JSON:", stateAttr, error); 84 + console.error("Element:", element); 85 + } 86 + } 87 + 88 + const computedAttrs = getComputedAttributes(element); 89 + for (const [name, expression] of computedAttrs) { 90 + try { 91 + const dependencies = extractDependencies(expression, scope); 92 + 93 + scope[name] = computed(() => evaluate(expression, scope), dependencies); 94 + } catch (error) { 95 + console.error(`Failed to create computed "${name}" with expression "${expression}":`, error); 96 + } 97 + } 98 + 99 + return scope; 100 + } 101 + 102 + /** 103 + * Get all data-volt-computed:name attributes from an element. 104 + * 105 + * Converts kebab-case names to camelCase to match JS conventions. 106 + */ 107 + function getComputedAttributes(element: Element): Map<string, string> { 108 + const computed = new Map<string, string>(); 109 + 110 + for (const attr of element.attributes) { 111 + if (attr.name.startsWith("data-volt-computed:")) { 112 + const name = attr.name.slice("data-volt-computed:".length); 113 + const camelName = kebabToCamel(name); 114 + computed.set(camelName, attr.value); 115 + } 116 + } 117 + 118 + return computed; 119 + } 120 + 121 + function kebabToCamel(str: string): string { 122 + return str.replaceAll(/-([a-z])/g, (_, letter) => letter.toUpperCase()); 123 + }
+27 -15
src/core/dom.ts
··· 3 3 */ 4 4 5 5 /** 6 - * Walk the DOM tree and collect all elements with data-x-* attributes. 7 - * Returns elements in document order (parent before children). 8 - * Skips children of elements with data-x-for or data-x-if since those 9 - * will be processed when the parent element is cloned and mounted. 6 + * Walk the DOM tree and collect all elements with data-volt-* attributes in document order (parent before children). 7 + * 8 + * Skips children of elements with data-volt-for or data-volt-if since those will be processed when the parent element is cloned and mounted. 10 9 * 11 10 * @param root - The root element to start walking from 12 - * @returns Array of elements with data-x-* attributes 11 + * @returns Array of elements with data-volt-* attributes 13 12 */ 14 13 export function walkDOM(root: Element): Element[] { 15 14 const elements: Element[] = []; ··· 19 18 elements.push(element); 20 19 21 20 if ( 22 - Object.hasOwn((element as HTMLElement).dataset, "xFor") 23 - || Object.hasOwn((element as HTMLElement).dataset, "xIf") 21 + Object.hasOwn((element as HTMLElement).dataset, "voltFor") 22 + || Object.hasOwn((element as HTMLElement).dataset, "voltIf") 24 23 ) { 25 24 return; 26 25 } ··· 37 36 } 38 37 39 38 /** 40 - * Check if an element has any data-x-* attributes. 39 + * Check if an element has any data-volt-* attributes. 41 40 * 42 41 * @param element - Element to check 43 42 * @returns true if element has any Volt attributes 44 43 */ 45 44 export function hasVoltAttribute(element: Element): boolean { 46 - return [...element.attributes].some((attribute) => attribute.name.startsWith("data-x-")); 45 + return [...element.attributes].some((attribute) => attribute.name.startsWith("data-volt-")); 47 46 } 48 47 49 48 /** 50 - * Get all data-x-* attributes from an element. 49 + * Get all data-volt-* attributes from an element. 50 + * Excludes charge metadata attributes (state, computed:*) that are processed separately. 51 51 * 52 52 * @param element - Element to get attributes from 53 - * @returns Map of attribute names to values (without the data-x- prefix) 53 + * @returns Map of attribute names to values (without the data-volt- prefix) 54 54 */ 55 55 export function getVoltAttributes(element: Element): Map<string, string> { 56 56 const attributes = new Map<string, string>(); 57 57 58 58 for (const attribute of element.attributes) { 59 - if (attribute.name.startsWith("data-x-")) { 60 - // Remove "data-x-" prefix 61 - attributes.set(attribute.name.slice(7), attribute.value); 59 + if (attribute.name.startsWith("data-volt-")) { 60 + const name = attribute.name.slice(10); 61 + 62 + // Skip charge metadata attributes 63 + if (name === "state" || name.startsWith("computed:")) { 64 + continue; 65 + } 66 + 67 + attributes.set(name, attribute.value); 62 68 } 63 69 } 64 70 ··· 99 105 100 106 /** 101 107 * Parse a class binding expression. 102 - * Supports both string values ("active") and object notation ({active: true}). 108 + * Supports string values ("active"), object notation ({active: true}), 109 + * and other primitives (true, false, numbers) which are converted to strings. 103 110 * 104 111 * @param value - The class value or object 105 112 * @returns Map of class names to boolean values ··· 119 126 classes.set(key, Boolean(value_)); 120 127 } 121 128 } 129 + break; 130 + } 131 + case "boolean": 132 + case "number": { 133 + classes.set(String(value), true); 122 134 break; 123 135 } 124 136 }
+516 -55
src/core/evaluator.ts
··· 1 1 /** 2 - * Safe expression evaluation of simple expressions without using eval() for bindings 2 + * Safe expression evaluation with operators support 3 + * Implements a recursive descent parser for expressions without using eval() 3 4 */ 4 5 5 - import type { Scope } from "../types/volt"; 6 + import type { Dep, Scope } from "$types/volt"; 6 7 7 8 /** 8 - * Evaluate a simple expression against a scope object. 9 - * Supports: 10 - * - Property access: "count", "user.name", "items.length" 11 - * - Simple literals: "true", "false", "null", "undefined" 12 - * - Numbers: "42", "3.14" 13 - * - Strings: "'hello'", '"world"' 9 + * Token types for lexical analysis 10 + */ 11 + type TokenType = 12 + | "NUMBER" 13 + | "STRING" 14 + | "TRUE" 15 + | "FALSE" 16 + | "NULL" 17 + | "UNDEFINED" 18 + | "IDENTIFIER" 19 + | "DOT" 20 + | "LBRACKET" 21 + | "RBRACKET" 22 + | "LPAREN" 23 + | "RPAREN" 24 + | "PLUS" 25 + | "MINUS" 26 + | "STAR" 27 + | "SLASH" 28 + | "PERCENT" 29 + | "BANG" 30 + | "EQ_EQ_EQ" 31 + | "BANG_EQ_EQ" 32 + | "LT" 33 + | "GT" 34 + | "LT_EQ" 35 + | "GT_EQ" 36 + | "AND_AND" 37 + | "OR_OR" 38 + | "EOF"; 39 + 40 + /** 41 + * Token representing a lexical unit 42 + */ 43 + type Token = { type: TokenType; value: unknown; start: number; end: number }; 44 + 45 + /** 46 + * Tokenize an expression string into a stream of tokens 14 47 * 15 - * @param expression - The expression string to evaluate 16 - * @param scope - The scope object containing values 17 - * @returns The evaluated result 48 + * @param expr - The expression string 49 + * @returns Array of tokens 18 50 */ 19 - export function evaluate(expression: string, scope: Scope): unknown { 20 - const trimmed = expression.trim(); 51 + function tokenize(expr: string): Token[] { 52 + const tokens: Token[] = []; 53 + let pos = 0; 21 54 22 - switch (trimmed) { 23 - case "true": { 24 - return true; 55 + while (pos < expr.length) { 56 + const char = expr[pos]; 57 + 58 + if (/\s/.test(char)) { 59 + pos++; 60 + continue; 25 61 } 26 - case "false": { 27 - return false; 62 + 63 + if (/\d/.test(char) || (char === "-" && pos + 1 < expr.length && /\d/.test(expr[pos + 1]))) { 64 + const start = pos; 65 + if (char === "-") pos++; 66 + while (pos < expr.length && /[\d.]/.test(expr[pos])) { 67 + pos++; 68 + } 69 + tokens.push({ type: "NUMBER", value: Number(expr.slice(start, pos)), start, end: pos }); 70 + continue; 28 71 } 29 - case "null": { 30 - return null; 72 + 73 + if (char === "\"" || char === "'") { 74 + const start = pos; 75 + const quote = char; 76 + pos++; 77 + let value = ""; 78 + while (pos < expr.length && expr[pos] !== quote) { 79 + if (expr[pos] === "\\") { 80 + pos++; 81 + if (pos < expr.length) { 82 + value += expr[pos]; 83 + } 84 + } else { 85 + value += expr[pos]; 86 + } 87 + pos++; 88 + } 89 + if (pos < expr.length) pos++; 90 + tokens.push({ type: "STRING", value, start, end: pos }); 91 + continue; 31 92 } 32 - case "undefined": { 33 - return undefined; 93 + 94 + if (/[a-zA-Z_$]/.test(char)) { 95 + const start = pos; 96 + while (pos < expr.length && /[a-zA-Z0-9_$]/.test(expr[pos])) { 97 + pos++; 98 + } 99 + const value = expr.slice(start, pos); 100 + 101 + switch (value) { 102 + case "true": { 103 + tokens.push({ type: "TRUE", value: true, start, end: pos }); 104 + break; 105 + } 106 + case "false": { 107 + tokens.push({ type: "FALSE", value: false, start, end: pos }); 108 + break; 109 + } 110 + case "null": { 111 + tokens.push({ type: "NULL", value: null, start, end: pos }); 112 + break; 113 + } 114 + case "undefined": { 115 + tokens.push({ type: "UNDEFINED", value: undefined, start, end: pos }); 116 + break; 117 + } 118 + default: { 119 + tokens.push({ type: "IDENTIFIER", value, start, end: pos }); 120 + } 121 + } 122 + continue; 34 123 } 35 - default: { 36 - const numberMatch = /^-?\d+(\.\d+)?$/.exec(trimmed); 37 - if (numberMatch) { 38 - return Number(trimmed); 124 + 125 + const start = pos; 126 + 127 + if (pos + 2 < expr.length) { 128 + const threeChar = expr.slice(pos, pos + 3); 129 + if (threeChar === "===") { 130 + tokens.push({ type: "EQ_EQ_EQ", value: "===", start, end: pos + 3 }); 131 + pos += 3; 132 + continue; 39 133 } 134 + if (threeChar === "!==") { 135 + tokens.push({ type: "BANG_EQ_EQ", value: "!==", start, end: pos + 3 }); 136 + pos += 3; 137 + continue; 138 + } 139 + } 40 140 41 - const stringMatch = /^(['"])(.*)\1$/.exec(trimmed); 42 - if (stringMatch) { 43 - return stringMatch[2]; 141 + if (pos + 1 < expr.length) { 142 + const twoChar = expr.slice(pos, pos + 2); 143 + switch (twoChar) { 144 + case "<=": { 145 + tokens.push({ type: "LT_EQ", value: "<=", start, end: pos + 2 }); 146 + pos += 2; 147 + continue; 148 + } 149 + case ">=": { 150 + tokens.push({ type: "GT_EQ", value: ">=", start, end: pos + 2 }); 151 + pos += 2; 152 + continue; 153 + } 154 + case "&&": { 155 + tokens.push({ type: "AND_AND", value: "&&", start, end: pos + 2 }); 156 + pos += 2; 157 + continue; 158 + } 159 + case "||": { 160 + tokens.push({ type: "OR_OR", value: "||", start, end: pos + 2 }); 161 + pos += 2; 162 + continue; 163 + } 44 164 } 165 + } 45 166 46 - return resolvePath(trimmed, scope); 167 + switch (char) { 168 + case ".": { 169 + tokens.push({ type: "DOT", value: ".", start, end: pos + 1 }); 170 + pos++; 171 + break; 172 + } 173 + case "[": { 174 + tokens.push({ type: "LBRACKET", value: "[", start, end: pos + 1 }); 175 + pos++; 176 + break; 177 + } 178 + case "]": { 179 + tokens.push({ type: "RBRACKET", value: "]", start, end: pos + 1 }); 180 + pos++; 181 + break; 182 + } 183 + case "(": { 184 + tokens.push({ type: "LPAREN", value: "(", start, end: pos + 1 }); 185 + pos++; 186 + break; 187 + } 188 + case ")": { 189 + tokens.push({ type: "RPAREN", value: ")", start, end: pos + 1 }); 190 + pos++; 191 + break; 192 + } 193 + case "+": { 194 + tokens.push({ type: "PLUS", value: "+", start, end: pos + 1 }); 195 + pos++; 196 + break; 197 + } 198 + case "-": { 199 + tokens.push({ type: "MINUS", value: "-", start, end: pos + 1 }); 200 + pos++; 201 + break; 202 + } 203 + case "*": { 204 + tokens.push({ type: "STAR", value: "*", start, end: pos + 1 }); 205 + pos++; 206 + break; 207 + } 208 + case "/": { 209 + tokens.push({ type: "SLASH", value: "/", start, end: pos + 1 }); 210 + pos++; 211 + break; 212 + } 213 + case "%": { 214 + tokens.push({ type: "PERCENT", value: "%", start, end: pos + 1 }); 215 + pos++; 216 + break; 217 + } 218 + case "!": { 219 + tokens.push({ type: "BANG", value: "!", start, end: pos + 1 }); 220 + pos++; 221 + break; 222 + } 223 + case "<": { 224 + tokens.push({ type: "LT", value: "<", start, end: pos + 1 }); 225 + pos++; 226 + break; 227 + } 228 + case ">": { 229 + tokens.push({ type: "GT", value: ">", start, end: pos + 1 }); 230 + pos++; 231 + break; 232 + } 233 + default: { 234 + throw new Error(`Unexpected character '${char}' at position ${pos}`); 235 + } 47 236 } 48 237 } 238 + 239 + tokens.push({ type: "EOF", value: null, start: pos, end: pos }); 240 + return tokens; 49 241 } 50 242 51 243 /** 52 - * Resolve a property path in a scope object. 53 - * Supports nested property access like "user.profile.name". 54 - * Automatically unwraps signals by calling .get(). 55 - * 56 - * @param path - The property path (e.g., "user.name") 57 - * @param scope - The scope object 58 - * @returns The value at that path, or undefined if not found 244 + * Recursive descent parser for expression evaluation with operator precedence 59 245 */ 60 - function resolvePath(path: string, scope: Scope): unknown { 61 - const parts = path.split("."); 62 - let current: unknown = scope; 246 + class Parser { 247 + private tokens: Token[]; 248 + private current = 0; 249 + private scope: Scope; 250 + 251 + constructor(tokens: Token[], scope: Scope) { 252 + this.tokens = tokens; 253 + this.scope = scope; 254 + } 255 + 256 + /** 257 + * Parse the expression and return the result 258 + */ 259 + parse(): unknown { 260 + return this.parseExpression(); 261 + } 262 + 263 + private parseExpression(): unknown { 264 + return this.parseLogicalOr(); 265 + } 266 + 267 + private parseLogicalOr(): unknown { 268 + let left = this.parseLogicalAnd(); 269 + 270 + while (this.match("OR_OR")) { 271 + const right = this.parseLogicalAnd(); 272 + left = Boolean(left) || Boolean(right); 273 + } 274 + 275 + return left; 276 + } 277 + 278 + private parseLogicalAnd(): unknown { 279 + let left = this.parseEquality(); 280 + 281 + while (this.match("AND_AND")) { 282 + const right = this.parseEquality(); 283 + left = Boolean(left) && Boolean(right); 284 + } 285 + 286 + return left; 287 + } 288 + 289 + private parseEquality(): unknown { 290 + let left = this.parseRelational(); 291 + 292 + while (true) { 293 + if (this.match("EQ_EQ_EQ")) { 294 + const right = this.parseRelational(); 295 + left = left === right; 296 + } else if (this.match("BANG_EQ_EQ")) { 297 + const right = this.parseRelational(); 298 + left = left !== right; 299 + } else { 300 + break; 301 + } 302 + } 303 + 304 + return left; 305 + } 306 + 307 + private parseRelational(): unknown { 308 + let left = this.parseAdditive(); 309 + 310 + while (true) { 311 + if (this.match("LT")) { 312 + const right = this.parseAdditive(); 313 + left = (left as number) < (right as number); 314 + } else if (this.match("GT")) { 315 + const right = this.parseAdditive(); 316 + left = (left as number) > (right as number); 317 + } else if (this.match("LT_EQ")) { 318 + const right = this.parseAdditive(); 319 + left = (left as number) <= (right as number); 320 + } else if (this.match("GT_EQ")) { 321 + const right = this.parseAdditive(); 322 + left = (left as number) >= (right as number); 323 + } else { 324 + break; 325 + } 326 + } 327 + 328 + return left; 329 + } 330 + 331 + private parseAdditive(): unknown { 332 + let left = this.parseMultiplicative(); 333 + 334 + while (true) { 335 + if (this.match("PLUS")) { 336 + const right = this.parseMultiplicative(); 337 + left = (left as number) + (right as number); 338 + } else if (this.match("MINUS")) { 339 + const right = this.parseMultiplicative(); 340 + left = (left as number) - (right as number); 341 + } else { 342 + break; 343 + } 344 + } 345 + 346 + return left; 347 + } 348 + 349 + private parseMultiplicative(): unknown { 350 + let left = this.parseUnary(); 351 + 352 + while (true) { 353 + if (this.match("STAR")) { 354 + const right = this.parseUnary(); 355 + left = (left as number) * (right as number); 356 + } else if (this.match("SLASH")) { 357 + const right = this.parseUnary(); 358 + left = (left as number) / (right as number); 359 + } else if (this.match("PERCENT")) { 360 + const right = this.parseUnary(); 361 + left = (left as number) % (right as number); 362 + } else { 363 + break; 364 + } 365 + } 366 + 367 + return left; 368 + } 369 + 370 + private parseUnary(): unknown { 371 + if (this.match("BANG")) { 372 + const operand = this.parseUnary(); 373 + return !operand; 374 + } 375 + 376 + if (this.match("MINUS")) { 377 + const operand = this.parseUnary(); 378 + return -(operand as number); 379 + } 380 + 381 + if (this.match("PLUS")) { 382 + const operand = this.parseUnary(); 383 + return +(operand as number); 384 + } 385 + 386 + return this.parseMemberAccess(); 387 + } 388 + 389 + private parseMemberAccess(): unknown { 390 + let object = this.parsePrimary(); 391 + 392 + while (true) { 393 + if (this.match("DOT")) { 394 + const property = this.consume("IDENTIFIER", "Expected property name after '.'"); 395 + object = this.getMember(object, property.value as string); 396 + } else if (this.match("LBRACKET")) { 397 + const index = this.parseExpression(); 398 + this.consume("RBRACKET", "Expected ']' after member access"); 399 + object = this.getMember(object, index); 400 + } else { 401 + break; 402 + } 403 + } 404 + 405 + return object; 406 + } 407 + 408 + private parsePrimary(): unknown { 409 + if (this.match("NUMBER", "STRING", "TRUE", "FALSE", "NULL", "UNDEFINED")) { 410 + return this.previous().value; 411 + } 412 + 413 + if (this.match("IDENTIFIER")) { 414 + const identifier = this.previous().value as string; 415 + return this.resolvePropPath(identifier); 416 + } 63 417 64 - for (const part of parts) { 65 - if (current === null || current === undefined) { 418 + if (this.match("LPAREN")) { 419 + const expr = this.parseExpression(); 420 + this.consume("RPAREN", "Expected ')' after expression"); 421 + return expr; 422 + } 423 + 424 + throw new Error(`Unexpected token: ${this.peek().type}`); 425 + } 426 + 427 + private getMember(object: unknown, key: unknown): unknown { 428 + if (object === null || object === undefined) { 66 429 return undefined; 67 430 } 68 431 69 - if (typeof current === "object" && part in (current as Record<string, unknown>)) { 70 - current = (current as Record<string, unknown>)[part]; 71 - } else { 432 + // Access property - works on objects, strings, arrays, etc. 433 + const value = (object as Record<string | number, unknown>)[key as string | number]; 434 + 435 + if (isSignal(value)) { 436 + return value.get(); 437 + } 438 + 439 + return value; 440 + } 441 + 442 + private resolvePropPath(path: string): unknown { 443 + if (!(path in this.scope)) { 72 444 return undefined; 73 445 } 446 + 447 + const value = this.scope[path]; 448 + 449 + if (isSignal(value)) { 450 + return value.get(); 451 + } 452 + 453 + return value; 74 454 } 75 455 76 - if (isSignal(current)) { 77 - return current.get(); 456 + private match(...types: TokenType[]): boolean { 457 + for (const type of types) { 458 + if (this.check(type)) { 459 + this.advance(); 460 + return true; 461 + } 462 + } 463 + return false; 464 + } 465 + 466 + private check(type: TokenType): boolean { 467 + if (this.isAtEnd()) return false; 468 + return this.peek().type === type; 469 + } 470 + 471 + private advance(): Token { 472 + if (!this.isAtEnd()) this.current++; 473 + return this.previous(); 474 + } 475 + 476 + private isAtEnd(): boolean { 477 + return this.peek().type === "EOF"; 78 478 } 79 479 80 - return current; 480 + private peek(): Token { 481 + return this.tokens[this.current]; 482 + } 483 + 484 + private previous(): Token { 485 + return this.tokens[this.current - 1]; 486 + } 487 + 488 + private consume(type: TokenType, message: string): Token { 489 + if (this.check(type)) return this.advance(); 490 + throw new Error(`${message} at position ${this.peek().start}`); 491 + } 81 492 } 82 493 83 - /** 84 - * Check if a value is a Signal or ComputedSignal. 85 - * 86 - * @param value - Value to check 87 - * @returns true if the value is a Signal or ComputedSignal 88 - */ 89 - function isSignal(value: unknown): value is { get: () => unknown } { 494 + export function isSignal(value: unknown): value is Dep { 90 495 return (typeof value === "object" 91 496 && value !== null 92 497 && "get" in value ··· 94 499 && typeof value.get === "function" 95 500 && typeof (value as { subscribe: unknown }).subscribe === "function"); 96 501 } 502 + 503 + /** 504 + * Evaluate an expression against a scope object. 505 + * 506 + * Supports literals, property access, operators, and member access. 507 + * 508 + * @param expression - The expression string to evaluate 509 + * @param scope - The scope object containing values 510 + * @returns The evaluated result 511 + */ 512 + export function evaluate(expression: string, scope: Scope): unknown { 513 + try { 514 + const tokens = tokenize(expression); 515 + const parser = new Parser(tokens, scope); 516 + return parser.parse(); 517 + } catch (error) { 518 + console.error(`Error evaluating expression "${expression}":`, error); 519 + return undefined; 520 + } 521 + } 522 + 523 + /** 524 + * Extract all signal dependencies from an expression by finding identifiers 525 + * that correspond to signals in the scope. 526 + * 527 + * @param expression - The expression to analyze 528 + * @param scope - The scope containing potential signal dependencies 529 + * @returns Array of signals found in the expression 530 + */ 531 + export function extractDependencies(expression: string, scope: Scope): Array<Dep> { 532 + const dependencies: Array<Dep> = []; 533 + const identifierRegex = /\b([a-zA-Z_$][\w$]*)\b/g; 534 + const matches = expression.matchAll(identifierRegex); 535 + const seen = new Set<string>(); 536 + 537 + for (const match of matches) { 538 + const identifier = match[1]; 539 + 540 + if (["true", "false", "null", "undefined"].includes(identifier)) { 541 + continue; 542 + } 543 + 544 + if (seen.has(identifier)) { 545 + continue; 546 + } 547 + 548 + seen.add(identifier); 549 + 550 + const value = scope[identifier]; 551 + if (isSignal(value)) { 552 + dependencies.push(value); 553 + } 554 + } 555 + 556 + return dependencies; 557 + }
+2 -1
src/index.ts
··· 4 4 * @packageDocumentation 5 5 */ 6 6 7 - export type { ComputedSignal, PluginContext, PluginHandler, Signal } from "$types/volt"; 7 + export type { ChargedRoot, ChargeResult, ComputedSignal, PluginContext, PluginHandler, Signal } from "$types/volt"; 8 8 export { mount } from "@volt/core/binder"; 9 + export { charge } from "@volt/core/charge"; 9 10 export { clearPlugins, getRegisteredPlugins, hasPlugin, registerPlugin, unregisterPlugin } from "@volt/core/plugin"; 10 11 export { computed, effect, signal } from "@volt/core/signal";
+6 -6
src/plugins/persist.ts
··· 14 14 /** 15 15 * Register a custom storage adapter. 16 16 * 17 - * @param name - Adapter name (used in data-x-persist="signal:name") 17 + * @param name - Adapter name (used in data-volt-persist="signal:name") 18 18 * @param adapter - Storage adapter implementation 19 19 */ 20 20 export function registerStorageAdapter(name: string, adapter: StorageAdapter): void { ··· 158 158 * Persist plugin handler. 159 159 * Synchronizes signal values with persistent storage. 160 160 * 161 - * Syntax: data-x-persist="signalPath:storageType" 161 + * Syntax: data-volt-persist="signalPath:storageType" 162 162 * Examples: 163 - * - data-x-persist="count:local" 164 - * - data-x-persist="formData:session" 165 - * - data-x-persist="userData:indexeddb" 166 - * - data-x-persist="settings:customAdapter" 163 + * - data-volt-persist="count:local" 164 + * - data-volt-persist="formData:session" 165 + * - data-volt-persist="userData:indexeddb" 166 + * - data-volt-persist="settings:customAdapter" 167 167 */ 168 168 export function persistPlugin(context: PluginContext, value: string): void { 169 169 const parts = value.split(":");
+6 -7
src/plugins/scroll.ts
··· 9 9 * Scroll plugin handler. 10 10 * Manages various scroll-related behaviors. 11 11 * 12 - * Syntax: data-x-scroll="mode:signalPath" 12 + * Syntax: data-volt-scroll="mode:signalPath" 13 13 * Modes: 14 14 * - restore:signalPath - Save/restore scroll position 15 15 * - scrollTo:signalPath - Scroll to element when signal changes ··· 49 49 } 50 50 51 51 /** 52 - * Save and restore scroll position. 53 - * Saves current scroll position to signal on scroll events. 54 - * Restores scroll position from signal on mount. 52 + * Saves current scroll position to signal on scroll events; Restores scroll position from signal on mount. 55 53 */ 56 54 function handleScrollRestore(context: PluginContext, signalPath: string): void { 57 55 const signal = context.findSignal(signalPath); ··· 79 77 80 78 /** 81 79 * Scroll to element when signal value matches element's ID or selector. 82 - * Listens for changes to the target signal and scrolls to this element. 80 + * 81 + * Listens for changes to the target signal to determine position 83 82 */ 84 83 function handleScrollTo(context: PluginContext, signalPath: string): void { 85 84 const signal = context.findSignal(signalPath); ··· 105 104 106 105 /** 107 106 * Update signal when element enters or exits viewport. 107 + * 108 108 * Uses Intersection Observer to track visibility. 109 109 */ 110 110 function handleScrollSpy(context: PluginContext, signalPath: string): void { ··· 132 132 } 133 133 134 134 /** 135 - * Enable smooth scrolling behavior. 136 - * Applies smooth scroll behavior based on signal value. 135 + * Enable smooth scrolling behavior and apply based on signal value. 137 136 */ 138 137 function handleSmoothScroll(context: PluginContext, signalPath: string): void { 139 138 const signal = context.findSignal(signalPath);
+1 -1
src/plugins/url.ts
··· 9 9 * URL plugin handler. 10 10 * Synchronizes signal values with URL parameters and hash. 11 11 * 12 - * Syntax: data-x-url="mode:signalPath" 12 + * Syntax: data-volt-url="mode:signalPath" 13 13 * Modes: 14 14 * - read:signalPath - Read URL param into signal on mount (one-way) 15 15 * - sync:signalPath - Bidirectional sync between signal and URL param
+20 -5
src/types/volt.d.ts
··· 5 5 /** 6 6 * Context object available to all bindings 7 7 */ 8 - export interface BindingContext { 9 - element: Element; 10 - scope: Scope; 11 - cleanups: CleanupFunction[]; 12 - } 8 + export type BindingContext = { element: Element; scope: Scope; cleanups: CleanupFunction[] }; 13 9 14 10 /** 15 11 * Context object provided to plugin handlers. ··· 102 98 set(key: string, value: unknown): Promise<void> | void; 103 99 remove(key: string): Promise<void> | void; 104 100 } 101 + 102 + /** 103 + * Information about a mounted Volt root after charging 104 + * 105 + * element: The root element that was mounted 106 + * scope: The reactive scope created for this root 107 + * cleanup: Cleanup function to unmount this root 108 + */ 109 + export type ChargedRoot = { element: Element; scope: Scope; cleanup: CleanupFunction }; 110 + 111 + /** 112 + * Result of charging Volt roots 113 + * 114 + * roots: Array of all charged roots 115 + * cleanup: Cleanup function to unmount all roots 116 + */ 117 + export type ChargeResult = { roots: ChargedRoot[]; cleanup: CleanupFunction }; 118 + 119 + export type Dep = { get: () => unknown; subscribe: (callback: (value: unknown) => void) => () => void };
+22 -22
test/core/binder.test.ts
··· 1 + import { mount } from "@volt/core/binder"; 2 + import { signal } from "@volt/core/signal"; 1 3 import { describe, expect, it } from "vitest"; 2 - import { mount } from "../../src/core/binder"; 3 - import { signal } from "../../src/core/signal"; 4 4 5 5 describe("binder", () => { 6 6 describe("mount", () => { ··· 12 12 cleanup(); 13 13 }); 14 14 15 - it("binds data-x-text to element text content", () => { 15 + it("binds data-volt-text to element text content", () => { 16 16 const element = document.createElement("div"); 17 - element.dataset.xText = "message"; 17 + element.dataset.voltText = "message"; 18 18 19 19 const scope = { message: "Hello, World!" }; 20 20 mount(element, scope); ··· 24 24 25 25 it("updates text content when signal changes", () => { 26 26 const element = document.createElement("div"); 27 - element.dataset.xText = "count"; 27 + element.dataset.voltText = "count"; 28 28 29 29 const count = signal(0); 30 30 const scope = { count }; ··· 39 39 expect(element.textContent).toBe("10"); 40 40 }); 41 41 42 - it("binds data-x-html to element HTML content", () => { 42 + it("binds data-volt-html to element HTML content", () => { 43 43 const element = document.createElement("div"); 44 - element.dataset.xHtml = "content"; 44 + element.dataset.voltHtml = "content"; 45 45 46 46 const scope = { content: "<strong>Bold</strong>" }; 47 47 mount(element, scope); ··· 51 51 52 52 it("updates HTML content when signal changes", () => { 53 53 const element = document.createElement("div"); 54 - element.dataset.xHtml = "html"; 54 + element.dataset.voltHtml = "html"; 55 55 56 56 const html = signal("<em>Italic</em>"); 57 57 const scope = { html }; ··· 63 63 expect(element.innerHTML).toBe("<strong>Bold</strong>"); 64 64 }); 65 65 66 - it("binds data-x-class with string value", () => { 66 + it("binds data-volt-class with string value", () => { 67 67 const element = document.createElement("div"); 68 - element.dataset.xClass = "classes"; 68 + element.dataset.voltClass = "classes"; 69 69 70 70 const scope = { classes: "active highlight" }; 71 71 mount(element, scope); ··· 74 74 expect(element.classList.contains("highlight")).toBe(true); 75 75 }); 76 76 77 - it("binds data-x-class with object value", () => { 77 + it("binds data-volt-class with object value", () => { 78 78 const element = document.createElement("div"); 79 - element.dataset.xClass = "classes"; 79 + element.dataset.voltClass = "classes"; 80 80 81 81 const scope = { classes: { active: true, disabled: false } }; 82 82 mount(element, scope); ··· 87 87 88 88 it("updates classes when signal changes", () => { 89 89 const element = document.createElement("div"); 90 - element.dataset.xClass = "classes"; 90 + element.dataset.voltClass = "classes"; 91 91 92 92 const classes = signal({ active: false, disabled: false }); 93 93 const scope = { classes }; ··· 106 106 107 107 it("removes old classes when signal changes", () => { 108 108 const element = document.createElement("div"); 109 - element.dataset.xClass = "classes"; 109 + element.dataset.voltClass = "classes"; 110 110 111 111 const classes = signal("foo bar"); 112 112 const scope = { classes }; ··· 127 127 const child2 = document.createElement("span"); 128 128 parent.append(child1, child2); 129 129 130 - child1.dataset.xText = "first"; 131 - child2.dataset.xText = "second"; 130 + child1.dataset.voltText = "first"; 131 + child2.dataset.voltText = "second"; 132 132 133 133 const scope = { first: "First", second: "Second" }; 134 134 mount(parent, scope); ··· 140 140 141 141 it("cleans up subscriptions on unmount", () => { 142 142 const element = document.createElement("div"); 143 - element.dataset.xText = "count"; 143 + element.dataset.voltText = "count"; 144 144 145 145 const count = signal(0); 146 146 const scope = { count }; ··· 157 157 158 158 it("handles multiple bindings on the same element", () => { 159 159 const element = document.createElement("div"); 160 - element.dataset.xText = "message"; 161 - element.dataset.xClass = "classes"; 160 + element.dataset.voltText = "message"; 161 + element.dataset.voltClass = "classes"; 162 162 163 163 const message = signal("Hello"); 164 164 const classes = signal("active"); ··· 177 177 178 178 it("evaluates nested property paths", () => { 179 179 const element = document.createElement("div"); 180 - element.dataset.xText = "user.name"; 180 + element.dataset.voltText = "user.name"; 181 181 182 182 const scope = { user: { name: "Alice" } }; 183 183 mount(element, scope); ··· 187 187 188 188 it("handles static values (no signals)", () => { 189 189 const element = document.createElement("div"); 190 - element.dataset.xText = "message"; 190 + element.dataset.voltText = "message"; 191 191 192 192 const scope = { message: "Static" }; 193 193 mount(element, scope); ··· 197 197 198 198 it("handles literal expressions", () => { 199 199 const element = document.createElement("div"); 200 - element.dataset.xText = "'Hello'"; 200 + element.dataset.voltText = "'Hello'"; 201 201 202 202 mount(element, {}); 203 203
+459
test/core/charge.test.ts
··· 1 + import type { Signal } from "$types/volt"; 2 + import { charge } from "@volt/core/charge"; 3 + import { afterEach, beforeEach, describe, expect, it } from "vitest"; 4 + 5 + describe("charge", () => { 6 + let container: HTMLDivElement; 7 + 8 + beforeEach(() => { 9 + container = document.createElement("div"); 10 + document.body.append(container); 11 + }); 12 + 13 + afterEach(() => { 14 + container.remove(); 15 + }); 16 + 17 + describe("basic charging", () => { 18 + it("discovers and mounts elements with data-volt attribute", () => { 19 + container.innerHTML = `<div data-volt id="root1"></div>`; 20 + 21 + const result = charge(); 22 + 23 + expect(result.roots).toHaveLength(1); 24 + expect(result.roots[0].element).toBe(container.querySelector("#root1")); 25 + expect(typeof result.cleanup).toBe("function"); 26 + 27 + result.cleanup(); 28 + }); 29 + 30 + it("mounts multiple roots", () => { 31 + container.innerHTML = ` 32 + <div data-volt id="root1"></div> 33 + <div data-volt id="root2"></div> 34 + <div data-volt id="root3"></div> 35 + `; 36 + 37 + const result = charge(); 38 + 39 + expect(result.roots).toHaveLength(3); 40 + result.cleanup(); 41 + }); 42 + 43 + it("accepts custom selector", () => { 44 + container.innerHTML = ` 45 + <div data-volt id="root1"></div> 46 + <div class="custom" id="root2"></div> 47 + `; 48 + 49 + const result = charge(".custom"); 50 + 51 + expect(result.roots).toHaveLength(1); 52 + expect(result.roots[0].element.id).toBe("root2"); 53 + result.cleanup(); 54 + }); 55 + 56 + it("returns empty array when no roots found", () => { 57 + container.innerHTML = `<div id="noRoots"></div>`; 58 + 59 + const result = charge(); 60 + 61 + expect(result.roots).toHaveLength(0); 62 + result.cleanup(); 63 + }); 64 + }); 65 + 66 + describe("data-volt-state parsing", () => { 67 + it("creates signals from data-volt-state JSON", () => { 68 + container.innerHTML = ` 69 + <div data-volt data-volt-state='{"count": 0, "message": "hello"}'> 70 + <span data-volt-text="count"></span> 71 + <span data-volt-text="message"></span> 72 + </div> 73 + `; 74 + 75 + const result = charge(); 76 + const root = container.querySelector("div[data-volt]")!; 77 + const spans = root.querySelectorAll("span"); 78 + 79 + expect(spans[0].textContent).toBe("0"); 80 + expect(spans[1].textContent).toBe("hello"); 81 + 82 + result.cleanup(); 83 + }); 84 + 85 + it("creates signals for nested objects", () => { 86 + container.innerHTML = ` 87 + <div data-volt data-volt-state='{"user": {"name": "Alice", "age": 30}}'> 88 + <span data-volt-text="user.name"></span> 89 + <span data-volt-text="user.age"></span> 90 + </div> 91 + `; 92 + 93 + const result = charge(); 94 + const spans = container.querySelectorAll("span"); 95 + 96 + expect(spans[0].textContent).toBe("Alice"); 97 + expect(spans[1].textContent).toBe("30"); 98 + 99 + result.cleanup(); 100 + }); 101 + 102 + it("creates reactive signals that can be updated", () => { 103 + container.innerHTML = ` 104 + <div data-volt data-volt-state='{"count": 0}'> 105 + <span data-volt-text="count"></span> 106 + </div> 107 + `; 108 + 109 + const result = charge(); 110 + const span = container.querySelector("span")!; 111 + const scope = result.roots[0].scope; 112 + 113 + expect(span.textContent).toBe("0"); 114 + 115 + (scope.count as Signal<number>).set(5); 116 + expect(span.textContent).toBe("5"); 117 + 118 + (scope.count as Signal<number>).set(10); 119 + expect(span.textContent).toBe("10"); 120 + 121 + result.cleanup(); 122 + }); 123 + 124 + it("handles arrays in state", () => { 125 + container.innerHTML = ` 126 + <div data-volt data-volt-state='{"items": ["a", "b", "c"]}'> 127 + <ul> 128 + <li data-volt-for="item in items" data-volt-text="item"></li> 129 + </ul> 130 + </div> 131 + `; 132 + 133 + const result = charge(); 134 + const items = container.querySelectorAll("li"); 135 + 136 + expect(items).toHaveLength(3); 137 + expect(items[0].textContent).toBe("a"); 138 + expect(items[1].textContent).toBe("b"); 139 + expect(items[2].textContent).toBe("c"); 140 + 141 + result.cleanup(); 142 + }); 143 + 144 + it("handles empty state object", () => { 145 + container.innerHTML = `<div data-volt data-volt-state='{}'>Content</div>`; 146 + 147 + const result = charge(); 148 + 149 + expect(result.roots).toHaveLength(1); 150 + expect(result.roots[0].scope).toEqual({}); 151 + 152 + result.cleanup(); 153 + }); 154 + 155 + it("handles missing data-volt-state gracefully", () => { 156 + container.innerHTML = `<div data-volt><span data-volt-text="'static'"></span></div>`; 157 + 158 + const result = charge(); 159 + const span = container.querySelector("span")!; 160 + 161 + expect(span.textContent).toBe("static"); 162 + 163 + result.cleanup(); 164 + }); 165 + 166 + it("logs error for invalid JSON", () => { 167 + const consoleError = console.error; 168 + const errors: unknown[] = []; 169 + console.error = (...args: unknown[]) => errors.push(args); 170 + 171 + container.innerHTML = `<div data-volt data-volt-state='invalid json'>Content</div>`; 172 + 173 + const result = charge(); 174 + 175 + expect(errors.length).toBeGreaterThan(0); 176 + console.error = consoleError; 177 + 178 + result.cleanup(); 179 + }); 180 + 181 + it("logs error for non-object state", () => { 182 + const consoleError = console.error; 183 + const errors: unknown[] = []; 184 + console.error = (...args: unknown[]) => errors.push(args); 185 + 186 + container.innerHTML = `<div data-volt data-volt-state='"string"'>Content</div>`; 187 + 188 + const result = charge(); 189 + 190 + expect(errors.length).toBeGreaterThan(0); 191 + console.error = consoleError; 192 + 193 + result.cleanup(); 194 + }); 195 + }); 196 + 197 + describe("data-volt-computed", () => { 198 + it("creates computed values from expressions", () => { 199 + container.innerHTML = ` 200 + <div data-volt 201 + data-volt-state='{"count": 5}' 202 + data-volt-computed:double="count * 2"> 203 + <span data-volt-text="count"></span> 204 + <span data-volt-text="double"></span> 205 + </div> 206 + `; 207 + 208 + const result = charge(); 209 + const spans = container.querySelectorAll("span"); 210 + 211 + expect(spans[0].textContent).toBe("5"); 212 + expect(spans[1].textContent).toBe("10"); 213 + 214 + result.cleanup(); 215 + }); 216 + 217 + it("updates computed values when dependencies change", () => { 218 + container.innerHTML = ` 219 + <div data-volt 220 + data-volt-state='{"count": 3}' 221 + data-volt-computed:double="count * 2" 222 + data-volt-computed:triple="count * 3"> 223 + <span id="double" data-volt-text="double"></span> 224 + <span id="triple" data-volt-text="triple"></span> 225 + </div> 226 + `; 227 + 228 + const result = charge(); 229 + const scope = result.roots[0].scope; 230 + const double = container.querySelector("#double")!; 231 + const triple = container.querySelector("#triple")!; 232 + 233 + expect(double.textContent).toBe("6"); 234 + expect(triple.textContent).toBe("9"); 235 + 236 + (scope.count as Signal<number>).set(5); 237 + 238 + expect(double.textContent).toBe("10"); 239 + expect(triple.textContent).toBe("15"); 240 + 241 + result.cleanup(); 242 + }); 243 + 244 + it("supports computed values with multiple dependencies", () => { 245 + container.innerHTML = ` 246 + <div data-volt 247 + data-volt-state='{"a": 5, "b": 3}' 248 + data-volt-computed:sum="a + b" 249 + data-volt-computed:product="a * b"> 250 + <span id="sum" data-volt-text="sum"></span> 251 + <span id="product" data-volt-text="product"></span> 252 + </div> 253 + `; 254 + 255 + const result = charge(); 256 + const scope = result.roots[0].scope; 257 + 258 + expect(container.querySelector("#sum")!.textContent).toBe("8"); 259 + expect(container.querySelector("#product")!.textContent).toBe("15"); 260 + 261 + (scope.a as Signal<number>).set(10); 262 + 263 + expect(container.querySelector("#sum")!.textContent).toBe("13"); 264 + expect(container.querySelector("#product")!.textContent).toBe("30"); 265 + 266 + result.cleanup(); 267 + }); 268 + 269 + it("supports complex expressions in computed", () => { 270 + container.innerHTML = ` 271 + <div data-volt 272 + data-volt-state='{"count": 10, "limit": 5}' 273 + data-volt-computed:is-valid="count > limit && count < 20"> 274 + <span data-volt-text="isValid"></span> 275 + </div> 276 + `; 277 + 278 + const result = charge(); 279 + const span = container.querySelector("span")!; 280 + 281 + expect(span.textContent).toBe("true"); 282 + 283 + result.cleanup(); 284 + }); 285 + 286 + it("handles computed with no dependencies", () => { 287 + container.innerHTML = ` 288 + <div data-volt 289 + data-volt-computed:constant="42 * 2"> 290 + <span data-volt-text="constant"></span> 291 + </div> 292 + `; 293 + 294 + const result = charge(); 295 + const span = container.querySelector("span")!; 296 + 297 + expect(span.textContent).toBe("84"); 298 + 299 + result.cleanup(); 300 + }); 301 + 302 + it("supports computed accessing nested properties", () => { 303 + container.innerHTML = ` 304 + <div data-volt 305 + data-volt-state='{"user": {"firstName": "John", "lastName": "Doe"}}' 306 + data-volt-computed:full-name="user.firstName"> 307 + <span data-volt-text="fullName"></span> 308 + </div> 309 + `; 310 + 311 + const result = charge(); 312 + const span = container.querySelector("span")!; 313 + 314 + expect(span.textContent).toBe("John"); 315 + 316 + result.cleanup(); 317 + }); 318 + }); 319 + 320 + describe("isolated scopes", () => { 321 + it("creates isolated scopes for each root", () => { 322 + container.innerHTML = ` 323 + <div data-volt data-volt-state='{"count": 1}'> 324 + <span id="root1" data-volt-text="count"></span> 325 + </div> 326 + <div data-volt data-volt-state='{"count": 2}'> 327 + <span id="root2" data-volt-text="count"></span> 328 + </div> 329 + `; 330 + 331 + const result = charge(); 332 + 333 + expect(container.querySelector("#root1")!.textContent).toBe("1"); 334 + expect(container.querySelector("#root2")!.textContent).toBe("2"); 335 + 336 + const scope = result.roots[0].scope; 337 + 338 + (scope.count as Signal<number>).set(10); 339 + 340 + expect(container.querySelector("#root1")!.textContent).toBe("10"); 341 + expect(container.querySelector("#root2")!.textContent).toBe("2"); 342 + 343 + result.cleanup(); 344 + }); 345 + 346 + it("does not share state between roots", () => { 347 + container.innerHTML = ` 348 + <div data-volt data-volt-state='{"shared": "root1"}'> 349 + <span id="s1" data-volt-text="shared"></span> 350 + </div> 351 + <div data-volt data-volt-state='{"shared": "root2"}'> 352 + <span id="s2" data-volt-text="shared"></span> 353 + </div> 354 + `; 355 + 356 + const result = charge(); 357 + 358 + expect(container.querySelector("#s1")!.textContent).toBe("root1"); 359 + expect(container.querySelector("#s2")!.textContent).toBe("root2"); 360 + 361 + result.cleanup(); 362 + }); 363 + }); 364 + 365 + describe("cleanup", () => { 366 + it("cleans up all roots when calling global cleanup", () => { 367 + container.innerHTML = ` 368 + <div data-volt data-volt-state='{"count": 0}'> 369 + <span data-volt-text="count"></span> 370 + </div> 371 + `; 372 + 373 + const result = charge(); 374 + const span = container.querySelector("span")!; 375 + const scope = result.roots[0].scope; 376 + 377 + (scope.count as Signal<number>).set(5); 378 + expect(span.textContent).toBe("5"); 379 + 380 + result.cleanup(); 381 + 382 + (scope.count as Signal<number>).set(10); 383 + expect(span.textContent).toBe("5"); 384 + }); 385 + 386 + it("cleans up individual roots", () => { 387 + container.innerHTML = ` 388 + <div data-volt data-volt-state='{"count": 0}'> 389 + <span data-volt-text="count"></span> 390 + </div> 391 + `; 392 + 393 + const result = charge(); 394 + const span = container.querySelector("span")!; 395 + const scope = result.roots[0].scope; 396 + 397 + (scope.count as Signal<number>).set(5); 398 + expect(span.textContent).toBe("5"); 399 + 400 + result.roots[0].cleanup(); 401 + 402 + (scope.count as Signal<number>).set(10); 403 + expect(span.textContent).toBe("5"); 404 + }); 405 + }); 406 + 407 + describe("integration with bindings", () => { 408 + it("works with all binding types", () => { 409 + container.innerHTML = ` 410 + <div data-volt data-volt-state='{"message": "Hello", "active": true}'> 411 + <span data-volt-text="message" data-volt-class="active"></span> 412 + </div> 413 + `; 414 + 415 + const result = charge(); 416 + const span = container.querySelector("span")!; 417 + 418 + expect(span.textContent).toBe("Hello"); 419 + expect(span.classList.contains("true")).toBe(true); 420 + 421 + result.cleanup(); 422 + }); 423 + 424 + it("works with conditional rendering", () => { 425 + container.innerHTML = ` 426 + <div data-volt data-volt-state='{"show": true}'> 427 + <p data-volt-if="show">Visible</p> 428 + </div> 429 + `; 430 + 431 + const result = charge(); 432 + 433 + expect(container.querySelector("p")).toBeTruthy(); 434 + 435 + const scope = result.roots[0].scope; 436 + (scope.show as Signal<boolean>).set(false); 437 + 438 + expect(container.querySelector("p")).toBeNull(); 439 + 440 + result.cleanup(); 441 + }); 442 + 443 + it("works with list rendering", () => { 444 + container.innerHTML = ` 445 + <div data-volt data-volt-state='{"items": ["a", "b"]}'> 446 + <ul> 447 + <li data-volt-for="item in items" data-volt-text="item"></li> 448 + </ul> 449 + </div> 450 + `; 451 + 452 + const result = charge(); 453 + 454 + expect(container.querySelectorAll("li")).toHaveLength(2); 455 + 456 + result.cleanup(); 457 + }); 458 + }); 459 + });
+331
test/core/evaluator.test.ts
··· 1 + import { evaluate } from "@volt/core/evaluator"; 2 + import { signal } from "@volt/core/signal"; 3 + import { describe, expect, it } from "vitest"; 4 + 5 + describe("evaluator", () => { 6 + describe("literals", () => { 7 + it("evaluates boolean literals", () => { 8 + expect(evaluate("true", {})).toBe(true); 9 + expect(evaluate("false", {})).toBe(false); 10 + }); 11 + 12 + it("evaluates null and undefined", () => { 13 + expect(evaluate("null", {})).toBe(null); 14 + expect(evaluate("undefined", {})).toBe(undefined); 15 + }); 16 + 17 + it("evaluates number literals", () => { 18 + expect(evaluate("42", {})).toBe(42); 19 + expect(evaluate("3.14", {})).toBe(3.14); 20 + expect(evaluate("0", {})).toBe(0); 21 + expect(evaluate("-5", {})).toBe(-5); 22 + expect(evaluate("-3.5", {})).toBe(-3.5); 23 + }); 24 + 25 + it("evaluates string literals", () => { 26 + expect(evaluate("'hello'", {})).toBe("hello"); 27 + expect(evaluate("\"world\"", {})).toBe("world"); 28 + expect(evaluate("'multi word string'", {})).toBe("multi word string"); 29 + expect(evaluate("''", {})).toBe(""); 30 + }); 31 + 32 + it("handles escaped characters in strings", () => { 33 + expect(evaluate(String.raw`'it\'s'`, {})).toBe("it's"); 34 + expect(evaluate(String.raw`"say \"hi\""`, {})).toBe("say \"hi\""); 35 + }); 36 + }); 37 + 38 + describe("property access", () => { 39 + it("resolves simple identifiers", () => { 40 + const scope = { count: 5, name: "Alice" }; 41 + expect(evaluate("count", scope)).toBe(5); 42 + expect(evaluate("name", scope)).toBe("Alice"); 43 + }); 44 + 45 + it("resolves nested property paths with dot notation", () => { 46 + const scope = { user: { name: "Bob", age: 30 } }; 47 + expect(evaluate("user.name", scope)).toBe("Bob"); 48 + expect(evaluate("user.age", scope)).toBe(30); 49 + }); 50 + 51 + it("resolves array elements with bracket notation", () => { 52 + const scope = { items: ["first", "second", "third"], index: 1 }; 53 + expect(evaluate("items[0]", scope)).toBe("first"); 54 + expect(evaluate("items[1]", scope)).toBe("second"); 55 + expect(evaluate("items[index]", scope)).toBe("second"); 56 + }); 57 + 58 + it("handles mixed dot and bracket notation", () => { 59 + const scope = { data: { users: [{ name: "Alice" }, { name: "Bob" }] } }; 60 + expect(evaluate("data.users[0].name", scope)).toBe("Alice"); 61 + expect(evaluate("data.users[1].name", scope)).toBe("Bob"); 62 + }); 63 + 64 + it("returns undefined for missing properties", () => { 65 + const scope = { exists: 42 }; 66 + expect(evaluate("missing", scope)).toBe(undefined); 67 + expect(evaluate("exists.nested", scope)).toBe(undefined); 68 + }); 69 + 70 + it("auto-unwraps signals", () => { 71 + const scope = { count: signal(10), user: { age: signal(25) } }; 72 + expect(evaluate("count", scope)).toBe(10); 73 + expect(evaluate("user.age", scope)).toBe(25); 74 + }); 75 + }); 76 + 77 + describe("arithmetic operators", () => { 78 + it("evaluates addition", () => { 79 + expect(evaluate("5 + 3", {})).toBe(8); 80 + expect(evaluate("10 + 20 + 30", {})).toBe(60); 81 + }); 82 + 83 + it("evaluates subtraction", () => { 84 + expect(evaluate("10 - 3", {})).toBe(7); 85 + expect(evaluate("100 - 20 - 5", {})).toBe(75); 86 + }); 87 + 88 + it("evaluates multiplication", () => { 89 + expect(evaluate("5 * 3", {})).toBe(15); 90 + expect(evaluate("2 * 3 * 4", {})).toBe(24); 91 + }); 92 + 93 + it("evaluates division", () => { 94 + expect(evaluate("10 / 2", {})).toBe(5); 95 + expect(evaluate("100 / 4 / 5", {})).toBe(5); 96 + }); 97 + 98 + it("evaluates modulo", () => { 99 + expect(evaluate("10 % 3", {})).toBe(1); 100 + expect(evaluate("7 % 2", {})).toBe(1); 101 + expect(evaluate("8 % 4", {})).toBe(0); 102 + }); 103 + 104 + it("respects operator precedence", () => { 105 + expect(evaluate("2 + 3 * 4", {})).toBe(14); 106 + expect(evaluate("10 - 2 * 3", {})).toBe(4); 107 + expect(evaluate("20 / 4 + 3", {})).toBe(8); 108 + expect(evaluate("10 % 3 + 2", {})).toBe(3); 109 + }); 110 + 111 + it("evaluates with variables", () => { 112 + const scope = { a: 5, b: 3, c: signal(2) }; 113 + expect(evaluate("a + b", scope)).toBe(8); 114 + expect(evaluate("a * b", scope)).toBe(15); 115 + expect(evaluate("a + c", scope)).toBe(7); 116 + expect(evaluate("a * b + c", scope)).toBe(17); 117 + }); 118 + }); 119 + 120 + describe("comparison operators", () => { 121 + it("evaluates strict equality", () => { 122 + expect(evaluate("5 === 5", {})).toBe(true); 123 + expect(evaluate("5 === 3", {})).toBe(false); 124 + expect(evaluate("'hello' === 'hello'", {})).toBe(true); 125 + expect(evaluate("'hello' === 'world'", {})).toBe(false); 126 + expect(evaluate("true === true", {})).toBe(true); 127 + expect(evaluate("true === false", {})).toBe(false); 128 + }); 129 + 130 + it("evaluates strict inequality", () => { 131 + expect(evaluate("5 !== 3", {})).toBe(true); 132 + expect(evaluate("5 !== 5", {})).toBe(false); 133 + expect(evaluate("'hello' !== 'world'", {})).toBe(true); 134 + }); 135 + 136 + it("evaluates less than", () => { 137 + expect(evaluate("3 < 5", {})).toBe(true); 138 + expect(evaluate("5 < 3", {})).toBe(false); 139 + expect(evaluate("5 < 5", {})).toBe(false); 140 + }); 141 + 142 + it("evaluates greater than", () => { 143 + expect(evaluate("5 > 3", {})).toBe(true); 144 + expect(evaluate("3 > 5", {})).toBe(false); 145 + expect(evaluate("5 > 5", {})).toBe(false); 146 + }); 147 + 148 + it("evaluates less than or equal", () => { 149 + expect(evaluate("3 <= 5", {})).toBe(true); 150 + expect(evaluate("5 <= 5", {})).toBe(true); 151 + expect(evaluate("7 <= 5", {})).toBe(false); 152 + }); 153 + 154 + it("evaluates greater than or equal", () => { 155 + expect(evaluate("5 >= 3", {})).toBe(true); 156 + expect(evaluate("5 >= 5", {})).toBe(true); 157 + expect(evaluate("3 >= 5", {})).toBe(false); 158 + }); 159 + 160 + it("compares variables", () => { 161 + const scope = { count: 10, limit: 5, target: signal(10) }; 162 + expect(evaluate("count > limit", scope)).toBe(true); 163 + expect(evaluate("count === target", scope)).toBe(true); 164 + expect(evaluate("limit < count", scope)).toBe(true); 165 + }); 166 + }); 167 + 168 + describe("logical operators", () => { 169 + it("evaluates logical AND", () => { 170 + expect(evaluate("true && true", {})).toBe(true); 171 + expect(evaluate("true && false", {})).toBe(false); 172 + expect(evaluate("false && true", {})).toBe(false); 173 + expect(evaluate("false && false", {})).toBe(false); 174 + }); 175 + 176 + it("evaluates logical OR", () => { 177 + expect(evaluate("true || true", {})).toBe(true); 178 + expect(evaluate("true || false", {})).toBe(true); 179 + expect(evaluate("false || true", {})).toBe(true); 180 + expect(evaluate("false || false", {})).toBe(false); 181 + }); 182 + 183 + it("evaluates logical NOT", () => { 184 + expect(evaluate("!true", {})).toBe(false); 185 + expect(evaluate("!false", {})).toBe(true); 186 + expect(evaluate("!!true", {})).toBe(true); 187 + }); 188 + 189 + it("combines logical operators", () => { 190 + expect(evaluate("true && true || false", {})).toBe(true); 191 + expect(evaluate("false || true && true", {})).toBe(true); 192 + expect(evaluate("!false && true", {})).toBe(true); 193 + }); 194 + 195 + it("evaluates with truthy/falsy values", () => { 196 + const scope = { zero: 0, one: 1, empty: "", text: "hello", nil: null }; 197 + expect(evaluate("zero && one", scope)).toBe(false); 198 + expect(evaluate("one && text", scope)).toBe(true); 199 + expect(evaluate("empty || text", scope)).toBe(true); 200 + expect(evaluate("!zero", scope)).toBe(true); 201 + expect(evaluate("!one", scope)).toBe(false); 202 + }); 203 + }); 204 + 205 + describe("unary operators", () => { 206 + it("evaluates unary minus", () => { 207 + expect(evaluate("-5", {})).toBe(-5); 208 + expect(evaluate("-(-5)", {})).toBe(5); 209 + expect(evaluate("-(3 + 2)", {})).toBe(-5); 210 + }); 211 + 212 + it("evaluates unary plus", () => { 213 + expect(evaluate("+5", {})).toBe(5); 214 + expect(evaluate("+(-5)", {})).toBe(-5); 215 + }); 216 + 217 + it("evaluates unary NOT", () => { 218 + expect(evaluate("!true", {})).toBe(false); 219 + expect(evaluate("!false", {})).toBe(true); 220 + expect(evaluate("!0", {})).toBe(true); 221 + expect(evaluate("!1", {})).toBe(false); 222 + }); 223 + }); 224 + 225 + describe("grouped expressions", () => { 226 + it("evaluates parenthesized expressions", () => { 227 + expect(evaluate("(5 + 3) * 2", {})).toBe(16); 228 + expect(evaluate("10 / (2 + 3)", {})).toBe(2); 229 + expect(evaluate("(10 - 5) * (3 + 2)", {})).toBe(25); 230 + }); 231 + 232 + it("handles nested parentheses", () => { 233 + expect(evaluate("((5 + 3) * 2)", {})).toBe(16); 234 + expect(evaluate("(5 + (3 * 2))", {})).toBe(11); 235 + expect(evaluate("((2 + 3) * (4 + 5))", {})).toBe(45); 236 + }); 237 + 238 + it("overrides operator precedence", () => { 239 + expect(evaluate("2 + 3 * 4", {})).toBe(14); 240 + expect(evaluate("(2 + 3) * 4", {})).toBe(20); 241 + }); 242 + }); 243 + 244 + describe("complex expressions", () => { 245 + it("evaluates combined arithmetic and comparison", () => { 246 + const scope = { count: 10, limit: 5 }; 247 + expect(evaluate("count * 2 > limit", scope)).toBe(true); 248 + expect(evaluate("count + 5 === 15", scope)).toBe(true); 249 + expect(evaluate("count - 3 < limit", scope)).toBe(false); 250 + }); 251 + 252 + it("evaluates combined comparison and logical", () => { 253 + const scope = { age: 25, min: 18, max: 65 }; 254 + expect(evaluate("age >= min && age <= max", scope)).toBe(true); 255 + expect(evaluate("age < min || age > max", scope)).toBe(false); 256 + }); 257 + 258 + it("evaluates complex nested expressions", () => { 259 + const scope = { a: 5, b: 3, c: 2, d: signal(10) }; 260 + expect(evaluate("(a + b) * c === d", scope)).toBe(false); 261 + expect(evaluate("a * b + c === d + 7", scope)).toBe(true); 262 + expect(evaluate("!(a < b) && c > 0", scope)).toBe(true); 263 + }); 264 + 265 + it("handles array length checks", () => { 266 + const scope = { items: [1, 2, 3] }; 267 + expect(evaluate("items.length === 3", scope)).toBe(true); 268 + expect(evaluate("items.length > 0", scope)).toBe(true); 269 + expect(evaluate("items.length === 0", scope)).toBe(false); 270 + }); 271 + }); 272 + 273 + describe("error handling", () => { 274 + it("returns undefined for invalid expressions", () => { 275 + expect(evaluate("@#$%", {})).toBe(undefined); 276 + }); 277 + 278 + it("handles null/undefined gracefully", () => { 279 + const scope = { nil: null, undef: undefined }; 280 + expect(evaluate("nil", scope)).toBe(null); 281 + expect(evaluate("undef", scope)).toBe(undefined); 282 + expect(evaluate("nil.property", scope)).toBe(undefined); 283 + }); 284 + 285 + it("handles errors in complex expressions", () => { 286 + const result = evaluate("unclosed (", {}); 287 + expect(result).toBe(undefined); 288 + }); 289 + }); 290 + 291 + describe("whitespace handling", () => { 292 + it("ignores whitespace", () => { 293 + expect(evaluate(" 5 + 3 ", {})).toBe(8); 294 + expect(evaluate("\n10\n*\n2\n", {})).toBe(20); 295 + expect(evaluate(" true && false ", {})).toBe(false); 296 + }); 297 + 298 + it("preserves whitespace in strings", () => { 299 + expect(evaluate("'hello world'", {})).toBe("hello world"); 300 + expect(evaluate("' spaces '", {})).toBe(" spaces "); 301 + }); 302 + }); 303 + 304 + describe("real-world use cases", () => { 305 + it("evaluates todo app conditions", () => { 306 + const scope = { todos: signal([{ completed: true }, { completed: false }]), filter: "active" }; 307 + 308 + expect(evaluate("todos.length > 0", scope)).toBe(true); 309 + expect(evaluate("todos.length === 0", scope)).toBe(false); 310 + expect(evaluate("filter === 'active'", scope)).toBe(true); 311 + }); 312 + 313 + it("evaluates form validation", () => { 314 + const scope = { email: signal("user@example.com"), password: signal("secret123"), agreed: signal(true) }; 315 + 316 + expect(evaluate("email.length > 0", scope)).toBe(true); 317 + expect(evaluate("password.length >= 8", scope)).toBe(true); 318 + expect(evaluate("agreed === true", scope)).toBe(true); 319 + expect(evaluate("email.length > 0 && password.length >= 8 && agreed", scope)).toBe(true); 320 + }); 321 + 322 + it("evaluates pagination", () => { 323 + const scope = { page: signal(2), totalPages: 5, items: [1, 2, 3] }; 324 + 325 + expect(evaluate("page > 1", scope)).toBe(true); 326 + expect(evaluate("page < totalPages", scope)).toBe(true); 327 + expect(evaluate("items.length > 0", scope)).toBe(true); 328 + expect(evaluate("(page - 1) * 10", scope)).toBe(10); 329 + }); 330 + }); 331 + });
+12 -12
test/core/events.test.ts
··· 5 5 describe("event bindings", () => { 6 6 it("binds click events", () => { 7 7 const button = document.createElement("button"); 8 - button.dataset.xOnClick = "handleClick"; 8 + button.dataset.voltOnClick = "handleClick"; 9 9 10 10 const handleClick = vi.fn(); 11 11 mount(button, { handleClick }); ··· 17 17 18 18 it("provides $event to the handler", () => { 19 19 const button = document.createElement("button"); 20 - button.dataset.xOnClick = "handleClick"; 20 + button.dataset.voltOnClick = "handleClick"; 21 21 22 22 const handleClick = vi.fn(); 23 23 mount(button, { handleClick }); ··· 29 29 30 30 it("provides $el in the scope", () => { 31 31 const button = document.createElement("button"); 32 - button.dataset.xOnClick = "clicked"; 32 + button.dataset.voltOnClick = "clicked"; 33 33 34 34 const clicked = signal(false); 35 - button.dataset.xOnClick = "setClicked"; 35 + button.dataset.voltOnClick = "setClicked"; 36 36 37 37 const setClicked = vi.fn(() => { 38 38 clicked.set(true); ··· 48 48 49 49 it("handles input events", () => { 50 50 const input = document.createElement("input"); 51 - input.dataset.xOnInput = "handleInput"; 51 + input.dataset.voltOnInput = "handleInput"; 52 52 53 53 const handleInput = vi.fn(); 54 54 mount(input, { handleInput }); ··· 61 61 62 62 it("cleans up event listeners on unmount", () => { 63 63 const button = document.createElement("button"); 64 - button.dataset.xOnClick = "handleClick"; 64 + button.dataset.voltOnClick = "handleClick"; 65 65 66 66 const handleClick = vi.fn(); 67 67 const cleanup = mount(button, { handleClick }); ··· 77 77 78 78 it("supports multiple event bindings on the same element", () => { 79 79 const button = document.createElement("button"); 80 - button.dataset.xOnClick = "handleClick"; 81 - button.dataset.xOnMouseover = "handleMouseover"; 80 + button.dataset.voltOnClick = "handleClick"; 81 + button.dataset.voltOnMouseover = "handleMouseover"; 82 82 83 83 const handleClick = vi.fn(); 84 84 const handleMouseover = vi.fn(); ··· 95 95 96 96 it("can update signals in event handlers", () => { 97 97 const button = document.createElement("button"); 98 - button.dataset.xOnClick = "increment"; 98 + button.dataset.voltOnClick = "increment"; 99 99 100 100 const count = signal(0); 101 101 const increment = () => { ··· 115 115 116 116 it("handles submit events", () => { 117 117 const form = document.createElement("form"); 118 - form.dataset.xOnSubmit = "handleSubmit"; 118 + form.dataset.voltOnSubmit = "handleSubmit"; 119 119 120 120 const handleSubmit = vi.fn((event) => { 121 121 event.preventDefault(); ··· 132 132 133 133 it("handles change events on inputs", () => { 134 134 const input = document.createElement("input"); 135 - input.dataset.xOnChange = "handleChange"; 135 + input.dataset.voltOnChange = "handleChange"; 136 136 137 137 const handleChange = vi.fn(); 138 138 mount(input, { handleChange }); ··· 145 145 it("can access element properties via $el", () => { 146 146 const input = document.createElement("input"); 147 147 input.value = "initial"; 148 - input.dataset.xOnInput = "updateValue"; 148 + input.dataset.voltOnInput = "updateValue"; 149 149 150 150 const value = signal(""); 151 151 const updateValue = vi.fn(() => {
+18 -18
test/core/for-binding.test.ts
··· 2 2 import { signal } from "@volt/core/signal"; 3 3 import { describe, expect, it } from "vitest"; 4 4 5 - describe("data-x-for binding", () => { 5 + describe("data-volt-for binding", () => { 6 6 it("renders a list from array signal", () => { 7 7 const container = document.createElement("div"); 8 - container.innerHTML = `<ul><li data-x-for="item in items" data-x-text="item"></li></ul>`; 8 + container.innerHTML = `<ul><li data-volt-for="item in items" data-volt-text="item"></li></ul>`; 9 9 10 10 const items = signal(["apple", "banana", "cherry"]); 11 11 mount(container, { items }); ··· 21 21 22 22 it("updates list when signal changes", () => { 23 23 const container = document.createElement("div"); 24 - container.innerHTML = `<ul><li data-x-for="item in items" data-x-text="item"></li></ul>`; 24 + container.innerHTML = `<ul><li data-volt-for="item in items" data-volt-text="item"></li></ul>`; 25 25 26 26 const items = signal(["one", "two"]); 27 27 mount(container, { items }); ··· 51 51 const container = document.createElement("div"); 52 52 container.innerHTML = ` 53 53 <ul> 54 - <li data-x-for="user in users"> 55 - <span data-x-text="user.name"></span> 54 + <li data-volt-for="user in users"> 55 + <span data-volt-text="user.name"></span> 56 56 </li> 57 57 </ul> 58 58 `; ··· 71 71 const container = document.createElement("div"); 72 72 container.innerHTML = ` 73 73 <ul> 74 - <li data-x-for="(item, i) in items"> 75 - <span data-x-text="i"></span>: <span data-x-text="item"></span> 74 + <li data-volt-for="(item, i) in items"> 75 + <span data-volt-text="i"></span>: <span data-volt-text="item"></span> 76 76 </li> 77 77 </ul> 78 78 `; ··· 94 94 95 95 it("handles empty arrays", () => { 96 96 const container = document.createElement("div"); 97 - container.innerHTML = `<ul><li data-x-for="item in items" data-x-text="item"></li></ul>`; 97 + container.innerHTML = `<ul><li data-volt-for="item in items" data-volt-text="item"></li></ul>`; 98 98 99 99 const items = signal<string[]>([]); 100 100 mount(container, { items }); ··· 109 109 110 110 it("handles static arrays (non-signal)", () => { 111 111 const container = document.createElement("div"); 112 - container.innerHTML = `<ul><li data-x-for="item in items" data-x-text="item"></li></ul>`; 112 + container.innerHTML = `<ul><li data-volt-for="item in items" data-volt-text="item"></li></ul>`; 113 113 114 114 mount(container, { items: ["static", "array"] }); 115 115 ··· 123 123 const container = document.createElement("div"); 124 124 container.innerHTML = ` 125 125 <ul> 126 - <li data-x-for="item in items"> 127 - <button data-x-on-click="handleClick" data-x-text="item.text"></button> 126 + <li data-volt-for="item in items"> 127 + <button data-volt-on-click="handleClick" data-volt-text="item.text"></button> 128 128 </li> 129 129 </ul> 130 130 `; ··· 151 151 it("supports nested loops", () => { 152 152 const container = document.createElement("div"); 153 153 container.innerHTML = ` 154 - <div data-x-for="group in groups"> 154 + <div data-volt-for="group in groups"> 155 155 <ul> 156 - <li data-x-for="item in group.items" data-x-text="item"></li> 156 + <li data-volt-for="item in group.items" data-volt-text="item"></li> 157 157 </ul> 158 158 </div> 159 159 `; ··· 176 176 177 177 it("properly cleans up when unmounting", () => { 178 178 const container = document.createElement("div"); 179 - container.innerHTML = `<ul><li data-x-for="item in items" data-x-text="item.value"></li></ul>`; 179 + container.innerHTML = `<ul><li data-volt-for="item in items" data-volt-text="item.value"></li></ul>`; 180 180 181 181 const items = signal([{ value: signal("A") }, { value: signal("B") }]); 182 182 const cleanup = mount(container, { items }); ··· 201 201 202 202 it("handles non-array values gracefully", () => { 203 203 const container = document.createElement("div"); 204 - container.innerHTML = `<ul><li data-x-for="item in notAnArray" data-x-text="item"></li></ul>`; 204 + container.innerHTML = `<ul><li data-volt-for="item in notAnArray" data-volt-text="item"></li></ul>`; 205 205 206 206 mount(container, { notAnArray: "not an array" }); 207 207 ··· 213 213 const container = document.createElement("div"); 214 214 container.innerHTML = ` 215 215 <ul> 216 - <li data-x-for="todo in todos"> 217 - <span data-x-text="todo.title"></span> 218 - <span data-x-class="todo.completed">Done</span> 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 219 </li> 220 220 </ul> 221 221 `;
+18 -18
test/core/if-binding.test.ts
··· 2 2 import { signal } from "@volt/core/signal"; 3 3 import { describe, expect, it } from "vitest"; 4 4 5 - describe("data-x-if binding", () => { 5 + describe("data-volt-if binding", () => { 6 6 it("shows element when condition is truthy", () => { 7 7 const container = document.createElement("div"); 8 8 container.innerHTML = ` 9 9 <div> 10 - <p data-x-if="show" data-x-text="message">Hidden</p> 10 + <p data-volt-if="show" data-volt-text="message">Hidden</p> 11 11 </div> 12 12 `; 13 13 ··· 25 25 const container = document.createElement("div"); 26 26 container.innerHTML = ` 27 27 <div> 28 - <p data-x-if="show">Should not appear</p> 28 + <p data-volt-if="show">Should not appear</p> 29 29 </div> 30 30 `; 31 31 ··· 40 40 const container = document.createElement("div"); 41 41 container.innerHTML = ` 42 42 <div> 43 - <span data-x-if="visible">Toggle Me</span> 43 + <span data-volt-if="visible">Toggle Me</span> 44 44 </div> 45 45 `; 46 46 ··· 64 64 const container = document.createElement("div"); 65 65 container.innerHTML = ` 66 66 <div> 67 - <p data-x-if="alwaysTrue">Always visible</p> 67 + <p data-volt-if="alwaysTrue">Always visible</p> 68 68 </div> 69 69 `; 70 70 ··· 79 79 const container = document.createElement("div"); 80 80 container.innerHTML = ` 81 81 <div> 82 - <p data-x-if="alwaysFalse">Never visible</p> 82 + <p data-volt-if="alwaysFalse">Never visible</p> 83 83 </div> 84 84 `; 85 85 ··· 92 92 const container = document.createElement("div"); 93 93 container.innerHTML = ` 94 94 <div> 95 - <div data-x-if="show"> 96 - <span data-x-text="message">Default</span> 95 + <div data-volt-if="show"> 96 + <span data-volt-text="message">Default</span> 97 97 </div> 98 98 </div> 99 99 `; ··· 109 109 expect(container.querySelector("span")?.textContent).toBe("Second"); 110 110 111 111 show.set(false); 112 - expect(container.querySelector("div[data-x-if]")).toBeNull(); 112 + expect(container.querySelector("div[data-volt-if]")).toBeNull(); 113 113 114 114 message.set("Third"); 115 115 show.set(true); ··· 121 121 const container = document.createElement("div"); 122 122 container.innerHTML = ` 123 123 <div> 124 - <div data-x-if="showOuter"> 124 + <div data-volt-if="showOuter"> 125 125 <p>Outer</p> 126 - <div data-x-if="showInner"> 126 + <div data-volt-if="showInner"> 127 127 <p>Inner</p> 128 128 </div> 129 129 </div> ··· 159 159 const container = document.createElement("div"); 160 160 container.innerHTML = ` 161 161 <div> 162 - <p data-x-if="show" data-x-text="message">Hidden</p> 162 + <p data-volt-if="show" data-volt-text="message">Hidden</p> 163 163 </div> 164 164 `; 165 165 ··· 185 185 const container = document.createElement("div"); 186 186 container.innerHTML = ` 187 187 <div> 188 - <button data-x-if="show" data-x-on-click="handleClick">Click Me</button> 188 + <button data-volt-if="show" data-volt-on-click="handleClick">Click Me</button> 189 189 </div> 190 190 `; 191 191 ··· 216 216 const container = document.createElement("div"); 217 217 container.innerHTML = ` 218 218 <div> 219 - <p data-x-if="user.isActive">User is active</p> 219 + <p data-volt-if="user.isActive">User is active</p> 220 220 </div> 221 221 `; 222 222 ··· 236 236 const container = document.createElement("div"); 237 237 container.innerHTML = ` 238 238 <div> 239 - <p id="zero" data-x-if="zero">0</p> 240 - <p id="empty" data-x-if="empty">Empty</p> 241 - <p id="one" data-x-if="one">1</p> 242 - <p id="string" data-x-if="string">String</p> 239 + <p id="zero" data-volt-if="zero">0</p> 240 + <p id="empty" data-volt-if="empty">Empty</p> 241 + <p id="one" data-volt-if="one">1</p> 242 + <p id="string" data-volt-if="string">String</p> 243 243 </div> 244 244 `; 245 245
+575
test/core/model-binding.test.ts
··· 1 + import { mount } from "@volt/core/binder"; 2 + import { signal } from "@volt/core/signal"; 3 + import { describe, expect, it } from "vitest"; 4 + 5 + describe("data-volt-model binding", () => { 6 + describe("text inputs", () => { 7 + it("binds signal to text input value", () => { 8 + const container = document.createElement("div"); 9 + container.innerHTML = `<input type="text" data-volt-model="name" />`; 10 + 11 + const name = signal("Alice"); 12 + mount(container, { name }); 13 + 14 + const input = container.querySelector("input")!; 15 + expect(input.value).toBe("Alice"); 16 + }); 17 + 18 + it("updates input when signal changes", () => { 19 + const container = document.createElement("div"); 20 + container.innerHTML = `<input type="text" data-volt-model="message" />`; 21 + 22 + const message = signal("Hello"); 23 + mount(container, { message }); 24 + 25 + const input = container.querySelector("input")!; 26 + expect(input.value).toBe("Hello"); 27 + 28 + message.set("World"); 29 + expect(input.value).toBe("World"); 30 + }); 31 + 32 + it("updates signal when input changes", () => { 33 + const container = document.createElement("div"); 34 + container.innerHTML = `<input type="text" data-volt-model="text" />`; 35 + 36 + const text = signal("initial"); 37 + mount(container, { text }); 38 + 39 + const input = container.querySelector("input")!; 40 + input.value = "changed"; 41 + input.dispatchEvent(new Event("input")); 42 + 43 + expect(text.get()).toBe("changed"); 44 + }); 45 + 46 + it("handles bidirectional updates", () => { 47 + const container = document.createElement("div"); 48 + container.innerHTML = ` 49 + <input data-volt-model="value" /> 50 + <span data-volt-text="value"></span> 51 + `; 52 + 53 + const value = signal("test"); 54 + mount(container, { value }); 55 + 56 + const input = container.querySelector("input")!; 57 + const span = container.querySelector("span")!; 58 + 59 + expect(input.value).toBe("test"); 60 + expect(span.textContent).toBe("test"); 61 + 62 + input.value = "updated"; 63 + input.dispatchEvent(new Event("input")); 64 + 65 + expect(value.get()).toBe("updated"); 66 + expect(span.textContent).toBe("updated"); 67 + }); 68 + }); 69 + 70 + describe("number inputs", () => { 71 + it("binds signal to number input", () => { 72 + const container = document.createElement("div"); 73 + container.innerHTML = `<input type="number" data-volt-model="count" />`; 74 + 75 + const count = signal(42); 76 + mount(container, { count }); 77 + 78 + const input = container.querySelector("input")!; 79 + expect(input.value).toBe("42"); 80 + }); 81 + 82 + it("updates signal with numeric value", () => { 83 + const container = document.createElement("div"); 84 + container.innerHTML = `<input type="number" data-volt-model="quantity" />`; 85 + 86 + const quantity = signal(0); 87 + mount(container, { quantity }); 88 + 89 + const input = container.querySelector("input")!; 90 + input.value = "10"; 91 + input.dispatchEvent(new Event("input")); 92 + 93 + expect(quantity.get()).toBe(10); 94 + expect(typeof quantity.get()).toBe("number"); 95 + }); 96 + }); 97 + 98 + describe("checkbox inputs", () => { 99 + it("binds signal to checkbox checked state", () => { 100 + const container = document.createElement("div"); 101 + container.innerHTML = `<input type="checkbox" data-volt-model="agreed" />`; 102 + 103 + const agreed = signal(true); 104 + mount(container, { agreed }); 105 + 106 + const checkbox = container.querySelector("input")!; 107 + expect(checkbox.checked).toBe(true); 108 + }); 109 + 110 + it("updates checkbox when signal changes", () => { 111 + const container = document.createElement("div"); 112 + container.innerHTML = `<input type="checkbox" data-volt-model="enabled" />`; 113 + 114 + const enabled = signal(false); 115 + mount(container, { enabled }); 116 + 117 + const checkbox = container.querySelector("input")!; 118 + expect(checkbox.checked).toBe(false); 119 + 120 + enabled.set(true); 121 + expect(checkbox.checked).toBe(true); 122 + }); 123 + 124 + it("updates signal when checkbox is clicked", () => { 125 + const container = document.createElement("div"); 126 + container.innerHTML = `<input type="checkbox" data-volt-model="checked" />`; 127 + 128 + const checked = signal(false); 129 + mount(container, { checked }); 130 + 131 + const checkbox = container.querySelector("input")!; 132 + checkbox.checked = true; 133 + checkbox.dispatchEvent(new Event("change")); 134 + 135 + expect(checked.get()).toBe(true); 136 + }); 137 + }); 138 + 139 + describe("radio buttons", () => { 140 + it("binds signal to radio button selection", () => { 141 + const container = document.createElement("div"); 142 + container.innerHTML = ` 143 + <input type="radio" name="choice" value="a" data-volt-model="selected" /> 144 + <input type="radio" name="choice" value="b" data-volt-model="selected" /> 145 + `; 146 + 147 + const selected = signal("b"); 148 + mount(container, { selected }); 149 + 150 + const radios = container.querySelectorAll("input"); 151 + expect(radios[0].checked).toBe(false); 152 + expect(radios[1].checked).toBe(true); 153 + }); 154 + 155 + it("updates signal when radio is selected", () => { 156 + const container = document.createElement("div"); 157 + container.innerHTML = ` 158 + <input type="radio" name="option" value="x" data-volt-model="choice" /> 159 + <input type="radio" name="option" value="y" data-volt-model="choice" /> 160 + `; 161 + 162 + const choice = signal("x"); 163 + mount(container, { choice }); 164 + 165 + const radios = container.querySelectorAll("input"); 166 + radios[1].checked = true; 167 + radios[1].dispatchEvent(new Event("change")); 168 + 169 + expect(choice.get()).toBe("y"); 170 + }); 171 + }); 172 + 173 + describe("select elements", () => { 174 + it("binds signal to select value", () => { 175 + const container = document.createElement("div"); 176 + container.innerHTML = ` 177 + <select data-volt-model="color"> 178 + <option value="red">Red</option> 179 + <option value="blue">Blue</option> 180 + </select> 181 + `; 182 + 183 + const color = signal("blue"); 184 + mount(container, { color }); 185 + 186 + const select = container.querySelector("select")!; 187 + expect(select.value).toBe("blue"); 188 + }); 189 + 190 + it("updates select when signal changes", () => { 191 + const container = document.createElement("div"); 192 + container.innerHTML = ` 193 + <select data-volt-model="size"> 194 + <option value="s">Small</option> 195 + <option value="m">Medium</option> 196 + <option value="l">Large</option> 197 + </select> 198 + `; 199 + 200 + const size = signal("s"); 201 + mount(container, { size }); 202 + 203 + const select = container.querySelector("select")!; 204 + expect(select.value).toBe("s"); 205 + 206 + size.set("l"); 207 + expect(select.value).toBe("l"); 208 + }); 209 + 210 + it("updates signal when selection changes", () => { 211 + const container = document.createElement("div"); 212 + container.innerHTML = ` 213 + <select data-volt-model="fruit"> 214 + <option value="apple">Apple</option> 215 + <option value="banana">Banana</option> 216 + </select> 217 + `; 218 + 219 + const fruit = signal("apple"); 220 + mount(container, { fruit }); 221 + 222 + const select = container.querySelector("select")!; 223 + select.value = "banana"; 224 + select.dispatchEvent(new Event("input")); 225 + 226 + expect(fruit.get()).toBe("banana"); 227 + }); 228 + }); 229 + 230 + describe("textarea elements", () => { 231 + it("binds signal to textarea value", () => { 232 + const container = document.createElement("div"); 233 + container.innerHTML = `<textarea data-volt-model="content"></textarea>`; 234 + 235 + const content = signal("Hello World"); 236 + mount(container, { content }); 237 + 238 + const textarea = container.querySelector("textarea")!; 239 + expect(textarea.value).toBe("Hello World"); 240 + }); 241 + 242 + it("updates textarea when signal changes", () => { 243 + const container = document.createElement("div"); 244 + container.innerHTML = `<textarea data-volt-model="notes"></textarea>`; 245 + 246 + const notes = signal("Initial"); 247 + mount(container, { notes }); 248 + 249 + const textarea = container.querySelector("textarea")!; 250 + expect(textarea.value).toBe("Initial"); 251 + 252 + notes.set("Updated"); 253 + expect(textarea.value).toBe("Updated"); 254 + }); 255 + 256 + it("updates signal when textarea changes", () => { 257 + const container = document.createElement("div"); 258 + container.innerHTML = `<textarea data-volt-model="message"></textarea>`; 259 + 260 + const message = signal(""); 261 + mount(container, { message }); 262 + 263 + const textarea = container.querySelector("textarea")!; 264 + textarea.value = "New content"; 265 + textarea.dispatchEvent(new Event("input")); 266 + 267 + expect(message.get()).toBe("New content"); 268 + }); 269 + }); 270 + }); 271 + 272 + describe("data-volt-bind:attr binding", () => { 273 + describe("boolean attributes", () => { 274 + it("binds disabled attribute", () => { 275 + const container = document.createElement("div"); 276 + container.innerHTML = `<button data-volt-bind:disabled="isDisabled">Click</button>`; 277 + 278 + const isDisabled = signal(true); 279 + mount(container, { isDisabled }); 280 + 281 + const button = container.querySelector("button")!; 282 + expect(button.hasAttribute("disabled")).toBe(true); 283 + 284 + isDisabled.set(false); 285 + expect(button.hasAttribute("disabled")).toBe(false); 286 + }); 287 + 288 + it("binds readonly attribute", () => { 289 + const container = document.createElement("div"); 290 + container.innerHTML = `<input data-volt-bind:readonly="locked" />`; 291 + 292 + const locked = signal(false); 293 + mount(container, { locked }); 294 + 295 + const input = container.querySelector("input")!; 296 + expect(input.hasAttribute("readonly")).toBe(false); 297 + 298 + locked.set(true); 299 + expect(input.hasAttribute("readonly")).toBe(true); 300 + }); 301 + 302 + it("binds checked attribute", () => { 303 + const container = document.createElement("div"); 304 + container.innerHTML = `<input type="checkbox" data-volt-bind:checked="isChecked" />`; 305 + 306 + const isChecked = signal(true); 307 + mount(container, { isChecked }); 308 + 309 + const input = container.querySelector("input")!; 310 + expect(input.hasAttribute("checked")).toBe(true); 311 + }); 312 + 313 + it("binds required attribute", () => { 314 + const container = document.createElement("div"); 315 + container.innerHTML = `<input data-volt-bind:required="mandatory" />`; 316 + 317 + const mandatory = signal(true); 318 + mount(container, { mandatory }); 319 + 320 + const input = container.querySelector("input")!; 321 + expect(input.hasAttribute("required")).toBe(true); 322 + }); 323 + }); 324 + 325 + describe("string attributes", () => { 326 + it("binds href attribute", () => { 327 + const container = document.createElement("div"); 328 + container.innerHTML = `<a data-volt-bind:href="url">Link</a>`; 329 + 330 + const url = signal("https://example.com"); 331 + mount(container, { url }); 332 + 333 + const link = container.querySelector("a")!; 334 + expect(link.getAttribute("href")).toBe("https://example.com"); 335 + 336 + url.set("https://volt.js.org"); 337 + expect(link.getAttribute("href")).toBe("https://volt.js.org"); 338 + }); 339 + 340 + it("binds src attribute", () => { 341 + const container = document.createElement("div"); 342 + container.innerHTML = `<img data-volt-bind:src="image" />`; 343 + 344 + const image = signal("/placeholder.png"); 345 + mount(container, { image }); 346 + 347 + const img = container.querySelector("img")!; 348 + expect(img.getAttribute("src")).toBe("/placeholder.png"); 349 + }); 350 + 351 + it("binds title attribute", () => { 352 + const container = document.createElement("div"); 353 + container.innerHTML = `<span data-volt-bind:title="tooltip">Hover me</span>`; 354 + 355 + const tooltip = signal("Help text"); 356 + mount(container, { tooltip }); 357 + 358 + const span = container.querySelector("span")!; 359 + expect(span.getAttribute("title")).toBe("Help text"); 360 + }); 361 + 362 + it("binds aria-label attribute", () => { 363 + const container = document.createElement("div"); 364 + container.innerHTML = `<button data-volt-bind:aria-label="label">Icon</button>`; 365 + 366 + const label = signal("Close"); 367 + mount(container, { label }); 368 + 369 + const button = container.querySelector("button")!; 370 + expect(button.getAttribute("aria-label")).toBe("Close"); 371 + }); 372 + }); 373 + 374 + describe("dynamic values", () => { 375 + it("updates attribute when expression changes", () => { 376 + const container = document.createElement("div"); 377 + container.innerHTML = `<a data-volt-bind:href="baseUrl">Link</a>`; 378 + 379 + const baseUrl = signal("/page1"); 380 + mount(container, { baseUrl }); 381 + 382 + const link = container.querySelector("a")!; 383 + expect(link.getAttribute("href")).toBe("/page1"); 384 + 385 + baseUrl.set("/page2"); 386 + expect(link.getAttribute("href")).toBe("/page2"); 387 + }); 388 + 389 + it("removes attribute when value is null/undefined/false", () => { 390 + const container = document.createElement("div"); 391 + container.innerHTML = `<div data-volt-bind:data-value="value">Content</div>`; 392 + 393 + const value = signal("present"); 394 + mount(container, { value }); 395 + 396 + const div = container.children[0] as HTMLElement; 397 + expect(div.dataset.value).toBe("present"); 398 + 399 + // @ts-expect-error incorrect type is intentional 400 + value.set(null); 401 + expect(Object.hasOwn(div.dataset, "value")).toBe(false); 402 + }); 403 + 404 + it("handles expressions", () => { 405 + const container = document.createElement("div"); 406 + container.innerHTML = `<button data-volt-bind:disabled="count > 5">Submit</button>`; 407 + 408 + const count = signal(3); 409 + mount(container, { count }); 410 + 411 + const button = container.querySelector("button")!; 412 + expect(button.hasAttribute("disabled")).toBe(false); 413 + 414 + count.set(10); 415 + expect(button.hasAttribute("disabled")).toBe(true); 416 + }); 417 + }); 418 + }); 419 + 420 + describe("data-volt-else binding", () => { 421 + it("shows else branch when if condition is false", () => { 422 + const container = document.createElement("div"); 423 + container.innerHTML = ` 424 + <div> 425 + <p data-volt-if="show">If content</p> 426 + <p data-volt-else>Else content</p> 427 + </div> 428 + `; 429 + 430 + const show = signal(false); 431 + mount(container, { show }); 432 + 433 + expect(container.textContent).toContain("Else content"); 434 + expect(container.textContent).not.toContain("If content"); 435 + }); 436 + 437 + it("shows if branch when condition is true", () => { 438 + const container = document.createElement("div"); 439 + container.innerHTML = ` 440 + <div> 441 + <p data-volt-if="show">If content</p> 442 + <p data-volt-else>Else content</p> 443 + </div> 444 + `; 445 + 446 + const show = signal(true); 447 + mount(container, { show }); 448 + 449 + expect(container.textContent).toContain("If content"); 450 + expect(container.textContent).not.toContain("Else content"); 451 + }); 452 + 453 + it("toggles between if and else branches", () => { 454 + const container = document.createElement("div"); 455 + container.innerHTML = ` 456 + <div> 457 + <span data-volt-if="visible">Visible</span> 458 + <span data-volt-else>Hidden</span> 459 + </div> 460 + `; 461 + 462 + const visible = signal(true); 463 + mount(container, { visible }); 464 + 465 + expect(container.textContent).toContain("Visible"); 466 + expect(container.textContent).not.toContain("Hidden"); 467 + 468 + visible.set(false); 469 + expect(container.textContent).not.toContain("Visible"); 470 + expect(container.textContent).toContain("Hidden"); 471 + 472 + visible.set(true); 473 + expect(container.textContent).toContain("Visible"); 474 + expect(container.textContent).not.toContain("Hidden"); 475 + }); 476 + 477 + it("supports bindings in else branch", () => { 478 + const container = document.createElement("div"); 479 + container.innerHTML = ` 480 + <div> 481 + <p data-volt-if="show" data-volt-text="ifMessage"></p> 482 + <p data-volt-else data-volt-text="elseMessage"></p> 483 + </div> 484 + `; 485 + 486 + const show = signal(false); 487 + const ifMessage = signal("If text"); 488 + const elseMessage = signal("Else text"); 489 + 490 + mount(container, { show, ifMessage, elseMessage }); 491 + 492 + expect(container.querySelector("p")?.textContent).toBe("Else text"); 493 + 494 + show.set(true); 495 + expect(container.querySelector("p")?.textContent).toBe("If text"); 496 + }); 497 + 498 + it("maintains separate state for each branch", () => { 499 + const container = document.createElement("div"); 500 + container.innerHTML = ` 501 + <div> 502 + <div data-volt-if="mode"> 503 + <span data-volt-text="ifValue"></span> 504 + </div> 505 + <div data-volt-else> 506 + <span data-volt-text="elseValue"></span> 507 + </div> 508 + </div> 509 + `; 510 + 511 + const mode = signal(true); 512 + const ifValue = signal("If"); 513 + const elseValue = signal("Else"); 514 + 515 + mount(container, { mode, ifValue, elseValue }); 516 + 517 + expect(container.querySelector("span")?.textContent).toBe("If"); 518 + 519 + mode.set(false); 520 + expect(container.querySelector("span")?.textContent).toBe("Else"); 521 + 522 + ifValue.set("Changed If"); 523 + mode.set(true); 524 + expect(container.querySelector("span")?.textContent).toBe("Changed If"); 525 + }); 526 + 527 + it("handles else without bindings", () => { 528 + const container = document.createElement("div"); 529 + container.innerHTML = ` 530 + <div> 531 + <p data-volt-if="condition">Condition true</p> 532 + <p data-volt-else>No condition</p> 533 + </div> 534 + `; 535 + 536 + const condition = signal(false); 537 + mount(container, { condition }); 538 + 539 + const paragraphs = container.querySelectorAll("p"); 540 + expect(paragraphs).toHaveLength(1); 541 + expect(paragraphs[0].textContent).toBe("No condition"); 542 + }); 543 + 544 + it("properly cleans up when switching branches", () => { 545 + const container = document.createElement("div"); 546 + container.innerHTML = ` 547 + <div> 548 + <div data-volt-if="branch"> 549 + <span data-volt-text="message">If</span> 550 + </div> 551 + <div data-volt-else> 552 + <span data-volt-text="message">Else</span> 553 + </div> 554 + </div> 555 + `; 556 + 557 + const branch = signal(true); 558 + const message = signal("Test"); 559 + 560 + const cleanup = mount(container, { branch, message }); 561 + 562 + expect(container.querySelector("span")?.textContent).toBe("Test"); 563 + 564 + message.set("Updated"); 565 + expect(container.querySelector("span")?.textContent).toBe("Updated"); 566 + 567 + branch.set(false); 568 + expect(container.querySelector("span")?.textContent).toBe("Updated"); 569 + 570 + cleanup(); 571 + 572 + message.set("After cleanup"); 573 + expect(container.querySelector("span")?.textContent).toBe("Updated"); 574 + }); 575 + });
+19 -19
test/integration/list-rendering.test.ts
··· 7 7 container.innerHTML = ` 8 8 <div> 9 9 <input id="new-todo" type="text" /> 10 - <button id="add-btn" data-x-on-click="addTodo">Add</button> 10 + <button id="add-btn" data-volt-on-click="addTodo">Add</button> 11 11 12 12 <ul id="todo-list"> 13 - <li data-x-for="todo in todos"> 14 - <input type="checkbox" data-x-on-click="toggleTodo" /> 15 - <span data-x-text="todo.text"></span> 16 - <button data-x-on-click="deleteTodo">Delete</button> 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 17 </li> 18 18 </ul> 19 19 20 - <div data-x-text="remaining">0</div> 20 + <div data-volt-text="remaining">0</div> 21 21 </div> 22 22 `; 23 23 ··· 67 67 expect(listItems[0]?.querySelector("span")?.textContent).toBe("Learn Volt.js"); 68 68 expect(listItems[1]?.querySelector("span")?.textContent).toBe("Build an app"); 69 69 70 - const remainingDiv = container.querySelector("div[data-x-text='remaining']"); 70 + const remainingDiv = container.querySelector("div[data-volt-text='remaining']"); 71 71 expect(remainingDiv?.textContent).toBe("2"); 72 72 73 73 const checkboxes = container.querySelectorAll("input[type='checkbox']"); ··· 76 76 77 77 expect(remainingDiv?.textContent).toBe("1"); 78 78 79 - const deleteButtons = container.querySelectorAll("button[data-x-on-click='deleteTodo']"); 79 + const deleteButtons = container.querySelectorAll("button[data-volt-on-click='deleteTodo']"); 80 80 deleteButtons[1]?.dispatchEvent(new Event("click", { bubbles: true })); 81 81 82 82 const updatedListItems = container.querySelectorAll("#todo-list li"); ··· 90 90 <div> 91 91 <div id="all-items"> 92 92 <h3>All Items</h3> 93 - <div data-x-for="item in allItems" data-x-text="item.name"></div> 93 + <div data-volt-for="item in allItems" data-volt-text="item.name"></div> 94 94 </div> 95 95 96 96 <div id="active-items"> 97 97 <h3>Active Items</h3> 98 - <div data-x-for="item in activeItems" data-x-text="item.name"></div> 98 + <div data-volt-for="item in activeItems" data-volt-text="item.name"></div> 99 99 </div> 100 100 </div> 101 101 `; ··· 109 109 110 110 mount(container, { allItems: items, activeItems }); 111 111 112 - const allItemDivs = container.querySelectorAll("#all-items > div[data-x-for]"); 113 - const activeItemDivs = container.querySelectorAll("#active-items > div[data-x-for]"); 112 + const allItemDivs = container.querySelectorAll("#all-items > div[data-volt-for]"); 113 + const activeItemDivs = container.querySelectorAll("#active-items > div[data-volt-for]"); 114 114 115 115 expect(allItemDivs.length).toBe(0); 116 116 expect(activeItemDivs.length).toBe(0); 117 117 118 - const renderedAll = container.querySelectorAll("#all-items div[data-x-text]"); 119 - const renderedActive = container.querySelectorAll("#active-items div[data-x-text]"); 118 + const renderedAll = container.querySelectorAll("#all-items div[data-volt-text]"); 119 + const renderedActive = container.querySelectorAll("#active-items div[data-volt-text]"); 120 120 121 121 expect(renderedAll.length).toBe(3); 122 122 expect(renderedActive.length).toBe(2); ··· 125 125 126 126 items.set([{ name: "Item 1", active: false }, { name: "Item 2", active: true }, { name: "Item 3", active: true }]); 127 127 128 - const updatedActive = container.querySelectorAll("#active-items div[data-x-text]"); 128 + const updatedActive = container.querySelectorAll("#active-items div[data-volt-text]"); 129 129 expect(updatedActive.length).toBe(2); 130 130 expect(updatedActive[0]?.textContent).toBe("Item 2"); 131 131 expect(updatedActive[1]?.textContent).toBe("Item 3"); ··· 135 135 const container = document.createElement("div"); 136 136 container.innerHTML = ` 137 137 <div> 138 - <div data-x-for="category in categories"> 139 - <h2 data-x-text="category.name"></h2> 138 + <div data-volt-for="category in categories"> 139 + <h2 data-volt-text="category.name"></h2> 140 140 <ul> 141 - <li data-x-for="product in category.products"> 142 - <span data-x-text="product.name"></span>: $<span data-x-text="product.price"></span> 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 143 </li> 144 144 </ul> 145 145 </div>
+14 -14
test/integration/mount.test.ts
··· 6 6 const container = document.createElement("div"); 7 7 container.innerHTML = ` 8 8 <div> 9 - <p data-x-text="count">0</p> 10 - <p data-x-class="countClass">Classes</p> 9 + <p data-volt-text="count">0</p> 10 + <p data-volt-class="countClass">Classes</p> 11 11 </div> 12 12 `; 13 13 ··· 34 34 it("handles complex nested structures", () => { 35 35 const container = document.createElement("div"); 36 36 container.innerHTML = ` 37 - <div data-x-text="title">Title</div> 37 + <div data-volt-text="title">Title</div> 38 38 <ul> 39 - <li data-x-text="items.first">First</li> 40 - <li data-x-text="items.second">Second</li> 39 + <li data-volt-text="items.first">First</li> 40 + <li data-volt-text="items.second">Second</li> 41 41 </ul> 42 - <footer data-x-html="footer">Footer</footer> 42 + <footer data-volt-html="footer">Footer</footer> 43 43 `; 44 44 45 45 const title = signal("My App"); ··· 48 48 49 49 mount(container, { title, items, footer }); 50 50 51 - expect(container.querySelector("[data-x-text='title']")?.textContent).toBe("My App"); 51 + expect(container.querySelector("[data-volt-text='title']")?.textContent).toBe("My App"); 52 52 expect(container.querySelector("li:first-child")?.textContent).toBe("Item 1"); 53 53 expect(container.querySelector("li:last-child")?.textContent).toBe("Item 2"); 54 54 expect(container.querySelector("footer")?.innerHTML).toBe("<strong>© 2025</strong>"); ··· 56 56 title.set("Updated App"); 57 57 footer.set("<em>New Footer</em>"); 58 58 59 - expect(container.querySelector("[data-x-text='title']")?.textContent).toBe("Updated App"); 59 + expect(container.querySelector("[data-volt-text='title']")?.textContent).toBe("Updated App"); 60 60 expect(container.querySelector("footer")?.innerHTML).toBe("<em>New Footer</em>"); 61 61 }); 62 62 63 63 it("properly cleans up all bindings", () => { 64 64 const container = document.createElement("div"); 65 65 container.innerHTML = ` 66 - <div data-x-text="a">A</div> 67 - <div data-x-text="b">B</div> 68 - <div data-x-text="c">C</div> 66 + <div data-volt-text="a">A</div> 67 + <div data-volt-text="b">B</div> 68 + <div data-volt-text="c">C</div> 69 69 `; 70 70 71 71 const a = signal("A"); ··· 101 101 it("supports mixed static and reactive values", () => { 102 102 const container = document.createElement("div"); 103 103 container.innerHTML = ` 104 - <h1 data-x-text="staticTitle">Title</h1> 105 - <p data-x-text="dynamicContent">Content</p> 106 - <span data-x-class="'always-visible'">Visible</span> 104 + <h1 data-volt-text="staticTitle">Title</h1> 105 + <p data-volt-text="dynamicContent">Content</p> 106 + <span data-volt-class="'always-visible'">Visible</span> 107 107 `; 108 108 109 109 const staticTitle = "Welcome";
+15 -15
test/integration/plugins.test.ts
··· 1 + import { mount } from "@volt/core/binder"; 2 + import { clearPlugins, registerPlugin } from "@volt/core/plugin"; 3 + import { signal } from "@volt/core/signal"; 1 4 import { beforeEach, describe, expect, it, vi } from "vitest"; 2 - import { mount } from "../../src/core/binder"; 3 - import { clearPlugins, registerPlugin } from "../../src/core/plugin"; 4 - import { signal } from "../../src/core/signal"; 5 5 6 6 describe("plugin integration with binder", () => { 7 7 beforeEach(() => { ··· 13 13 registerPlugin("custom", pluginHandler); 14 14 15 15 const element = document.createElement("div"); 16 - element.dataset.xCustom = "testValue"; 16 + element.dataset.voltCustom = "testValue"; 17 17 18 18 const scope = { test: "value" }; 19 19 mount(element, scope); ··· 34 34 it("warns when unknown binding is used without plugin", () => { 35 35 const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); 36 36 const element = document.createElement("div"); 37 - element.dataset.xUnknown = "value"; 37 + element.dataset.voltUnknown = "value"; 38 38 39 39 mount(element, {}); 40 40 41 - expect(warnSpy).toHaveBeenCalledWith("Unknown binding: data-x-unknown"); 41 + expect(warnSpy).toHaveBeenCalledWith("Unknown binding: data-volt-unknown"); 42 42 43 43 warnSpy.mockRestore(); 44 44 }); ··· 50 50 }); 51 51 52 52 const element = document.createElement("div"); 53 - element.dataset.xFinder = "test"; 53 + element.dataset.voltFinder = "test"; 54 54 55 55 const count = signal(42); 56 56 mount(element, { count }); ··· 65 65 }); 66 66 67 67 const element = document.createElement("div"); 68 - element.dataset.xEvaluator = "count"; 68 + element.dataset.voltEvaluator = "count"; 69 69 70 70 const count = signal(100); 71 71 mount(element, { count }); ··· 80 80 }); 81 81 82 82 const element = document.createElement("div"); 83 - element.dataset.xCleaner = "test"; 83 + element.dataset.voltCleaner = "test"; 84 84 85 85 const unmount = mount(element, {}); 86 86 ··· 99 99 registerPlugin("plugin2", plugin2); 100 100 101 101 const element = document.createElement("div"); 102 - element.dataset.xPlugin1 = "value1"; 103 - element.dataset.xPlugin2 = "value2"; 102 + element.dataset.voltPlugin1 = "value1"; 103 + element.dataset.voltPlugin2 = "value2"; 104 104 105 105 mount(element, {}); 106 106 ··· 113 113 registerPlugin("custom", pluginHandler); 114 114 115 115 const element = document.createElement("div"); 116 - element.dataset.xText = "message"; 117 - element.dataset.xCustom = "customValue"; 116 + element.dataset.voltText = "message"; 117 + element.dataset.voltCustom = "customValue"; 118 118 119 119 const scope = { message: "Hello" }; 120 120 mount(element, scope); ··· 132 132 registerPlugin("bad", badPlugin); 133 133 134 134 const element = document.createElement("div"); 135 - element.dataset.xBad = "value"; 135 + element.dataset.voltBad = "value"; 136 136 137 137 mount(element, {}); 138 138 ··· 155 155 }); 156 156 157 157 const element = document.createElement("div"); 158 - element.dataset.xReactive = "count"; 158 + element.dataset.voltReactive = "count"; 159 159 160 160 const count = signal(1); 161 161 mount(element, { count });
+23 -29
test/plugins/persist.test.ts
··· 1 + import { mount } from "@volt/core/binder"; 2 + import { registerPlugin } from "@volt/core/plugin"; 3 + import { signal } from "@volt/core/signal"; 4 + import { persistPlugin, registerStorageAdapter } from "@volt/plugins/persist"; 1 5 import { beforeEach, describe, expect, it, vi } from "vitest"; 2 - import { mount } from "../../src/core/binder"; 3 - import { registerPlugin } from "../../src/core/plugin"; 4 - import { signal } from "../../src/core/signal"; 5 - import { persistPlugin, registerStorageAdapter } from "../../src/plugins/persist"; 6 6 7 7 describe("persist plugin", () => { 8 8 beforeEach(() => { ··· 16 16 localStorage.setItem("volt:count", "42"); 17 17 18 18 const element = document.createElement("div"); 19 - element.dataset.xPersist = "count:local"; 19 + element.dataset.voltPersist = "count:local"; 20 20 21 21 const count = signal(0); 22 22 mount(element, { count }); ··· 26 26 27 27 it("saves signal value to localStorage on change", async () => { 28 28 const element = document.createElement("div"); 29 - element.dataset.xPersist = "count:local"; 29 + element.dataset.voltPersist = "count:local"; 30 30 31 31 const count = signal(0); 32 32 mount(element, { count }); ··· 40 40 41 41 it("persists string values", async () => { 42 42 const element = document.createElement("div"); 43 - element.dataset.xPersist = "name:local"; 43 + element.dataset.voltPersist = "name:local"; 44 44 45 45 const name = signal("Alice"); 46 46 mount(element, { name }); ··· 49 49 50 50 await new Promise((resolve) => setTimeout(resolve, 0)); 51 51 52 - expect(localStorage.getItem("volt:name")).toBe('"Bob"'); 52 + expect(localStorage.getItem("volt:name")).toBe("\"Bob\""); 53 53 }); 54 54 55 55 it("persists object values", async () => { 56 56 const element = document.createElement("div"); 57 - element.dataset.xPersist = "user:local"; 57 + element.dataset.voltPersist = "user:local"; 58 58 59 59 const user = signal({ name: "Alice", age: 30 }); 60 60 mount(element, { user }); ··· 64 64 await new Promise((resolve) => setTimeout(resolve, 0)); 65 65 66 66 const stored = localStorage.getItem("volt:user"); 67 - expect(stored).toBe('{"name":"Bob","age":35}'); 67 + expect(stored).toBe("{\"name\":\"Bob\",\"age\":35}"); 68 68 }); 69 69 70 70 it("does not override signal if localStorage is empty", () => { 71 71 const element = document.createElement("div"); 72 - element.dataset.xPersist = "count:local"; 72 + element.dataset.voltPersist = "count:local"; 73 73 74 74 const count = signal(100); 75 75 mount(element, { count }); ··· 83 83 sessionStorage.setItem("volt:sessionData", "123"); 84 84 85 85 const element = document.createElement("div"); 86 - element.dataset.xPersist = "sessionData:session"; 86 + element.dataset.voltPersist = "sessionData:session"; 87 87 88 88 const sessionData = signal(0); 89 89 mount(element, { sessionData }); ··· 93 93 94 94 it("saves signal value to sessionStorage on change", async () => { 95 95 const element = document.createElement("div"); 96 - element.dataset.xPersist = "sessionData:session"; 96 + element.dataset.voltPersist = "sessionData:session"; 97 97 98 98 const sessionData = signal(0); 99 99 mount(element, { sessionData }); ··· 122 122 customStore.set("volt:data", 999); 123 123 124 124 const element = document.createElement("div"); 125 - element.dataset.xPersist = "data:custom"; 125 + element.dataset.voltPersist = "data:custom"; 126 126 127 127 const data = signal(0); 128 128 mount(element, { data }); ··· 156 156 customStore.set("volt:asyncData", 888); 157 157 158 158 const element = document.createElement("div"); 159 - element.dataset.xPersist = "asyncData:async"; 159 + element.dataset.voltPersist = "asyncData:async"; 160 160 161 161 const asyncData = signal(0); 162 162 mount(element, { asyncData }); ··· 171 171 it("logs error for invalid binding format", () => { 172 172 const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); 173 173 const element = document.createElement("div"); 174 - element.dataset.xPersist = "invalidformat"; 174 + element.dataset.voltPersist = "invalidformat"; 175 175 176 176 mount(element, {}); 177 177 178 - expect(errorSpy).toHaveBeenCalledWith( 179 - expect.stringContaining("Invalid persist binding"), 180 - ); 178 + expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining("Invalid persist binding")); 181 179 182 180 errorSpy.mockRestore(); 183 181 }); ··· 185 183 it("logs error when signal not found", () => { 186 184 const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); 187 185 const element = document.createElement("div"); 188 - element.dataset.xPersist = "nonexistent:local"; 186 + element.dataset.voltPersist = "nonexistent:local"; 189 187 190 188 mount(element, {}); 191 189 192 - expect(errorSpy).toHaveBeenCalledWith( 193 - expect.stringContaining('Signal "nonexistent" not found'), 194 - ); 190 + expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining("Signal \"nonexistent\" not found")); 195 191 196 192 errorSpy.mockRestore(); 197 193 }); ··· 199 195 it("logs error for unknown storage type", () => { 200 196 const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); 201 197 const element = document.createElement("div"); 202 - element.dataset.xPersist = "data:unknown"; 198 + element.dataset.voltPersist = "data:unknown"; 203 199 204 200 const data = signal(0); 205 201 mount(element, { data }); 206 202 207 - expect(errorSpy).toHaveBeenCalledWith( 208 - expect.stringContaining('Unknown storage type: "unknown"'), 209 - ); 203 + expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining("Unknown storage type: \"unknown\"")); 210 204 211 205 errorSpy.mockRestore(); 212 206 }); ··· 225 219 }); 226 220 227 221 const element = document.createElement("div"); 228 - element.dataset.xPersist = "data:faulty"; 222 + element.dataset.voltPersist = "data:faulty"; 229 223 230 224 const data = signal(0); 231 225 mount(element, { data }); ··· 247 241 describe("cleanup", () => { 248 242 it("stops persisting after unmount", async () => { 249 243 const element = document.createElement("div"); 250 - element.dataset.xPersist = "count:local"; 244 + element.dataset.voltPersist = "count:local"; 251 245 252 246 const count = signal(0); 253 247 const cleanup = mount(element, { count });
+37 -74
test/plugins/scroll.test.ts
··· 1 + import { mount } from "@volt/core/binder"; 2 + import { registerPlugin } from "@volt/core/plugin"; 3 + import { signal } from "@volt/core/signal"; 4 + import { scrollPlugin } from "@volt/plugins/scroll"; 1 5 import { beforeEach, describe, expect, it, vi } from "vitest"; 2 - import { mount } from "../../src/core/binder"; 3 - import { registerPlugin } from "../../src/core/plugin"; 4 - import { signal } from "../../src/core/signal"; 5 - import { scrollPlugin } from "../../src/plugins/scroll"; 6 6 7 7 describe("scroll plugin", () => { 8 8 beforeEach(() => { ··· 12 12 describe("restore mode", () => { 13 13 it("restores scroll position from signal on mount", () => { 14 14 const element = document.createElement("div"); 15 - element.dataset.xScroll = "restore:scrollPos"; 16 - Object.defineProperty(element, "scrollTop", { 17 - writable: true, 18 - value: 0, 19 - }); 15 + element.dataset.voltScroll = "restore:scrollPos"; 16 + Object.defineProperty(element, "scrollTop", { writable: true, value: 0 }); 20 17 21 18 const scrollPos = signal(250); 22 19 mount(element, { scrollPos }); ··· 26 23 27 24 it("saves scroll position to signal on scroll", () => { 28 25 const element = document.createElement("div"); 29 - element.dataset.xScroll = "restore:scrollPos"; 26 + element.dataset.voltScroll = "restore:scrollPos"; 30 27 31 28 const scrollPos = signal(0); 32 29 mount(element, { scrollPos }); 33 30 34 - Object.defineProperty(element, "scrollTop", { 35 - writable: true, 36 - value: 100, 37 - }); 31 + Object.defineProperty(element, "scrollTop", { writable: true, value: 100 }); 38 32 39 33 element.dispatchEvent(new Event("scroll")); 40 34 ··· 43 37 44 38 it("does not restore if signal value is not a number", () => { 45 39 const element = document.createElement("div"); 46 - element.dataset.xScroll = "restore:scrollPos"; 47 - Object.defineProperty(element, "scrollTop", { 48 - writable: true, 49 - value: 0, 50 - }); 40 + element.dataset.voltScroll = "restore:scrollPos"; 41 + Object.defineProperty(element, "scrollTop", { writable: true, value: 0 }); 51 42 52 43 const scrollPos = signal("not a number" as unknown as number); 53 44 mount(element, { scrollPos }); ··· 57 48 58 49 it("cleans up scroll listener on unmount", () => { 59 50 const element = document.createElement("div"); 60 - element.dataset.xScroll = "restore:scrollPos"; 51 + element.dataset.voltScroll = "restore:scrollPos"; 61 52 62 53 const scrollPos = signal(0); 63 54 const cleanup = mount(element, { scrollPos }); 64 55 65 - Object.defineProperty(element, "scrollTop", { 66 - writable: true, 67 - value: 100, 68 - }); 56 + Object.defineProperty(element, "scrollTop", { writable: true, value: 100 }); 69 57 element.dispatchEvent(new Event("scroll")); 70 58 expect(scrollPos.get()).toBe(100); 71 59 72 60 cleanup(); 73 61 74 - Object.defineProperty(element, "scrollTop", { 75 - writable: true, 76 - value: 200, 77 - }); 62 + Object.defineProperty(element, "scrollTop", { writable: true, value: 200 }); 78 63 element.dispatchEvent(new Event("scroll")); 79 64 expect(scrollPos.get()).toBe(100); 80 65 }); ··· 84 69 it("scrolls to element when signal matches element ID", () => { 85 70 const element = document.createElement("div"); 86 71 element.id = "section1"; 87 - element.dataset.xScroll = "scrollTo:targetId"; 72 + element.dataset.voltScroll = "scrollTo:targetId"; 88 73 89 74 const scrollIntoViewMock = vi.fn(); 90 75 element.scrollIntoView = scrollIntoViewMock; ··· 94 79 95 80 targetId.set("section1"); 96 81 97 - expect(scrollIntoViewMock).toHaveBeenCalledWith({ 98 - behavior: "smooth", 99 - block: "start", 100 - }); 82 + expect(scrollIntoViewMock).toHaveBeenCalledWith({ behavior: "smooth", block: "start" }); 101 83 }); 102 84 103 85 it("scrolls to element when signal matches #elementId format", () => { 104 86 const element = document.createElement("div"); 105 87 element.id = "section2"; 106 - element.dataset.xScroll = "scrollTo:targetId"; 88 + element.dataset.voltScroll = "scrollTo:targetId"; 107 89 108 90 const scrollIntoViewMock = vi.fn(); 109 91 element.scrollIntoView = scrollIntoViewMock; ··· 113 95 114 96 targetId.set("#section2"); 115 97 116 - expect(scrollIntoViewMock).toHaveBeenCalledWith({ 117 - behavior: "smooth", 118 - block: "start", 119 - }); 98 + expect(scrollIntoViewMock).toHaveBeenCalledWith({ behavior: "smooth", block: "start" }); 120 99 }); 121 100 122 101 it("does not scroll if signal does not match element ID", () => { 123 102 const element = document.createElement("div"); 124 103 element.id = "section1"; 125 - element.dataset.xScroll = "scrollTo:targetId"; 104 + element.dataset.voltScroll = "scrollTo:targetId"; 126 105 127 106 const scrollIntoViewMock = vi.fn(); 128 107 element.scrollIntoView = scrollIntoViewMock; ··· 136 115 it("scrolls on initial mount if signal already matches", () => { 137 116 const element = document.createElement("div"); 138 117 element.id = "section1"; 139 - element.dataset.xScroll = "scrollTo:targetId"; 118 + element.dataset.voltScroll = "scrollTo:targetId"; 140 119 141 120 const scrollIntoViewMock = vi.fn(); 142 121 element.scrollIntoView = scrollIntoViewMock; ··· 151 130 describe("spy mode", () => { 152 131 it("updates signal when element enters viewport", () => { 153 132 const element = document.createElement("div"); 154 - element.dataset.xScroll = "spy:isVisible"; 133 + element.dataset.voltScroll = "spy:isVisible"; 155 134 156 135 const isVisible = signal(false); 157 136 ··· 166 145 thresholds: [], 167 146 }; 168 147 169 - (window as typeof globalThis).IntersectionObserver = vi.fn((callback) => { 148 + (globalThis as typeof globalThis).IntersectionObserver = vi.fn((callback) => { 170 149 observerCallback = callback; 171 150 return mockObserver; 172 151 }) as unknown as typeof IntersectionObserver; ··· 176 155 expect(mockObserver.observe).toHaveBeenCalledWith(element); 177 156 178 157 observerCallback( 179 - [ 180 - { 181 - isIntersecting: true, 182 - target: element, 183 - } as unknown as IntersectionObserverEntry, 184 - ], 158 + [{ isIntersecting: true, target: element } as unknown as IntersectionObserverEntry], 185 159 mockObserver as IntersectionObserver, 186 160 ); 187 161 188 162 expect(isVisible.get()).toBe(true); 189 163 190 164 observerCallback( 191 - [ 192 - { 193 - isIntersecting: false, 194 - target: element, 195 - } as unknown as IntersectionObserverEntry, 196 - ], 165 + [{ isIntersecting: false, target: element } as unknown as IntersectionObserverEntry], 197 166 mockObserver as IntersectionObserver, 198 167 ); 199 168 ··· 202 171 203 172 it("disconnects observer on cleanup", () => { 204 173 const element = document.createElement("div"); 205 - element.dataset.xScroll = "spy:isVisible"; 174 + element.dataset.voltScroll = "spy:isVisible"; 206 175 207 176 const isVisible = signal(false); 208 177 ··· 216 185 thresholds: [], 217 186 }; 218 187 219 - (window as typeof globalThis).IntersectionObserver = vi.fn(() => { 188 + (globalThis as typeof globalThis).IntersectionObserver = vi.fn(() => { 220 189 return mockObserver; 221 190 }) as unknown as typeof IntersectionObserver; 222 191 ··· 231 200 describe("smooth mode", () => { 232 201 it("applies smooth scroll behavior when signal is true", () => { 233 202 const element = document.createElement("div"); 234 - element.dataset.xScroll = "smooth:smoothScroll"; 203 + element.dataset.voltScroll = "smooth:smoothScroll"; 235 204 236 205 const smoothScroll = signal(true); 237 206 mount(element, { smoothScroll }); ··· 241 210 242 211 it("applies smooth scroll behavior when signal is 'smooth'", () => { 243 212 const element = document.createElement("div"); 244 - element.dataset.xScroll = "smooth:smoothScroll"; 213 + element.dataset.voltScroll = "smooth:smoothScroll"; 245 214 246 215 const smoothScroll = signal("smooth"); 247 216 mount(element, { smoothScroll }); ··· 251 220 252 221 it("applies auto scroll behavior when signal is false", () => { 253 222 const element = document.createElement("div"); 254 - element.dataset.xScroll = "smooth:smoothScroll"; 223 + element.dataset.voltScroll = "smooth:smoothScroll"; 255 224 256 225 const smoothScroll = signal(false); 257 226 mount(element, { smoothScroll }); ··· 261 230 262 231 it("applies auto scroll behavior when signal is 'auto'", () => { 263 232 const element = document.createElement("div"); 264 - element.dataset.xScroll = "smooth:smoothScroll"; 233 + element.dataset.voltScroll = "smooth:smoothScroll"; 265 234 266 235 const smoothScroll = signal("auto"); 267 236 mount(element, { smoothScroll }); ··· 271 240 272 241 it("updates scroll behavior when signal changes", () => { 273 242 const element = document.createElement("div"); 274 - element.dataset.xScroll = "smooth:smoothScroll"; 243 + element.dataset.voltScroll = "smooth:smoothScroll"; 275 244 276 245 const smoothScroll = signal(false); 277 246 mount(element, { smoothScroll }); ··· 287 256 288 257 it("resets scroll behavior on cleanup", () => { 289 258 const element = document.createElement("div"); 290 - element.dataset.xScroll = "smooth:smoothScroll"; 259 + element.dataset.voltScroll = "smooth:smoothScroll"; 291 260 292 261 const smoothScroll = signal(true); 293 262 const cleanup = mount(element, { smoothScroll }); ··· 304 273 it("logs error for invalid binding format", () => { 305 274 const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); 306 275 const element = document.createElement("div"); 307 - element.dataset.xScroll = "invalidformat"; 276 + element.dataset.voltScroll = "invalidformat"; 308 277 309 278 mount(element, {}); 310 279 311 - expect(errorSpy).toHaveBeenCalledWith( 312 - expect.stringContaining("Invalid scroll binding"), 313 - ); 280 + expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining("Invalid scroll binding")); 314 281 315 282 errorSpy.mockRestore(); 316 283 }); ··· 318 285 it("logs error for unknown scroll mode", () => { 319 286 const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); 320 287 const element = document.createElement("div"); 321 - element.dataset.xScroll = "unknown:signal"; 288 + element.dataset.voltScroll = "unknown:signal"; 322 289 323 290 mount(element, {}); 324 291 325 - expect(errorSpy).toHaveBeenCalledWith( 326 - expect.stringContaining('Unknown scroll mode: "unknown"'), 327 - ); 292 + expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining("Unknown scroll mode: \"unknown\"")); 328 293 329 294 errorSpy.mockRestore(); 330 295 }); ··· 332 297 it("logs error when signal not found", () => { 333 298 const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); 334 299 const element = document.createElement("div"); 335 - element.dataset.xScroll = "restore:nonexistent"; 300 + element.dataset.voltScroll = "restore:nonexistent"; 336 301 337 302 mount(element, {}); 338 303 339 - expect(errorSpy).toHaveBeenCalledWith( 340 - expect.stringContaining('Signal "nonexistent" not found'), 341 - ); 304 + expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining("Signal \"nonexistent\" not found")); 342 305 343 306 errorSpy.mockRestore(); 344 307 });
+60 -66
test/plugins/url.test.ts
··· 1 + import { mount } from "@volt/core/binder"; 2 + import { registerPlugin } from "@volt/core/plugin"; 3 + import { signal } from "@volt/core/signal"; 4 + import { urlPlugin } from "@volt/plugins/url"; 1 5 import { beforeEach, describe, expect, it, vi } from "vitest"; 2 - import { mount } from "../../src/core/binder"; 3 - import { registerPlugin } from "../../src/core/plugin"; 4 - import { signal } from "../../src/core/signal"; 5 - import { urlPlugin } from "../../src/plugins/url"; 6 6 7 7 describe("url plugin", () => { 8 8 beforeEach(() => { 9 9 registerPlugin("url", urlPlugin); 10 - window.history.replaceState({}, "", "/"); 10 + globalThis.history.replaceState({}, "", "/"); 11 11 }); 12 12 13 13 describe("read mode", () => { 14 14 it("reads URL parameter into signal on mount", () => { 15 - window.history.replaceState({}, "", "/?tab=profile"); 15 + globalThis.history.replaceState({}, "", "/?tab=profile"); 16 16 17 17 const element = document.createElement("div"); 18 - element.dataset.xUrl = "read:tab"; 18 + element.dataset.voltUrl = "read:tab"; 19 19 20 20 const tab = signal(""); 21 21 mount(element, { tab }); ··· 24 24 }); 25 25 26 26 it("does not update URL when signal changes", async () => { 27 - window.history.replaceState({}, "", "/?tab=home"); 27 + globalThis.history.replaceState({}, "", "/?tab=home"); 28 28 29 29 const element = document.createElement("div"); 30 - element.dataset.xUrl = "read:tab"; 30 + element.dataset.voltUrl = "read:tab"; 31 31 32 32 const tab = signal(""); 33 33 mount(element, { tab }); ··· 36 36 37 37 await new Promise((resolve) => setTimeout(resolve, 200)); 38 38 39 - expect(window.location.search).toBe("?tab=home"); 39 + expect(globalThis.location.search).toBe("?tab=home"); 40 40 }); 41 41 42 42 it("handles missing URL parameter", () => { 43 - window.history.replaceState({}, "", "/"); 43 + globalThis.history.replaceState({}, "", "/"); 44 44 45 45 const element = document.createElement("div"); 46 - element.dataset.xUrl = "read:missing"; 46 + element.dataset.voltUrl = "read:missing"; 47 47 48 48 const missing = signal("default"); 49 49 mount(element, { missing }); ··· 52 52 }); 53 53 54 54 it("deserializes boolean values", () => { 55 - window.history.replaceState({}, "", "/?active=true"); 55 + globalThis.history.replaceState({}, "", "/?active=true"); 56 56 57 57 const element = document.createElement("div"); 58 - element.dataset.xUrl = "read:active"; 58 + element.dataset.voltUrl = "read:active"; 59 59 60 60 const active = signal(false); 61 61 mount(element, { active }); ··· 64 64 }); 65 65 66 66 it("deserializes number values", () => { 67 - window.history.replaceState({}, "", "/?count=42"); 67 + globalThis.history.replaceState({}, "", "/?count=42"); 68 68 69 69 const element = document.createElement("div"); 70 - element.dataset.xUrl = "read:count"; 70 + element.dataset.voltUrl = "read:count"; 71 71 72 72 const count = signal(0); 73 73 mount(element, { count }); ··· 78 78 79 79 describe("sync mode", () => { 80 80 it("reads URL parameter into signal on mount", () => { 81 - window.history.replaceState({}, "", "/?filter=active"); 81 + globalThis.history.replaceState({}, "", "/?filter=active"); 82 82 83 83 const element = document.createElement("div"); 84 - element.dataset.xUrl = "sync:filter"; 84 + element.dataset.voltUrl = "sync:filter"; 85 85 86 86 const filter = signal(""); 87 87 mount(element, { filter }); ··· 90 90 }); 91 91 92 92 it("updates URL when signal changes", async () => { 93 - window.history.replaceState({}, "", "/"); 93 + globalThis.history.replaceState({}, "", "/"); 94 94 95 95 const element = document.createElement("div"); 96 - element.dataset.xUrl = "sync:query"; 96 + element.dataset.voltUrl = "sync:query"; 97 97 98 98 const query = signal(""); 99 99 mount(element, { query }); ··· 102 102 103 103 await new Promise((resolve) => setTimeout(resolve, 150)); 104 104 105 - expect(window.location.search).toContain("query=search+term"); 105 + expect(globalThis.location.search).toContain("query=search+term"); 106 106 }); 107 107 108 108 it("removes parameter from URL when signal is empty", async () => { 109 - window.history.replaceState({}, "", "/?query=test"); 109 + globalThis.history.replaceState({}, "", "/?query=test"); 110 110 111 111 const element = document.createElement("div"); 112 - element.dataset.xUrl = "sync:query"; 112 + element.dataset.voltUrl = "sync:query"; 113 113 114 114 const query = signal(""); 115 115 mount(element, { query }); ··· 118 118 119 119 await new Promise((resolve) => setTimeout(resolve, 150)); 120 120 121 - expect(window.location.search).toBe(""); 121 + expect(globalThis.location.search).toBe(""); 122 122 }); 123 123 124 124 it("handles popstate events from browser navigation", () => { 125 - window.history.replaceState({}, "", "/?filter=all"); 125 + globalThis.history.replaceState({}, "", "/?filter=all"); 126 126 127 127 const element = document.createElement("div"); 128 - element.dataset.xUrl = "sync:filter"; 128 + element.dataset.voltUrl = "sync:filter"; 129 129 130 130 const filter = signal(""); 131 131 mount(element, { filter }); 132 132 133 133 expect(filter.get()).toBe("all"); 134 134 135 - window.history.replaceState({}, "", "/?filter=completed"); 136 - window.dispatchEvent(new PopStateEvent("popstate")); 135 + globalThis.history.replaceState({}, "", "/?filter=completed"); 136 + globalThis.dispatchEvent(new PopStateEvent("popstate")); 137 137 138 138 expect(filter.get()).toBe("completed"); 139 139 }); 140 140 141 141 it("sets signal to empty string when parameter removed from URL", () => { 142 - window.history.replaceState({}, "", "/?filter=test"); 142 + globalThis.history.replaceState({}, "", "/?filter=test"); 143 143 144 144 const element = document.createElement("div"); 145 - element.dataset.xUrl = "sync:filter"; 145 + element.dataset.voltUrl = "sync:filter"; 146 146 147 147 const filter = signal(""); 148 148 mount(element, { filter }); 149 149 150 150 expect(filter.get()).toBe("test"); 151 151 152 - window.history.replaceState({}, "", "/"); 153 - window.dispatchEvent(new PopStateEvent("popstate")); 152 + globalThis.history.replaceState({}, "", "/"); 153 + globalThis.dispatchEvent(new PopStateEvent("popstate")); 154 154 155 155 expect(filter.get()).toBe(""); 156 156 }); 157 157 158 158 it("debounces URL updates", async () => { 159 - const pushStateSpy = vi.spyOn(window.history, "pushState"); 159 + const pushStateSpy = vi.spyOn(globalThis.history, "pushState"); 160 160 161 161 const element = document.createElement("div"); 162 - element.dataset.xUrl = "sync:query"; 162 + element.dataset.voltUrl = "sync:query"; 163 163 164 164 const query = signal(""); 165 165 mount(element, { query }); ··· 178 178 }); 179 179 180 180 it("cleans up popstate listener on unmount", () => { 181 - window.history.replaceState({}, "", "/?filter=test"); 181 + globalThis.history.replaceState({}, "", "/?filter=test"); 182 182 183 183 const element = document.createElement("div"); 184 - element.dataset.xUrl = "sync:filter"; 184 + element.dataset.voltUrl = "sync:filter"; 185 185 186 186 const filter = signal(""); 187 187 const cleanup = mount(element, { filter }); ··· 190 190 191 191 cleanup(); 192 192 193 - window.history.replaceState({}, "", "/?filter=other"); 194 - window.dispatchEvent(new PopStateEvent("popstate")); 193 + globalThis.history.replaceState({}, "", "/?filter=other"); 194 + globalThis.dispatchEvent(new PopStateEvent("popstate")); 195 195 196 196 expect(filter.get()).toBe("test"); 197 197 }); ··· 199 199 200 200 describe("hash mode", () => { 201 201 it("reads hash into signal on mount", () => { 202 - window.location.hash = "#/about"; 202 + globalThis.location.hash = "#/about"; 203 203 204 204 const element = document.createElement("div"); 205 - element.dataset.xUrl = "hash:route"; 205 + element.dataset.voltUrl = "hash:route"; 206 206 207 207 const route = signal(""); 208 208 mount(element, { route }); ··· 211 211 }); 212 212 213 213 it("updates hash when signal changes", () => { 214 - window.location.hash = ""; 214 + globalThis.location.hash = ""; 215 215 216 216 const element = document.createElement("div"); 217 - element.dataset.xUrl = "hash:route"; 217 + element.dataset.voltUrl = "hash:route"; 218 218 219 219 const route = signal(""); 220 220 mount(element, { route }); 221 221 222 222 route.set("/contact"); 223 223 224 - expect(window.location.hash).toBe("#/contact"); 224 + expect(globalThis.location.hash).toBe("#/contact"); 225 225 }); 226 226 227 227 it("clears hash when signal is empty", () => { 228 - window.location.hash = "#/page"; 228 + globalThis.location.hash = "#/page"; 229 229 230 230 const element = document.createElement("div"); 231 - element.dataset.xUrl = "hash:route"; 231 + element.dataset.voltUrl = "hash:route"; 232 232 233 233 const route = signal(""); 234 234 mount(element, { route }); 235 235 236 236 route.set(""); 237 237 238 - expect(window.location.hash).toBe(""); 238 + expect(globalThis.location.hash).toBe(""); 239 239 }); 240 240 241 241 it("handles hashchange events", () => { 242 - window.location.hash = "#/home"; 242 + globalThis.location.hash = "#/home"; 243 243 244 244 const element = document.createElement("div"); 245 - element.dataset.xUrl = "hash:route"; 245 + element.dataset.voltUrl = "hash:route"; 246 246 247 247 const route = signal(""); 248 248 mount(element, { route }); 249 249 250 250 expect(route.get()).toBe("/home"); 251 251 252 - window.location.hash = "#/settings"; 253 - window.dispatchEvent(new Event("hashchange")); 252 + globalThis.location.hash = "#/settings"; 253 + globalThis.dispatchEvent(new Event("hashchange")); 254 254 255 255 expect(route.get()).toBe("/settings"); 256 256 }); 257 257 258 258 it("cleans up hashchange listener on unmount", () => { 259 - window.location.hash = "#/page1"; 259 + globalThis.location.hash = "#/page1"; 260 260 261 261 const element = document.createElement("div"); 262 - element.dataset.xUrl = "hash:route"; 262 + element.dataset.voltUrl = "hash:route"; 263 263 264 264 const route = signal(""); 265 265 const cleanup = mount(element, { route }); ··· 268 268 269 269 cleanup(); 270 270 271 - window.location.hash = "#/page2"; 272 - window.dispatchEvent(new Event("hashchange")); 271 + globalThis.location.hash = "#/page2"; 272 + globalThis.dispatchEvent(new Event("hashchange")); 273 273 274 274 expect(route.get()).toBe("/page1"); 275 275 }); ··· 279 279 it("logs error for invalid binding format", () => { 280 280 const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); 281 281 const element = document.createElement("div"); 282 - element.dataset.xUrl = "invalidformat"; 282 + element.dataset.voltUrl = "invalidformat"; 283 283 284 284 mount(element, {}); 285 285 286 - expect(errorSpy).toHaveBeenCalledWith( 287 - expect.stringContaining("Invalid url binding"), 288 - ); 286 + expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining("Invalid url binding")); 289 287 290 288 errorSpy.mockRestore(); 291 289 }); ··· 293 291 it("logs error for unknown url mode", () => { 294 292 const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); 295 293 const element = document.createElement("div"); 296 - element.dataset.xUrl = "unknown:signal"; 294 + element.dataset.voltUrl = "unknown:signal"; 297 295 298 296 mount(element, {}); 299 297 300 - expect(errorSpy).toHaveBeenCalledWith( 301 - expect.stringContaining('Unknown url mode: "unknown"'), 302 - ); 298 + expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining("Unknown url mode: \"unknown\"")); 303 299 304 300 errorSpy.mockRestore(); 305 301 }); ··· 307 303 it("logs error when signal not found", () => { 308 304 const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); 309 305 const element = document.createElement("div"); 310 - element.dataset.xUrl = "read:nonexistent"; 306 + element.dataset.voltUrl = "read:nonexistent"; 311 307 312 308 mount(element, {}); 313 309 314 - expect(errorSpy).toHaveBeenCalledWith( 315 - expect.stringContaining('Signal "nonexistent" not found'), 316 - ); 310 + expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining("Signal \"nonexistent\" not found")); 317 311 318 312 errorSpy.mockRestore(); 319 313 });