From b91d7501f9c60549184c482754d4ea276afb4ea9 Mon Sep 17 00:00:00 2001
From: Owais <71664048+desertthunder@users.noreply.github.com>
Date: Wed, 22 Oct 2025 16:51:38 -0500
Subject: [PATCH] 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
---
docs/internals/proxies.md | 19 +-
docs/internals/reactivity.md | 35 ++-
docs/spec/plugin-spec.md | 9 +
docs/usage/bindings.md | 1 +
docs/usage/routing.md | 2 +-
docs/usage/state.md | 3 +
lib/README.md | 6 +-
lib/src/core/binder.ts | 111 ++++++----
lib/src/core/evaluator.ts | 61 +++++-
lib/src/core/shared.ts | 27 ++-
lib/src/demo/index.ts | 17 +-
lib/src/demo/sections/animations.ts | 21 +-
lib/src/plugins/persist.ts | 119 ++++++++--
lib/src/plugins/shift.ts | 174 +++++++++++++--
lib/src/plugins/surge.ts | 67 ++++--
lib/src/plugins/url.ts | 264 ++++++++++++++++-------
lib/test/core/evaluator.test.ts | 45 ++++
lib/test/integration/transitions.test.ts | 42 ++--
lib/test/plugins/persist.test.ts | 17 ++
lib/test/plugins/shift.test.ts | 170 ++++++++++-----
lib/test/plugins/surge.test.ts | 24 ++-
lib/test/plugins/url.test.ts | 18 +-
22 files changed, 978 insertions(+), 274 deletions(-)
diff --git a/docs/internals/proxies.md b/docs/internals/proxies.md
index 6dabdbe..5c745cd 100644
--- a/docs/internals/proxies.md
+++ b/docs/internals/proxies.md
@@ -33,7 +33,7 @@ When a consumer reads `proxy.key`:
If it’s an object/function, we recursively call `reactive()` so nested access stays reactive.
Otherwise we return `signal.get()` which unwraps the value.
-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.
+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.
## Property Mutation (set trap)
@@ -64,7 +64,7 @@ Methods that do not mutate (e.g. `slice`) pass through unwrapped.
## Integration with Signals
-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.
+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.
Because signals already integrate with dependency tracking, reactive object reads automatically wire into computeds, effects, and DOM bindings without extra bookkeeping.
## Interop Utilities
@@ -82,6 +82,21 @@ Bindings and expressions run via the hardened evaluator. When it encounters a re
- Signals returned from proxy properties expose `get`, `set`, and `subscribe`, but property reads on the signal proxy delegate back to the underlying value.
- Primitive coercion works because the wrapper defines `valueOf`, `toString`, and `Symbol.toPrimitive` on demand.
- Boolean negation (`!signal`) is rewritten to `!$unwrap(signal)` before compilation so reactive values behave like plain booleans.
+- **Iteration support** - Signal wrappers implement `Symbol.iterator` to enable spread operations on signals containing iterable values.
+
+### Spread Operator Support
+
+When a signal contains an iterable value (like an array), the wrapper proxy delegates the `Symbol.iterator` property to the unwrapped value.
+This enables the JavaScript spread operator to work transparently:
+
+```javascript
+const todos = signal([{id: 1, text: "Learn"}, {id: 2, text: "Build"}]);
+const newTodos = [...todos, {id: 3, text: "Ship"}];
+```
+
+Without this, the spread operator would fail because the JS runtime can't iterate over the signal wrapper.
+The implementation returns the iterator from the unwrapped array directly, ensuring spread operations receive raw values rather than wrapped proxies.
+This is critical for immutable update patterns where new arrays are constructed from existing signal values.
## Challenges & Lessons
diff --git a/docs/internals/reactivity.md b/docs/internals/reactivity.md
index b3b8334..2919fa0 100644
--- a/docs/internals/reactivity.md
+++ b/docs/internals/reactivity.md
@@ -1,6 +1,6 @@
# Reactivity Architecture
-VoltX’s reactivity system is built around a small set of primitives—signals, computed signals, and effects—that coordinate via an explicit dependency tracker.
+VoltX’s reactivity system is built around a small set of primitives: signals, computed signals, and effects, that coordinate via an explicit dependency tracker.
This document explains how those pieces fit together, how updates flow through the system, and the trade-offs we made while hardening the implementation.
## Signals
@@ -88,6 +88,33 @@ The evaluator uses a scope proxy that wraps signal objects differently based on
This dual behavior is controlled by the `opts.unwrapSignals` parameter passed to `evaluate()`.
+### Object Literal Unwrapping
+
+A subtle challenge arises when event handlers create object literals using signal values. Consider this common pattern:
+
+```html
+
+```
+
+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.
+
+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:
+
+```javascript
+{id: $unwrap(todoId), text: $unwrap(newText), done: false}
+```
+
+This transformation:
+
+- Only applies to simple identifiers after `:` in object literals (e.g., `{key: identifier}`)
+- Does not affect method calls (e.g., `{text: newText.trim()}` remains unchanged)
+- Does not affect property access or computed values (e.g., `{id: obj.id}` remains unchanged)
+- Ensures object literals created in write-mode contexts contain primitive values, not wrapper proxies
+
+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.
+
## Scope Helpers
When a scope is mounted, VoltX injects several helpers that lean on the reactive core:
@@ -114,13 +141,11 @@ When batching is needed, use `$pulse` or wrap updates in a custom queue.
## Challenges & Trade-offs
- **Minimal core vs features** - The system intentionally avoids hidden mutation queues or scheduler magic.
-This keeps mental models simple but means users must explicitly batch when necessary.
+ This keeps mental models simple but means users must explicitly batch when necessary.
- **Signal identity** - Equality checks are referential.
While fast, it means that mutating nested objects without cloning can bypass change detection unless you touch the signal again.
We emphasises immutable patterns or explicit `set()` calls with copies.
- **Dependency discovery** - Parsing expressions to pre-collect dependencies (`extractDeps`) introduces heuristics (e.g. `$store.get()` handling).
We balance accuracy with performance by focusing on common patterns and falling back to runtime evaluation if static analysis fails.
- **Error resilience** - Subscriber callbacks, cleanup functions, and recompute bodies are wrapped in try/catch to prevent one failure from derailing the reactive loop.
- The trade-off is noisy console logs, but the alternative—silently swallowing issues—was harder to debug.
-
-Despite the lightweight implementation, these primitives provide deterministic, traceable update flows that underpin VoltX’s declarative bindings and plugin ecosystem.
+ The trade-off is noisy console logs, but the alternative (silent errors & no observability) was harder to debug.
diff --git a/docs/spec/plugin-spec.md b/docs/spec/plugin-spec.md
index 723c7db..8e05e6a 100644
--- a/docs/spec/plugin-spec.md
+++ b/docs/spec/plugin-spec.md
@@ -249,6 +249,13 @@ Reads URL parameter on mount and sets signal value. Signal changes do not update
```
+You can also use the shorthand attribute form where the signal name is encoded in the attribute suffix:
+
+```html
+
+
+```
+
Changes to signal update URL parameter, changes to URL update signal. Uses History API for clean URLs.
**Hash Routing:**
@@ -267,6 +274,8 @@ Keeps hash portion of URL in sync with signal. Useful for client-side routing.
- Listens to `popstate` for browser back/forward
- Debounces URL updates to avoid excessive history entries
- Automatically serializes/deserializes values (strings, numbers, booleans)
+- Accepts `data-volt-url="mode:signal"` or `data-volt-url:signal="mode"` forms
+- Supports `query`, `hash`, and `history` mode aliases in shorthand attributes (e.g., `data-volt-url:filter="query"`)
## Implementation
diff --git a/docs/usage/bindings.md b/docs/usage/bindings.md
index 83d93d2..67c5e1e 100644
--- a/docs/usage/bindings.md
+++ b/docs/usage/bindings.md
@@ -364,6 +364,7 @@ The binding syntax is `data-volt-url:signalName="urlPart"` where URL part is:
- `query`: Sync with query parameter (e.g., `?page=1`)
- `hash`: Sync with URL hash (e.g., `#section`)
+- `history`: Sync with the full pathname + search (e.g., `data-volt-url:route="history:/app"`)
Signal changes update the URL, and URL changes (back/forward navigation) update signals. This enables client-side routing without additional libraries.
diff --git a/docs/usage/routing.md b/docs/usage/routing.md
index 5223de4..9e61c91 100644
--- a/docs/usage/routing.md
+++ b/docs/usage/routing.md
@@ -31,7 +31,7 @@ This guide walks through building both hash-based and History API routers that s
```
```ts
- // src/main.ts — bundled projects
+ // src/main.ts -> entry point for a bundled project
import { charge, initNavigationListener, registerPlugin, urlPlugin } from "voltx.js";
registerPlugin("url", urlPlugin);
diff --git a/docs/usage/state.md b/docs/usage/state.md
index 0b8d3f7..e6a593e 100644
--- a/docs/usage/state.md
+++ b/docs/usage/state.md
@@ -64,6 +64,7 @@ The name becomes a signal in the scope, and the attribute value is the computati
```
Computed values defined this way follow the same rules as programmatic computed signals: they track dependencies and update automatically.
+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.
## Programmatic State
@@ -105,6 +106,7 @@ VoltX automatically unwraps signals in read contexts, making expressions simpler
```
**Read Contexts** (signals auto-unwrapped):
+
- `data-volt-text`, `data-volt-html`
- `data-volt-if`, `data-volt-else`
- `data-volt-for`
@@ -113,6 +115,7 @@ VoltX automatically unwraps signals in read contexts, making expressions simpler
- `data-volt-computed:*` expressions
**Write Contexts** (signals not auto-unwrapped):
+
- `data-volt-on-*` event handlers
- `data-volt-init` initialization code
- `data-volt-model` (handles both read and write automatically)
diff --git a/lib/README.md b/lib/README.md
index 0948ab2..51c9199 100644
--- a/lib/README.md
+++ b/lib/README.md
@@ -66,7 +66,9 @@ Plugins are opt-in and can be combined declaratively or registered programmatica
## VoltX.css
-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.
+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.
### Features
@@ -94,7 +96,7 @@ Or include via CDN:
### Usage
-No classes needed—just write semantic HTML:
+No classes needed. Just write semantic HTML:
```html
diff --git a/lib/src/core/binder.ts b/lib/src/core/binder.ts
index 9cff21e..3506214 100644
--- a/lib/src/core/binder.ts
+++ b/lib/src/core/binder.ts
@@ -1,5 +1,5 @@
/**
- * Binder system for mounting and managing VoltX.js bindings
+ * Binder system for mounting and managing VoltX bindings
*/
import { executeSurgeEnter, executeSurgeLeave, hasSurge } from "$plugins/surge";
@@ -46,8 +46,25 @@ export function registerDirective(name: string, handler: DirectiveHandler): void
directiveRegistry.set(name, handler);
}
+function scheduleTransitionTask(cb: () => void): void {
+ let executed = false;
+ const wrapped = () => {
+ if (executed) {
+ return;
+ }
+ executed = true;
+ cb();
+ };
+
+ if (typeof requestAnimationFrame === "function") {
+ requestAnimationFrame(wrapped);
+ }
+
+ setTimeout(wrapped, 16);
+}
+
/**
- * Mount VoltX.js on a root element and its descendants and binds all data-volt-* attributes to the provided scope.
+ * Mount VoltX on a root element and its descendants and binds all data-volt-* attributes to the provided scope.
*
* @param root - Root element to mount on
* @param scope - Scope object containing signals and data
@@ -313,7 +330,7 @@ function bindShow(ctx: BindingContext, expr: string): void {
isTransitioning = true;
- requestAnimationFrame(() => {
+ scheduleTransitionTask(() => {
void (async () => {
try {
if (shouldShow) {
@@ -874,6 +891,7 @@ function bindIf(ctx: BindingContext, expr: string): void {
let currentCleanup: Optional;
let currentBranch: Optional<"if" | "else">;
let isTransitioning = false;
+ let pendingRender = false;
const render = () => {
const condition = evaluate(expr, ctx.scope);
@@ -882,6 +900,9 @@ function bindIf(ctx: BindingContext, expr: string): void {
const targetBranch = shouldShow ? "if" : (elseTempl ? "else" : undefined);
if (targetBranch === currentBranch || isTransitioning) {
+ if (isTransitioning) {
+ pendingRender = true;
+ }
return;
}
@@ -915,55 +936,57 @@ function bindIf(ctx: BindingContext, expr: string): void {
isTransitioning = true;
- requestAnimationFrame(() => {
- void (async () => {
- try {
- if (currentElement) {
- const currentEl = currentElement as HTMLElement;
- const currentHasSurge = currentBranch === "if" ? ifHasSurge : elseHasSurge;
-
- if (currentHasSurge) {
- await executeSurgeLeave(currentEl);
- }
-
- if (currentCleanup) {
- currentCleanup();
- currentCleanup = undefined;
- }
- currentElement.remove();
- currentElement = undefined;
+ void (async () => {
+ try {
+ if (currentElement) {
+ const currentEl = currentElement as HTMLElement;
+ const currentHasSurge = currentBranch === "if" ? ifHasSurge : elseHasSurge;
+
+ if (currentHasSurge) {
+ await executeSurgeLeave(currentEl);
}
- if (targetBranch === "if") {
- currentElement = ifTempl.cloneNode(true) as Element;
- delete (currentElement as HTMLElement).dataset.voltIf;
- placeholder.before(currentElement);
+ if (currentCleanup) {
+ currentCleanup();
+ currentCleanup = undefined;
+ }
+ currentElement.remove();
+ currentElement = undefined;
+ }
- if (ifHasSurge) {
- await executeSurgeEnter(currentElement as HTMLElement);
- }
+ if (targetBranch === "if") {
+ currentElement = ifTempl.cloneNode(true) as Element;
+ delete (currentElement as HTMLElement).dataset.voltIf;
+ placeholder.before(currentElement);
- currentCleanup = mount(currentElement, ctx.scope);
- currentBranch = "if";
- } else if (targetBranch === "else" && elseTempl) {
- currentElement = elseTempl.cloneNode(true) as Element;
- delete (currentElement as HTMLElement).dataset.voltElse;
- placeholder.before(currentElement);
+ if (ifHasSurge) {
+ await executeSurgeEnter(currentElement as HTMLElement);
+ }
- if (elseHasSurge) {
- await executeSurgeEnter(currentElement as HTMLElement);
- }
+ currentCleanup = mount(currentElement, ctx.scope);
+ currentBranch = "if";
+ } else if (targetBranch === "else" && elseTempl) {
+ currentElement = elseTempl.cloneNode(true) as Element;
+ delete (currentElement as HTMLElement).dataset.voltElse;
+ placeholder.before(currentElement);
- currentCleanup = mount(currentElement, ctx.scope);
- currentBranch = "else";
- } else {
- currentBranch = undefined;
+ if (elseHasSurge) {
+ await executeSurgeEnter(currentElement as HTMLElement);
}
- } finally {
- isTransitioning = false;
+
+ currentCleanup = mount(currentElement, ctx.scope);
+ currentBranch = "else";
+ } else {
+ currentBranch = undefined;
}
- })();
- });
+ } finally {
+ isTransitioning = false;
+ if (pendingRender) {
+ pendingRender = false;
+ render();
+ }
+ }
+ })();
};
updateAndRegister(ctx, render, expr);
diff --git a/lib/src/core/evaluator.ts b/lib/src/core/evaluator.ts
index 5a67133..93bdc68 100644
--- a/lib/src/core/evaluator.ts
+++ b/lib/src/core/evaluator.ts
@@ -5,7 +5,7 @@
* Includes hardened scope proxy to prevent prototype pollution and auto-unwrap signals.
*/
-import type { Scope } from "$types/volt";
+import type { Dep, Scope, Signal } from "$types/volt";
import { DANGEROUS_GLOBALS, DANGEROUS_PROPERTIES, SAFE_GLOBALS } from "./constants";
import { isSignal } from "./shared";
@@ -52,9 +52,7 @@ function isDangerousProperty(key: unknown): boolean {
/**
* Type guard to check if a Dep has a set method (is a Signal vs ComputedSignal)
*/
-function hasSetMethod(
- dep: unknown,
-): dep is { get: () => unknown; set: (v: unknown) => void; subscribe: (fn: () => void) => () => void } {
+function hasSetMethod(dep: unknown): dep is Dep & { set: (v: unknown) => void } {
return (typeof dep === "object"
&& dep !== null
&& "set" in dep
@@ -71,10 +69,7 @@ function hasSetMethod(
*
* Handles both Signal (has set) and ComputedSignal (no set)
*/
-function wrapSignal(
- signal: { get: () => unknown; subscribe: (fn: () => void) => () => void },
- options: WrapOptions,
-): unknown {
+function wrapSignal(signal: Signal, options: WrapOptions): unknown {
const hasSet = hasSetMethod(signal);
const wrapper: Record = {
@@ -107,6 +102,14 @@ function wrapSignal(
return target[prop];
}
+ if (prop === Symbol.iterator) {
+ const unwrapped = signal.get();
+ if (unwrapped && typeof unwrapped === "object" && Symbol.iterator in unwrapped) {
+ return (unwrapped as Iterable)[Symbol.iterator].bind(unwrapped);
+ }
+ return;
+ }
+
const unwrapped = signal.get();
if (unwrapped && (typeof unwrapped === "object" || typeof unwrapped === "function")) {
const wrapped = wrapValue(unwrapped, options);
@@ -140,6 +143,12 @@ function wrapSignal(
return true;
}
+ if (prop === Symbol.iterator) {
+ const unwrapped = signal.get();
+ return unwrapped !== null && unwrapped !== undefined && typeof unwrapped === "object"
+ && Symbol.iterator in unwrapped;
+ }
+
const unwrapped = signal.get();
if (unwrapped && (typeof unwrapped === "object" || typeof unwrapped === "function")) {
return prop in unwrapped;
@@ -168,7 +177,7 @@ function wrapValue(value: unknown, options: WrapOptions = defaultWrapOptions): u
if (options.unwrapSignals) {
return wrapValue((value as { get: () => unknown }).get(), options);
}
- return wrapSignal(value, options);
+ return wrapSignal(value as Signal, options);
}
if (typeof value !== "object" && typeof value !== "function") {
@@ -387,6 +396,40 @@ function transformExpr(expr: string): string {
continue;
}
+ if (char === ":" && index > 0) {
+ result += char;
+ index += 1;
+
+ while (index < expr.length && isWhitespace(expr[index])) {
+ result += expr[index];
+ index += 1;
+ }
+
+ if (index < expr.length && isIdentifierStart(expr[index])) {
+ const identStart = index;
+ let identEnd = identStart + 1;
+
+ while (identEnd < expr.length && isIdentifierPart(expr[identEnd])) {
+ identEnd += 1;
+ }
+
+ let lookahead = identEnd;
+ while (lookahead < expr.length && isWhitespace(expr[lookahead])) {
+ lookahead += 1;
+ }
+
+ const afterIdent = expr[lookahead] ?? "";
+ if (afterIdent === "," || afterIdent === "}" || lookahead >= expr.length || afterIdent === ")") {
+ const identifier = expr.slice(identStart, identEnd);
+ result += "$unwrap(" + identifier + ")";
+ index = identEnd;
+ continue;
+ }
+ }
+
+ continue;
+ }
+
result += char;
index += 1;
}
diff --git a/lib/src/core/shared.ts b/lib/src/core/shared.ts
index 1d7d406..b169d7f 100644
--- a/lib/src/core/shared.ts
+++ b/lib/src/core/shared.ts
@@ -28,19 +28,38 @@ export function isSignal(value: unknown): value is Dep {
export function findScopedSignal(scope: Scope, path: string): Optional> {
const trimmed = path.trim();
+ if (!trimmed) {
+ return undefined;
+ }
+
const parts = trimmed.split(".");
let current: unknown = scope;
for (const part of parts) {
- if (isNil(current)) {
+ if (isNil(current) || typeof current !== "object") {
return undefined;
}
- if (typeof current === "object" && part in (current as Record)) {
- current = (current as Record)[part];
- } else {
+ const record = current as Record;
+
+ if (Object.hasOwn(record, part)) {
+ current = record[part];
+ continue;
+ }
+
+ const camelCandidate = kebabToCamel(part);
+ if (Object.hasOwn(record, camelCandidate)) {
+ current = record[camelCandidate];
+ continue;
+ }
+
+ const lowerPart = part.toLowerCase();
+ const matchedKey = Object.keys(record).find((key) => key.toLowerCase() === lowerPart);
+ if (!matchedKey) {
return undefined;
}
+
+ current = record[matchedKey];
}
if (isSignal(current)) {
diff --git a/lib/src/demo/index.ts b/lib/src/demo/index.ts
index 507c66d..5ca5a2f 100644
--- a/lib/src/demo/index.ts
+++ b/lib/src/demo/index.ts
@@ -1,8 +1,7 @@
/**
- * Demo module for showcasing VoltX.js features and volt.css styling
+ * Demo module for showcasing VoltX features and voltx.css styling
*
- * This module creates the entire demo structure programmatically using DOM APIs,
- * then uses charge() to mount it declaratively.
+ * This module creates the entire demo structure programmatically using DOM APIs, then uses charge() to mount it declaratively.
*/
import { charge } from "$core/charge";
@@ -94,7 +93,7 @@ const buildNav = () =>
function getCurrentPageFromPath(): string {
const path = globalThis.location.pathname;
if (path === "/" || path === "") return "home";
- return path.slice(1); // Remove leading slash
+ return path.slice(1);
}
function buildDemoStructure(): HTMLElement {
@@ -131,6 +130,7 @@ function buildDemoStructure(): HTMLElement {
triggerFlash: 0,
triggerTripleBounce: 0,
triggerLongShake: 0,
+ spinningGear: true,
};
return dom.div(
@@ -138,8 +138,8 @@ function buildDemoStructure(): HTMLElement {
"data-volt": "",
"data-volt-state": JSON.stringify(initialState),
"data-volt-computed:doubled": "count * 2",
- "data-volt-computed:activeTodos": "todos.filter(t => !t.done)",
- "data-volt-computed:completedTodos": "todos.filter(t => t.done)",
+ "data-volt-computed:active-todos": "todos.filter(t => !t.done)",
+ "data-volt-computed:completed-todos": "todos.filter(t => t.done)",
},
dom.header(
null,
@@ -173,10 +173,7 @@ function buildDemoStructure(): HTMLElement {
dom.a({ href: "https://github.com/stormlightlabs/volt" }, "VoltX.js"),
" - A lightweight, reactive hypermedia framework",
),
- dom.p(
- null,
- "This demo showcases both VoltX.js reactive features and Volt CSS classless styling. View source to see how everything works!",
- ),
+ dom.p(null, "This demo showcases both VoltX's reactive features and VoltX.css' classless styling."),
),
);
}
diff --git a/lib/src/demo/sections/animations.ts b/lib/src/demo/sections/animations.ts
index bb91353..227ed20 100644
--- a/lib/src/demo/sections/animations.ts
+++ b/lib/src/demo/sections/animations.ts
@@ -108,7 +108,12 @@ export function createAnimationsSection(): HTMLElement {
"data-volt-shift": "triggerFlash:flash",
}, "Flash"),
),
- dom.p(null, "Spinning gear: ", dom.span({ "data-volt-shift": "spin", style: "font-size: 2rem;" }, "⚙️")),
+ dom.p(
+ null,
+ dom.button({ "data-volt-on-click": "spinningGear.set(!spinningGear)" }, "Toggle Spin"),
+ " Spinning gear: ",
+ dom.span({ "data-volt-shift": "spinningGear:spin", style: "font-size: 2rem;" }, "⚙️"),
+ ),
),
dom.section(
null,
@@ -139,12 +144,14 @@ export function createAnimationsSection(): HTMLElement {
dom.small(null, "Toggle to see content that fades in, then bounces on mount"),
),
dom.button({ "data-volt-on-click": "showCombined.set(!showCombined)" }, "Toggle Combined Animation"),
- dom.aside(
- { "data-volt-if": "showCombined", "data-volt-surge": "fade.400", "data-volt-shift": "bounce" },
- dom.p(
- null,
- dom.strong(null, "Animated aside:"),
- " This content fades in smoothly, then bounces when it appears!",
+ dom.p(
+ null,
+ dom.strong(null, "Animated sidenote:"),
+ " This paragraph keeps the flow of the article while the sidenote animates into view.",
+ " ",
+ dom.small(
+ { "data-volt-if": "showCombined", "data-volt-surge": "fade.400", "data-volt-shift": "bounce" },
+ "This margin note fades into place and bounces to grab your attention.",
),
),
),
diff --git a/lib/src/plugins/persist.ts b/lib/src/plugins/persist.ts
index f939ce7..f9149a1 100644
--- a/lib/src/plugins/persist.ts
+++ b/lib/src/plugins/persist.ts
@@ -4,9 +4,9 @@
* Supports localStorage, sessionStorage, IndexedDB, and custom adapters
*/
-import { isNil } from "$core/shared";
+import { isNil, kebabToCamel } from "$core/shared";
import type { Optional } from "$types/helpers";
-import type { PluginContext, Signal, StorageAdapter } from "$types/volt";
+import type { PluginContext, Scope, Signal, StorageAdapter } from "$types/volt";
const storageAdapterRegistry = new Map();
@@ -151,6 +151,94 @@ function getStorageAdapter(type: string): Optional {
}
}
+function resolveCanonicalPath(scope: Scope, rawPath: string): string {
+ const trimmed = rawPath.trim();
+ if (!trimmed) {
+ return trimmed;
+ }
+
+ const parts = trimmed.split(".");
+ const resolved: string[] = [];
+ let current: unknown = scope;
+
+ for (const part of parts) {
+ if (isNil(current) || typeof current !== "object") {
+ resolved.push(part);
+ current = undefined;
+ continue;
+ }
+
+ const record = current as Record;
+
+ if (Object.hasOwn(record, part)) {
+ resolved.push(part);
+ current = record[part];
+ continue;
+ }
+
+ const camelCandidate = kebabToCamel(part);
+ if (Object.hasOwn(record, camelCandidate)) {
+ resolved.push(camelCandidate);
+ current = record[camelCandidate];
+ continue;
+ }
+
+ const lower = part.toLowerCase();
+ const matchedKey = Object.keys(record).find((key) => key.toLowerCase() === lower);
+
+ if (matchedKey) {
+ resolved.push(matchedKey);
+ current = record[matchedKey];
+ continue;
+ }
+
+ resolved.push(part);
+ current = undefined;
+ }
+
+ return resolved.join(".");
+}
+
+function resolveSignal(ctx: PluginContext, rawPath: string): Optional<{ path: string; signal: Signal }> {
+ const trimmed = rawPath.trim();
+ if (!trimmed) {
+ return undefined;
+ }
+
+ const canonicalPath = resolveCanonicalPath(ctx.scope, trimmed);
+ const candidatePaths = new Set([canonicalPath, trimmed]);
+
+ for (const candidate of candidatePaths) {
+ const found = ctx.findSignal(candidate);
+ if (found) {
+ return { path: candidate, signal: found as Signal };
+ }
+ }
+}
+
+function normalizeStorageType(type: string): { key: string; original: string } {
+ const original = type.trim();
+ const normalized = original.toLowerCase().replaceAll(/[\s_-]/g, "");
+
+ switch (normalized) {
+ case "local":
+ case "localstorage": {
+ return { key: "local", original };
+ }
+ case "session":
+ case "sessionstorage": {
+ return { key: "session", original };
+ }
+ case "indexeddb":
+ case "indexed-db": {
+ return { key: "indexeddb", original };
+ }
+ default: {
+ return { key: original, original };
+ }
+ }
+}
+
/**
* Persist plugin handler.
* Synchronizes signal values with persistent storage.
@@ -170,48 +258,49 @@ export function persistPlugin(ctx: PluginContext, value: string): void {
}
const [signalPath, storageType] = parts;
- const signal = ctx.findSignal(signalPath.trim());
+ const resolvedSignal = resolveSignal(ctx, signalPath);
- if (!signal) {
- console.error(`Signal "${signalPath}" not found in scope for persist binding`);
+ if (!resolvedSignal) {
+ console.error(`Signal "${signalPath.trim()}" not found in scope for persist binding`);
return;
}
- const adapter = getStorageAdapter(storageType.trim());
+ const { key: adapterKey, original } = normalizeStorageType(storageType);
+ const adapter = getStorageAdapter(adapterKey) ?? (adapterKey === original ? undefined : getStorageAdapter(original));
if (!adapter) {
- console.error(`Unknown storage type: "${storageType}"`);
+ console.error(`Unknown storage type: "${storageType.trim()}"`);
return;
}
- const storageKey = `volt:${signalPath.trim()}`;
+ const storageKey = `volt:${resolvedSignal.path}`;
try {
const result = adapter.get(storageKey);
if (result instanceof Promise) {
result.then((storedValue) => {
if (storedValue !== undefined) {
- (signal as Signal).set(storedValue);
+ resolvedSignal.signal.set(storedValue);
}
}).catch((error) => {
- console.error(`Failed to load persisted value for "${signalPath}":`, error);
+ console.error(`Failed to load persisted value for "${signalPath.trim()}":`, error);
});
} else if (result !== undefined) {
- (signal as Signal).set(result);
+ resolvedSignal.signal.set(result);
}
} catch (error) {
- console.error(`Failed to load persisted value for "${signalPath}":`, error);
+ console.error(`Failed to load persisted value for "${signalPath.trim()}":`, error);
}
- const unsubscribe = signal.subscribe((newValue) => {
+ const unsubscribe = resolvedSignal.signal.subscribe((newValue) => {
try {
const result = adapter.set(storageKey, newValue);
if (result instanceof Promise) {
result.catch((error) => {
- console.error(`Failed to persist value for "${signalPath}":`, error);
+ console.error(`Failed to persist value for "${signalPath.trim()}":`, error);
});
}
} catch (error) {
- console.error(`Failed to persist value for "${signalPath}":`, error);
+ console.error(`Failed to persist value for "${signalPath.trim()}":`, error);
}
});
diff --git a/lib/src/plugins/shift.ts b/lib/src/plugins/shift.ts
index 347f870..63df82d 100644
--- a/lib/src/plugins/shift.ts
+++ b/lib/src/plugins/shift.ts
@@ -11,6 +11,10 @@ import type { AnimationPreset, PluginContext, Signal } from "$types/volt";
* Registry of animation presets
*/
const animationRegistry = new Map();
+const keyframeRegistry = new Map();
+
+let keyframeSheet: Optional;
+let keyframeCounter = 0;
/**
* Built-in animation presets with CSS keyframes
@@ -211,24 +215,156 @@ function parseAnimationValue(value: string): Optional {
return result;
}
-function applyAnimation(element: HTMLElement, preset: AnimationPreset, duration?: number, iterations?: number): void {
+function stopAnimation(el: HTMLElement): void {
+ el.style.animation = "";
+ el.style.animationName = "";
+ el.style.animationDuration = "";
+ el.style.animationTimingFunction = "";
+ el.style.animationIterationCount = "";
+ el.style.animationFillMode = "";
+ restoreOriginalDisplay(el);
+}
+
+function applyAnimation(el: HTMLElement, preset: AnimationPreset, duration?: number, iterations?: number): void {
if (prefersReducedMotion()) {
return;
}
const effectiveDuration = duration ?? preset.duration;
const effectiveIterations = iterations ?? preset.iterations;
+ const animationName = getOrCreateKeyframes(preset);
+ if (!animationName) {
+ return;
+ }
+
+ ensureInlineBlockForTransforms(el, effectiveIterations === Number.POSITIVE_INFINITY);
+ resetCssAnimation(el);
+
+ el.style.animationName = animationName;
+ el.style.animationDuration = `${effectiveDuration}ms`;
+ el.style.animationTimingFunction = preset.timing;
+ el.style.animationIterationCount = effectiveIterations === Number.POSITIVE_INFINITY
+ ? "infinite"
+ : String(effectiveIterations);
+ el.style.animationFillMode = "forwards";
+
+ const runs = Number.parseInt(el.dataset.voltShiftRuns ?? "0", 10) + 1;
+ el.dataset.voltShiftRuns = String(runs);
+
+ if (effectiveIterations !== Number.POSITIVE_INFINITY) {
+ const totalDuration = effectiveDuration * effectiveIterations;
+ setTimeout(() => {
+ if (el.style.animationName === animationName) {
+ stopAnimation(el);
+ }
+ }, totalDuration);
+ }
+}
+
+function resetCssAnimation(el: HTMLElement): void {
+ const previousName = el.style.animationName;
+ if (!previousName) {
+ return;
+ }
+ el.style.animation = "none";
+ void el.offsetWidth;
+ el.style.animation = "";
+ el.style.animationName = "";
+}
+
+function ensureKeyframeSheet(): Optional {
+ if (keyframeSheet) {
+ return keyframeSheet;
+ }
+
+ if (typeof document === "undefined" || !document.head) {
+ return undefined;
+ }
+
+ const styleEl = document.createElement("style");
+ styleEl.dataset.voltShift = "true";
+ document.head.append(styleEl);
+ keyframeSheet = styleEl.sheet ?? undefined;
+ return keyframeSheet;
+}
+
+function toCssProperty(property: string): string {
+ return property.replaceAll(/[A-Z]/g, (match) => `-${match.toLowerCase()}`);
+}
+
+function getOrCreateKeyframes(preset: AnimationPreset): Optional {
+ const key = JSON.stringify(preset.keyframes) + preset.timing;
+ if (keyframeRegistry.has(key)) {
+ return keyframeRegistry.get(key);
+ }
+
+ const sheet = ensureKeyframeSheet();
+ if (!sheet) {
+ return undefined;
+ }
+
+ const animationName = `volt-shift-${keyframeCounter += 1}`;
+ keyframeRegistry.set(key, animationName);
- const animation = element.animate(preset.keyframes, {
- duration: effectiveDuration,
- iterations: effectiveIterations,
- easing: preset.timing,
- fill: "forwards",
- });
+ const frames = preset.keyframes.map((frame, index) => {
+ const offset = frame.offset ?? (preset.keyframes.length > 1 ? index / (preset.keyframes.length - 1) : 0);
+ const percent = Math.round(offset * 10_000) / 100;
+ const declarations = Object.entries(frame).filter(([prop]) => prop !== "offset").map(([prop, value]) =>
+ `${toCssProperty(prop)}: ${value};`
+ ).join(" ");
+ return `${percent}% { ${declarations} }`;
+ }).join(" ");
- animation.onfinish = () => {
- animation.cancel();
- };
+ sheet.insertRule(`@keyframes ${animationName} { ${frames} }`, sheet.cssRules.length);
+ return animationName;
+}
+
+function ensureInlineBlockForTransforms(el: HTMLElement, isInf: boolean): void {
+ if (el.dataset.voltShiftDisplayManaged) {
+ return;
+ }
+
+ if (typeof getComputedStyle !== "function") {
+ return;
+ }
+
+ if (!el.isConnected) {
+ return;
+ }
+
+ void el.offsetHeight;
+
+ const computedDisplay = getComputedStyle(el).display;
+ if (computedDisplay !== "inline") {
+ return;
+ }
+
+ el.dataset.voltShiftDisplayManaged = isInf ? "infinite" : "managed";
+ el.dataset.voltShiftOriginalDisplay = el.style.display ?? "";
+
+ if (!el.dataset.voltShiftOriginalTransformOrigin) {
+ el.dataset.voltShiftOriginalTransformOrigin = el.style.transformOrigin ?? "";
+ }
+
+ el.style.display = "inline-block";
+ if (!el.style.transformOrigin) {
+ el.style.transformOrigin = "center center";
+ }
+}
+
+function restoreOriginalDisplay(element: HTMLElement): void {
+ const state = element.dataset.voltShiftDisplayManaged;
+ if (!state || state === "infinite") {
+ return;
+ }
+
+ const original = element.dataset.voltShiftOriginalDisplay ?? "";
+ element.style.display = original;
+ const originalOrigin = element.dataset.voltShiftOriginalTransformOrigin ?? "";
+ element.style.transformOrigin = originalOrigin;
+ delete element.dataset.voltShiftDisplayManaged;
+ delete element.dataset.voltShiftOriginalDisplay;
+ delete element.dataset.voltShiftOriginalTransformOrigin;
}
/**
@@ -277,11 +413,17 @@ export function shiftPlugin(ctx: PluginContext, value: string): void {
return;
}
+ const effectiveIterations = parsed.iterations ?? preset.iterations;
+ const isInfinite = effectiveIterations === Number.POSITIVE_INFINITY;
let previousValue = signal.get();
const unsubscribe = signal.subscribe((value) => {
- if (value !== previousValue && Boolean(value)) {
- applyAnimation(el, preset, parsed.duration, parsed.iterations);
+ if (value !== previousValue) {
+ if (value) {
+ applyAnimation(el, preset, parsed.duration, parsed.iterations);
+ } else if (isInfinite && el.style.animationName) {
+ stopAnimation(el);
+ }
}
previousValue = value;
});
@@ -290,12 +432,16 @@ export function shiftPlugin(ctx: PluginContext, value: string): void {
if (signal.get()) {
ctx.lifecycle.onMount(() => {
- applyAnimation(el, preset, parsed.duration, parsed.iterations);
+ requestAnimationFrame(() => {
+ applyAnimation(el, preset, parsed.duration, parsed.iterations);
+ });
});
}
} else {
ctx.lifecycle.onMount(() => {
- applyAnimation(el, preset, parsed.duration, parsed.iterations);
+ requestAnimationFrame(() => {
+ applyAnimation(el, preset, parsed.duration, parsed.iterations);
+ });
});
}
}
diff --git a/lib/src/plugins/surge.ts b/lib/src/plugins/surge.ts
index 958f06f..b7e3dc0 100644
--- a/lib/src/plugins/surge.ts
+++ b/lib/src/plugins/surge.ts
@@ -9,6 +9,12 @@ import { withViewTransition } from "$core/view-transitions";
import type { Optional } from "$types/helpers";
import type { PluginContext, Signal, TransitionPhase } from "$types/volt";
+type SurgeElement = HTMLElement & {
+ _vxSurgeConf?: SurgeConfig;
+ _vxSurgeEnter?: TransitionPhase;
+ _vxSurgeLeave?: TransitionPhase;
+};
+
type SurgeConfig = {
enterPreset?: TransitionPhase;
leavePreset?: TransitionPhase;
@@ -205,6 +211,38 @@ function parseSurgeValue(value: string): Optional {
return { enterPreset: parsed.preset.enter, leavePreset: parsed.preset.leave, useViewTransitions: true };
}
+function ensureInlineSurgeState(element: SurgeElement): void {
+ if (!element._vxSurgeConf) {
+ const attr = element.dataset.voltSurge;
+ if (attr) {
+ const parsed = parseSurgeValue(attr);
+ if (parsed) {
+ element._vxSurgeConf = parsed;
+ }
+ }
+ }
+
+ if (!element._vxSurgeEnter) {
+ const enterAttr = element.dataset["voltSurge:enter"];
+ if (enterAttr) {
+ const enterPhase = parsePhaseValue(enterAttr, "enter");
+ if (enterPhase) {
+ element._vxSurgeEnter = enterPhase;
+ }
+ }
+ }
+
+ if (!element._vxSurgeLeave) {
+ const leaveAttr = element.dataset["voltSurge:leave"];
+ if (leaveAttr) {
+ const leavePhase = parsePhaseValue(leaveAttr, "leave");
+ if (leavePhase) {
+ element._vxSurgeLeave = leavePhase;
+ }
+ }
+ }
+}
+
function parsePhaseValue(value: string, phase: "enter" | "leave"): Optional {
const parsed = parseTransitionValue(value.trim());
if (!parsed) {
@@ -238,7 +276,7 @@ function parsePhaseValue(value: string, phase: "enter" | "leave"): Optional {
- const config = (element as HTMLElement & { _voltSurgeConfig?: SurgeConfig })._voltSurgeConfig;
- const customEnter = (element as HTMLElement & { _voltSurgeEnter?: TransitionPhase })._voltSurgeEnter;
+ const surgeEl = element as SurgeElement;
+ ensureInlineSurgeState(surgeEl);
+
+ const config = surgeEl._vxSurgeConf;
+ const customEnter = surgeEl._vxSurgeEnter;
const enterPhase = customEnter ?? config?.enterPreset;
if (!enterPhase) {
@@ -338,8 +379,11 @@ export async function executeSurgeEnter(element: HTMLElement): Promise {
* @internal
*/
export async function executeSurgeLeave(element: HTMLElement): Promise {
- const config = (element as HTMLElement & { _voltSurgeConfig?: SurgeConfig })._voltSurgeConfig;
- const customLeave = (element as HTMLElement & { _voltSurgeLeave?: TransitionPhase })._voltSurgeLeave;
+ const surgeEl = element as SurgeElement;
+ ensureInlineSurgeState(surgeEl);
+
+ const config = surgeEl._vxSurgeConf;
+ const customLeave = surgeEl._vxSurgeLeave;
const leavePhase = customLeave ?? config?.leavePreset;
if (!leavePhase) {
@@ -354,9 +398,8 @@ export async function executeSurgeLeave(element: HTMLElement): Promise {
* @internal
*/
export function hasSurge(element: HTMLElement): boolean {
- const config = (element as HTMLElement & { _voltSurgeConfig?: SurgeConfig })._voltSurgeConfig;
- const customEnter = (element as HTMLElement & { _voltSurgeEnter?: TransitionPhase })._voltSurgeEnter;
- const customLeave = (element as HTMLElement & { _voltSurgeLeave?: TransitionPhase })._voltSurgeLeave;
+ const surgeEl = element as SurgeElement;
+ ensureInlineSurgeState(surgeEl);
- return Boolean(config || customEnter || customLeave);
+ return Boolean(surgeEl._vxSurgeConf || surgeEl._vxSurgeEnter || surgeEl._vxSurgeLeave);
}
diff --git a/lib/src/plugins/url.ts b/lib/src/plugins/url.ts
index 9f82d14..db95899 100644
--- a/lib/src/plugins/url.ts
+++ b/lib/src/plugins/url.ts
@@ -3,15 +3,118 @@
* Supports one-way read, bidirectional sync, and hash-based routing
*/
-import { isNil } from "$core/shared";
+import { isNil, kebabToCamel } from "$core/shared";
import type { Optional } from "$types/helpers";
-import type { PluginContext, Signal } from "$types/volt";
+import type { PluginContext, Scope, Signal } from "$types/volt";
+
+type UrlMode = "read" | "sync" | "hash" | "history";
+
+interface ResolvedSignal {
+ path: string;
+ signal: Signal;
+}
+
+function normalizeMode(mode: string): Optional {
+ const normalized = mode.trim().toLowerCase().replaceAll(/[\s_-]/g, "");
+
+ switch (normalized) {
+ case "read": {
+ return "read";
+ }
+ case "sync":
+ case "bidirectional": {
+ return "sync";
+ }
+ case "query":
+ case "search": {
+ return "sync";
+ }
+ case "hash": {
+ return "hash";
+ }
+ case "history":
+ case "route": {
+ return "history";
+ }
+ default: {
+ return undefined;
+ }
+ }
+}
+
+function resolveCanonicalPath(scope: Scope, rawPath: string): string {
+ const trimmed = rawPath.trim();
+ if (!trimmed) {
+ return trimmed;
+ }
+
+ const parts = trimmed.split(".");
+ const resolved: string[] = [];
+ let current: unknown = scope;
+
+ for (const part of parts) {
+ if (isNil(current) || typeof current !== "object") {
+ resolved.push(part);
+ current = undefined;
+ continue;
+ }
+
+ const record = current as Record;
+
+ if (Object.hasOwn(record, part)) {
+ resolved.push(part);
+ current = record[part];
+ continue;
+ }
+
+ const camelCandidate = kebabToCamel(part);
+ if (Object.hasOwn(record, camelCandidate)) {
+ resolved.push(camelCandidate);
+ current = record[camelCandidate];
+ continue;
+ }
+
+ const lower = part.toLowerCase();
+ const matchedKey = Object.keys(record).find((key) => key.toLowerCase() === lower);
+
+ if (matchedKey) {
+ resolved.push(matchedKey);
+ current = record[matchedKey];
+ continue;
+ }
+
+ resolved.push(part);
+ current = undefined;
+ }
+
+ return resolved.join(".");
+}
+
+function resolveSignal(ctx: PluginContext, rawPath: string): Optional {
+ const trimmed = rawPath.trim();
+ if (!trimmed) {
+ return undefined;
+ }
+
+ const canonicalPath = resolveCanonicalPath(ctx.scope, trimmed);
+ const candidatePaths = new Set([canonicalPath, trimmed]);
+
+ for (const candidate of candidatePaths) {
+ const found = ctx.findSignal(candidate);
+ if (found) {
+ return { path: candidate, signal: found as Signal };
+ }
+ }
+
+ return undefined;
+}
/**
* URL plugin handler.
* Synchronizes signal values with URL parameters, hash, and full history state.
*
* Syntax: data-volt-url="mode:signalPath" or data-volt-url="mode:signalPath:basePath"
+ * Alternate syntax: data-volt-url:signalPath="mode" (e.g., data-volt-url:search="query")
* Modes:
* - read:signalPath - Read URL param into signal on mount (one-way)
* - sync:signalPath - Bidirectional sync between signal and URL param
@@ -19,34 +122,62 @@ import type { PluginContext, Signal } from "$types/volt";
* - history:signalPath[:basePath] - Sync with full path + search (History API routing)
*/
export function urlPlugin(ctx: PluginContext, value: string): void {
- const parts = value.split(":");
+ const parts = value.split(":").map((part) => part.trim()).filter((part) => part.length > 0);
if (parts.length < 2) {
- console.error(`Invalid url binding: "${value}". Expected format: "mode:signalPath[:basePath]"`);
+ console.error(
+ `Invalid url binding: "${value}". Expected format: "mode:signalPath[:basePath]" or "signalPath:mode[:basePath]"`,
+ );
+ return;
+ }
+
+ const firstMode = normalizeMode(parts[0]);
+ const secondMode = normalizeMode(parts[1] ?? "");
+
+ let mode: Optional;
+ let signalPath: string;
+ let basePath: Optional;
+
+ if (firstMode) {
+ mode = firstMode;
+ signalPath = parts[1] ?? "";
+ basePath = parts.slice(2).join(":") || undefined;
+ } else if (secondMode) {
+ mode = secondMode;
+ signalPath = parts[0];
+ basePath = parts.slice(2).join(":") || undefined;
+ } else {
+ console.error(`Unknown url mode in binding "${value}"`);
return;
}
- const [mode, signalPath, basePath] = parts.map((p) => p.trim());
+ if (!signalPath) {
+ console.error(`Signal path missing for url binding "${value}"`);
+ return;
+ }
+
+ const resolvedSignal = resolveSignal(ctx, signalPath);
+ if (!resolvedSignal) {
+ console.error(`Signal "${signalPath}" not found for url binding`);
+ return;
+ }
switch (mode) {
case "read": {
- handleReadURL(ctx, signalPath);
+ handleReadURL(resolvedSignal);
break;
}
case "sync": {
- handleSyncURL(ctx, signalPath);
+ handleSyncURL(ctx, resolvedSignal);
break;
}
case "hash": {
- handleHashRouting(ctx, signalPath);
+ handleHashRouting(ctx, resolvedSignal as ResolvedSignal);
break;
}
case "history": {
- handleHistoryRouting(ctx, signalPath, basePath);
+ handleHistoryRouting(ctx, resolvedSignal as ResolvedSignal, basePath);
break;
}
- default: {
- console.error(`Unknown url mode: "${mode}"`);
- }
}
}
@@ -54,18 +185,12 @@ export function urlPlugin(ctx: PluginContext, value: string): void {
* Read URL parameter into signal on mount (one-way).
* Signal changes do not update URL.
*/
-function handleReadURL(ctx: PluginContext, signalPath: string): void {
- const signal = ctx.findSignal(signalPath);
- if (!signal) {
- console.error(`Signal "${signalPath}" not found for url read`);
- return;
- }
-
+function handleReadURL(resolved: ResolvedSignal): void {
const params = new URLSearchParams(globalThis.location.search);
- const paramValue = params.get(signalPath);
+ const paramValue = params.get(resolved.path);
if (paramValue !== null) {
- (signal as Signal).set(deserializeValue(paramValue));
+ resolved.signal.set(deserializeValue(paramValue));
}
}
@@ -73,24 +198,20 @@ function handleReadURL(ctx: PluginContext, signalPath: string): void {
* Bidirectional sync between signal and URL parameter.
* Changes to either the signal or URL update the other.
*/
-function handleSyncURL(ctx: PluginContext, signalPath: string): void {
- const signal = ctx.findSignal(signalPath);
- if (!signal) {
- console.error(`Signal "${signalPath}" not found for url sync`);
- return;
- }
-
+function handleSyncURL(ctx: PluginContext, resolved: ResolvedSignal): void {
const params = new URLSearchParams(globalThis.location.search);
- const paramValue = params.get(signalPath);
+ const paramValue = params.get(resolved.path);
if (paramValue !== null) {
- (signal as Signal).set(deserializeValue(paramValue));
+ resolved.signal.set(deserializeValue(paramValue));
}
let isUpdatingFromUrl = false;
let updateTimeout: Optional;
const updateUrl = (value: unknown) => {
- if (isUpdatingFromUrl) return;
+ if (isUpdatingFromUrl) {
+ return;
+ }
if (updateTimeout) {
clearTimeout(updateTimeout);
@@ -101,9 +222,9 @@ function handleSyncURL(ctx: PluginContext, signalPath: string): void {
const serialized = serializeValue(value);
if (isNil(serialized) || serialized === "") {
- params.delete(signalPath);
+ params.delete(resolved.path);
} else {
- params.set(signalPath, serialized);
+ params.set(resolved.path, serialized);
}
const newSearch = params.toString();
@@ -116,17 +237,17 @@ function handleSyncURL(ctx: PluginContext, signalPath: string): void {
const handlePopState = () => {
isUpdatingFromUrl = true;
const params = new URLSearchParams(globalThis.location.search);
- const paramValue = params.get(signalPath);
+ const paramValue = params.get(resolved.path);
if (isNil(paramValue)) {
- (signal as Signal).set("");
+ resolved.signal.set("");
} else {
- (signal as Signal).set(deserializeValue(paramValue));
+ resolved.signal.set(deserializeValue(paramValue));
}
isUpdatingFromUrl = false;
};
- const unsubscribe = signal.subscribe(updateUrl);
+ const unsubscribe = resolved.signal.subscribe(updateUrl);
globalThis.addEventListener("popstate", handlePopState);
ctx.addCleanup(() => {
@@ -142,22 +263,18 @@ function handleSyncURL(ctx: PluginContext, signalPath: string): void {
* Sync signal with hash portion of URL for client-side routing.
* Bidirectional sync between signal and window.location.hash.
*/
-function handleHashRouting(ctx: PluginContext, signalPath: string): void {
- const signal = ctx.findSignal(signalPath);
- if (!signal) {
- console.error(`Signal "${signalPath}" not found for hash routing`);
- return;
- }
-
+function handleHashRouting(ctx: PluginContext, resolved: ResolvedSignal): void {
const currentHash = globalThis.location.hash.slice(1);
if (currentHash) {
- (signal as Signal).set(currentHash);
+ resolved.signal.set(currentHash);
}
let isUpdatingFromHash = false;
const updateHash = (value: unknown) => {
- if (isUpdatingFromHash) return;
+ if (isUpdatingFromHash) {
+ return;
+ }
const hashValue = String(value ?? "");
const newHash = hashValue ? `#${hashValue}` : "";
@@ -170,11 +287,11 @@ function handleHashRouting(ctx: PluginContext, signalPath: string): void {
const handleHashChange = () => {
isUpdatingFromHash = true;
const currentHash = globalThis.location.hash.slice(1);
- (signal as Signal).set(currentHash);
+ resolved.signal.set(currentHash);
isUpdatingFromHash = false;
};
- const unsubscribe = signal.subscribe(updateHash);
+ const unsubscribe = resolved.signal.subscribe(updateHash);
globalThis.addEventListener("hashchange", handleHashChange);
ctx.addCleanup(() => {
@@ -221,44 +338,44 @@ function deserializeValue(value: string): unknown {
}
}
+function normalizeRoute(path: string) {
+ if (!path) {
+ return "/";
+ }
+ return path.startsWith("/") ? path : `/${path}`;
+}
+
/**
* Sync signal with full path + search params for History API routing.
* Bidirectional sync between signal and window.location.pathname + search.
- *
- * @param ctx - Plugin context
- * @param signalPath - Signal path to sync
- * @param basePath - Optional base path to strip from routes (e.g., "/app")
*/
-function handleHistoryRouting(ctx: PluginContext, signalPath: string, basePath?: string): void {
- const signal = ctx.findSignal(signalPath);
- if (!signal) {
- console.error(`Signal "${signalPath}" not found for history routing`);
- return;
- }
+function handleHistoryRouting(ctx: PluginContext, resolved: ResolvedSignal, basePath?: string): void {
+ const base = basePath?.trim() ?? "";
- const base = basePath || "";
- const getCurrentRoute = (): string => {
+ const extractRoute = () => {
const fullPath = globalThis.location.pathname + globalThis.location.search;
if (base && fullPath.startsWith(base)) {
- return fullPath.slice(base.length) || "/";
+ const stripped = fullPath.slice(base.length) || "/";
+ return normalizeRoute(stripped);
}
- return fullPath;
+ return normalizeRoute(fullPath);
};
- const currentRoute = getCurrentRoute();
- if (currentRoute) {
- (signal as Signal).set(currentRoute);
- }
+ const currentRoute = extractRoute();
+ resolved.signal.set(currentRoute);
let isUpdatingFromHistory = false;
const updateUrl = (value: unknown) => {
- if (isUpdatingFromHistory) return;
+ if (isUpdatingFromHistory) {
+ return;
+ }
- const route = String(value ?? "/");
+ const route = normalizeRoute(String(value ?? "/"));
const fullPath = base ? `${base}${route}` : route;
+ const currentFull = globalThis.location.pathname + globalThis.location.search;
- if (globalThis.location.pathname + globalThis.location.search !== fullPath) {
+ if (currentFull !== fullPath) {
globalThis.history.pushState({}, "", fullPath);
globalThis.dispatchEvent(
new CustomEvent("volt:navigate", { detail: { url: fullPath, route }, bubbles: true, cancelable: false }),
@@ -268,20 +385,19 @@ function handleHistoryRouting(ctx: PluginContext, signalPath: string, basePath?:
const handlePopState = () => {
isUpdatingFromHistory = true;
- const route = getCurrentRoute();
- (signal as Signal).set(route);
+ const route = extractRoute();
+ resolved.signal.set(route);
globalThis.dispatchEvent(new CustomEvent("volt:popstate", { detail: { route }, bubbles: true, cancelable: false }));
isUpdatingFromHistory = false;
};
const handleNavigate = () => {
isUpdatingFromHistory = true;
- const route = getCurrentRoute();
- (signal as Signal).set(route);
+ resolved.signal.set(extractRoute());
isUpdatingFromHistory = false;
};
- const unsubscribe = signal.subscribe(updateUrl);
+ const unsubscribe = resolved.signal.subscribe(updateUrl);
globalThis.addEventListener("popstate", handlePopState);
globalThis.addEventListener("volt:navigate", handleNavigate);
diff --git a/lib/test/core/evaluator.test.ts b/lib/test/core/evaluator.test.ts
index 9787d82..090d3fe 100644
--- a/lib/test/core/evaluator.test.ts
+++ b/lib/test/core/evaluator.test.ts
@@ -294,6 +294,51 @@ describe("Evaluator - Functional Tests", () => {
expect(evaluate("status == 'active'", scope)).toBe(true);
expect(evaluate("status == 'inactive'", scope)).toBe(false);
});
+
+ it("should support spreading signals containing arrays", () => {
+ scope.items = signal([2, 3, 4]);
+ const result = evaluate("[1, ...items, 5]", scope);
+ expect(result).toEqual([1, 2, 3, 4, 5]);
+ });
+
+ it("should support spreading signals in complex expressions", () => {
+ scope.todos = signal([{ id: 1, text: "Learn" }, { id: 2, text: "Build" }]);
+ scope.newTodo = { id: 3, text: "Ship" };
+ const result = evaluate("[...todos, newTodo]", scope);
+ expect(result).toEqual([{ id: 1, text: "Learn" }, { id: 2, text: "Build" }, { id: 3, text: "Ship" }]);
+ });
+
+ it("should support iterating over signals containing arrays", () => {
+ scope.items = signal([1, 2, 3]);
+ const result = evaluate("[...items].map(x => x * 2)", scope);
+ expect(result).toEqual([2, 4, 6]);
+ });
+
+ it("should handle spreading non-iterable signals gracefully", () => {
+ scope.count = signal(42);
+ expect(() => evaluate("[...count]", scope)).toThrow();
+ });
+
+ it("should unwrap signals in object literals when unwrapSignals is false", () => {
+ scope.id = signal(42);
+ scope.name = signal("Alice");
+ const result = evaluate("{id: id, name: name}", scope, { unwrapSignals: false });
+ expect(result).toEqual({ id: 42, name: "Alice" });
+ });
+
+ it("should unwrap signals in complex object literals", () => {
+ scope.todoId = signal(3);
+ scope.todoText = signal("New task");
+ scope.todoDone = signal(false);
+ const result = evaluate("{id: todoId, text: todoText, done: todoDone}", scope, { unwrapSignals: false });
+ expect(result).toEqual({ id: 3, text: "New task", done: false });
+ });
+
+ it("should not unwrap method calls in object literals", () => {
+ scope.text = signal(" hello ");
+ const result = evaluate("{value: text.trim()}", scope, { unwrapSignals: false });
+ expect(result).toEqual({ value: "hello" });
+ });
});
describe("Expression Caching", () => {
diff --git a/lib/test/integration/transitions.test.ts b/lib/test/integration/transitions.test.ts
index 19dd290..0ad2a2c 100644
--- a/lib/test/integration/transitions.test.ts
+++ b/lib/test/integration/transitions.test.ts
@@ -114,7 +114,7 @@ describe("integration: transitions", () => {
const show = signal(true);
mount(container, { show });
- await vi.advanceTimersByTimeAsync(50);
+ await vi.advanceTimersByTimeAsync(400);
let shownEl = [...container.querySelectorAll("div")].find((el) => el.textContent?.includes("Shown"));
let hiddenEl = [...container.querySelectorAll("div")].find((el) => el.textContent?.includes("Hidden"));
@@ -275,13 +275,7 @@ describe("integration: transitions", () => {
});
describe("Shift animations", () => {
- beforeEach(() => {
- HTMLElement.prototype.animate = vi.fn((_keyframes: Keyframe[], _options?: KeyframeAnimationOptions) => {
- return { onfinish: null, cancel: vi.fn() } as unknown as Animation;
- });
- });
-
- it("should apply animation on mount", () => {
+ it("should apply animation on mount", async () => {
const container = document.createElement("div");
const testEl = document.createElement("div");
testEl.dataset.voltShift = "bounce";
@@ -292,7 +286,11 @@ describe("integration: transitions", () => {
mount(container, {});
- expect(element.animate).toHaveBeenCalled();
+ // Wait for requestAnimationFrame to apply the animation
+ await vi.waitFor(() => {
+ expect(element.dataset.voltShiftRuns).toBe("1");
+ expect(element.style.animationName).toMatch(/^volt-shift-/);
+ });
});
it("should trigger animation based on signal", () => {
@@ -307,13 +305,13 @@ describe("integration: transitions", () => {
mount(container, { trigger });
const button = container.querySelector("button") as HTMLElement;
- expect(button.animate).not.toHaveBeenCalled();
+ expect(button.dataset.voltShiftRuns ?? "0").toBe("0");
trigger.set(true);
- expect(button.animate).toHaveBeenCalled();
+ expect(button.dataset.voltShiftRuns).toBe("1");
});
- it("should support duration and iteration modifiers", () => {
+ it("should support duration and iteration modifiers", async () => {
const container = document.createElement("div");
const testEl = document.createElement("div");
testEl.dataset.voltShift = "bounce.1000.3";
@@ -324,12 +322,12 @@ describe("integration: transitions", () => {
mount(container, {});
- expect(element.animate).toHaveBeenCalled();
-
- const animateMock = element.animate as unknown as ReturnType;
- const options = animateMock.mock.calls[0]?.[1] as KeyframeAnimationOptions;
- expect(options?.duration).toBe(1000);
- expect(options?.iterations).toBe(3);
+ // Wait for requestAnimationFrame to apply the animation
+ await vi.waitFor(() => {
+ expect(element.dataset.voltShiftRuns).toBe("1");
+ expect(element.style.animationDuration).toBe("1000ms");
+ expect(element.style.animationIterationCount).toBe("3");
+ });
});
it("should cleanup signal subscription on unmount", () => {
@@ -348,7 +346,7 @@ describe("integration: transitions", () => {
const button = container.querySelector("button") as HTMLElement;
trigger.set(true);
- expect(button.animate).not.toHaveBeenCalled();
+ expect(button.dataset.voltShiftRuns ?? "0").toBe("0");
});
});
@@ -544,10 +542,6 @@ describe("integration: transitions", () => {
});
it("should combine surge and shift on same element", async () => {
- HTMLElement.prototype.animate = vi.fn((_keyframes: Keyframe[], _options?: KeyframeAnimationOptions) => {
- return { onfinish: null, cancel: vi.fn() } as unknown as Animation;
- });
-
const container = document.createElement("div");
const testEl = document.createElement("div");
testEl.dataset.voltShow = "visible";
@@ -565,7 +559,7 @@ describe("integration: transitions", () => {
setTimeout(resolve, 100);
});
- expect(element.animate).toHaveBeenCalled();
+ expect(element.dataset.voltShiftRuns).toBe("1");
});
});
});
diff --git a/lib/test/plugins/persist.test.ts b/lib/test/plugins/persist.test.ts
index 75ca842..d0919ee 100644
--- a/lib/test/plugins/persist.test.ts
+++ b/lib/test/plugins/persist.test.ts
@@ -12,6 +12,23 @@ describe("persist plugin", () => {
});
describe("localStorage persistence", () => {
+ it("supports attribute suffix syntax with camelCase signal and storage aliases", async () => {
+ localStorage.setItem("volt:persistedCount", "7");
+
+ const element = document.createElement("div");
+ element.dataset["voltPersist:persistedcount"] = "localStorage";
+
+ const persistedCount = signal(0);
+ mount(element, { persistedCount });
+
+ await new Promise((resolve) => setTimeout(resolve, 0));
+ expect(persistedCount.get()).toBe(7);
+
+ persistedCount.set(9);
+ await new Promise((resolve) => setTimeout(resolve, 0));
+ expect(localStorage.getItem("volt:persistedCount")).toBe("9");
+ });
+
it("loads persisted value from localStorage on mount", () => {
localStorage.setItem("volt:count", "42");
diff --git a/lib/test/plugins/shift.test.ts b/lib/test/plugins/shift.test.ts
index f208af7..d895c42 100644
--- a/lib/test/plugins/shift.test.ts
+++ b/lib/test/plugins/shift.test.ts
@@ -48,9 +48,7 @@ describe("Shift Plugin", () => {
globalThis.matchMedia = vi.fn().mockReturnValue({ matches: false });
- element.animate = vi.fn((keyframes: Keyframe[], options?: KeyframeAnimationOptions) => {
- return { onfinish: null, cancel: vi.fn(), _keyframes: keyframes, _options: options };
- }) as unknown as typeof element.animate;
+ element.style.animation = "";
});
afterEach(() => {
@@ -135,41 +133,38 @@ describe("Shift Plugin", () => {
});
describe("Basic Animation Application", () => {
- it("should apply animation on mount", () => {
+ it("should apply animation on mount", async () => {
shiftPlugin(mockContext, "bounce");
- expect(element.animate).toHaveBeenCalled();
- const animateCall = (element.animate as ReturnType).mock.calls[0];
- expect(animateCall).toBeDefined();
+ await vi.waitFor(() => {
+ expect(element.style.animationName).toMatch(/^volt-shift-/);
+ });
});
- it("should use default duration and iterations", () => {
+ it("should use default duration and iterations", async () => {
shiftPlugin(mockContext, "bounce");
- expect(element.animate).toHaveBeenCalled();
- const animateMock = element.animate as unknown as ReturnType;
- const options = animateMock.mock.calls[0]?.[1] as KeyframeAnimationOptions;
- expect(options?.duration).toBe(100);
- expect(options?.iterations).toBe(1);
+ await vi.waitFor(() => {
+ expect(element.style.animationDuration).toBe("100ms");
+ expect(element.style.animationIterationCount).toBe("1");
+ });
});
- it("should apply custom duration", () => {
+ it("should apply custom duration", async () => {
shiftPlugin(mockContext, "bounce.1000");
- expect(element.animate).toHaveBeenCalled();
- const animateMock = element.animate as unknown as ReturnType;
- const options = animateMock.mock.calls[0]?.[1] as KeyframeAnimationOptions;
- expect(options?.duration).toBe(1000);
+ await vi.waitFor(() => {
+ expect(element.style.animationDuration).toBe("1000ms");
+ });
});
- it("should apply custom duration and iterations", () => {
+ it("should apply custom duration and iterations", async () => {
shiftPlugin(mockContext, "bounce.500.3");
- expect(element.animate).toHaveBeenCalled();
- const animateMock = element.animate as unknown as ReturnType;
- const options = animateMock.mock.calls[0]?.[1] as KeyframeAnimationOptions;
- expect(options?.duration).toBe(500);
- expect(options?.iterations).toBe(3);
+ await vi.waitFor(() => {
+ expect(element.style.animationDuration).toBe("500ms");
+ expect(element.style.animationIterationCount).toBe("3");
+ });
});
it("should handle unknown animation preset", () => {
@@ -177,7 +172,7 @@ describe("Shift Plugin", () => {
shiftPlugin(mockContext, "unknown");
- expect(element.animate).not.toHaveBeenCalled();
+ expect(element.style.animationName).toBe("");
expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining("Unknown animation preset: \"unknown\""));
consoleSpy.mockRestore();
@@ -188,11 +183,39 @@ describe("Shift Plugin", () => {
shiftPlugin(mockContext, "");
- expect(element.animate).not.toHaveBeenCalled();
+ expect(element.style.animationName).toBe("");
expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining("Invalid shift value"));
consoleSpy.mockRestore();
});
+
+ it("should work when Web Animations API is unavailable", async () => {
+ // @ts-expect-error mutate for test
+ element.animate = undefined;
+
+ shiftPlugin(mockContext, "bounce");
+
+ await vi.waitFor(() => {
+ expect(element.style.animationName).toMatch(/^volt-shift-/);
+ });
+ });
+
+ it("should normalize inline elements for transform animations", async () => {
+ const span = document.createElement("span");
+ span.textContent = "⚙️";
+ container.append(span);
+
+ const context: PluginContext = { ...mockContext, element: span };
+
+ shiftPlugin(context, "spin");
+
+ await vi.waitFor(() => {
+ expect(span.style.display).toBe("inline-block");
+ expect(span.dataset.voltShiftDisplayManaged).toBe("infinite");
+ expect(span.dataset.voltShiftRuns).toBe("1");
+ expect(span.style.transformOrigin).toBe("center center");
+ });
+ });
});
describe("Signal-Triggered Animations", () => {
@@ -202,33 +225,37 @@ describe("Shift Plugin", () => {
shiftPlugin(mockContext, "trigger:bounce");
- expect(element.animate).not.toHaveBeenCalled();
+ expect(element.dataset.voltShiftRuns ?? "0").toBe("0");
triggerSignal.set(true);
- expect(element.animate).toHaveBeenCalled();
+ expect(element.dataset.voltShiftRuns).toBe("1");
});
- it("should not trigger animation when signal stays truthy", () => {
+ it("should not trigger animation when signal stays truthy", async () => {
const triggerSignal = signal(true);
mockContext.findSignal = vi.fn().mockReturnValue(triggerSignal);
shiftPlugin(mockContext, "trigger:bounce");
- expect(element.animate).toHaveBeenCalledTimes(1);
+ await vi.waitFor(() => {
+ expect(element.dataset.voltShiftRuns).toBe("1");
+ });
triggerSignal.set(true);
- expect(element.animate).toHaveBeenCalledTimes(1);
+ expect(element.dataset.voltShiftRuns).toBe("1");
});
- it("should trigger animation on initial mount if signal is truthy", () => {
+ it("should trigger animation on initial mount if signal is truthy", async () => {
const triggerSignal = signal(true);
mockContext.findSignal = vi.fn().mockReturnValue(triggerSignal);
shiftPlugin(mockContext, "trigger:bounce");
- expect(element.animate).toHaveBeenCalledTimes(1);
+ await vi.waitFor(() => {
+ expect(element.dataset.voltShiftRuns).toBe("1");
+ });
});
it("should handle signal not found", () => {
@@ -249,11 +276,59 @@ describe("Shift Plugin", () => {
triggerSignal.set(true);
- expect(element.animate).toHaveBeenCalled();
- const animateMock = element.animate as unknown as ReturnType;
- const options = animateMock.mock.calls[0]?.[1] as KeyframeAnimationOptions;
- expect(options?.duration).toBe(800);
- expect(options?.iterations).toBe(2);
+ expect(element.dataset.voltShiftRuns).toBe("1");
+ expect(element.style.animationDuration).toBe("800ms");
+ expect(element.style.animationIterationCount).toBe("2");
+ });
+
+ it("should stop infinite animations when signal becomes falsy", async () => {
+ const spinSignal = signal(true);
+ mockContext.findSignal = vi.fn().mockReturnValue(spinSignal);
+
+ shiftPlugin(mockContext, "spin:spin");
+
+ await vi.waitFor(() => {
+ expect(element.style.animationName).toMatch(/^volt-shift-/);
+ expect(element.style.animationIterationCount).toBe("infinite");
+ });
+
+ spinSignal.set(false);
+
+ expect(element.style.animationName).toBe("");
+ expect(element.style.animationIterationCount).toBe("");
+ });
+
+ it("should not stop finite animations when signal becomes falsy", async () => {
+ const triggerSignal = signal(true);
+ mockContext.findSignal = vi.fn().mockReturnValue(triggerSignal);
+
+ shiftPlugin(mockContext, "trigger:bounce");
+
+ await vi.waitFor(() => {
+ expect(element.style.animationName).toMatch(/^volt-shift-/);
+ });
+
+ triggerSignal.set(false);
+
+ expect(element.style.animationName).toMatch(/^volt-shift-/);
+ });
+
+ it("should restart infinite animation when signal toggles", async () => {
+ const spinSignal = signal(true);
+ mockContext.findSignal = vi.fn().mockReturnValue(spinSignal);
+
+ shiftPlugin(mockContext, "spin:spin");
+
+ await vi.waitFor(() => {
+ expect(element.dataset.voltShiftRuns).toBe("1");
+ });
+
+ spinSignal.set(false);
+ expect(element.style.animationName).toBe("");
+
+ spinSignal.set(true);
+
+ expect(element.dataset.voltShiftRuns).toBe("2");
});
});
@@ -263,7 +338,7 @@ describe("Shift Plugin", () => {
shiftPlugin(mockContext, "bounce");
- expect(element.animate).not.toHaveBeenCalled();
+ expect(element.style.animationName).toBe("");
});
it("should not animate when prefers-reduced-motion is active and signal triggers", () => {
@@ -276,23 +351,22 @@ describe("Shift Plugin", () => {
triggerSignal.set(true);
- expect(element.animate).not.toHaveBeenCalled();
+ expect(element.style.animationName).toBe("");
});
});
describe("Animation Cleanup", () => {
- it("should cancel animation on finish", () => {
- const mockAnimation = { onfinish: null as (() => void) | null, cancel: vi.fn() };
-
- element.animate = vi.fn().mockReturnValue(mockAnimation);
-
+ it("should clear inline animation after it completes", async () => {
shiftPlugin(mockContext, "bounce");
- expect(mockAnimation.onfinish).toBeDefined();
+ await vi.waitFor(() => {
+ expect(element.style.animationName).toMatch(/^volt-shift-/);
+ });
- mockAnimation.onfinish?.();
+ await new Promise((resolve) => setTimeout(resolve, 150));
- expect(mockAnimation.cancel).toHaveBeenCalled();
+ expect(element.style.animationName).toBe("");
+ expect(element.style.animationFillMode).toBe("");
});
it("should cleanup signal subscription", () => {
diff --git a/lib/test/plugins/surge.test.ts b/lib/test/plugins/surge.test.ts
index 83107bd..8820716 100644
--- a/lib/test/plugins/surge.test.ts
+++ b/lib/test/plugins/surge.test.ts
@@ -49,15 +49,35 @@ describe("Surge Plugin", () => {
expect(hasSurge(element as HTMLElement)).toBe(true);
});
+ it("should detect surge attributes before plugin execution", async () => {
+ vi.useFakeTimers();
+
+ element.dataset.voltSurge = "fade";
+ expect(hasSurge(element as HTMLElement)).toBe(true);
+
+ const enterPromise = executeSurgeEnter(element as HTMLElement);
+ await vi.advanceTimersByTimeAsync(400);
+ await enterPromise;
+ expect(element.style.opacity).toBe("1");
+
+ element.dataset["voltSurge:leave"] = "fade";
+ const leavePromise = executeSurgeLeave(element as HTMLElement);
+ await vi.advanceTimersByTimeAsync(400);
+ await leavePromise;
+ expect(element.style.opacity).toBe("0");
+
+ vi.useRealTimers();
+ });
+
it("should store enter-specific config", () => {
surgePlugin(mockContext, "enter:slide-down");
- const stored = (element as HTMLElement & { _voltSurgeEnter?: unknown })._voltSurgeEnter;
+ const stored = (element as HTMLElement & { _vxSurgeEnter?: unknown })._vxSurgeEnter;
expect(stored).toBeDefined();
});
it("should store leave-specific config", () => {
surgePlugin(mockContext, "leave:fade.300");
- const stored = (element as HTMLElement & { _voltSurgeLeave?: unknown })._voltSurgeLeave;
+ const stored = (element as HTMLElement & { _vxSurgeLeave?: unknown })._vxSurgeLeave;
expect(stored).toBeDefined();
});
});
diff --git a/lib/test/plugins/url.test.ts b/lib/test/plugins/url.test.ts
index 47721aa..5ea6b63 100644
--- a/lib/test/plugins/url.test.ts
+++ b/lib/test/plugins/url.test.ts
@@ -89,6 +89,22 @@ describe("url plugin", () => {
expect(filter.get()).toBe("active");
});
+ it("supports attribute suffix syntax with query alias", async () => {
+ globalThis.history.replaceState({}, "", "/");
+
+ const element = document.createElement("div");
+ element.dataset["voltUrl:searchterm"] = "query";
+
+ const searchTerm = signal("");
+ mount(element, { searchTerm });
+
+ searchTerm.set("hello");
+
+ await new Promise((resolve) => setTimeout(resolve, 150));
+
+ expect(globalThis.location.search).toBe("?searchTerm=hello");
+ });
+
it("updates URL when signal changes", async () => {
globalThis.history.replaceState({}, "", "/");
@@ -564,7 +580,7 @@ describe("url plugin", () => {
mount(element, {});
- expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining("Unknown url mode: \"unknown\""));
+ expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining("Unknown url mode"));
errorSpy.mockRestore();
});
--
2.51.2