a reactive (signals based) hypermedia web framework (wip) stormlightlabs.github.io/volt/
hypermedia frontend signals
at main 33 kB view raw
1/** 2 * Binder system for mounting and managing VoltX bindings 3 */ 4 5import { executeSurgeEnter, executeSurgeLeave, hasSurge } from "$plugins/surge"; 6import type { Nullable, Optional } from "$types/helpers"; 7import type { 8 BindingContext, 9 CleanupFunction, 10 FormControlElement, 11 Modifier, 12 PluginContext, 13 PluginHandler, 14 Scope, 15 Signal, 16} from "$types/volt"; 17import { BOOLEAN_ATTRS } from "./constants"; 18import { getVoltAttrs, parseClassBinding, setHTML, setText, toggleClass, walkDOM } from "./dom"; 19import { report } from "./error"; 20import { evaluate } from "./evaluator"; 21import { execGlobalHooks, notifyBindingCreated, notifyElementMounted, notifyElementUnmounted } from "./lifecycle"; 22import { debounce, getModifierValue, hasModifier, parseModifiers, throttle } from "./modifiers"; 23import { getPlugin } from "./plugin"; 24import { createScopeMetadata, getPin, registerPin } from "./scope-metadata"; 25import { createArc, createProbe, createPulse, createUid } from "./scope-vars"; 26import { findScopedSignal, isNil, updateAndRegister } from "./shared"; 27import { getStore } from "./store"; 28 29/** 30 * Directive registry for custom bindings 31 * 32 * Allows modules (like HTTP) to register directive handlers that can be tree-shaken when not imported. 33 */ 34type DirectiveHandler = (ctx: BindingContext, value: string, modifiers?: Modifier[]) => void; 35 36const directiveRegistry = new Map<string, DirectiveHandler>(); 37 38/** 39 * Register a custom directive handler 40 * 41 * Used by optional modules (HTTP, plugins) to register directive handlers that can be tree-shaken when the module is not imported. 42 * 43 * @param name - Directive name (without data-volt- prefix) 44 * @param handler - Handler function that processes the directive 45 */ 46export function registerDirective(name: string, handler: DirectiveHandler): void { 47 directiveRegistry.set(name, handler); 48} 49 50function scheduleTransitionTask(cb: () => void): void { 51 let executed = false; 52 const wrapped = () => { 53 if (executed) { 54 return; 55 } 56 executed = true; 57 cb(); 58 }; 59 60 if (typeof requestAnimationFrame === "function") { 61 requestAnimationFrame(wrapped); 62 } 63 64 setTimeout(wrapped, 16); 65} 66 67/** 68 * Mount VoltX on a root element and its descendants and binds all data-volt-* attributes to the provided scope. 69 * 70 * @param root - Root element to mount on 71 * @param scope - Scope object containing signals and data 72 * @returns Cleanup function to unmount and dispose all bindings. 73 */ 74export function mount(root: Element, scope: Scope): CleanupFunction { 75 injectSpecialVars(scope, root); 76 execGlobalHooks("beforeMount", root, scope); 77 78 const allElements = walkDOM(root); 79 80 const elements = allElements.filter((element) => { 81 let current: Nullable<Element> = element; 82 while (current) { 83 if (Object.hasOwn((current as HTMLElement).dataset, "voltSkip")) { 84 return false; 85 } 86 if (current === root) break; 87 current = current.parentElement; 88 } 89 return true; 90 }); 91 92 const allCleanups: CleanupFunction[] = []; 93 const mountedElements: Element[] = []; 94 95 for (const el of elements) { 96 if (Object.hasOwn((el as HTMLElement).dataset, "voltCloak")) { 97 delete (el as HTMLElement).dataset.voltCloak; 98 } 99 100 const attributes = getVoltAttrs(el); 101 const context: BindingContext = { element: el, scope, cleanups: [] }; 102 103 if (attributes.has("for")) { 104 const forExpression = attributes.get("for")!; 105 bindFor(context, forExpression); 106 notifyBindingCreated(el, "for"); 107 } else if (attributes.has("if")) { 108 const ifExpression = attributes.get("if")!; 109 bindIf(context, ifExpression); 110 notifyBindingCreated(el, "if"); 111 } else { 112 for (const [name, value] of attributes) { 113 bindAttribute(context, name, value); 114 notifyBindingCreated(el, name); 115 } 116 } 117 118 notifyElementMounted(el); 119 mountedElements.push(el); 120 allCleanups.push(...context.cleanups); 121 } 122 123 execGlobalHooks("afterMount", root, scope); 124 125 return () => { 126 execGlobalHooks("beforeUnmount", root); 127 128 for (const element of mountedElements) { 129 notifyElementUnmounted(element); 130 } 131 132 for (const cleanup of allCleanups) { 133 try { 134 cleanup(); 135 } catch (error) { 136 report(error as Error, { source: "binding", element: root as HTMLElement }); 137 } 138 } 139 140 execGlobalHooks("afterUnmount", root); 141 }; 142} 143 144function execPlugin(plugin: PluginHandler, ctx: BindingContext, val: string, base: string) { 145 const pluginCtx = createPluginCtx(ctx); 146 try { 147 plugin(pluginCtx, val); 148 } catch (error) { 149 report(error as Error, { 150 source: "plugin", 151 element: ctx.element as HTMLElement, 152 directive: `data-volt-${base}`, 153 pluginName: base, 154 }); 155 } 156} 157 158/** 159 * Bind a single data-volt-* attribute to an element. 160 * Routes to the appropriate binding handler. 161 */ 162function bindAttribute(ctx: BindingContext, name: string, value: string): void { 163 if (name.startsWith("on-")) { 164 const eventSpec = name.slice(3); 165 const { baseName: eventName, modifiers } = parseModifiers(eventSpec); 166 bindEvent(ctx, eventName, value, modifiers); 167 return; 168 } 169 170 if (name.startsWith("bind:") || name.startsWith("bind-")) { 171 const attrSpec = name.slice(5); 172 const { baseName: attrName, modifiers } = parseModifiers(attrSpec); 173 bindAttr(ctx, attrName, value, modifiers); 174 return; 175 } 176 177 if (name.includes(":")) { 178 const colonIndex = name.indexOf(":"); 179 const pluginName = name.slice(0, colonIndex); 180 const suffix = name.slice(colonIndex + 1); 181 const plugin = getPlugin(pluginName); 182 183 if (plugin) { 184 const combinedVal = `${suffix}:${value}`; 185 execPlugin(plugin, ctx, combinedVal, pluginName); 186 return; 187 } 188 } 189 190 const { baseName, modifiers } = parseModifiers(name); 191 192 switch (baseName) { 193 case "text": { 194 const bindText = bindNode("text"); 195 bindText(ctx, value); 196 break; 197 } 198 case "html": { 199 const bindHTML = bindNode("html"); 200 bindHTML(ctx, value); 201 break; 202 } 203 case "class": { 204 bindClass(ctx, value); 205 break; 206 } 207 case "show": { 208 bindShow(ctx, value); 209 break; 210 } 211 case "style": { 212 bindStyle(ctx, value); 213 break; 214 } 215 case "model": { 216 bindModel(ctx, value, modifiers); 217 break; 218 } 219 case "pin": { 220 bindPin(ctx, value); 221 break; 222 } 223 case "init": { 224 bindInit(ctx, value); 225 break; 226 } 227 case "for": { 228 bindFor(ctx, value); 229 break; 230 } 231 // data-volt-else is a marker attribute handled by bindIf when processing data-volt-if 232 case "else": { 233 break; 234 } 235 default: { 236 const directiveHandler = directiveRegistry.get(baseName); 237 if (directiveHandler) { 238 directiveHandler(ctx, value, modifiers); 239 return; 240 } 241 242 const plugin = getPlugin(baseName); 243 if (plugin) { 244 execPlugin(plugin, ctx, value, baseName); 245 } else { 246 console.warn(`Unknown binding: data-volt-${baseName}`); 247 } 248 } 249 } 250} 251 252/** 253 * Creates a reactive binding for data-volt-text or data-volt-html that updates element content. 254 * Returns a curried function that handles binding data-volt-text|html to update an element's text or html content 255 * Subscribes to signals in the expression and updates on change. 256 */ 257function bindNode(kind: "text" | "html") { 258 return function(ctx: BindingContext, expr: string): void { 259 const update = () => { 260 const value = evaluate(expr, ctx.scope); 261 if (kind === "text") { 262 setText(ctx.element, value); 263 } else { 264 setHTML(ctx.element, String(value ?? "")); 265 } 266 }; 267 updateAndRegister(ctx, update, expr); 268 }; 269} 270 271/** 272 * Bind data-volt-class to toggle CSS classes. Supports both string and object notation. 273 * Subscribes to signals in the expression and updates on change. 274 */ 275function bindClass(ctx: BindingContext, expr: string): void { 276 let prevClasses = new Map<string, boolean>(); 277 278 const update = () => { 279 const value = evaluate(expr, ctx.scope); 280 const classes = parseClassBinding(value); 281 282 for (const [className] of prevClasses) { 283 if (!classes.has(className)) { 284 toggleClass(ctx.element, className, false); 285 } 286 } 287 288 for (const [className, shouldAdd] of classes) { 289 toggleClass(ctx.element, className, shouldAdd); 290 } 291 292 prevClasses = classes; 293 }; 294 295 updateAndRegister(ctx, update, expr); 296} 297 298/** 299 * Bind data-volt-show to toggle element visibility via CSS display property. 300 * Unlike data-volt-if, this keeps the element in the DOM and toggles display: none. 301 * Integrates with surge plugin for smooth transitions when available. 302 */ 303function bindShow(ctx: BindingContext, expr: string): void { 304 const el = ctx.element as HTMLElement; 305 const originalInlineDisplay = el.style.display; 306 const hasSurgeTransition = hasSurge(el); 307 308 if (!hasSurgeTransition) { 309 const update = () => { 310 const value = evaluate(expr, ctx.scope); 311 const shouldShow = Boolean(value); 312 313 if (shouldShow) { 314 el.style.display = originalInlineDisplay; 315 } else { 316 el.style.display = "none"; 317 } 318 }; 319 320 updateAndRegister(ctx, update, expr); 321 return; 322 } 323 324 let isVisible = el.style.display !== "none"; 325 let isTransitioning = false; 326 327 const update = () => { 328 const value = evaluate(expr, ctx.scope); 329 const shouldShow = Boolean(value); 330 331 if (shouldShow === isVisible || isTransitioning) { 332 return; 333 } 334 335 isTransitioning = true; 336 337 scheduleTransitionTask(() => { 338 void (async () => { 339 try { 340 if (shouldShow) { 341 el.style.display = originalInlineDisplay; 342 await executeSurgeEnter(el); 343 isVisible = true; 344 } else { 345 await executeSurgeLeave(el); 346 el.style.display = "none"; 347 isVisible = false; 348 } 349 } finally { 350 isTransitioning = false; 351 } 352 })(); 353 }); 354 }; 355 356 updateAndRegister(ctx, update, expr); 357} 358 359/** 360 * Bind data-volt-style to reactively apply inline styles. 361 * Supports 362 * - object notation {color: 'red', fontSize: '16px'} 363 * - string notation 'color: red; font-size: 16px'. 364 */ 365function bindStyle(ctx: BindingContext, expr: string): void { 366 const element = ctx.element as HTMLElement; 367 368 const update = () => { 369 const value = evaluate(expr, ctx.scope); 370 371 if (typeof value === "object" && value !== null) { 372 for (const [key, val] of Object.entries(value)) { 373 const cssKey = key.replaceAll(/[A-Z]/g, (m) => `-${m.toLowerCase()}`); 374 375 if (isNil(val)) { 376 element.style.removeProperty(cssKey); 377 } else { 378 try { 379 element.style.setProperty(cssKey, String(val)); 380 } catch (error) { 381 report(error as Error, { 382 source: "binding", 383 element: element, 384 directive: "data-volt-style", 385 expression: expr, 386 }); 387 } 388 } 389 } 390 } else if (typeof value === "string") { 391 element.style.cssText = value; 392 } 393 }; 394 395 updateAndRegister(ctx, update, expr); 396} 397 398function extractStatements(expr: string) { 399 const statements: string[] = []; 400 let current = ""; 401 let depth = 0; 402 let inString: string | null = null; 403 404 for (const [i, char] of [...expr].entries()) { 405 const prev = i > 0 ? expr[i - 1] : ""; 406 407 if ((char === "\"" || char === "'") && prev !== "\\") { 408 if (inString === char) { 409 inString = null; 410 } else if (inString === null) { 411 inString = char; 412 } 413 } 414 415 if (inString === null) { 416 if (char === "(" || char === "{" || char === "[") { 417 depth++; 418 } else if (char === ")" || char === "}" || char === "]") { 419 depth--; 420 } 421 } 422 423 if (char === ";" && depth === 0 && inString === null) { 424 if (current.trim()) { 425 statements.push(current.trim()); 426 } 427 current = ""; 428 } else { 429 current += char; 430 } 431 } 432 433 if (current.trim()) { 434 statements.push(current.trim()); 435 } 436 437 return statements; 438} 439 440/** 441 * Bind data-volt-on-* to attach event listeners with support for modifiers. 442 * Provides $el and $event in the scope for the event handler. 443 * 444 * Supported modifiers: 445 * - .prevent - calls preventDefault() 446 * - .stop - calls stopPropagation() 447 * - .self - only trigger if event.target === element 448 * - .window - attach listener to window 449 * - .document - attach listener to document 450 * - .once - run handler only once 451 * - .debounce[.ms] - debounce handler (default 300ms) 452 * - .throttle[.ms] - throttle handler (default 300ms) 453 * - .passive - add passive event listener 454 */ 455function bindEvent(ctx: BindingContext, eventName: string, expr: string, modifiers: Modifier[] = []): void { 456 const executeHandler = (event: Event) => { 457 const eventScope: Scope = { ...ctx.scope, $el: ctx.element, $event: event }; 458 459 try { 460 const statements = extractStatements(expr); 461 let result: unknown; 462 for (const stmt of statements) { 463 result = evaluate(stmt, eventScope, { unwrapSignals: false }); 464 } 465 466 if (typeof result === "function") { 467 result(event); 468 } 469 } catch (error) { 470 report(error as Error, { 471 source: "binding", 472 element: ctx.element as HTMLElement, 473 directive: `data-volt-on-${eventName}`, 474 expression: expr, 475 }); 476 } 477 }; 478 479 let wrappedExecute = executeHandler; 480 481 if (hasModifier(modifiers, "debounce")) { 482 const wait = getModifierValue(modifiers, "debounce", 300); 483 const debouncedExecute = debounce(executeHandler, wait); 484 wrappedExecute = debouncedExecute as typeof executeHandler; 485 ctx.cleanups.push(() => debouncedExecute.cancel()); 486 } else if (hasModifier(modifiers, "throttle")) { 487 const wait = getModifierValue(modifiers, "throttle", 300); 488 const throttledExecute = throttle(executeHandler, wait); 489 wrappedExecute = throttledExecute as typeof executeHandler; 490 ctx.cleanups.push(() => throttledExecute.cancel()); 491 } 492 493 const handler = (event: Event) => { 494 if (hasModifier(modifiers, "self") && event.target !== ctx.element) { 495 return; 496 } 497 498 if (hasModifier(modifiers, "prevent")) { 499 event.preventDefault(); 500 } 501 502 if (hasModifier(modifiers, "stop")) { 503 event.stopPropagation(); 504 } 505 506 wrappedExecute(event); 507 }; 508 509 const target = hasModifier(modifiers, "window") 510 ? globalThis 511 : (hasModifier(modifiers, "document") ? document : ctx.element); 512 513 const options: AddEventListenerOptions = {}; 514 if (hasModifier(modifiers, "once")) { 515 options.once = true; 516 } 517 if (hasModifier(modifiers, "passive")) { 518 options.passive = true; 519 } 520 521 target.addEventListener(eventName, handler, options); 522 523 ctx.cleanups.push(() => { 524 target.removeEventListener(eventName, handler, options); 525 }); 526} 527 528/** 529 * Get a nested property value from an object using a path array 530 * 531 * @example 532 * getNestedProperty({ user: { name: "Alice" } }, ["user", "name"]) // "Alice" 533 */ 534function getNestedProperty(obj: unknown, path: string[]): unknown { 535 let current = obj; 536 for (const key of path) { 537 if (current == null || typeof current !== "object") { 538 return undefined; 539 } 540 current = (current as Record<string, unknown>)[key]; 541 } 542 return current; 543} 544 545/** 546 * Set a nested property value in an object immutably using a path array 547 * 548 * @example 549 * setNestedProperty({ user: { name: "Alice" } }, ["user", "name"], "Bob") 550 * // Returns: { user: { name: "Bob" } } 551 */ 552function setNestedProperty(obj: unknown, path: string[], value: unknown): unknown { 553 if (path.length === 0) { 554 return value; 555 } 556 557 if (obj == null || typeof obj !== "object") { 558 return obj; 559 } 560 561 const clone = Array.isArray(obj) ? [...obj] : { ...obj }; 562 const [head, ...tail] = path; 563 564 if (tail.length === 0) { 565 (clone as Record<string, unknown>)[head] = value; 566 } else { 567 (clone as Record<string, unknown>)[head] = setNestedProperty((clone as Record<string, unknown>)[head], tail, value); 568 } 569 570 return clone; 571} 572 573/** 574 * Find a signal and optional nested property path for data-volt-model binding 575 * 576 * Supports two patterns: 577 * 1. Nested signals: { formData: { name: signal("") } } with path "formData.name" 578 * 2. Signal with object value: { formData: signal({ name: "" }) } with path "formData.name" 579 * 580 * @returns Object with signal and propertyPath, or null if not found 581 */ 582function findModelSignal(scope: Scope, path: string): Nullable<{ signal: Signal<unknown>; propertyPath: string[] }> { 583 const signal = findScopedSignal(scope, path); 584 if (signal) { 585 return { signal, propertyPath: [] }; 586 } 587 588 const parts = path.split("."); 589 for (let i = parts.length - 1; i > 0; i--) { 590 const prefix = parts.slice(0, i).join("."); 591 const testSignal = findScopedSignal(scope, prefix); 592 593 if (testSignal) { 594 const propertyPath = parts.slice(i); 595 return { signal: testSignal, propertyPath }; 596 } 597 } 598 599 return null; 600} 601 602/** 603 * Bind data-volt-model for two-way data binding on form elements with support for modifiers. 604 * Syncs the signal value with the input value bidirectionally. 605 * 606 * Supported modifiers: 607 * - .number - coerces values to numbers 608 * - .trim - removes surrounding whitespace 609 * - .lazy - syncs on 'change' instead of 'input' 610 * - .debounce[.ms] - debounces signal updates (default 300ms) 611 */ 612function bindModel(context: BindingContext, signalPath: string, modifiers: Modifier[] = []): void { 613 const result = findModelSignal(context.scope, signalPath); 614 if (!result) { 615 report(new Error(`Signal "${signalPath}" not found`), { 616 source: "binding", 617 element: context.element as HTMLElement, 618 directive: "data-volt-model", 619 expression: signalPath, 620 }); 621 return; 622 } 623 624 const { signal, propertyPath } = result; 625 626 const element = context.element as FormControlElement; 627 const type = element instanceof HTMLInputElement ? element.type : null; 628 629 const getValue = (): unknown => { 630 const signalValue = signal.get(); 631 return propertyPath.length > 0 ? getNestedProperty(signalValue, propertyPath) : signalValue; 632 }; 633 634 const initialValue = getValue(); 635 setElementValue(element, initialValue, type); 636 637 const unsubscribe = signal.subscribe(() => { 638 const value = getValue(); 639 setElementValue(element, value, type); 640 }); 641 context.cleanups.push(unsubscribe); 642 643 const isLazy = hasModifier(modifiers, "lazy"); 644 const isNumber = hasModifier(modifiers, "number"); 645 const isTrim = hasModifier(modifiers, "trim"); 646 647 const defaultEventName = type === "checkbox" || type === "radio" ? "change" : "input"; 648 const eventName = isLazy ? "change" : defaultEventName; 649 650 const baseHandler = () => { 651 let value = getElementValue(element, type); 652 653 if (typeof value === "string") { 654 if (isTrim) { 655 value = value.trim(); 656 } 657 if (isNumber) { 658 value = value === "" ? Number.NaN : Number(value); 659 } 660 } 661 662 if (propertyPath.length > 0) { 663 const currentObj = signal.get(); 664 const updatedObj = setNestedProperty(currentObj, propertyPath, value); 665 (signal as Signal<unknown>).set(updatedObj); 666 } else { 667 (signal as Signal<unknown>).set(value); 668 } 669 }; 670 671 let handler = baseHandler; 672 673 if (hasModifier(modifiers, "debounce")) { 674 const wait = getModifierValue(modifiers, "debounce", 300); 675 const debouncedHandler = debounce(baseHandler, wait); 676 handler = debouncedHandler as typeof baseHandler; 677 context.cleanups.push(() => debouncedHandler.cancel()); 678 } 679 680 element.addEventListener(eventName, handler); 681 context.cleanups.push(() => { 682 element.removeEventListener(eventName, handler); 683 }); 684} 685 686function setElementValue(el: FormControlElement, value: unknown, type: string | null): void { 687 if (el instanceof HTMLInputElement) { 688 switch (type) { 689 case "checkbox": { 690 el.checked = Boolean(value); 691 break; 692 } 693 case "radio": { 694 el.checked = el.value === String(value); 695 break; 696 } 697 case "number": { 698 el.value = String(value ?? ""); 699 break; 700 } 701 default: { 702 el.value = String(value ?? ""); 703 } 704 } 705 } else if (el instanceof HTMLSelectElement) { 706 el.value = String(value ?? ""); 707 } else if (el instanceof HTMLTextAreaElement) { 708 el.value = String(value ?? ""); 709 } 710} 711 712function getElementValue(el: FormControlElement, type: string | null): unknown { 713 if (el instanceof HTMLInputElement) { 714 if (type === "checkbox") { 715 return el.checked; 716 } 717 if (type === "number") { 718 return el.valueAsNumber; 719 } 720 return el.value; 721 } 722 723 if (el instanceof HTMLSelectElement) { 724 return el.value; 725 } 726 727 if (el instanceof HTMLTextAreaElement) { 728 return el.value; 729 } 730 731 return ""; 732} 733 734/** 735 * Bind data-volt-bind:attr for generic attribute binding with support for modifiers. 736 * Updates any HTML attribute reactively based on expression value. 737 * 738 * Supported modifiers: 739 * - .number - coerces values to numbers 740 * - .trim - removes surrounding whitespace 741 */ 742function bindAttr(ctx: BindingContext, attrName: string, expr: string, modifiers: Modifier[] = []): void { 743 const isNumber = hasModifier(modifiers, "number"); 744 const isTrim = hasModifier(modifiers, "trim"); 745 746 const update = () => { 747 let value = evaluate(expr, ctx.scope); 748 749 if (typeof value === "string") { 750 if (isTrim) { 751 value = value.trim(); 752 } 753 if (isNumber) { 754 value = value === "" ? Number.NaN : Number(value); 755 } 756 } 757 758 const booleanAttrs = new Set(BOOLEAN_ATTRS); 759 760 if (booleanAttrs.has(attrName)) { 761 if (value) { 762 ctx.element.setAttribute(attrName, ""); 763 } else { 764 ctx.element.removeAttribute(attrName); 765 } 766 } else { 767 if (isNil(value) || value === false) { 768 ctx.element.removeAttribute(attrName); 769 } else { 770 ctx.element.setAttribute(attrName, String(value)); 771 } 772 } 773 }; 774 775 updateAndRegister(ctx, update, expr); 776} 777 778/** 779 * Bind data-volt-init to run initialization code once when the element is mounted. 780 */ 781function bindInit(ctx: BindingContext, expr: string): void { 782 try { 783 const statements = extractStatements(expr); 784 for (const stmt of statements) { 785 evaluate(stmt, ctx.scope, { unwrapSignals: false }); 786 } 787 } catch (error) { 788 report(error as Error, { 789 source: "binding", 790 element: ctx.element as HTMLElement, 791 directive: "data-volt-init", 792 expression: expr, 793 }); 794 } 795} 796 797/** 798 * Bind data-volt-pin to register an element reference in the scope's pin registry. 799 * Makes the element accessible via $pins.name ($pins[name]) in expressions and event handlers. 800 * 801 * @example 802 * ```html 803 * <input data-volt-pin="username" /> 804 * <button data-volt-on-click="$pins.username.focus()">Focus Input</button> 805 * ``` 806 */ 807function bindPin(ctx: BindingContext, name: string): void { 808 registerPin(ctx.scope, name, ctx.element); 809} 810 811/** 812 * Bind data-volt-for to render a list of items. 813 * Subscribes to array signal and re-renders when array changes. 814 */ 815function bindFor(ctx: BindingContext, expr: string): void { 816 const parsed = parseForExpr(expr); 817 if (!parsed) { 818 report(new Error(`Invalid data-volt-for expression: "${expr}"`), { 819 source: "binding", 820 element: ctx.element as HTMLElement, 821 directive: "data-volt-for", 822 expression: expr, 823 }); 824 return; 825 } 826 827 const { itemName, indexName, arrayPath } = parsed; 828 const templ = ctx.element as HTMLElement; 829 const parent = templ.parentElement; 830 831 if (!parent) { 832 report(new Error("data-volt-for element must have a parent"), { 833 source: "binding", 834 element: ctx.element as HTMLElement, 835 directive: "data-volt-for", 836 expression: expr, 837 }); 838 return; 839 } 840 841 const placeholder = document.createComment(`for: ${expr}`); 842 templ.before(placeholder); 843 templ.remove(); 844 845 const renderedElements: Element[] = []; 846 const renderedCleanups: CleanupFunction[] = []; 847 848 const render = () => { 849 for (const cleanup of renderedCleanups) { 850 cleanup(); 851 } 852 renderedCleanups.length = 0; 853 854 for (const element of renderedElements) { 855 element.remove(); 856 } 857 renderedElements.length = 0; 858 859 const arrayValue = evaluate(arrayPath, ctx.scope); 860 if (!Array.isArray(arrayValue)) { 861 return; 862 } 863 864 for (const [index, item] of arrayValue.entries()) { 865 const clone = templ.cloneNode(true) as Element; 866 delete (clone as HTMLElement).dataset.voltFor; 867 868 const itemScope: Scope = { ...ctx.scope, [itemName]: item }; 869 if (indexName) { 870 itemScope[indexName] = index; 871 } 872 873 const cleanup = mount(clone, itemScope); 874 renderedCleanups.push(cleanup); 875 renderedElements.push(clone); 876 877 placeholder.before(clone); 878 } 879 }; 880 881 updateAndRegister(ctx, render, expr); 882 883 ctx.cleanups.push(() => { 884 for (const cleanup of renderedCleanups) { 885 cleanup(); 886 } 887 }); 888} 889 890/** 891 * Bind data-volt-if to conditionally render an element. Supports data-volt-else on the next sibling element. 892 * Subscribes to condition signal and shows/hides elements when condition changes. 893 * Integrates with surge plugin for smooth enter/leave transitions when available. 894 */ 895function bindIf(ctx: BindingContext, expr: string): void { 896 const ifTempl = ctx.element as HTMLElement; 897 const parent = ifTempl.parentElement; 898 899 if (!parent) { 900 report(new Error("data-volt-if element must have a parent"), { 901 source: "binding", 902 element: ctx.element as HTMLElement, 903 directive: "data-volt-if", 904 expression: expr, 905 }); 906 return; 907 } 908 909 let elseTempl: Optional<HTMLElement>; 910 let nextSibling = ifTempl.nextElementSibling; 911 912 while (nextSibling && nextSibling.nodeType !== 1) { 913 nextSibling = nextSibling.nextElementSibling; 914 } 915 916 if (nextSibling && Object.hasOwn((nextSibling as HTMLElement).dataset, "voltElse")) { 917 elseTempl = nextSibling as HTMLElement; 918 elseTempl.remove(); 919 } 920 921 const placeholder = document.createComment(`if: ${expr}`); 922 ifTempl.before(placeholder); 923 ifTempl.remove(); 924 925 const ifHasSurge = hasSurge(ifTempl); 926 const elseHasSurge = elseTempl ? hasSurge(elseTempl) : false; 927 const anySurge = ifHasSurge || elseHasSurge; 928 929 let currentElement: Optional<Element>; 930 let currentCleanup: Optional<CleanupFunction>; 931 let currentBranch: Optional<"if" | "else">; 932 let isTransitioning = false; 933 let pendingRender = false; 934 935 const render = () => { 936 const condition = evaluate(expr, ctx.scope); 937 const shouldShow = Boolean(condition); 938 939 const targetBranch = shouldShow ? "if" : (elseTempl ? "else" : undefined); 940 941 if (targetBranch === currentBranch || isTransitioning) { 942 if (isTransitioning) { 943 pendingRender = true; 944 } 945 return; 946 } 947 948 if (!anySurge) { 949 if (currentCleanup) { 950 currentCleanup(); 951 currentCleanup = undefined; 952 } 953 if (currentElement) { 954 currentElement.remove(); 955 currentElement = undefined; 956 } 957 958 if (targetBranch === "if") { 959 currentElement = ifTempl.cloneNode(true) as Element; 960 delete (currentElement as HTMLElement).dataset.voltIf; 961 currentCleanup = mount(currentElement, ctx.scope); 962 placeholder.before(currentElement); 963 currentBranch = "if"; 964 } else if (targetBranch === "else" && elseTempl) { 965 currentElement = elseTempl.cloneNode(true) as Element; 966 delete (currentElement as HTMLElement).dataset.voltElse; 967 currentCleanup = mount(currentElement, ctx.scope); 968 placeholder.before(currentElement); 969 currentBranch = "else"; 970 } else { 971 currentBranch = undefined; 972 } 973 return; 974 } 975 976 isTransitioning = true; 977 978 void (async () => { 979 try { 980 if (currentElement) { 981 const currentEl = currentElement as HTMLElement; 982 const currentHasSurge = currentBranch === "if" ? ifHasSurge : elseHasSurge; 983 984 if (currentHasSurge) { 985 await executeSurgeLeave(currentEl); 986 } 987 988 if (currentCleanup) { 989 currentCleanup(); 990 currentCleanup = undefined; 991 } 992 currentElement.remove(); 993 currentElement = undefined; 994 } 995 996 if (targetBranch === "if") { 997 currentElement = ifTempl.cloneNode(true) as Element; 998 delete (currentElement as HTMLElement).dataset.voltIf; 999 placeholder.before(currentElement); 1000 1001 if (ifHasSurge) { 1002 await executeSurgeEnter(currentElement as HTMLElement); 1003 } 1004 1005 currentCleanup = mount(currentElement, ctx.scope); 1006 currentBranch = "if"; 1007 } else if (targetBranch === "else" && elseTempl) { 1008 currentElement = elseTempl.cloneNode(true) as Element; 1009 delete (currentElement as HTMLElement).dataset.voltElse; 1010 placeholder.before(currentElement); 1011 1012 if (elseHasSurge) { 1013 await executeSurgeEnter(currentElement as HTMLElement); 1014 } 1015 1016 currentCleanup = mount(currentElement, ctx.scope); 1017 currentBranch = "else"; 1018 } else { 1019 currentBranch = undefined; 1020 } 1021 } finally { 1022 isTransitioning = false; 1023 if (pendingRender) { 1024 pendingRender = false; 1025 render(); 1026 } 1027 } 1028 })(); 1029 }; 1030 1031 updateAndRegister(ctx, render, expr); 1032 1033 ctx.cleanups.push(() => { 1034 if (currentCleanup) { 1035 currentCleanup(); 1036 } 1037 }); 1038} 1039 1040/** 1041 * Parse a data-volt-for expression 1042 * Supports: "item in items" or "(item, index) in items" 1043 */ 1044function parseForExpr(expr: string): Optional<{ itemName: string; indexName?: string; arrayPath: string }> { 1045 const trimmed = expr.trim(); 1046 1047 const withIndex = /^\((\w+)\s*,\s*(\w+)\)\s+in\s+(.+)$/.exec(trimmed); 1048 if (withIndex) { 1049 return { itemName: withIndex[1], indexName: withIndex[2], arrayPath: withIndex[3].trim() }; 1050 } 1051 1052 const simple = /^(\w+)\s+in\s+(.+)$/.exec(trimmed); 1053 if (simple) { 1054 return { itemName: simple[1], indexName: undefined, arrayPath: simple[2].trim() }; 1055 } 1056 1057 return undefined; 1058} 1059 1060/** 1061 * Create a plugin context from a binding context. 1062 * Provides the plugin with access to utilities and cleanup registration. 1063 */ 1064function createPluginCtx(ctx: BindingContext): PluginContext { 1065 const mountCallbacks: Array<() => void> = []; 1066 const unmountCallbacks: Array<() => void> = []; 1067 const beforeBindingCallbacks: Array<() => void> = []; 1068 const afterBindingCallbacks: Array<() => void> = []; 1069 1070 const lifecycle = { 1071 onMount: (cb: () => void) => { 1072 mountCallbacks.push(cb); 1073 try { 1074 cb(); 1075 } catch (error) { 1076 report(error as Error, { source: "plugin", element: ctx.element as HTMLElement, hookName: "onMount" }); 1077 } 1078 }, 1079 onUnmount: (cb: () => void) => { 1080 unmountCallbacks.push(cb); 1081 }, 1082 beforeBinding: (cb: () => void) => { 1083 beforeBindingCallbacks.push(cb); 1084 try { 1085 cb(); 1086 } catch (error) { 1087 report(error as Error, { source: "plugin", element: ctx.element as HTMLElement, hookName: "beforeBinding" }); 1088 } 1089 }, 1090 afterBinding: (cb: () => void) => { 1091 afterBindingCallbacks.push(cb); 1092 queueMicrotask(() => { 1093 try { 1094 cb(); 1095 } catch (error) { 1096 report(error as Error, { source: "plugin", element: ctx.element as HTMLElement, hookName: "afterBinding" }); 1097 } 1098 }); 1099 }, 1100 }; 1101 1102 ctx.cleanups.push(() => { 1103 for (const cb of unmountCallbacks) { 1104 try { 1105 cb(); 1106 } catch (error) { 1107 report(error as Error, { source: "plugin", element: ctx.element as HTMLElement, hookName: "onUnmount" }); 1108 } 1109 } 1110 }); 1111 1112 return { 1113 element: ctx.element, 1114 scope: ctx.scope, 1115 addCleanup: (fn) => { 1116 ctx.cleanups.push(fn); 1117 }, 1118 findSignal: (path) => findScopedSignal(ctx.scope, path), 1119 evaluate: (expr, options) => evaluate(expr, ctx.scope, options), 1120 lifecycle, 1121 }; 1122} 1123 1124/** 1125 * Inject special variables ($store, $origin, $scope, $pins, $pulse, $uid, $arc, $probe) 1126 * into the scope for this root element. 1127 * 1128 * Creates scope metadata and makes runtime utilities available in expressions. 1129 * We create a Proxy for $pins that dynamically reads from metadata to ensure pins registered later are immediately accessible 1130 */ 1131function injectSpecialVars(scope: Scope, root: Element): void { 1132 createScopeMetadata(scope, root); 1133 1134 scope.$store = getStore(); 1135 scope.$pulse = createPulse(); 1136 scope.$origin = root; 1137 scope.$scope = scope; 1138 1139 scope.$pins = new Proxy({}, { 1140 get(_target, prop: string) { 1141 if (typeof prop === "string") { 1142 return getPin(scope, prop); 1143 } 1144 return void 0; 1145 }, 1146 has(_target, prop: string) { 1147 if (typeof prop === "string") { 1148 return getPin(scope, prop) !== undefined; 1149 } 1150 return false; 1151 }, 1152 }); 1153 1154 scope.$uid = createUid(scope); 1155 scope.$arc = createArc(root); 1156 scope.$probe = createProbe(scope); 1157}