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.

fix: Plugin demo (#8)

* fix: ensure computed keys are emitted in kebab case in markup
* added shorthand attribute forms
* added CSS fallback for shift animations

* fix: spread handling of signals
* transformExpr automatically unwraps signals in obj
* docs: updated internal docs to reflect proxy & iterator handling

authored by

Owais and committed by
GitHub
b91d7501 e259f0de

+977 -273
+17 -2
docs/internals/proxies.md
··· 33 33 If it’s an object/function, we recursively call `reactive()` so nested access stays reactive. 34 34 Otherwise we return `signal.get()` which unwraps the value. 35 35 36 - This layered approach means `reactive()` objects are safe to embed in evaluator scopes—the same dangerous keys are filtered and every nested property remains reactive. 36 + This layered approach means `reactive()` objects are safe to embed in evaluator scopes. The same dangerous keys are filtered and every nested property remains reactive. 37 37 38 38 ## Property Mutation (set trap) 39 39 ··· 64 64 65 65 ## Integration with Signals 66 66 67 - Every reactive property is backed by a `signal`. This keeps the proxy layer thin—core logic lives in `signal.ts`, and the proxy simply orchestrates reads/writes against those signals. 67 + Every reactive property is backed by a `signal`. This keeps the proxy layer thin. Core logic lives in `signal.ts`, and the proxy simply orchestrates reads/writes against those signals. 68 68 Because signals already integrate with dependency tracking, reactive object reads automatically wire into computeds, effects, and DOM bindings without extra bookkeeping. 69 69 70 70 ## Interop Utilities ··· 82 82 - Signals returned from proxy properties expose `get`, `set`, and `subscribe`, but property reads on the signal proxy delegate back to the underlying value. 83 83 - Primitive coercion works because the wrapper defines `valueOf`, `toString`, and `Symbol.toPrimitive` on demand. 84 84 - Boolean negation (`!signal`) is rewritten to `!$unwrap(signal)` before compilation so reactive values behave like plain booleans. 85 + - **Iteration support** - Signal wrappers implement `Symbol.iterator` to enable spread operations on signals containing iterable values. 86 + 87 + ### Spread Operator Support 88 + 89 + When a signal contains an iterable value (like an array), the wrapper proxy delegates the `Symbol.iterator` property to the unwrapped value. 90 + This enables the JavaScript spread operator to work transparently: 91 + 92 + ```javascript 93 + const todos = signal([{id: 1, text: "Learn"}, {id: 2, text: "Build"}]); 94 + const newTodos = [...todos, {id: 3, text: "Ship"}]; 95 + ``` 96 + 97 + Without this, the spread operator would fail because the JS runtime can't iterate over the signal wrapper. 98 + The implementation returns the iterator from the unwrapped array directly, ensuring spread operations receive raw values rather than wrapped proxies. 99 + This is critical for immutable update patterns where new arrays are constructed from existing signal values. 85 100 86 101 ## Challenges & Lessons 87 102
+30 -5
docs/internals/reactivity.md
··· 1 1 # Reactivity Architecture 2 2 3 - VoltX’s reactivity system is built around a small set of primitives—signals, computed signals, and effects—that coordinate via an explicit dependency tracker. 3 + VoltX’s reactivity system is built around a small set of primitives: signals, computed signals, and effects, that coordinate via an explicit dependency tracker. 4 4 This document explains how those pieces fit together, how updates flow through the system, and the trade-offs we made while hardening the implementation. 5 5 6 6 ## Signals ··· 88 88 89 89 This dual behavior is controlled by the `opts.unwrapSignals` parameter passed to `evaluate()`. 90 90 91 + ### Object Literal Unwrapping 92 + 93 + A subtle challenge arises when event handlers create object literals using signal values. Consider this common pattern: 94 + 95 + ```html 96 + <button data-volt-on-click="todos.set([...todos, {id: todoId, text: newText, done: false}])"> 97 + Add Todo 98 + </button> 99 + ``` 100 + 101 + Without special handling, the object literal `{id: todoId, text: newText, done: false}` would capture **wrapped signal proxies** as property values instead of their unwrapped values. This breaks equality comparisons later when trying to match todos by ID. 102 + 103 + To solve this, the `transformExpr` function applies a compile-time transformation: it automatically unwraps signal identifiers used directly as object property values. The expression above is rewritten to: 104 + 105 + ```javascript 106 + {id: $unwrap(todoId), text: $unwrap(newText), done: false} 107 + ``` 108 + 109 + This transformation: 110 + 111 + - Only applies to simple identifiers after `:` in object literals (e.g., `{key: identifier}`) 112 + - Does not affect method calls (e.g., `{text: newText.trim()}` remains unchanged) 113 + - Does not affect property access or computed values (e.g., `{id: obj.id}` remains unchanged) 114 + - Ensures object literals created in write-mode contexts contain primitive values, not wrapper proxies 115 + 116 + This keeps the mental model simple: users write natural JavaScript object literals and the evaluator ensures signal values are materialized correctly, regardless of whether `unwrapSignals` is true or false. 117 + 91 118 ## Scope Helpers 92 119 93 120 When a scope is mounted, VoltX injects several helpers that lean on the reactive core: ··· 114 141 ## Challenges & Trade-offs 115 142 116 143 - **Minimal core vs features** - The system intentionally avoids hidden mutation queues or scheduler magic. 117 - This keeps mental models simple but means users must explicitly batch when necessary. 144 + This keeps mental models simple but means users must explicitly batch when necessary. 118 145 - **Signal identity** - Equality checks are referential. 119 146 While fast, it means that mutating nested objects without cloning can bypass change detection unless you touch the signal again. 120 147 We emphasises immutable patterns or explicit `set()` calls with copies. 121 148 - **Dependency discovery** - Parsing expressions to pre-collect dependencies (`extractDeps`) introduces heuristics (e.g. `$store.get()` handling). 122 149 We balance accuracy with performance by focusing on common patterns and falling back to runtime evaluation if static analysis fails. 123 150 - **Error resilience** - Subscriber callbacks, cleanup functions, and recompute bodies are wrapped in try/catch to prevent one failure from derailing the reactive loop. 124 - The trade-off is noisy console logs, but the alternative—silently swallowing issues—was harder to debug. 125 - 126 - Despite the lightweight implementation, these primitives provide deterministic, traceable update flows that underpin VoltX’s declarative bindings and plugin ecosystem. 151 + The trade-off is noisy console logs, but the alternative (silent errors & no observability) was harder to debug.
+9
docs/spec/plugin-spec.md
··· 249 249 <input data-volt-on-input="handleSearch" data-volt-url="sync:searchQuery" /> 250 250 ``` 251 251 252 + You can also use the shorthand attribute form where the signal name is encoded in the attribute suffix: 253 + 254 + ```html 255 + <!-- Equivalent to data-volt-url="sync:searchQuery" --> 256 + <input data-volt-url:searchQuery="query" /> 257 + ``` 258 + 252 259 Changes to signal update URL parameter, changes to URL update signal. Uses History API for clean URLs. 253 260 254 261 **Hash Routing:** ··· 267 274 - Listens to `popstate` for browser back/forward 268 275 - Debounces URL updates to avoid excessive history entries 269 276 - Automatically serializes/deserializes values (strings, numbers, booleans) 277 + - Accepts `data-volt-url="mode:signal"` or `data-volt-url:signal="mode"` forms 278 + - Supports `query`, `hash`, and `history` mode aliases in shorthand attributes (e.g., `data-volt-url:filter="query"`) 270 279 271 280 ## Implementation 272 281
+1
docs/usage/bindings.md
··· 364 364 365 365 - `query`: Sync with query parameter (e.g., `?page=1`) 366 366 - `hash`: Sync with URL hash (e.g., `#section`) 367 + - `history`: Sync with the full pathname + search (e.g., `data-volt-url:route="history:/app"`) 367 368 368 369 Signal changes update the URL, and URL changes (back/forward navigation) update signals. This enables client-side routing without additional libraries. 369 370
+1 -1
docs/usage/routing.md
··· 31 31 ``` 32 32 33 33 ```ts 34 - // src/main.ts — bundled projects 34 + // src/main.ts -> entry point for a bundled project 35 35 import { charge, initNavigationListener, registerPlugin, urlPlugin } from "voltx.js"; 36 36 37 37 registerPlugin("url", urlPlugin);
+3
docs/usage/state.md
··· 64 64 ``` 65 65 66 66 Computed values defined this way follow the same rules as programmatic computed signals: they track dependencies and update automatically. 67 + For multi-word signal names, prefer kebab-case in the attribute (e.g., `data-volt-computed:active-todos`) — HTML lowercases attribute names and Volt converts kebab-case back to camelCase (`activeTodos`) automatically. 67 68 68 69 ## Programmatic State 69 70 ··· 105 106 ``` 106 107 107 108 **Read Contexts** (signals auto-unwrapped): 109 + 108 110 - `data-volt-text`, `data-volt-html` 109 111 - `data-volt-if`, `data-volt-else` 110 112 - `data-volt-for` ··· 113 115 - `data-volt-computed:*` expressions 114 116 115 117 **Write Contexts** (signals not auto-unwrapped): 118 + 116 119 - `data-volt-on-*` event handlers 117 120 - `data-volt-init` initialization code 118 121 - `data-volt-model` (handles both read and write automatically)
+4 -2
lib/README.md
··· 66 66 67 67 ## VoltX.css 68 68 69 - VoltX ships with an optional classless CSS framework inspired by Pico CSS and Tufte CSS. It provides beautiful, semantic styling without requiring any CSS classes—just write semantic HTML and it looks great. It's perfect for prototyping. 69 + VoltX ships with an optional classless CSS framework inspired by Pico CSS and Tufte CSS. 70 + It provides beautiful, semantic styling without requiring any CSS classes. 71 + Just write semantic HTML and it looks great. It's perfect for prototyping. 70 72 71 73 ### Features 72 74 ··· 94 96 95 97 ### Usage 96 98 97 - No classes needed—just write semantic HTML: 99 + No classes needed. Just write semantic HTML: 98 100 99 101 ```html 100 102 <article>
+66 -43
lib/src/core/binder.ts
··· 1 1 /** 2 - * Binder system for mounting and managing VoltX.js bindings 2 + * Binder system for mounting and managing VoltX bindings 3 3 */ 4 4 5 5 import { executeSurgeEnter, executeSurgeLeave, hasSurge } from "$plugins/surge"; ··· 46 46 directiveRegistry.set(name, handler); 47 47 } 48 48 49 + function scheduleTransitionTask(cb: () => void): void { 50 + let executed = false; 51 + const wrapped = () => { 52 + if (executed) { 53 + return; 54 + } 55 + executed = true; 56 + cb(); 57 + }; 58 + 59 + if (typeof requestAnimationFrame === "function") { 60 + requestAnimationFrame(wrapped); 61 + } 62 + 63 + setTimeout(wrapped, 16); 64 + } 65 + 49 66 /** 50 - * Mount VoltX.js on a root element and its descendants and binds all data-volt-* attributes to the provided scope. 67 + * Mount VoltX on a root element and its descendants and binds all data-volt-* attributes to the provided scope. 51 68 * 52 69 * @param root - Root element to mount on 53 70 * @param scope - Scope object containing signals and data ··· 313 330 314 331 isTransitioning = true; 315 332 316 - requestAnimationFrame(() => { 333 + scheduleTransitionTask(() => { 317 334 void (async () => { 318 335 try { 319 336 if (shouldShow) { ··· 874 891 let currentCleanup: Optional<CleanupFunction>; 875 892 let currentBranch: Optional<"if" | "else">; 876 893 let isTransitioning = false; 894 + let pendingRender = false; 877 895 878 896 const render = () => { 879 897 const condition = evaluate(expr, ctx.scope); ··· 882 900 const targetBranch = shouldShow ? "if" : (elseTempl ? "else" : undefined); 883 901 884 902 if (targetBranch === currentBranch || isTransitioning) { 903 + if (isTransitioning) { 904 + pendingRender = true; 905 + } 885 906 return; 886 907 } 887 908 ··· 915 936 916 937 isTransitioning = true; 917 938 918 - requestAnimationFrame(() => { 919 - void (async () => { 920 - try { 921 - if (currentElement) { 922 - const currentEl = currentElement as HTMLElement; 923 - const currentHasSurge = currentBranch === "if" ? ifHasSurge : elseHasSurge; 939 + void (async () => { 940 + try { 941 + if (currentElement) { 942 + const currentEl = currentElement as HTMLElement; 943 + const currentHasSurge = currentBranch === "if" ? ifHasSurge : elseHasSurge; 924 944 925 - if (currentHasSurge) { 926 - await executeSurgeLeave(currentEl); 927 - } 945 + if (currentHasSurge) { 946 + await executeSurgeLeave(currentEl); 947 + } 928 948 929 - if (currentCleanup) { 930 - currentCleanup(); 931 - currentCleanup = undefined; 932 - } 933 - currentElement.remove(); 934 - currentElement = undefined; 949 + if (currentCleanup) { 950 + currentCleanup(); 951 + currentCleanup = undefined; 935 952 } 953 + currentElement.remove(); 954 + currentElement = undefined; 955 + } 936 956 937 - if (targetBranch === "if") { 938 - currentElement = ifTempl.cloneNode(true) as Element; 939 - delete (currentElement as HTMLElement).dataset.voltIf; 940 - placeholder.before(currentElement); 941 - 942 - if (ifHasSurge) { 943 - await executeSurgeEnter(currentElement as HTMLElement); 944 - } 957 + if (targetBranch === "if") { 958 + currentElement = ifTempl.cloneNode(true) as Element; 959 + delete (currentElement as HTMLElement).dataset.voltIf; 960 + placeholder.before(currentElement); 945 961 946 - currentCleanup = mount(currentElement, ctx.scope); 947 - currentBranch = "if"; 948 - } else if (targetBranch === "else" && elseTempl) { 949 - currentElement = elseTempl.cloneNode(true) as Element; 950 - delete (currentElement as HTMLElement).dataset.voltElse; 951 - placeholder.before(currentElement); 962 + if (ifHasSurge) { 963 + await executeSurgeEnter(currentElement as HTMLElement); 964 + } 952 965 953 - if (elseHasSurge) { 954 - await executeSurgeEnter(currentElement as HTMLElement); 955 - } 966 + currentCleanup = mount(currentElement, ctx.scope); 967 + currentBranch = "if"; 968 + } else if (targetBranch === "else" && elseTempl) { 969 + currentElement = elseTempl.cloneNode(true) as Element; 970 + delete (currentElement as HTMLElement).dataset.voltElse; 971 + placeholder.before(currentElement); 956 972 957 - currentCleanup = mount(currentElement, ctx.scope); 958 - currentBranch = "else"; 959 - } else { 960 - currentBranch = undefined; 973 + if (elseHasSurge) { 974 + await executeSurgeEnter(currentElement as HTMLElement); 961 975 } 962 - } finally { 963 - isTransitioning = false; 976 + 977 + currentCleanup = mount(currentElement, ctx.scope); 978 + currentBranch = "else"; 979 + } else { 980 + currentBranch = undefined; 981 + } 982 + } finally { 983 + isTransitioning = false; 984 + if (pendingRender) { 985 + pendingRender = false; 986 + render(); 964 987 } 965 - })(); 966 - }); 988 + } 989 + })(); 967 990 }; 968 991 969 992 updateAndRegister(ctx, render, expr);
+52 -9
lib/src/core/evaluator.ts
··· 5 5 * Includes hardened scope proxy to prevent prototype pollution and auto-unwrap signals. 6 6 */ 7 7 8 - import type { Scope } from "$types/volt"; 8 + import type { Dep, Scope, Signal } from "$types/volt"; 9 9 import { DANGEROUS_GLOBALS, DANGEROUS_PROPERTIES, SAFE_GLOBALS } from "./constants"; 10 10 import { isSignal } from "./shared"; 11 11 ··· 52 52 /** 53 53 * Type guard to check if a Dep has a set method (is a Signal vs ComputedSignal) 54 54 */ 55 - function hasSetMethod( 56 - dep: unknown, 57 - ): dep is { get: () => unknown; set: (v: unknown) => void; subscribe: (fn: () => void) => () => void } { 55 + function hasSetMethod(dep: unknown): dep is Dep & { set: (v: unknown) => void } { 58 56 return (typeof dep === "object" 59 57 && dep !== null 60 58 && "set" in dep ··· 71 69 * 72 70 * Handles both Signal (has set) and ComputedSignal (no set) 73 71 */ 74 - function wrapSignal( 75 - signal: { get: () => unknown; subscribe: (fn: () => void) => () => void }, 76 - options: WrapOptions, 77 - ): unknown { 72 + function wrapSignal(signal: Signal<unknown>, options: WrapOptions): unknown { 78 73 const hasSet = hasSetMethod(signal); 79 74 80 75 const wrapper: Record<string | symbol, unknown> = { ··· 107 102 return target[prop]; 108 103 } 109 104 105 + if (prop === Symbol.iterator) { 106 + const unwrapped = signal.get(); 107 + if (unwrapped && typeof unwrapped === "object" && Symbol.iterator in unwrapped) { 108 + return (unwrapped as Iterable<unknown>)[Symbol.iterator].bind(unwrapped); 109 + } 110 + return; 111 + } 112 + 110 113 const unwrapped = signal.get(); 111 114 if (unwrapped && (typeof unwrapped === "object" || typeof unwrapped === "function")) { 112 115 const wrapped = wrapValue(unwrapped, options); ··· 140 143 return true; 141 144 } 142 145 146 + if (prop === Symbol.iterator) { 147 + const unwrapped = signal.get(); 148 + return unwrapped !== null && unwrapped !== undefined && typeof unwrapped === "object" 149 + && Symbol.iterator in unwrapped; 150 + } 151 + 143 152 const unwrapped = signal.get(); 144 153 if (unwrapped && (typeof unwrapped === "object" || typeof unwrapped === "function")) { 145 154 return prop in unwrapped; ··· 168 177 if (options.unwrapSignals) { 169 178 return wrapValue((value as { get: () => unknown }).get(), options); 170 179 } 171 - return wrapSignal(value, options); 180 + return wrapSignal(value as Signal<unknown>, options); 172 181 } 173 182 174 183 if (typeof value !== "object" && typeof value !== "function") { ··· 384 393 const identifier = expr.slice(cursor, end); 385 394 result += "!$unwrap(" + identifier + ")"; 386 395 index = end; 396 + continue; 397 + } 398 + 399 + if (char === ":" && index > 0) { 400 + result += char; 401 + index += 1; 402 + 403 + while (index < expr.length && isWhitespace(expr[index])) { 404 + result += expr[index]; 405 + index += 1; 406 + } 407 + 408 + if (index < expr.length && isIdentifierStart(expr[index])) { 409 + const identStart = index; 410 + let identEnd = identStart + 1; 411 + 412 + while (identEnd < expr.length && isIdentifierPart(expr[identEnd])) { 413 + identEnd += 1; 414 + } 415 + 416 + let lookahead = identEnd; 417 + while (lookahead < expr.length && isWhitespace(expr[lookahead])) { 418 + lookahead += 1; 419 + } 420 + 421 + const afterIdent = expr[lookahead] ?? ""; 422 + if (afterIdent === "," || afterIdent === "}" || lookahead >= expr.length || afterIdent === ")") { 423 + const identifier = expr.slice(identStart, identEnd); 424 + result += "$unwrap(" + identifier + ")"; 425 + index = identEnd; 426 + continue; 427 + } 428 + } 429 + 387 430 continue; 388 431 } 389 432
+23 -4
lib/src/core/shared.ts
··· 28 28 29 29 export function findScopedSignal(scope: Scope, path: string): Optional<Signal<unknown>> { 30 30 const trimmed = path.trim(); 31 + if (!trimmed) { 32 + return undefined; 33 + } 34 + 31 35 const parts = trimmed.split("."); 32 36 let current: unknown = scope; 33 37 34 38 for (const part of parts) { 35 - if (isNil(current)) { 39 + if (isNil(current) || typeof current !== "object") { 36 40 return undefined; 37 41 } 38 42 39 - if (typeof current === "object" && part in (current as Record<string, unknown>)) { 40 - current = (current as Record<string, unknown>)[part]; 41 - } else { 43 + const record = current as Record<string, unknown>; 44 + 45 + if (Object.hasOwn(record, part)) { 46 + current = record[part]; 47 + continue; 48 + } 49 + 50 + const camelCandidate = kebabToCamel(part); 51 + if (Object.hasOwn(record, camelCandidate)) { 52 + current = record[camelCandidate]; 53 + continue; 54 + } 55 + 56 + const lowerPart = part.toLowerCase(); 57 + const matchedKey = Object.keys(record).find((key) => key.toLowerCase() === lowerPart); 58 + if (!matchedKey) { 42 59 return undefined; 43 60 } 61 + 62 + current = record[matchedKey]; 44 63 } 45 64 46 65 if (isSignal(current)) {
+7 -10
lib/src/demo/index.ts
··· 1 1 /** 2 - * Demo module for showcasing VoltX.js features and volt.css styling 2 + * Demo module for showcasing VoltX features and voltx.css styling 3 3 * 4 - * This module creates the entire demo structure programmatically using DOM APIs, 5 - * then uses charge() to mount it declaratively. 4 + * This module creates the entire demo structure programmatically using DOM APIs, then uses charge() to mount it declaratively. 6 5 */ 7 6 8 7 import { charge } from "$core/charge"; ··· 94 93 function getCurrentPageFromPath(): string { 95 94 const path = globalThis.location.pathname; 96 95 if (path === "/" || path === "") return "home"; 97 - return path.slice(1); // Remove leading slash 96 + return path.slice(1); 98 97 } 99 98 100 99 function buildDemoStructure(): HTMLElement { ··· 131 130 triggerFlash: 0, 132 131 triggerTripleBounce: 0, 133 132 triggerLongShake: 0, 133 + spinningGear: true, 134 134 }; 135 135 136 136 return dom.div( ··· 138 138 "data-volt": "", 139 139 "data-volt-state": JSON.stringify(initialState), 140 140 "data-volt-computed:doubled": "count * 2", 141 - "data-volt-computed:activeTodos": "todos.filter(t => !t.done)", 142 - "data-volt-computed:completedTodos": "todos.filter(t => t.done)", 141 + "data-volt-computed:active-todos": "todos.filter(t => !t.done)", 142 + "data-volt-computed:completed-todos": "todos.filter(t => t.done)", 143 143 }, 144 144 dom.header( 145 145 null, ··· 173 173 dom.a({ href: "https://github.com/stormlightlabs/volt" }, "VoltX.js"), 174 174 " - A lightweight, reactive hypermedia framework", 175 175 ), 176 - dom.p( 177 - null, 178 - "This demo showcases both VoltX.js reactive features and Volt CSS classless styling. View source to see how everything works!", 179 - ), 176 + dom.p(null, "This demo showcases both VoltX's reactive features and VoltX.css' classless styling."), 180 177 ), 181 178 ); 182 179 }
+14 -7
lib/src/demo/sections/animations.ts
··· 108 108 "data-volt-shift": "triggerFlash:flash", 109 109 }, "Flash"), 110 110 ), 111 - dom.p(null, "Spinning gear: ", dom.span({ "data-volt-shift": "spin", style: "font-size: 2rem;" }, "⚙️")), 111 + dom.p( 112 + null, 113 + dom.button({ "data-volt-on-click": "spinningGear.set(!spinningGear)" }, "Toggle Spin"), 114 + " Spinning gear: ", 115 + dom.span({ "data-volt-shift": "spinningGear:spin", style: "font-size: 2rem;" }, "⚙️"), 116 + ), 112 117 ), 113 118 dom.section( 114 119 null, ··· 139 144 dom.small(null, "Toggle to see content that fades in, then bounces on mount"), 140 145 ), 141 146 dom.button({ "data-volt-on-click": "showCombined.set(!showCombined)" }, "Toggle Combined Animation"), 142 - dom.aside( 143 - { "data-volt-if": "showCombined", "data-volt-surge": "fade.400", "data-volt-shift": "bounce" }, 144 - dom.p( 145 - null, 146 - dom.strong(null, "Animated aside:"), 147 - " This content fades in smoothly, then bounces when it appears!", 147 + dom.p( 148 + null, 149 + dom.strong(null, "Animated sidenote:"), 150 + " This paragraph keeps the flow of the article while the sidenote animates into view.", 151 + " ", 152 + dom.small( 153 + { "data-volt-if": "showCombined", "data-volt-surge": "fade.400", "data-volt-shift": "bounce" }, 154 + "This margin note fades into place and bounces to grab your attention.", 148 155 ), 149 156 ), 150 157 ),
+104 -15
lib/src/plugins/persist.ts
··· 4 4 * Supports localStorage, sessionStorage, IndexedDB, and custom adapters 5 5 */ 6 6 7 - import { isNil } from "$core/shared"; 7 + import { isNil, kebabToCamel } from "$core/shared"; 8 8 import type { Optional } from "$types/helpers"; 9 - import type { PluginContext, Signal, StorageAdapter } from "$types/volt"; 9 + import type { PluginContext, Scope, Signal, StorageAdapter } from "$types/volt"; 10 10 11 11 const storageAdapterRegistry = new Map<string, StorageAdapter>(); 12 12 ··· 151 151 } 152 152 } 153 153 154 + function resolveCanonicalPath(scope: Scope, rawPath: string): string { 155 + const trimmed = rawPath.trim(); 156 + if (!trimmed) { 157 + return trimmed; 158 + } 159 + 160 + const parts = trimmed.split("."); 161 + const resolved: string[] = []; 162 + let current: unknown = scope; 163 + 164 + for (const part of parts) { 165 + if (isNil(current) || typeof current !== "object") { 166 + resolved.push(part); 167 + current = undefined; 168 + continue; 169 + } 170 + 171 + const record = current as Record<string, unknown>; 172 + 173 + if (Object.hasOwn(record, part)) { 174 + resolved.push(part); 175 + current = record[part]; 176 + continue; 177 + } 178 + 179 + const camelCandidate = kebabToCamel(part); 180 + if (Object.hasOwn(record, camelCandidate)) { 181 + resolved.push(camelCandidate); 182 + current = record[camelCandidate]; 183 + continue; 184 + } 185 + 186 + const lower = part.toLowerCase(); 187 + const matchedKey = Object.keys(record).find((key) => key.toLowerCase() === lower); 188 + 189 + if (matchedKey) { 190 + resolved.push(matchedKey); 191 + current = record[matchedKey]; 192 + continue; 193 + } 194 + 195 + resolved.push(part); 196 + current = undefined; 197 + } 198 + 199 + return resolved.join("."); 200 + } 201 + 202 + function resolveSignal(ctx: PluginContext, rawPath: string): Optional<{ path: string; signal: Signal<unknown> }> { 203 + const trimmed = rawPath.trim(); 204 + if (!trimmed) { 205 + return undefined; 206 + } 207 + 208 + const canonicalPath = resolveCanonicalPath(ctx.scope, trimmed); 209 + const candidatePaths = new Set([canonicalPath, trimmed]); 210 + 211 + for (const candidate of candidatePaths) { 212 + const found = ctx.findSignal(candidate); 213 + if (found) { 214 + return { path: candidate, signal: found as Signal<unknown> }; 215 + } 216 + } 217 + } 218 + 219 + function normalizeStorageType(type: string): { key: string; original: string } { 220 + const original = type.trim(); 221 + const normalized = original.toLowerCase().replaceAll(/[\s_-]/g, ""); 222 + 223 + switch (normalized) { 224 + case "local": 225 + case "localstorage": { 226 + return { key: "local", original }; 227 + } 228 + case "session": 229 + case "sessionstorage": { 230 + return { key: "session", original }; 231 + } 232 + case "indexeddb": 233 + case "indexed-db": { 234 + return { key: "indexeddb", original }; 235 + } 236 + default: { 237 + return { key: original, original }; 238 + } 239 + } 240 + } 241 + 154 242 /** 155 243 * Persist plugin handler. 156 244 * Synchronizes signal values with persistent storage. ··· 170 258 } 171 259 172 260 const [signalPath, storageType] = parts; 173 - const signal = ctx.findSignal(signalPath.trim()); 261 + const resolvedSignal = resolveSignal(ctx, signalPath); 174 262 175 - if (!signal) { 176 - console.error(`Signal "${signalPath}" not found in scope for persist binding`); 263 + if (!resolvedSignal) { 264 + console.error(`Signal "${signalPath.trim()}" not found in scope for persist binding`); 177 265 return; 178 266 } 179 267 180 - const adapter = getStorageAdapter(storageType.trim()); 268 + const { key: adapterKey, original } = normalizeStorageType(storageType); 269 + const adapter = getStorageAdapter(adapterKey) ?? (adapterKey === original ? undefined : getStorageAdapter(original)); 181 270 if (!adapter) { 182 - console.error(`Unknown storage type: "${storageType}"`); 271 + console.error(`Unknown storage type: "${storageType.trim()}"`); 183 272 return; 184 273 } 185 274 186 - const storageKey = `volt:${signalPath.trim()}`; 275 + const storageKey = `volt:${resolvedSignal.path}`; 187 276 188 277 try { 189 278 const result = adapter.get(storageKey); 190 279 if (result instanceof Promise) { 191 280 result.then((storedValue) => { 192 281 if (storedValue !== undefined) { 193 - (signal as Signal<unknown>).set(storedValue); 282 + resolvedSignal.signal.set(storedValue); 194 283 } 195 284 }).catch((error) => { 196 - console.error(`Failed to load persisted value for "${signalPath}":`, error); 285 + console.error(`Failed to load persisted value for "${signalPath.trim()}":`, error); 197 286 }); 198 287 } else if (result !== undefined) { 199 - (signal as Signal<unknown>).set(result); 288 + resolvedSignal.signal.set(result); 200 289 } 201 290 } catch (error) { 202 - console.error(`Failed to load persisted value for "${signalPath}":`, error); 291 + console.error(`Failed to load persisted value for "${signalPath.trim()}":`, error); 203 292 } 204 293 205 - const unsubscribe = signal.subscribe((newValue) => { 294 + const unsubscribe = resolvedSignal.signal.subscribe((newValue) => { 206 295 try { 207 296 const result = adapter.set(storageKey, newValue); 208 297 if (result instanceof Promise) { 209 298 result.catch((error) => { 210 - console.error(`Failed to persist value for "${signalPath}":`, error); 299 + console.error(`Failed to persist value for "${signalPath.trim()}":`, error); 211 300 }); 212 301 } 213 302 } catch (error) { 214 - console.error(`Failed to persist value for "${signalPath}":`, error); 303 + console.error(`Failed to persist value for "${signalPath.trim()}":`, error); 215 304 } 216 305 }); 217 306
+160 -14
lib/src/plugins/shift.ts
··· 11 11 * Registry of animation presets 12 12 */ 13 13 const animationRegistry = new Map<string, AnimationPreset>(); 14 + const keyframeRegistry = new Map<string, string>(); 15 + 16 + let keyframeSheet: Optional<CSSStyleSheet>; 17 + let keyframeCounter = 0; 14 18 15 19 /** 16 20 * Built-in animation presets with CSS keyframes ··· 211 215 return result; 212 216 } 213 217 214 - function applyAnimation(element: HTMLElement, preset: AnimationPreset, duration?: number, iterations?: number): void { 218 + function stopAnimation(el: HTMLElement): void { 219 + el.style.animation = ""; 220 + el.style.animationName = ""; 221 + el.style.animationDuration = ""; 222 + el.style.animationTimingFunction = ""; 223 + el.style.animationIterationCount = ""; 224 + el.style.animationFillMode = ""; 225 + restoreOriginalDisplay(el); 226 + } 227 + 228 + function applyAnimation(el: HTMLElement, preset: AnimationPreset, duration?: number, iterations?: number): void { 215 229 if (prefersReducedMotion()) { 216 230 return; 217 231 } 218 232 219 233 const effectiveDuration = duration ?? preset.duration; 220 234 const effectiveIterations = iterations ?? preset.iterations; 235 + const animationName = getOrCreateKeyframes(preset); 236 + if (!animationName) { 237 + return; 238 + } 221 239 222 - const animation = element.animate(preset.keyframes, { 223 - duration: effectiveDuration, 224 - iterations: effectiveIterations, 225 - easing: preset.timing, 226 - fill: "forwards", 227 - }); 240 + ensureInlineBlockForTransforms(el, effectiveIterations === Number.POSITIVE_INFINITY); 241 + resetCssAnimation(el); 242 + 243 + el.style.animationName = animationName; 244 + el.style.animationDuration = `${effectiveDuration}ms`; 245 + el.style.animationTimingFunction = preset.timing; 246 + el.style.animationIterationCount = effectiveIterations === Number.POSITIVE_INFINITY 247 + ? "infinite" 248 + : String(effectiveIterations); 249 + el.style.animationFillMode = "forwards"; 250 + 251 + const runs = Number.parseInt(el.dataset.voltShiftRuns ?? "0", 10) + 1; 252 + el.dataset.voltShiftRuns = String(runs); 253 + 254 + if (effectiveIterations !== Number.POSITIVE_INFINITY) { 255 + const totalDuration = effectiveDuration * effectiveIterations; 256 + setTimeout(() => { 257 + if (el.style.animationName === animationName) { 258 + stopAnimation(el); 259 + } 260 + }, totalDuration); 261 + } 262 + } 263 + 264 + function resetCssAnimation(el: HTMLElement): void { 265 + const previousName = el.style.animationName; 266 + if (!previousName) { 267 + return; 268 + } 269 + el.style.animation = "none"; 270 + void el.offsetWidth; 271 + el.style.animation = ""; 272 + el.style.animationName = ""; 273 + } 274 + 275 + function ensureKeyframeSheet(): Optional<CSSStyleSheet> { 276 + if (keyframeSheet) { 277 + return keyframeSheet; 278 + } 279 + 280 + if (typeof document === "undefined" || !document.head) { 281 + return undefined; 282 + } 283 + 284 + const styleEl = document.createElement("style"); 285 + styleEl.dataset.voltShift = "true"; 286 + document.head.append(styleEl); 287 + keyframeSheet = styleEl.sheet ?? undefined; 288 + return keyframeSheet; 289 + } 228 290 229 - animation.onfinish = () => { 230 - animation.cancel(); 231 - }; 291 + function toCssProperty(property: string): string { 292 + return property.replaceAll(/[A-Z]/g, (match) => `-${match.toLowerCase()}`); 293 + } 294 + 295 + function getOrCreateKeyframes(preset: AnimationPreset): Optional<string> { 296 + const key = JSON.stringify(preset.keyframes) + preset.timing; 297 + if (keyframeRegistry.has(key)) { 298 + return keyframeRegistry.get(key); 299 + } 300 + 301 + const sheet = ensureKeyframeSheet(); 302 + if (!sheet) { 303 + return undefined; 304 + } 305 + 306 + const animationName = `volt-shift-${keyframeCounter += 1}`; 307 + keyframeRegistry.set(key, animationName); 308 + 309 + const frames = preset.keyframes.map((frame, index) => { 310 + const offset = frame.offset ?? (preset.keyframes.length > 1 ? index / (preset.keyframes.length - 1) : 0); 311 + const percent = Math.round(offset * 10_000) / 100; 312 + const declarations = Object.entries(frame).filter(([prop]) => prop !== "offset").map(([prop, value]) => 313 + `${toCssProperty(prop)}: ${value};` 314 + ).join(" "); 315 + return `${percent}% { ${declarations} }`; 316 + }).join(" "); 317 + 318 + sheet.insertRule(`@keyframes ${animationName} { ${frames} }`, sheet.cssRules.length); 319 + return animationName; 320 + } 321 + 322 + function ensureInlineBlockForTransforms(el: HTMLElement, isInf: boolean): void { 323 + if (el.dataset.voltShiftDisplayManaged) { 324 + return; 325 + } 326 + 327 + if (typeof getComputedStyle !== "function") { 328 + return; 329 + } 330 + 331 + if (!el.isConnected) { 332 + return; 333 + } 334 + 335 + void el.offsetHeight; 336 + 337 + const computedDisplay = getComputedStyle(el).display; 338 + if (computedDisplay !== "inline") { 339 + return; 340 + } 341 + 342 + el.dataset.voltShiftDisplayManaged = isInf ? "infinite" : "managed"; 343 + el.dataset.voltShiftOriginalDisplay = el.style.display ?? ""; 344 + 345 + if (!el.dataset.voltShiftOriginalTransformOrigin) { 346 + el.dataset.voltShiftOriginalTransformOrigin = el.style.transformOrigin ?? ""; 347 + } 348 + 349 + el.style.display = "inline-block"; 350 + if (!el.style.transformOrigin) { 351 + el.style.transformOrigin = "center center"; 352 + } 353 + } 354 + 355 + function restoreOriginalDisplay(element: HTMLElement): void { 356 + const state = element.dataset.voltShiftDisplayManaged; 357 + if (!state || state === "infinite") { 358 + return; 359 + } 360 + 361 + const original = element.dataset.voltShiftOriginalDisplay ?? ""; 362 + element.style.display = original; 363 + const originalOrigin = element.dataset.voltShiftOriginalTransformOrigin ?? ""; 364 + element.style.transformOrigin = originalOrigin; 365 + delete element.dataset.voltShiftDisplayManaged; 366 + delete element.dataset.voltShiftOriginalDisplay; 367 + delete element.dataset.voltShiftOriginalTransformOrigin; 232 368 } 233 369 234 370 /** ··· 277 413 return; 278 414 } 279 415 416 + const effectiveIterations = parsed.iterations ?? preset.iterations; 417 + const isInfinite = effectiveIterations === Number.POSITIVE_INFINITY; 280 418 let previousValue = signal.get(); 281 419 282 420 const unsubscribe = signal.subscribe((value) => { 283 - if (value !== previousValue && Boolean(value)) { 284 - applyAnimation(el, preset, parsed.duration, parsed.iterations); 421 + if (value !== previousValue) { 422 + if (value) { 423 + applyAnimation(el, preset, parsed.duration, parsed.iterations); 424 + } else if (isInfinite && el.style.animationName) { 425 + stopAnimation(el); 426 + } 285 427 } 286 428 previousValue = value; 287 429 }); ··· 290 432 291 433 if (signal.get()) { 292 434 ctx.lifecycle.onMount(() => { 293 - applyAnimation(el, preset, parsed.duration, parsed.iterations); 435 + requestAnimationFrame(() => { 436 + applyAnimation(el, preset, parsed.duration, parsed.iterations); 437 + }); 294 438 }); 295 439 } 296 440 } else { 297 441 ctx.lifecycle.onMount(() => { 298 - applyAnimation(el, preset, parsed.duration, parsed.iterations); 442 + requestAnimationFrame(() => { 443 + applyAnimation(el, preset, parsed.duration, parsed.iterations); 444 + }); 299 445 }); 300 446 } 301 447 }
+55 -12
lib/src/plugins/surge.ts
··· 9 9 import type { Optional } from "$types/helpers"; 10 10 import type { PluginContext, Signal, TransitionPhase } from "$types/volt"; 11 11 12 + type SurgeElement = HTMLElement & { 13 + _vxSurgeConf?: SurgeConfig; 14 + _vxSurgeEnter?: TransitionPhase; 15 + _vxSurgeLeave?: TransitionPhase; 16 + }; 17 + 12 18 type SurgeConfig = { 13 19 enterPreset?: TransitionPhase; 14 20 leavePreset?: TransitionPhase; ··· 205 211 return { enterPreset: parsed.preset.enter, leavePreset: parsed.preset.leave, useViewTransitions: true }; 206 212 } 207 213 214 + function ensureInlineSurgeState(element: SurgeElement): void { 215 + if (!element._vxSurgeConf) { 216 + const attr = element.dataset.voltSurge; 217 + if (attr) { 218 + const parsed = parseSurgeValue(attr); 219 + if (parsed) { 220 + element._vxSurgeConf = parsed; 221 + } 222 + } 223 + } 224 + 225 + if (!element._vxSurgeEnter) { 226 + const enterAttr = element.dataset["voltSurge:enter"]; 227 + if (enterAttr) { 228 + const enterPhase = parsePhaseValue(enterAttr, "enter"); 229 + if (enterPhase) { 230 + element._vxSurgeEnter = enterPhase; 231 + } 232 + } 233 + } 234 + 235 + if (!element._vxSurgeLeave) { 236 + const leaveAttr = element.dataset["voltSurge:leave"]; 237 + if (leaveAttr) { 238 + const leavePhase = parsePhaseValue(leaveAttr, "leave"); 239 + if (leavePhase) { 240 + element._vxSurgeLeave = leavePhase; 241 + } 242 + } 243 + } 244 + } 245 + 208 246 function parsePhaseValue(value: string, phase: "enter" | "leave"): Optional<TransitionPhase> { 209 247 const parsed = parseTransitionValue(value.trim()); 210 248 if (!parsed) { ··· 238 276 * ``` 239 277 */ 240 278 export function surgePlugin(ctx: PluginContext, value: string): void { 241 - const el = ctx.element as HTMLElement; 279 + const el = ctx.element as SurgeElement; 242 280 243 281 if (value.includes(":")) { 244 282 const [phase, presetValue] = value.split(":", 2); ··· 250 288 return; 251 289 } 252 290 253 - (el as HTMLElement & { _voltSurgeEnter?: TransitionPhase })._voltSurgeEnter = enterPhase; 291 + el._vxSurgeEnter = enterPhase; 254 292 return; 255 293 } 256 294 ··· 261 299 return; 262 300 } 263 301 264 - (el as HTMLElement & { _voltSurgeLeave?: TransitionPhase })._voltSurgeLeave = leavePhase; 302 + el._vxSurgeLeave = leavePhase; 265 303 return; 266 304 } 267 305 } ··· 273 311 } 274 312 275 313 if (!config.signalPath) { 276 - (el as HTMLElement & { _voltSurgeConfig?: SurgeConfig })._voltSurgeConfig = config; 314 + el._vxSurgeConf = config; 277 315 return; 278 316 } 279 317 ··· 322 360 * @internal 323 361 */ 324 362 export async function executeSurgeEnter(element: HTMLElement): Promise<void> { 325 - const config = (element as HTMLElement & { _voltSurgeConfig?: SurgeConfig })._voltSurgeConfig; 326 - const customEnter = (element as HTMLElement & { _voltSurgeEnter?: TransitionPhase })._voltSurgeEnter; 363 + const surgeEl = element as SurgeElement; 364 + ensureInlineSurgeState(surgeEl); 365 + 366 + const config = surgeEl._vxSurgeConf; 367 + const customEnter = surgeEl._vxSurgeEnter; 327 368 328 369 const enterPhase = customEnter ?? config?.enterPreset; 329 370 if (!enterPhase) { ··· 338 379 * @internal 339 380 */ 340 381 export async function executeSurgeLeave(element: HTMLElement): Promise<void> { 341 - const config = (element as HTMLElement & { _voltSurgeConfig?: SurgeConfig })._voltSurgeConfig; 342 - const customLeave = (element as HTMLElement & { _voltSurgeLeave?: TransitionPhase })._voltSurgeLeave; 382 + const surgeEl = element as SurgeElement; 383 + ensureInlineSurgeState(surgeEl); 384 + 385 + const config = surgeEl._vxSurgeConf; 386 + const customLeave = surgeEl._vxSurgeLeave; 343 387 344 388 const leavePhase = customLeave ?? config?.leavePreset; 345 389 if (!leavePhase) { ··· 354 398 * @internal 355 399 */ 356 400 export function hasSurge(element: HTMLElement): boolean { 357 - const config = (element as HTMLElement & { _voltSurgeConfig?: SurgeConfig })._voltSurgeConfig; 358 - const customEnter = (element as HTMLElement & { _voltSurgeEnter?: TransitionPhase })._voltSurgeEnter; 359 - const customLeave = (element as HTMLElement & { _voltSurgeLeave?: TransitionPhase })._voltSurgeLeave; 401 + const surgeEl = element as SurgeElement; 402 + ensureInlineSurgeState(surgeEl); 360 403 361 - return Boolean(config || customEnter || customLeave); 404 + return Boolean(surgeEl._vxSurgeConf || surgeEl._vxSurgeEnter || surgeEl._vxSurgeLeave); 362 405 }
+190 -74
lib/src/plugins/url.ts
··· 3 3 * Supports one-way read, bidirectional sync, and hash-based routing 4 4 */ 5 5 6 - import { isNil } from "$core/shared"; 6 + import { isNil, kebabToCamel } from "$core/shared"; 7 7 import type { Optional } from "$types/helpers"; 8 - import type { PluginContext, Signal } from "$types/volt"; 8 + import type { PluginContext, Scope, Signal } from "$types/volt"; 9 + 10 + type UrlMode = "read" | "sync" | "hash" | "history"; 11 + 12 + interface ResolvedSignal<T = unknown> { 13 + path: string; 14 + signal: Signal<T>; 15 + } 16 + 17 + function normalizeMode(mode: string): Optional<UrlMode> { 18 + const normalized = mode.trim().toLowerCase().replaceAll(/[\s_-]/g, ""); 19 + 20 + switch (normalized) { 21 + case "read": { 22 + return "read"; 23 + } 24 + case "sync": 25 + case "bidirectional": { 26 + return "sync"; 27 + } 28 + case "query": 29 + case "search": { 30 + return "sync"; 31 + } 32 + case "hash": { 33 + return "hash"; 34 + } 35 + case "history": 36 + case "route": { 37 + return "history"; 38 + } 39 + default: { 40 + return undefined; 41 + } 42 + } 43 + } 44 + 45 + function resolveCanonicalPath(scope: Scope, rawPath: string): string { 46 + const trimmed = rawPath.trim(); 47 + if (!trimmed) { 48 + return trimmed; 49 + } 50 + 51 + const parts = trimmed.split("."); 52 + const resolved: string[] = []; 53 + let current: unknown = scope; 54 + 55 + for (const part of parts) { 56 + if (isNil(current) || typeof current !== "object") { 57 + resolved.push(part); 58 + current = undefined; 59 + continue; 60 + } 61 + 62 + const record = current as Record<string, unknown>; 63 + 64 + if (Object.hasOwn(record, part)) { 65 + resolved.push(part); 66 + current = record[part]; 67 + continue; 68 + } 69 + 70 + const camelCandidate = kebabToCamel(part); 71 + if (Object.hasOwn(record, camelCandidate)) { 72 + resolved.push(camelCandidate); 73 + current = record[camelCandidate]; 74 + continue; 75 + } 76 + 77 + const lower = part.toLowerCase(); 78 + const matchedKey = Object.keys(record).find((key) => key.toLowerCase() === lower); 79 + 80 + if (matchedKey) { 81 + resolved.push(matchedKey); 82 + current = record[matchedKey]; 83 + continue; 84 + } 85 + 86 + resolved.push(part); 87 + current = undefined; 88 + } 89 + 90 + return resolved.join("."); 91 + } 92 + 93 + function resolveSignal(ctx: PluginContext, rawPath: string): Optional<ResolvedSignal> { 94 + const trimmed = rawPath.trim(); 95 + if (!trimmed) { 96 + return undefined; 97 + } 98 + 99 + const canonicalPath = resolveCanonicalPath(ctx.scope, trimmed); 100 + const candidatePaths = new Set([canonicalPath, trimmed]); 101 + 102 + for (const candidate of candidatePaths) { 103 + const found = ctx.findSignal(candidate); 104 + if (found) { 105 + return { path: candidate, signal: found as Signal<unknown> }; 106 + } 107 + } 108 + 109 + return undefined; 110 + } 9 111 10 112 /** 11 113 * URL plugin handler. 12 114 * Synchronizes signal values with URL parameters, hash, and full history state. 13 115 * 14 116 * Syntax: data-volt-url="mode:signalPath" or data-volt-url="mode:signalPath:basePath" 117 + * Alternate syntax: data-volt-url:signalPath="mode" (e.g., data-volt-url:search="query") 15 118 * Modes: 16 119 * - read:signalPath - Read URL param into signal on mount (one-way) 17 120 * - sync:signalPath - Bidirectional sync between signal and URL param ··· 19 122 * - history:signalPath[:basePath] - Sync with full path + search (History API routing) 20 123 */ 21 124 export function urlPlugin(ctx: PluginContext, value: string): void { 22 - const parts = value.split(":"); 125 + const parts = value.split(":").map((part) => part.trim()).filter((part) => part.length > 0); 23 126 if (parts.length < 2) { 24 - console.error(`Invalid url binding: "${value}". Expected format: "mode:signalPath[:basePath]"`); 127 + console.error( 128 + `Invalid url binding: "${value}". Expected format: "mode:signalPath[:basePath]" or "signalPath:mode[:basePath]"`, 129 + ); 25 130 return; 26 131 } 27 132 28 - const [mode, signalPath, basePath] = parts.map((p) => p.trim()); 133 + const firstMode = normalizeMode(parts[0]); 134 + const secondMode = normalizeMode(parts[1] ?? ""); 135 + 136 + let mode: Optional<UrlMode>; 137 + let signalPath: string; 138 + let basePath: Optional<string>; 139 + 140 + if (firstMode) { 141 + mode = firstMode; 142 + signalPath = parts[1] ?? ""; 143 + basePath = parts.slice(2).join(":") || undefined; 144 + } else if (secondMode) { 145 + mode = secondMode; 146 + signalPath = parts[0]; 147 + basePath = parts.slice(2).join(":") || undefined; 148 + } else { 149 + console.error(`Unknown url mode in binding "${value}"`); 150 + return; 151 + } 152 + 153 + if (!signalPath) { 154 + console.error(`Signal path missing for url binding "${value}"`); 155 + return; 156 + } 157 + 158 + const resolvedSignal = resolveSignal(ctx, signalPath); 159 + if (!resolvedSignal) { 160 + console.error(`Signal "${signalPath}" not found for url binding`); 161 + return; 162 + } 29 163 30 164 switch (mode) { 31 165 case "read": { 32 - handleReadURL(ctx, signalPath); 166 + handleReadURL(resolvedSignal); 33 167 break; 34 168 } 35 169 case "sync": { 36 - handleSyncURL(ctx, signalPath); 170 + handleSyncURL(ctx, resolvedSignal); 37 171 break; 38 172 } 39 173 case "hash": { 40 - handleHashRouting(ctx, signalPath); 174 + handleHashRouting(ctx, resolvedSignal as ResolvedSignal<string>); 41 175 break; 42 176 } 43 177 case "history": { 44 - handleHistoryRouting(ctx, signalPath, basePath); 178 + handleHistoryRouting(ctx, resolvedSignal as ResolvedSignal<string>, basePath); 45 179 break; 46 180 } 47 - default: { 48 - console.error(`Unknown url mode: "${mode}"`); 49 - } 50 181 } 51 182 } 52 183 ··· 54 185 * Read URL parameter into signal on mount (one-way). 55 186 * Signal changes do not update URL. 56 187 */ 57 - function handleReadURL(ctx: PluginContext, signalPath: string): void { 58 - const signal = ctx.findSignal(signalPath); 59 - if (!signal) { 60 - console.error(`Signal "${signalPath}" not found for url read`); 61 - return; 62 - } 63 - 188 + function handleReadURL(resolved: ResolvedSignal): void { 64 189 const params = new URLSearchParams(globalThis.location.search); 65 - const paramValue = params.get(signalPath); 190 + const paramValue = params.get(resolved.path); 66 191 67 192 if (paramValue !== null) { 68 - (signal as Signal<unknown>).set(deserializeValue(paramValue)); 193 + resolved.signal.set(deserializeValue(paramValue)); 69 194 } 70 195 } 71 196 ··· 73 198 * Bidirectional sync between signal and URL parameter. 74 199 * Changes to either the signal or URL update the other. 75 200 */ 76 - function handleSyncURL(ctx: PluginContext, signalPath: string): void { 77 - const signal = ctx.findSignal(signalPath); 78 - if (!signal) { 79 - console.error(`Signal "${signalPath}" not found for url sync`); 80 - return; 81 - } 82 - 201 + function handleSyncURL(ctx: PluginContext, resolved: ResolvedSignal): void { 83 202 const params = new URLSearchParams(globalThis.location.search); 84 - const paramValue = params.get(signalPath); 203 + const paramValue = params.get(resolved.path); 85 204 if (paramValue !== null) { 86 - (signal as Signal<unknown>).set(deserializeValue(paramValue)); 205 + resolved.signal.set(deserializeValue(paramValue)); 87 206 } 88 207 89 208 let isUpdatingFromUrl = false; 90 209 let updateTimeout: Optional<number>; 91 210 92 211 const updateUrl = (value: unknown) => { 93 - if (isUpdatingFromUrl) return; 212 + if (isUpdatingFromUrl) { 213 + return; 214 + } 94 215 95 216 if (updateTimeout) { 96 217 clearTimeout(updateTimeout); ··· 101 222 const serialized = serializeValue(value); 102 223 103 224 if (isNil(serialized) || serialized === "") { 104 - params.delete(signalPath); 225 + params.delete(resolved.path); 105 226 } else { 106 - params.set(signalPath, serialized); 227 + params.set(resolved.path, serialized); 107 228 } 108 229 109 230 const newSearch = params.toString(); ··· 116 237 const handlePopState = () => { 117 238 isUpdatingFromUrl = true; 118 239 const params = new URLSearchParams(globalThis.location.search); 119 - const paramValue = params.get(signalPath); 240 + const paramValue = params.get(resolved.path); 120 241 121 242 if (isNil(paramValue)) { 122 - (signal as Signal<unknown>).set(""); 243 + resolved.signal.set(""); 123 244 } else { 124 - (signal as Signal<unknown>).set(deserializeValue(paramValue)); 245 + resolved.signal.set(deserializeValue(paramValue)); 125 246 } 126 247 isUpdatingFromUrl = false; 127 248 }; 128 249 129 - const unsubscribe = signal.subscribe(updateUrl); 250 + const unsubscribe = resolved.signal.subscribe(updateUrl); 130 251 globalThis.addEventListener("popstate", handlePopState); 131 252 132 253 ctx.addCleanup(() => { ··· 142 263 * Sync signal with hash portion of URL for client-side routing. 143 264 * Bidirectional sync between signal and window.location.hash. 144 265 */ 145 - function handleHashRouting(ctx: PluginContext, signalPath: string): void { 146 - const signal = ctx.findSignal(signalPath); 147 - if (!signal) { 148 - console.error(`Signal "${signalPath}" not found for hash routing`); 149 - return; 150 - } 151 - 266 + function handleHashRouting(ctx: PluginContext, resolved: ResolvedSignal<string>): void { 152 267 const currentHash = globalThis.location.hash.slice(1); 153 268 if (currentHash) { 154 - (signal as Signal<string>).set(currentHash); 269 + resolved.signal.set(currentHash); 155 270 } 156 271 157 272 let isUpdatingFromHash = false; 158 273 159 274 const updateHash = (value: unknown) => { 160 - if (isUpdatingFromHash) return; 275 + if (isUpdatingFromHash) { 276 + return; 277 + } 161 278 162 279 const hashValue = String(value ?? ""); 163 280 const newHash = hashValue ? `#${hashValue}` : ""; ··· 170 287 const handleHashChange = () => { 171 288 isUpdatingFromHash = true; 172 289 const currentHash = globalThis.location.hash.slice(1); 173 - (signal as Signal<string>).set(currentHash); 290 + resolved.signal.set(currentHash); 174 291 isUpdatingFromHash = false; 175 292 }; 176 293 177 - const unsubscribe = signal.subscribe(updateHash); 294 + const unsubscribe = resolved.signal.subscribe(updateHash); 178 295 globalThis.addEventListener("hashchange", handleHashChange); 179 296 180 297 ctx.addCleanup(() => { ··· 221 338 } 222 339 } 223 340 341 + function normalizeRoute(path: string) { 342 + if (!path) { 343 + return "/"; 344 + } 345 + return path.startsWith("/") ? path : `/${path}`; 346 + } 347 + 224 348 /** 225 349 * Sync signal with full path + search params for History API routing. 226 350 * Bidirectional sync between signal and window.location.pathname + search. 227 - * 228 - * @param ctx - Plugin context 229 - * @param signalPath - Signal path to sync 230 - * @param basePath - Optional base path to strip from routes (e.g., "/app") 231 351 */ 232 - function handleHistoryRouting(ctx: PluginContext, signalPath: string, basePath?: string): void { 233 - const signal = ctx.findSignal(signalPath); 234 - if (!signal) { 235 - console.error(`Signal "${signalPath}" not found for history routing`); 236 - return; 237 - } 352 + function handleHistoryRouting(ctx: PluginContext, resolved: ResolvedSignal<string>, basePath?: string): void { 353 + const base = basePath?.trim() ?? ""; 238 354 239 - const base = basePath || ""; 240 - const getCurrentRoute = (): string => { 355 + const extractRoute = () => { 241 356 const fullPath = globalThis.location.pathname + globalThis.location.search; 242 357 if (base && fullPath.startsWith(base)) { 243 - return fullPath.slice(base.length) || "/"; 358 + const stripped = fullPath.slice(base.length) || "/"; 359 + return normalizeRoute(stripped); 244 360 } 245 - return fullPath; 361 + return normalizeRoute(fullPath); 246 362 }; 247 363 248 - const currentRoute = getCurrentRoute(); 249 - if (currentRoute) { 250 - (signal as Signal<string>).set(currentRoute); 251 - } 364 + const currentRoute = extractRoute(); 365 + resolved.signal.set(currentRoute); 252 366 253 367 let isUpdatingFromHistory = false; 254 368 255 369 const updateUrl = (value: unknown) => { 256 - if (isUpdatingFromHistory) return; 370 + if (isUpdatingFromHistory) { 371 + return; 372 + } 257 373 258 - const route = String(value ?? "/"); 374 + const route = normalizeRoute(String(value ?? "/")); 259 375 const fullPath = base ? `${base}${route}` : route; 376 + const currentFull = globalThis.location.pathname + globalThis.location.search; 260 377 261 - if (globalThis.location.pathname + globalThis.location.search !== fullPath) { 378 + if (currentFull !== fullPath) { 262 379 globalThis.history.pushState({}, "", fullPath); 263 380 globalThis.dispatchEvent( 264 381 new CustomEvent("volt:navigate", { detail: { url: fullPath, route }, bubbles: true, cancelable: false }), ··· 268 385 269 386 const handlePopState = () => { 270 387 isUpdatingFromHistory = true; 271 - const route = getCurrentRoute(); 272 - (signal as Signal<string>).set(route); 388 + const route = extractRoute(); 389 + resolved.signal.set(route); 273 390 globalThis.dispatchEvent(new CustomEvent("volt:popstate", { detail: { route }, bubbles: true, cancelable: false })); 274 391 isUpdatingFromHistory = false; 275 392 }; 276 393 277 394 const handleNavigate = () => { 278 395 isUpdatingFromHistory = true; 279 - const route = getCurrentRoute(); 280 - (signal as Signal<string>).set(route); 396 + resolved.signal.set(extractRoute()); 281 397 isUpdatingFromHistory = false; 282 398 }; 283 399 284 - const unsubscribe = signal.subscribe(updateUrl); 400 + const unsubscribe = resolved.signal.subscribe(updateUrl); 285 401 globalThis.addEventListener("popstate", handlePopState); 286 402 globalThis.addEventListener("volt:navigate", handleNavigate); 287 403
+45
lib/test/core/evaluator.test.ts
··· 294 294 expect(evaluate("status == 'active'", scope)).toBe(true); 295 295 expect(evaluate("status == 'inactive'", scope)).toBe(false); 296 296 }); 297 + 298 + it("should support spreading signals containing arrays", () => { 299 + scope.items = signal([2, 3, 4]); 300 + const result = evaluate("[1, ...items, 5]", scope); 301 + expect(result).toEqual([1, 2, 3, 4, 5]); 302 + }); 303 + 304 + it("should support spreading signals in complex expressions", () => { 305 + scope.todos = signal([{ id: 1, text: "Learn" }, { id: 2, text: "Build" }]); 306 + scope.newTodo = { id: 3, text: "Ship" }; 307 + const result = evaluate("[...todos, newTodo]", scope); 308 + expect(result).toEqual([{ id: 1, text: "Learn" }, { id: 2, text: "Build" }, { id: 3, text: "Ship" }]); 309 + }); 310 + 311 + it("should support iterating over signals containing arrays", () => { 312 + scope.items = signal([1, 2, 3]); 313 + const result = evaluate("[...items].map(x => x * 2)", scope); 314 + expect(result).toEqual([2, 4, 6]); 315 + }); 316 + 317 + it("should handle spreading non-iterable signals gracefully", () => { 318 + scope.count = signal(42); 319 + expect(() => evaluate("[...count]", scope)).toThrow(); 320 + }); 321 + 322 + it("should unwrap signals in object literals when unwrapSignals is false", () => { 323 + scope.id = signal(42); 324 + scope.name = signal("Alice"); 325 + const result = evaluate("{id: id, name: name}", scope, { unwrapSignals: false }); 326 + expect(result).toEqual({ id: 42, name: "Alice" }); 327 + }); 328 + 329 + it("should unwrap signals in complex object literals", () => { 330 + scope.todoId = signal(3); 331 + scope.todoText = signal("New task"); 332 + scope.todoDone = signal(false); 333 + const result = evaluate("{id: todoId, text: todoText, done: todoDone}", scope, { unwrapSignals: false }); 334 + expect(result).toEqual({ id: 3, text: "New task", done: false }); 335 + }); 336 + 337 + it("should not unwrap method calls in object literals", () => { 338 + scope.text = signal(" hello "); 339 + const result = evaluate("{value: text.trim()}", scope, { unwrapSignals: false }); 340 + expect(result).toEqual({ value: "hello" }); 341 + }); 297 342 }); 298 343 299 344 describe("Expression Caching", () => {
+18 -24
lib/test/integration/transitions.test.ts
··· 114 114 const show = signal(true); 115 115 mount(container, { show }); 116 116 117 - await vi.advanceTimersByTimeAsync(50); 117 + await vi.advanceTimersByTimeAsync(400); 118 118 119 119 let shownEl = [...container.querySelectorAll("div")].find((el) => el.textContent?.includes("Shown")); 120 120 let hiddenEl = [...container.querySelectorAll("div")].find((el) => el.textContent?.includes("Hidden")); ··· 275 275 }); 276 276 277 277 describe("Shift animations", () => { 278 - beforeEach(() => { 279 - HTMLElement.prototype.animate = vi.fn((_keyframes: Keyframe[], _options?: KeyframeAnimationOptions) => { 280 - return { onfinish: null, cancel: vi.fn() } as unknown as Animation; 281 - }); 282 - }); 283 - 284 - it("should apply animation on mount", () => { 278 + it("should apply animation on mount", async () => { 285 279 const container = document.createElement("div"); 286 280 const testEl = document.createElement("div"); 287 281 testEl.dataset.voltShift = "bounce"; ··· 292 286 293 287 mount(container, {}); 294 288 295 - expect(element.animate).toHaveBeenCalled(); 289 + // Wait for requestAnimationFrame to apply the animation 290 + await vi.waitFor(() => { 291 + expect(element.dataset.voltShiftRuns).toBe("1"); 292 + expect(element.style.animationName).toMatch(/^volt-shift-/); 293 + }); 296 294 }); 297 295 298 296 it("should trigger animation based on signal", () => { ··· 307 305 mount(container, { trigger }); 308 306 309 307 const button = container.querySelector("button") as HTMLElement; 310 - expect(button.animate).not.toHaveBeenCalled(); 308 + expect(button.dataset.voltShiftRuns ?? "0").toBe("0"); 311 309 312 310 trigger.set(true); 313 - expect(button.animate).toHaveBeenCalled(); 311 + expect(button.dataset.voltShiftRuns).toBe("1"); 314 312 }); 315 313 316 - it("should support duration and iteration modifiers", () => { 314 + it("should support duration and iteration modifiers", async () => { 317 315 const container = document.createElement("div"); 318 316 const testEl = document.createElement("div"); 319 317 testEl.dataset.voltShift = "bounce.1000.3"; ··· 324 322 325 323 mount(container, {}); 326 324 327 - expect(element.animate).toHaveBeenCalled(); 328 - 329 - const animateMock = element.animate as unknown as ReturnType<typeof vi.fn>; 330 - const options = animateMock.mock.calls[0]?.[1] as KeyframeAnimationOptions; 331 - expect(options?.duration).toBe(1000); 332 - expect(options?.iterations).toBe(3); 325 + // Wait for requestAnimationFrame to apply the animation 326 + await vi.waitFor(() => { 327 + expect(element.dataset.voltShiftRuns).toBe("1"); 328 + expect(element.style.animationDuration).toBe("1000ms"); 329 + expect(element.style.animationIterationCount).toBe("3"); 330 + }); 333 331 }); 334 332 335 333 it("should cleanup signal subscription on unmount", () => { ··· 348 346 const button = container.querySelector("button") as HTMLElement; 349 347 trigger.set(true); 350 348 351 - expect(button.animate).not.toHaveBeenCalled(); 349 + expect(button.dataset.voltShiftRuns ?? "0").toBe("0"); 352 350 }); 353 351 }); 354 352 ··· 544 542 }); 545 543 546 544 it("should combine surge and shift on same element", async () => { 547 - HTMLElement.prototype.animate = vi.fn((_keyframes: Keyframe[], _options?: KeyframeAnimationOptions) => { 548 - return { onfinish: null, cancel: vi.fn() } as unknown as Animation; 549 - }); 550 - 551 545 const container = document.createElement("div"); 552 546 const testEl = document.createElement("div"); 553 547 testEl.dataset.voltShow = "visible"; ··· 565 559 setTimeout(resolve, 100); 566 560 }); 567 561 568 - expect(element.animate).toHaveBeenCalled(); 562 + expect(element.dataset.voltShiftRuns).toBe("1"); 569 563 }); 570 564 }); 571 565 });
+17
lib/test/plugins/persist.test.ts
··· 12 12 }); 13 13 14 14 describe("localStorage persistence", () => { 15 + it("supports attribute suffix syntax with camelCase signal and storage aliases", async () => { 16 + localStorage.setItem("volt:persistedCount", "7"); 17 + 18 + const element = document.createElement("div"); 19 + element.dataset["voltPersist:persistedcount"] = "localStorage"; 20 + 21 + const persistedCount = signal(0); 22 + mount(element, { persistedCount }); 23 + 24 + await new Promise((resolve) => setTimeout(resolve, 0)); 25 + expect(persistedCount.get()).toBe(7); 26 + 27 + persistedCount.set(9); 28 + await new Promise((resolve) => setTimeout(resolve, 0)); 29 + expect(localStorage.getItem("volt:persistedCount")).toBe("9"); 30 + }); 31 + 15 32 it("loads persisted value from localStorage on mount", () => { 16 33 localStorage.setItem("volt:count", "42"); 17 34
+122 -48
lib/test/plugins/shift.test.ts
··· 48 48 49 49 globalThis.matchMedia = vi.fn().mockReturnValue({ matches: false }); 50 50 51 - element.animate = vi.fn((keyframes: Keyframe[], options?: KeyframeAnimationOptions) => { 52 - return { onfinish: null, cancel: vi.fn(), _keyframes: keyframes, _options: options }; 53 - }) as unknown as typeof element.animate; 51 + element.style.animation = ""; 54 52 }); 55 53 56 54 afterEach(() => { ··· 135 133 }); 136 134 137 135 describe("Basic Animation Application", () => { 138 - it("should apply animation on mount", () => { 136 + it("should apply animation on mount", async () => { 139 137 shiftPlugin(mockContext, "bounce"); 140 138 141 - expect(element.animate).toHaveBeenCalled(); 142 - const animateCall = (element.animate as ReturnType<typeof vi.fn>).mock.calls[0]; 143 - expect(animateCall).toBeDefined(); 139 + await vi.waitFor(() => { 140 + expect(element.style.animationName).toMatch(/^volt-shift-/); 141 + }); 144 142 }); 145 143 146 - it("should use default duration and iterations", () => { 144 + it("should use default duration and iterations", async () => { 147 145 shiftPlugin(mockContext, "bounce"); 148 146 149 - expect(element.animate).toHaveBeenCalled(); 150 - const animateMock = element.animate as unknown as ReturnType<typeof vi.fn>; 151 - const options = animateMock.mock.calls[0]?.[1] as KeyframeAnimationOptions; 152 - expect(options?.duration).toBe(100); 153 - expect(options?.iterations).toBe(1); 147 + await vi.waitFor(() => { 148 + expect(element.style.animationDuration).toBe("100ms"); 149 + expect(element.style.animationIterationCount).toBe("1"); 150 + }); 154 151 }); 155 152 156 - it("should apply custom duration", () => { 153 + it("should apply custom duration", async () => { 157 154 shiftPlugin(mockContext, "bounce.1000"); 158 155 159 - expect(element.animate).toHaveBeenCalled(); 160 - const animateMock = element.animate as unknown as ReturnType<typeof vi.fn>; 161 - const options = animateMock.mock.calls[0]?.[1] as KeyframeAnimationOptions; 162 - expect(options?.duration).toBe(1000); 156 + await vi.waitFor(() => { 157 + expect(element.style.animationDuration).toBe("1000ms"); 158 + }); 163 159 }); 164 160 165 - it("should apply custom duration and iterations", () => { 161 + it("should apply custom duration and iterations", async () => { 166 162 shiftPlugin(mockContext, "bounce.500.3"); 167 163 168 - expect(element.animate).toHaveBeenCalled(); 169 - const animateMock = element.animate as unknown as ReturnType<typeof vi.fn>; 170 - const options = animateMock.mock.calls[0]?.[1] as KeyframeAnimationOptions; 171 - expect(options?.duration).toBe(500); 172 - expect(options?.iterations).toBe(3); 164 + await vi.waitFor(() => { 165 + expect(element.style.animationDuration).toBe("500ms"); 166 + expect(element.style.animationIterationCount).toBe("3"); 167 + }); 173 168 }); 174 169 175 170 it("should handle unknown animation preset", () => { ··· 177 172 178 173 shiftPlugin(mockContext, "unknown"); 179 174 180 - expect(element.animate).not.toHaveBeenCalled(); 175 + expect(element.style.animationName).toBe(""); 181 176 expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining("Unknown animation preset: \"unknown\"")); 182 177 183 178 consoleSpy.mockRestore(); ··· 188 183 189 184 shiftPlugin(mockContext, ""); 190 185 191 - expect(element.animate).not.toHaveBeenCalled(); 186 + expect(element.style.animationName).toBe(""); 192 187 expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining("Invalid shift value")); 193 188 194 189 consoleSpy.mockRestore(); 195 190 }); 191 + 192 + it("should work when Web Animations API is unavailable", async () => { 193 + // @ts-expect-error mutate for test 194 + element.animate = undefined; 195 + 196 + shiftPlugin(mockContext, "bounce"); 197 + 198 + await vi.waitFor(() => { 199 + expect(element.style.animationName).toMatch(/^volt-shift-/); 200 + }); 201 + }); 202 + 203 + it("should normalize inline elements for transform animations", async () => { 204 + const span = document.createElement("span"); 205 + span.textContent = "⚙️"; 206 + container.append(span); 207 + 208 + const context: PluginContext = { ...mockContext, element: span }; 209 + 210 + shiftPlugin(context, "spin"); 211 + 212 + await vi.waitFor(() => { 213 + expect(span.style.display).toBe("inline-block"); 214 + expect(span.dataset.voltShiftDisplayManaged).toBe("infinite"); 215 + expect(span.dataset.voltShiftRuns).toBe("1"); 216 + expect(span.style.transformOrigin).toBe("center center"); 217 + }); 218 + }); 196 219 }); 197 220 198 221 describe("Signal-Triggered Animations", () => { ··· 202 225 203 226 shiftPlugin(mockContext, "trigger:bounce"); 204 227 205 - expect(element.animate).not.toHaveBeenCalled(); 228 + expect(element.dataset.voltShiftRuns ?? "0").toBe("0"); 206 229 207 230 triggerSignal.set(true); 208 231 209 - expect(element.animate).toHaveBeenCalled(); 232 + expect(element.dataset.voltShiftRuns).toBe("1"); 210 233 }); 211 234 212 - it("should not trigger animation when signal stays truthy", () => { 235 + it("should not trigger animation when signal stays truthy", async () => { 213 236 const triggerSignal = signal(true); 214 237 mockContext.findSignal = vi.fn().mockReturnValue(triggerSignal); 215 238 216 239 shiftPlugin(mockContext, "trigger:bounce"); 217 240 218 - expect(element.animate).toHaveBeenCalledTimes(1); 241 + await vi.waitFor(() => { 242 + expect(element.dataset.voltShiftRuns).toBe("1"); 243 + }); 219 244 220 245 triggerSignal.set(true); 221 246 222 - expect(element.animate).toHaveBeenCalledTimes(1); 247 + expect(element.dataset.voltShiftRuns).toBe("1"); 223 248 }); 224 249 225 - it("should trigger animation on initial mount if signal is truthy", () => { 250 + it("should trigger animation on initial mount if signal is truthy", async () => { 226 251 const triggerSignal = signal(true); 227 252 mockContext.findSignal = vi.fn().mockReturnValue(triggerSignal); 228 253 229 254 shiftPlugin(mockContext, "trigger:bounce"); 230 255 231 - expect(element.animate).toHaveBeenCalledTimes(1); 256 + await vi.waitFor(() => { 257 + expect(element.dataset.voltShiftRuns).toBe("1"); 258 + }); 232 259 }); 233 260 234 261 it("should handle signal not found", () => { ··· 249 276 250 277 triggerSignal.set(true); 251 278 252 - expect(element.animate).toHaveBeenCalled(); 253 - const animateMock = element.animate as unknown as ReturnType<typeof vi.fn>; 254 - const options = animateMock.mock.calls[0]?.[1] as KeyframeAnimationOptions; 255 - expect(options?.duration).toBe(800); 256 - expect(options?.iterations).toBe(2); 279 + expect(element.dataset.voltShiftRuns).toBe("1"); 280 + expect(element.style.animationDuration).toBe("800ms"); 281 + expect(element.style.animationIterationCount).toBe("2"); 282 + }); 283 + 284 + it("should stop infinite animations when signal becomes falsy", async () => { 285 + const spinSignal = signal(true); 286 + mockContext.findSignal = vi.fn().mockReturnValue(spinSignal); 287 + 288 + shiftPlugin(mockContext, "spin:spin"); 289 + 290 + await vi.waitFor(() => { 291 + expect(element.style.animationName).toMatch(/^volt-shift-/); 292 + expect(element.style.animationIterationCount).toBe("infinite"); 293 + }); 294 + 295 + spinSignal.set(false); 296 + 297 + expect(element.style.animationName).toBe(""); 298 + expect(element.style.animationIterationCount).toBe(""); 299 + }); 300 + 301 + it("should not stop finite animations when signal becomes falsy", async () => { 302 + const triggerSignal = signal(true); 303 + mockContext.findSignal = vi.fn().mockReturnValue(triggerSignal); 304 + 305 + shiftPlugin(mockContext, "trigger:bounce"); 306 + 307 + await vi.waitFor(() => { 308 + expect(element.style.animationName).toMatch(/^volt-shift-/); 309 + }); 310 + 311 + triggerSignal.set(false); 312 + 313 + expect(element.style.animationName).toMatch(/^volt-shift-/); 314 + }); 315 + 316 + it("should restart infinite animation when signal toggles", async () => { 317 + const spinSignal = signal(true); 318 + mockContext.findSignal = vi.fn().mockReturnValue(spinSignal); 319 + 320 + shiftPlugin(mockContext, "spin:spin"); 321 + 322 + await vi.waitFor(() => { 323 + expect(element.dataset.voltShiftRuns).toBe("1"); 324 + }); 325 + 326 + spinSignal.set(false); 327 + expect(element.style.animationName).toBe(""); 328 + 329 + spinSignal.set(true); 330 + 331 + expect(element.dataset.voltShiftRuns).toBe("2"); 257 332 }); 258 333 }); 259 334 ··· 263 338 264 339 shiftPlugin(mockContext, "bounce"); 265 340 266 - expect(element.animate).not.toHaveBeenCalled(); 341 + expect(element.style.animationName).toBe(""); 267 342 }); 268 343 269 344 it("should not animate when prefers-reduced-motion is active and signal triggers", () => { ··· 276 351 277 352 triggerSignal.set(true); 278 353 279 - expect(element.animate).not.toHaveBeenCalled(); 354 + expect(element.style.animationName).toBe(""); 280 355 }); 281 356 }); 282 357 283 358 describe("Animation Cleanup", () => { 284 - it("should cancel animation on finish", () => { 285 - const mockAnimation = { onfinish: null as (() => void) | null, cancel: vi.fn() }; 286 - 287 - element.animate = vi.fn().mockReturnValue(mockAnimation); 288 - 359 + it("should clear inline animation after it completes", async () => { 289 360 shiftPlugin(mockContext, "bounce"); 290 361 291 - expect(mockAnimation.onfinish).toBeDefined(); 362 + await vi.waitFor(() => { 363 + expect(element.style.animationName).toMatch(/^volt-shift-/); 364 + }); 292 365 293 - mockAnimation.onfinish?.(); 366 + await new Promise((resolve) => setTimeout(resolve, 150)); 294 367 295 - expect(mockAnimation.cancel).toHaveBeenCalled(); 368 + expect(element.style.animationName).toBe(""); 369 + expect(element.style.animationFillMode).toBe(""); 296 370 }); 297 371 298 372 it("should cleanup signal subscription", () => {
+22 -2
lib/test/plugins/surge.test.ts
··· 49 49 expect(hasSurge(element as HTMLElement)).toBe(true); 50 50 }); 51 51 52 + it("should detect surge attributes before plugin execution", async () => { 53 + vi.useFakeTimers(); 54 + 55 + element.dataset.voltSurge = "fade"; 56 + expect(hasSurge(element as HTMLElement)).toBe(true); 57 + 58 + const enterPromise = executeSurgeEnter(element as HTMLElement); 59 + await vi.advanceTimersByTimeAsync(400); 60 + await enterPromise; 61 + expect(element.style.opacity).toBe("1"); 62 + 63 + element.dataset["voltSurge:leave"] = "fade"; 64 + const leavePromise = executeSurgeLeave(element as HTMLElement); 65 + await vi.advanceTimersByTimeAsync(400); 66 + await leavePromise; 67 + expect(element.style.opacity).toBe("0"); 68 + 69 + vi.useRealTimers(); 70 + }); 71 + 52 72 it("should store enter-specific config", () => { 53 73 surgePlugin(mockContext, "enter:slide-down"); 54 - const stored = (element as HTMLElement & { _voltSurgeEnter?: unknown })._voltSurgeEnter; 74 + const stored = (element as HTMLElement & { _vxSurgeEnter?: unknown })._vxSurgeEnter; 55 75 expect(stored).toBeDefined(); 56 76 }); 57 77 58 78 it("should store leave-specific config", () => { 59 79 surgePlugin(mockContext, "leave:fade.300"); 60 - const stored = (element as HTMLElement & { _voltSurgeLeave?: unknown })._voltSurgeLeave; 80 + const stored = (element as HTMLElement & { _vxSurgeLeave?: unknown })._vxSurgeLeave; 61 81 expect(stored).toBeDefined(); 62 82 }); 63 83 });
+17 -1
lib/test/plugins/url.test.ts
··· 89 89 expect(filter.get()).toBe("active"); 90 90 }); 91 91 92 + it("supports attribute suffix syntax with query alias", async () => { 93 + globalThis.history.replaceState({}, "", "/"); 94 + 95 + const element = document.createElement("div"); 96 + element.dataset["voltUrl:searchterm"] = "query"; 97 + 98 + const searchTerm = signal(""); 99 + mount(element, { searchTerm }); 100 + 101 + searchTerm.set("hello"); 102 + 103 + await new Promise((resolve) => setTimeout(resolve, 150)); 104 + 105 + expect(globalThis.location.search).toBe("?searchTerm=hello"); 106 + }); 107 + 92 108 it("updates URL when signal changes", async () => { 93 109 globalThis.history.replaceState({}, "", "/"); 94 110 ··· 564 580 565 581 mount(element, {}); 566 582 567 - expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining("Unknown url mode: \"unknown\"")); 583 + expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining("Unknown url mode")); 568 584 569 585 errorSpy.mockRestore(); 570 586 });