+12
ROADMAP.md
+12
ROADMAP.md
···
17
17
| v0.2.0 | ✓ | [Reactive Attributes & Event Modifiers](#reactive-attributes--event-modifiers) |
18
18
| v0.3.0 | ✓ | [Global State](#global-state) |
19
19
| v0.4.0 | | [Animation & Transitions](#animation--transitions) |
20
+
| | | [History API Routing Plugin](#history-api-routing-plugin) |
20
21
| v0.5.0 | | [Persistence & Offline](#persistence--offline) |
21
22
| | | [Background Requests & Reactive Polling](#background-requests--reactive-polling) |
22
23
| v0.6.0 | | [Navigation & History Management](#navigation--history-management) |
···
173
174
- Preloading of linked resources on hover or idle
174
175
- `data-volt-url` for declarative history updates
175
176
- View Transition API integration for animated route changes
177
+
178
+
### History API Routing Plugin
179
+
180
+
**Goal:** Deliver a first-class path-based router that leverages the History API while staying signal-driven.
181
+
**Outcome:** Volt apps can opt into clean URLs (no hash) with back/forward support, nested segments, and SSR-friendly hydration.
182
+
**Deliverables:**
183
+
- `data-volt-url="history:signal"` mode with path + search preservation and optional base path configuration
184
+
- Route parsing utilities for dynamic params (e.g. `/blog/:slug`) and programmatic redirects
185
+
- Scroll restoration hooks and focus management aligned with `navigation` and `popstate` events
186
+
- Integration tests covering pushState navigation, deep links, and server-rendered bootstraps
187
+
- Documentation updates in `docs/usage/routing.md` contrasting hash vs. history strategies
176
188
177
189
### Inspector & Developer Tools
178
190
+29
-11
docs/.vitepress/config.ts
+29
-11
docs/.vitepress/config.ts
···
1
-
import { defineConfig } from "vitepress";
1
+
import { DefaultTheme, defineConfig } from "vitepress";
2
+
import pkg from "../package.json" assert { type: "json" };
2
3
import { u } from "./utils";
4
+
5
+
const repoURL = "https://github.com/stormlightlabs/volt";
3
6
4
7
/**
5
8
* @see https://vitepress.dev/reference/site-config
···
10
13
base: "/volt/",
11
14
appearance: "dark",
12
15
themeConfig: {
13
-
nav: [{ text: "Home", link: "/" }, { text: "Overview", link: "/overview" }, { text: "CSS", link: "/css/volt-css" }],
16
+
search: { provider: "local" },
17
+
nav: [
18
+
{ text: "Home", link: "/" },
19
+
{ text: "Overview", link: "/overview" },
20
+
{ text: "CSS", link: "/css/volt-css" },
21
+
{
22
+
text: "Version",
23
+
items: [{ text: pkg.version, link: `${repoURL}/releases/tag/v${pkg.version}` }, {
24
+
text: "Contributing",
25
+
link: `${repoURL}/blob/main/CONTRIBUTING.md`,
26
+
}, { text: "Changelog", link: "${repoURL}/blob/main/README.md" }],
27
+
},
28
+
],
14
29
sidebar: [
15
30
{
16
31
text: "Getting Started",
17
-
items: [{ text: "Overview", link: "/overview" }, { text: "Installation", link: "/installation" }],
32
+
items:
33
+
([{ text: "Overview", link: "/overview" }, {
34
+
text: "Installation",
35
+
link: "/installation",
36
+
}] as DefaultTheme.SidebarItem[]).concat(...u.scanDir("usage", "/usage")),
18
37
},
19
38
{
20
39
text: "Core Concepts",
···
26
45
{ text: "Animations & Transitions", link: "/animations" },
27
46
],
28
47
},
29
-
{ text: "Tutorials", items: [{ text: "Counter", link: "/usage/counter" }] },
48
+
{ text: "Tutorials", collapsed: false, items: u.scanDir("usage", "/usage") },
30
49
{
31
50
text: "CSS",
32
51
collapsed: false,
33
52
items: [{ text: "Volt CSS", link: "/css/volt-css" }, { text: "Reference", link: "/css/semantics" }],
53
+
docFooterText: "Auto-generated CSS Docs",
34
54
},
35
55
{ text: "Specs", collapsed: true, items: u.scanDir("spec", "/spec") },
36
56
{
···
39
59
items: u.scanDir("api", "/api"),
40
60
docFooterText: "Auto-generated API Docs",
41
61
},
42
-
{
43
-
text: "Internals",
44
-
collapsed: false,
45
-
items: u.scanDir("internals", "/internals"),
46
-
docFooterText: "Auto-generated CSS Docs",
47
-
},
62
+
{ text: "Internals", collapsed: false, items: u.scanDir("internals", "/internals") },
48
63
],
49
-
socialLinks: [{ icon: "github", link: "https://github.com/stormlightlabs/volt" }],
64
+
socialLinks: [{ icon: "github", link: repoURL }, {
65
+
icon: "bluesky",
66
+
link: "https://bsky.app/profile/stormlightlabs.org",
67
+
}],
50
68
},
51
69
});
+165
docs/animations.md
+165
docs/animations.md
···
1
1
# Animations & Transitions
2
+
3
+
VoltX provides a powerful, declarative animation system through two complementary plugins:
4
+
5
+
1. **surge** for enter/leave transitions and
6
+
2. **shift** for CSS keyframe animations.
7
+
8
+
Both integrate with the reactivity system and respect user accessibility preferences.
9
+
10
+
## Quick Start
11
+
12
+
Add transitions to any element with `data-volt-if` or `data-volt-show` by adding `data-volt-surge` with a preset name:
13
+
14
+
```html
15
+
<div data-volt-if="isVisible" data-volt-surge="fade">
16
+
Content fades in and out smoothly
17
+
</div>
18
+
```
19
+
20
+
That's it! The surge plugin automatically hooks into the element's lifecycle, applying transitions when it appears or disappears.
21
+
22
+
## Built-in Presets
23
+
24
+
VoltX includes ready-to-use transition presets:
25
+
26
+
| Preset | Description |
27
+
| --------------- | --------------------------------- |
28
+
| **fade** | Simple opacity transition |
29
+
| **slide-up** | Sliding motion with opacity |
30
+
| **slide-down** | |
31
+
| **slide-left** | |
32
+
| **slide-right** | |
33
+
| **scale** | Subtle scale effect with opacity |
34
+
| **blur** | Blur effect combined with opacity |
35
+
36
+
All presets are designed with smooth, professional timing curves and 300ms duration by default.
37
+
38
+
## Surge Plugin: Enter/Leave Transitions
39
+
40
+
The surge plugin provides two modes of operation: automatic (with if/show bindings) and explicit (signal-based).
41
+
42
+
### Automatic Mode
43
+
44
+
When used with `data-volt-if` or `data-volt-show`, surge automatically manages the element's visibility transitions. The element smoothly animates in when the condition becomes true and animates out before removal.
45
+
46
+
### Explicit Mode
47
+
48
+
Watch any signal by providing a signal path. The element will transition in/out based on the signal's truthiness:
49
+
50
+
```html
51
+
<div data-volt-surge="showPanel:slide-down">
52
+
Panel slides down when showPanel is true
53
+
</div>
54
+
```
55
+
56
+
### Duration and Delay Modifiers
57
+
58
+
Override preset timing using dot notation:
59
+
60
+
```html
61
+
<!-- 500ms duration -->
62
+
<div data-volt-surge="fade.500">...</div>
63
+
64
+
<!-- 600ms duration, 100ms delay -->
65
+
<div data-volt-surge="slide-down.600.100">...</div>
66
+
```
67
+
68
+
### Granular Control
69
+
70
+
Specify different transitions for enter and leave phases:
71
+
72
+
```html
73
+
<div
74
+
data-volt-if="show"
75
+
data-volt-surge:enter="slide-down.400"
76
+
data-volt-surge:leave="fade.200">
77
+
Slides in, fades out
78
+
</div>
79
+
```
80
+
81
+
## Shift Plugin: Keyframe Animations
82
+
83
+
The shift plugin applies CSS keyframe animations, perfect for attention-grabbing effects and continuous animations.
84
+
85
+
### Built-in Animations
86
+
87
+
| Animation | Description |
88
+
| ---------- | --------------------------------- |
89
+
| **bounce** | Quick bounce effect |
90
+
| **shake** | Horizontal shake motion |
91
+
| **pulse** | Subtle pulsing scale (continuous) |
92
+
| **spin** | Full rotation (continuous) |
93
+
| **flash** | Opacity flash effect |
94
+
95
+
### One-Time Animations
96
+
97
+
Apply animation when element mounts:
98
+
99
+
```html
100
+
<button data-volt-shift="bounce">Bounces on mount</button>
101
+
```
102
+
103
+
### Signal-Triggered Animations
104
+
105
+
Trigger animations based on signal changes:
106
+
107
+
```html
108
+
<div data-volt-shift="error:shake.600.2">
109
+
Shakes twice when error becomes truthy
110
+
</div>
111
+
```
112
+
113
+
The syntax supports duration and iteration overrides: `animationName.duration.iterations`
114
+
115
+
## View Transitions API
116
+
117
+
VoltX automatically uses the View Transitions API when available, providing native browser-level transitions for ultra-smooth visual updates.
118
+
The system gracefully falls back to CSS transitions on unsupported browsers.
119
+
120
+
For advanced use cases, manually trigger view transitions using `startViewTransition` or `namedViewTransition` from the programmatic API.
121
+
122
+
## Custom Presets (Programmatic Mode)
123
+
124
+
Register custom transitions for reuse across your application:
125
+
126
+
```javascript
127
+
import { registerTransition } from "voltx.js";
128
+
129
+
registerTransition("custom-slide", {
130
+
enter: {
131
+
from: { opacity: 0, transform: "translateX(-100px)" },
132
+
to: { opacity: 1, transform: "translateX(0)" },
133
+
duration: 400,
134
+
easing: "cubic-bezier(0.4, 0, 0.2, 1)",
135
+
},
136
+
leave: {
137
+
from: { opacity: 1, transform: "translateX(0)" },
138
+
to: { opacity: 0, transform: "translateX(100px)" },
139
+
duration: 300,
140
+
easing: "ease-out",
141
+
},
142
+
});
143
+
```
144
+
145
+
Similarly, register custom shift animations with `registerAnimation`.
146
+
147
+
## Easing Functions
148
+
149
+
Surge supports standard CSS easing values plus extended named easings:
150
+
151
+
- Standard: `linear`, `ease`, `ease-in`, `ease-out`, `ease-in-out`
152
+
- Extended: `ease-in-sine`, `ease-out-quad`, `ease-in-out-cubic`, `ease-in-back`
153
+
154
+
Custom cubic-bezier values are also supported.
155
+
156
+
## Integration with Bindings
157
+
158
+
- With `data-volt-if`, surge defers element insertion/removal until transitions complete, preventing visual glitches.
159
+
- With `data-volt-show`, surge manages display property changes around the transition lifecycle.
160
+
- Simply add `data-volt-surge` to elements already using these bindings.
161
+
162
+
## Accessibility
163
+
164
+
The animation system automatically respects the `prefers-reduced-motion` media query. When enabled, animations are skipped or significantly reduced, instantly applying final states instead.
165
+
166
+
Both surge and shift plugins honor this setting by default, ensuring your application remains accessible without additional configuration.
+2
-4
docs/overview.md
+2
-4
docs/overview.md
···
2
2
outline: deep
3
3
---
4
4
5
-
# Framework Overview
5
+
# Overview
6
6
7
7
VoltX is a lightweight, hypermedia based reactive framework for building declarative UIs.
8
8
···
43
43
44
44
## Browser Support
45
45
46
-
Modern browsers with support for:
46
+
Modern browsers (Chrome 90+, Firefox 88+, Safari 14+) with support for:
47
47
48
48
- ES modules
49
49
- Proxy objects
50
50
- CSS custom properties
51
-
52
-
Chrome 90+, Firefox 88+, Safari 14+
+1
-1
docs/package.json
+1
-1
docs/package.json
+157
docs/usage/routing.md
+157
docs/usage/routing.md
···
1
+
# Routing
2
+
3
+
Client-side routing lets VoltX applications feel like multi-page sites without full page reloads.
4
+
The `url` plugin keeps a signal in sync with the browser URL so your application can react declaratively to route changes.
5
+
This guide walks through building a hash-based router that swaps entire page sections while preserving the advantages
6
+
of VoltX's signal system.
7
+
8
+
## Why?
9
+
10
+
- **Zero reloads:** Route changes update `window.location.hash` via `history.pushState`, so the browser history stack is maintained while the document stays mounted and stateful widgets keep their values.
11
+
- **Shareable URLs:** Users can refresh or share a link such as `/#/pricing` and land directly on the same view.
12
+
- **Declarative rendering:** Routing is just another signal; templates choose what to display with conditional bindings like `data-volt-if` or `data-volt-show`.
13
+
- **Simple integration:** No extra router dependency is required—register the plugin once and opt-in per signal.
14
+
15
+
> The plugin also supports synchronising signals with query parameters (`read:` and `sync:` modes).
16
+
> For multi-page navigation the `hash:` mode is the simplest option because it avoids server configuration and works on static hosting.
17
+
18
+
## How?
19
+
20
+
1. Install Volt normally (see [Installation](../installation.md)).
21
+
2. Register the plugin before calling `charge()` or `mount()`:
22
+
23
+
```html
24
+
<script type="module">
25
+
import {
26
+
charge,
27
+
registerPlugin,
28
+
urlPlugin,
29
+
} from 'https://unpkg.com/voltx.js@latest/dist/volt.js';
30
+
31
+
registerPlugin('url', urlPlugin);
32
+
charge();
33
+
</script>
34
+
```
35
+
36
+
3. In your markup, opt a signal into hash synchronisation with `data-volt-url="hash:signalName"`.
37
+
38
+
## Building a multi-page shell
39
+
40
+
The example below delivers a three-page marketing site entirely on the client. Each "page" is a section that only renders
41
+
when the current route matches its slug.
42
+
43
+
```html
44
+
<main
45
+
data-volt
46
+
data-volt-state='{"route": "home"}'
47
+
data-volt-url="hash:route">
48
+
<nav>
49
+
<button data-volt-class:active="route === 'home'" data-volt-on-click="route.set('home')">
50
+
Home
51
+
</button>
52
+
<button data-volt-class:active="route === 'pricing'" data-volt-on-click="route.set('pricing')">
53
+
Pricing
54
+
</button>
55
+
<button data-volt-class:active="route === 'about'" data-volt-on-click="route.set('about')">
56
+
About
57
+
</button>
58
+
</nav>
59
+
60
+
<section data-volt-if="route === 'home'">
61
+
<h1>Volt</h1>
62
+
<p>A lightning-fast reactive runtime for the DOM.</p>
63
+
</section>
64
+
65
+
<section data-volt-if="route === 'pricing'">
66
+
<h1>Pricing</h1>
67
+
<ul>
68
+
<li>Starter — $0</li>
69
+
<li>Team — $29</li>
70
+
<li>Enterprise — Contact us</li>
71
+
</ul>
72
+
</section>
73
+
74
+
<section data-volt-if="route === 'about'">
75
+
<h1>About</h1>
76
+
<p>Learn more about the Volt runtime and ecosystem.</p>
77
+
</section>
78
+
79
+
<section data-volt-if="route !== 'home' && route !== 'pricing' && route !== 'about'">
80
+
<h1>Not found</h1>
81
+
<p data-volt-text="'No page named \"' + route + '\"'"></p>
82
+
<button data-volt-on-click="route.set('home')">Return home</button>
83
+
</section>
84
+
</main>
85
+
```
86
+
87
+
### How it works
88
+
89
+
- On first mount, the plugin reads `window.location.hash` and updates the `route` signal (defaulting to `"home"` if empty).
90
+
- Clicking navigation buttons calls `route.set(...)`, which updates the signal and immediately pushes the new hash to history.
91
+
The hash-change event also keeps the signal in sync when the user clicks the browser back button.
92
+
- Each section uses `data-volt-if` to opt-in to rendering when the `route` value matches.
93
+
Volt removes sections that no longer match, so each "page" has a distinct DOM subtree.
94
+
95
+
You can style the `"active"` class however you like; it toggles purely through declarative class bindings.
96
+
97
+
## Linking with anchors
98
+
99
+
Prefer plain `<a>` elements when appropriate so the browser shows the target hash in tooltips and lets users open the route in new tabs:
100
+
101
+
```html
102
+
<a href="#pricing" data-volt-on-click="route.set('pricing')">Pricing</a>
103
+
```
104
+
105
+
Setting `href="#pricing"` ensures non-JavaScript fallbacks still land on the right section, while the click handler keeps
106
+
the signal aligned with the hash plugin.
107
+
108
+
## Nested & Computed Routes
109
+
110
+
Because the route is just a string signal, you can derive extra information using computed signals or watchers:
111
+
112
+
```html
113
+
<div
114
+
data-volt
115
+
data-volt-state='{"route": "home"}'
116
+
data-volt-url="hash:route"
117
+
data-volt-computed:segments="route.split('/')">
118
+
<p data-volt-text="'Section: ' + segments[0]"></p>
119
+
<p data-volt-if="segments.length > 1" data-volt-text="'Item: ' + segments[1]"></p>
120
+
</div>
121
+
```
122
+
123
+
Use this pattern to build nested routes like `#/blog/introducing-volt`. Parse the segments in a computed signal and update child components accordingly.
124
+
125
+
For richer logic (e.g., mapping slugs to component functions), register a handler in `data-volt-methods` or mount with the programmatic API.
126
+
This would allow something like a switch statement or usage of a look up of route definitions in a collection.
127
+
128
+
## Preserving State
129
+
130
+
Client-side routing works best when page-level state lives alongside the route signal.
131
+
Volt keeps signals alive as long as their elements remain mounted, so consider nesting pages inside `data-volt-if` blocks that wrap the entire section.
132
+
When you need to reset state upon navigation, call `.set()` explicitly inside your route change handlers or watch the `route` signal and perform cleanup in `ctx.addCleanup`.
133
+
134
+
## Query Params
135
+
136
+
Hash routing is ideal for static sites, but you can combine it with query parameter syncing.
137
+
138
+
For example:
139
+
140
+
```html
141
+
<div
142
+
data-volt
143
+
data-volt-state='{"route": "home", "preview": false}'
144
+
data-volt-url="hash:route">
145
+
<span hidden data-volt-url="sync:preview"></span>
146
+
<!-- ... -->
147
+
</div>
148
+
```
149
+
150
+
Now `#/pricing?preview=true` keeps both the route and a feature flag in sync with the URL.
151
+
Add the extra `data-volt-url="sync:preview"` binding on a child element when you need more than one signal to participate in URL synchronisation.
152
+
153
+
## Progressive Enhancement
154
+
155
+
- Always provide semantic HTML in each section so the site remains usable without JavaScript or when crawled.
156
+
- Consider prefetching data when a link becomes visible: attach a watcher to `route` and trigger fetch logic from the programmatic API.
157
+
- Use `scrollPlugin` for auto-scrolling on navigation if you have tall pages (`data-volt-scroll="route"`).
+1
-1
lib/deno.json
+1
-1
lib/deno.json
+1
-1
lib/jsr.json
+1
-1
lib/jsr.json
+1
-1
lib/package.json
+1
-1
lib/package.json
+142
-41
lib/src/core/binder.ts
+142
-41
lib/src/core/binder.ts
···
2
2
* Binder system for mounting and managing Volt.js bindings
3
3
*/
4
4
5
+
import { executeSurgeEnter, executeSurgeLeave, hasSurge } from "$plugins/surge";
5
6
import type { Nullable, Optional } from "$types/helpers";
6
7
import type {
7
8
BindingContext,
···
268
269
/**
269
270
* Bind data-volt-show to toggle element visibility via CSS display property.
270
271
* Unlike data-volt-if, this keeps the element in the DOM and toggles display: none.
272
+
* Integrates with surge plugin for smooth transitions when available.
271
273
*/
272
274
function bindShow(ctx: BindingContext, expr: string): void {
273
275
const el = ctx.element as HTMLElement;
274
276
const originalInlineDisplay = el.style.display;
277
+
const hasSurgeTransition = hasSurge(el);
278
+
279
+
if (!hasSurgeTransition) {
280
+
const update = () => {
281
+
const value = evaluate(expr, ctx.scope);
282
+
const shouldShow = Boolean(value);
283
+
284
+
if (shouldShow) {
285
+
el.style.display = originalInlineDisplay;
286
+
} else {
287
+
el.style.display = "none";
288
+
}
289
+
};
290
+
291
+
updateAndRegister(ctx, update, expr);
292
+
return;
293
+
}
294
+
295
+
let isVisible = el.style.display !== "none";
296
+
let isTransitioning = false;
275
297
276
298
const update = () => {
277
299
const value = evaluate(expr, ctx.scope);
278
300
const shouldShow = Boolean(value);
279
301
280
-
if (shouldShow) {
281
-
el.style.display = originalInlineDisplay;
282
-
} else {
283
-
el.style.display = "none";
302
+
if (shouldShow === isVisible || isTransitioning) {
303
+
return;
284
304
}
305
+
306
+
isTransitioning = true;
307
+
308
+
requestAnimationFrame(() => {
309
+
void (async () => {
310
+
try {
311
+
if (shouldShow) {
312
+
el.style.display = originalInlineDisplay;
313
+
await executeSurgeEnter(el);
314
+
isVisible = true;
315
+
} else {
316
+
await executeSurgeLeave(el);
317
+
el.style.display = "none";
318
+
isVisible = false;
319
+
}
320
+
} finally {
321
+
isTransitioning = false;
322
+
}
323
+
})();
324
+
});
285
325
};
286
326
287
327
updateAndRegister(ctx, update, expr);
···
643
683
}
644
684
645
685
const { itemName, indexName, arrayPath } = parsed;
646
-
const template = ctx.element as HTMLElement;
647
-
const parent = template.parentElement;
686
+
const templ = ctx.element as HTMLElement;
687
+
const parent = templ.parentElement;
648
688
649
689
if (!parent) {
650
690
console.error("data-volt-for element must have a parent");
···
652
692
}
653
693
654
694
const placeholder = document.createComment(`for: ${expr}`);
655
-
template.before(placeholder);
656
-
template.remove();
695
+
templ.before(placeholder);
696
+
templ.remove();
657
697
658
698
const renderedElements: Element[] = [];
659
699
const renderedCleanups: CleanupFunction[] = [];
···
675
715
}
676
716
677
717
for (const [index, item] of arrayValue.entries()) {
678
-
const clone = template.cloneNode(true) as Element;
718
+
const clone = templ.cloneNode(true) as Element;
679
719
delete (clone as HTMLElement).dataset.voltFor;
680
720
681
721
const itemScope: Scope = { ...ctx.scope, [itemName]: item };
···
703
743
/**
704
744
* Bind data-volt-if to conditionally render an element. Supports data-volt-else on the next sibling element.
705
745
* Subscribes to condition signal and shows/hides elements when condition changes.
746
+
* Integrates with surge plugin for smooth enter/leave transitions when available.
706
747
*/
707
748
function bindIf(ctx: BindingContext, expr: string): void {
708
-
const ifTemplate = ctx.element as HTMLElement;
709
-
const parent = ifTemplate.parentElement;
749
+
const ifTempl = ctx.element as HTMLElement;
750
+
const parent = ifTempl.parentElement;
710
751
711
752
if (!parent) {
712
753
console.error("data-volt-if element must have a parent");
713
754
return;
714
755
}
715
756
716
-
let elseTemplate: Optional<HTMLElement>;
717
-
let nextSibling = ifTemplate.nextElementSibling;
757
+
let elseTempl: Optional<HTMLElement>;
758
+
let nextSibling = ifTempl.nextElementSibling;
718
759
719
760
while (nextSibling && nextSibling.nodeType !== 1) {
720
761
nextSibling = nextSibling.nextElementSibling;
721
762
}
722
763
723
764
if (nextSibling && Object.hasOwn((nextSibling as HTMLElement).dataset, "voltElse")) {
724
-
elseTemplate = nextSibling as HTMLElement;
725
-
elseTemplate.remove();
765
+
elseTempl = nextSibling as HTMLElement;
766
+
elseTempl.remove();
726
767
}
727
768
728
769
const placeholder = document.createComment(`if: ${expr}`);
729
-
ifTemplate.before(placeholder);
730
-
ifTemplate.remove();
770
+
ifTempl.before(placeholder);
771
+
ifTempl.remove();
772
+
773
+
const ifHasSurge = hasSurge(ifTempl);
774
+
const elseHasSurge = elseTempl ? hasSurge(elseTempl) : false;
775
+
const anySurge = ifHasSurge || elseHasSurge;
731
776
732
777
let currentElement: Optional<Element>;
733
778
let currentCleanup: Optional<CleanupFunction>;
734
779
let currentBranch: Optional<"if" | "else">;
780
+
let isTransitioning = false;
735
781
736
782
const render = () => {
737
783
const condition = evaluate(expr, ctx.scope);
738
784
const shouldShow = Boolean(condition);
739
785
740
-
const targetBranch = shouldShow ? "if" : (elseTemplate ? "else" : undefined);
786
+
const targetBranch = shouldShow ? "if" : (elseTempl ? "else" : undefined);
741
787
742
-
if (targetBranch === currentBranch) {
788
+
if (targetBranch === currentBranch || isTransitioning) {
743
789
return;
744
790
}
745
791
746
-
if (currentCleanup) {
747
-
currentCleanup();
748
-
currentCleanup = undefined;
792
+
if (!anySurge) {
793
+
if (currentCleanup) {
794
+
currentCleanup();
795
+
currentCleanup = undefined;
796
+
}
797
+
if (currentElement) {
798
+
currentElement.remove();
799
+
currentElement = undefined;
800
+
}
801
+
802
+
if (targetBranch === "if") {
803
+
currentElement = ifTempl.cloneNode(true) as Element;
804
+
delete (currentElement as HTMLElement).dataset.voltIf;
805
+
currentCleanup = mount(currentElement, ctx.scope);
806
+
placeholder.before(currentElement);
807
+
currentBranch = "if";
808
+
} else if (targetBranch === "else" && elseTempl) {
809
+
currentElement = elseTempl.cloneNode(true) as Element;
810
+
delete (currentElement as HTMLElement).dataset.voltElse;
811
+
currentCleanup = mount(currentElement, ctx.scope);
812
+
placeholder.before(currentElement);
813
+
currentBranch = "else";
814
+
} else {
815
+
currentBranch = undefined;
816
+
}
817
+
return;
749
818
}
750
-
if (currentElement) {
751
-
currentElement.remove();
752
-
currentElement = undefined;
753
-
}
819
+
820
+
isTransitioning = true;
821
+
822
+
requestAnimationFrame(() => {
823
+
void (async () => {
824
+
try {
825
+
if (currentElement) {
826
+
const currentEl = currentElement as HTMLElement;
827
+
const currentHasSurge = currentBranch === "if" ? ifHasSurge : elseHasSurge;
828
+
829
+
if (currentHasSurge) {
830
+
await executeSurgeLeave(currentEl);
831
+
}
832
+
833
+
if (currentCleanup) {
834
+
currentCleanup();
835
+
currentCleanup = undefined;
836
+
}
837
+
currentElement.remove();
838
+
currentElement = undefined;
839
+
}
840
+
841
+
if (targetBranch === "if") {
842
+
currentElement = ifTempl.cloneNode(true) as Element;
843
+
delete (currentElement as HTMLElement).dataset.voltIf;
844
+
placeholder.before(currentElement);
845
+
846
+
if (ifHasSurge) {
847
+
await executeSurgeEnter(currentElement as HTMLElement);
848
+
}
849
+
850
+
currentCleanup = mount(currentElement, ctx.scope);
851
+
currentBranch = "if";
852
+
} else if (targetBranch === "else" && elseTempl) {
853
+
currentElement = elseTempl.cloneNode(true) as Element;
854
+
delete (currentElement as HTMLElement).dataset.voltElse;
855
+
placeholder.before(currentElement);
856
+
857
+
if (elseHasSurge) {
858
+
await executeSurgeEnter(currentElement as HTMLElement);
859
+
}
754
860
755
-
if (targetBranch === "if") {
756
-
currentElement = ifTemplate.cloneNode(true) as Element;
757
-
delete (currentElement as HTMLElement).dataset.voltIf;
758
-
currentCleanup = mount(currentElement, ctx.scope);
759
-
placeholder.before(currentElement);
760
-
currentBranch = "if";
761
-
} else if (targetBranch === "else" && elseTemplate) {
762
-
currentElement = elseTemplate.cloneNode(true) as Element;
763
-
delete (currentElement as HTMLElement).dataset.voltElse;
764
-
currentCleanup = mount(currentElement, ctx.scope);
765
-
placeholder.before(currentElement);
766
-
currentBranch = "else";
767
-
} else {
768
-
currentBranch = undefined;
769
-
}
861
+
currentCleanup = mount(currentElement, ctx.scope);
862
+
currentBranch = "else";
863
+
} else {
864
+
currentBranch = undefined;
865
+
}
866
+
} finally {
867
+
isTransitioning = false;
868
+
}
869
+
})();
870
+
});
770
871
};
771
872
772
873
updateAndRegister(ctx, render, expr);
+178
lib/src/core/view-transitions.ts
+178
lib/src/core/view-transitions.ts
···
1
+
/**
2
+
* View Transitions API integration with CSS fallback
3
+
* Provides progressive enhancement for smooth DOM transitions
4
+
*/
5
+
6
+
import { prefersReducedMotion } from "$core/transitions";
7
+
import type { Optional } from "$types/helpers";
8
+
import type { ViewTransitionOptions as ViewTransitionOpts } from "$types/volt";
9
+
10
+
type StartViewTransitionResult = {
11
+
finished: Promise<void>;
12
+
ready: Promise<void>;
13
+
updateCbDone: Promise<void>;
14
+
skipTransition(): void;
15
+
};
16
+
17
+
/**
18
+
* Extended Document type with View Transitions API support
19
+
*/
20
+
type DocumentWithViewTransition = Document & {
21
+
startViewTransition(updateCb: () => void | Promise<void>): StartViewTransitionResult;
22
+
};
23
+
24
+
/**
25
+
* Check if the browser supports the View Transitions API
26
+
*
27
+
* @returns true if document.startViewTransition is available
28
+
*/
29
+
export function supportsViewTransitions(): boolean {
30
+
return typeof document !== "undefined" && "startViewTransition" in document;
31
+
}
32
+
33
+
/**
34
+
* Execute a DOM update with View Transitions API.
35
+
* Falls back to direct execution if unsupported or reduced motion is preferred.
36
+
*
37
+
* @param cb - Function that performs DOM updates
38
+
* @param opts - Optional configuration for the transition
39
+
* @returns Promise that resolves when transition completes
40
+
*
41
+
* @example
42
+
* ```typescript
43
+
* // Simple transition
44
+
* await startViewTransition(() => {
45
+
* element.textContent = 'Updated!';
46
+
* });
47
+
*
48
+
* // Named transition for specific element
49
+
* await startViewTransition(() => {
50
+
* element.classList.add('active');
51
+
* }, { name: 'card-flip', elements: [element] });
52
+
* ```
53
+
*/
54
+
export async function startViewTransition(
55
+
cb: () => void | Promise<void>,
56
+
opts: ViewTransitionOpts = {},
57
+
): Promise<void> {
58
+
const { respectReducedMotion = true, forceFallback = false } = opts;
59
+
60
+
if (respectReducedMotion && prefersReducedMotion()) {
61
+
await cb();
62
+
return;
63
+
}
64
+
65
+
if (!forceFallback && supportsViewTransitions()) {
66
+
const namedElements = applyViewTransitionNames(opts.name, opts.elements);
67
+
68
+
try {
69
+
const transition = (document as DocumentWithViewTransition).startViewTransition(cb);
70
+
await transition.finished;
71
+
} finally {
72
+
removeViewTransitionNames(namedElements);
73
+
}
74
+
} else {
75
+
await cb();
76
+
}
77
+
}
78
+
79
+
/**
80
+
* Execute a transition with a specific named view transition.
81
+
* This is a convenience wrapper around startViewTransition for named transitions.
82
+
*
83
+
* @param name - View transition name (maps to view-transition-name CSS property)
84
+
* @param elements - Elements to apply the named transition to
85
+
* @param cb - Function that performs DOM updates
86
+
* @returns Promise that resolves when transition completes
87
+
*
88
+
* @example
89
+
* ```typescript
90
+
* const card = document.querySelector('.card');
91
+
* await namedViewTransition('card-flip', [card], () => {
92
+
* card.classList.toggle('flipped');
93
+
* });
94
+
* ```
95
+
*/
96
+
export async function namedViewTransition(
97
+
name: string,
98
+
elements: HTMLElement[],
99
+
cb: () => void | Promise<void>,
100
+
): Promise<void> {
101
+
return startViewTransition(cb, { name, elements });
102
+
}
103
+
104
+
/**
105
+
* Apply view-transition-name CSS property to elements.
106
+
* Returns a map of elements to their original view-transition-name values
107
+
* for later restoration.
108
+
*
109
+
* @param baseName - Base name for the transition (suffixed with index if multiple elements)
110
+
* @param elements - Elements to apply names to
111
+
* @returns Map of elements to their original view-transition-name values
112
+
*
113
+
* @internal
114
+
*/
115
+
function applyViewTransitionNames(
116
+
baseName: Optional<string>,
117
+
elements: Optional<HTMLElement[]>,
118
+
): Map<HTMLElement, string> {
119
+
const originalNames = new Map<HTMLElement, string>();
120
+
121
+
if (!baseName || !elements || elements.length === 0) {
122
+
return originalNames;
123
+
}
124
+
125
+
for (const [index, element] of elements.entries()) {
126
+
const originalValue = element.style.viewTransitionName;
127
+
originalNames.set(element, originalValue);
128
+
129
+
const transitionName = elements.length === 1 ? baseName : `${baseName}-${index}`;
130
+
element.style.viewTransitionName = transitionName;
131
+
}
132
+
133
+
return originalNames;
134
+
}
135
+
136
+
/**
137
+
* Remove view-transition-name CSS properties and restore original values.
138
+
*
139
+
* @param namedElements - Map of elements to their original view-transition-name values
140
+
*
141
+
* @internal
142
+
*/
143
+
function removeViewTransitionNames(namedElements: Map<HTMLElement, string>): void {
144
+
for (const [element, originalValue] of namedElements) {
145
+
if (originalValue) {
146
+
element.style.viewTransitionName = originalValue;
147
+
} else {
148
+
element.style.viewTransitionName = "";
149
+
}
150
+
}
151
+
}
152
+
153
+
/**
154
+
* Wraps a callback with View Transitions API if supported.
155
+
* This is a simpler version without named transitions support.
156
+
*
157
+
* @param cb - Function to execute
158
+
* @param respectReducedMotion - Skip transition if prefers-reduced-motion
159
+
*
160
+
* @example
161
+
* ```typescript
162
+
* withViewTransition(() => {
163
+
* element.remove();
164
+
* });
165
+
* ```
166
+
*/
167
+
export function withViewTransition(cb: () => void, respectReducedMotion = true): void {
168
+
if (respectReducedMotion && prefersReducedMotion()) {
169
+
cb();
170
+
return;
171
+
}
172
+
173
+
if (supportsViewTransitions()) {
174
+
(document as DocumentWithViewTransition).startViewTransition(cb);
175
+
} else {
176
+
cb();
177
+
}
178
+
}
+35
lib/src/demo/index.ts
+35
lib/src/demo/index.ts
···
7
7
8
8
import { persistPlugin } from "$plugins/persist";
9
9
import { scrollPlugin } from "$plugins/scroll";
10
+
import { shiftPlugin } from "$plugins/shift";
11
+
import { surgePlugin } from "$plugins/surge";
10
12
import { urlPlugin } from "$plugins/url";
11
13
import { computed, effect, mount, registerPlugin, signal } from "$volt";
14
+
import { createAnimationsSection } from "./sections/animations";
12
15
import { createFormsSection } from "./sections/forms";
13
16
import { createInteractivitySection } from "./sections/interactivity";
14
17
import { createPluginsSection } from "./sections/plugins";
···
19
22
registerPlugin("persist", persistPlugin);
20
23
registerPlugin("scroll", scrollPlugin);
21
24
registerPlugin("url", urlPlugin);
25
+
registerPlugin("surge", surgePlugin);
26
+
registerPlugin("shift", shiftPlugin);
22
27
23
28
const message = signal("Welcome to the Volt.js Demo");
24
29
const count = signal(0);
···
47
52
const scrollPosition = signal(0);
48
53
const urlParam = signal("");
49
54
55
+
const showFade = signal(false);
56
+
const showSlideDown = signal(false);
57
+
const showScale = signal(false);
58
+
const showBlur = signal(false);
59
+
const showSlowFade = signal(false);
60
+
const showDelayedSlide = signal(false);
61
+
const showGranular = signal(false);
62
+
const showCombined = signal(false);
63
+
const triggerBounce = signal(0);
64
+
const triggerShake = signal(0);
65
+
const triggerFlash = signal(0);
66
+
const triggerTripleBounce = signal(0);
67
+
const triggerLongShake = signal(0);
68
+
50
69
const activeTodos = computed(() => todos.get().filter((todo) => !todo.done));
51
70
const completedTodos = computed(() => todos.get().filter((todo) => todo.done));
52
71
···
154
173
persistedCount,
155
174
scrollPosition,
156
175
urlParam,
176
+
showFade,
177
+
showSlideDown,
178
+
showScale,
179
+
showBlur,
180
+
showSlowFade,
181
+
showDelayedSlide,
182
+
showGranular,
183
+
showCombined,
184
+
triggerBounce,
185
+
triggerShake,
186
+
triggerFlash,
187
+
triggerTripleBounce,
188
+
triggerLongShake,
157
189
increment,
158
190
decrement,
159
191
reset,
···
184
216
dom.a({ href: "#reactivity" }, "Reactivity"),
185
217
" | ",
186
218
dom.a({ href: "#plugins" }, "Plugins"),
219
+
" | ",
220
+
dom.a({ href: "#animations" }, "Animations"),
187
221
);
188
222
189
223
function buildDemoStructure(): HTMLElement {
···
210
244
createFormsSection(),
211
245
createReactivitySection(),
212
246
createPluginsSection(),
247
+
createAnimationsSection(),
213
248
),
214
249
dom.footer(
215
250
null,
+152
lib/src/demo/sections/animations.ts
+152
lib/src/demo/sections/animations.ts
···
1
+
/**
2
+
* Animations Section
3
+
* Demonstrates surge and shift animation plugins
4
+
*/
5
+
6
+
import * as dom from "../utils";
7
+
8
+
export function createAnimationsSection(): HTMLElement {
9
+
return dom.article(
10
+
{ id: "animations" },
11
+
dom.h2(null, "Animation Demos"),
12
+
dom.section(
13
+
null,
14
+
dom.h3(null, "Surge Plugin: Enter/Leave Transitions"),
15
+
dom.p(
16
+
null,
17
+
"The surge plugin provides smooth transitions when elements appear or disappear.",
18
+
dom.small(
19
+
null,
20
+
"Toggle elements to see fade, slide, scale, and blur transitions. All integrate automatically with data-volt-if and data-volt-show bindings.",
21
+
),
22
+
),
23
+
dom.details(
24
+
null,
25
+
dom.summary(null, "Fade"),
26
+
dom.button({ "data-volt-on-click": "showFade.set(!showFade.get())" }, "Toggle Fade"),
27
+
dom.blockquote({ "data-volt-if": "showFade", "data-volt-surge": "fade" }, "Fades in and out smoothly"),
28
+
),
29
+
dom.details(
30
+
null,
31
+
dom.summary(null, "Slide Down"),
32
+
dom.button({ "data-volt-on-click": "showSlideDown.set(!showSlideDown.get())" }, "Toggle Slide"),
33
+
dom.blockquote({ "data-volt-if": "showSlideDown", "data-volt-surge": "slide-down" }, "Slides down from above"),
34
+
),
35
+
dom.details(
36
+
null,
37
+
dom.summary(null, "Scale"),
38
+
dom.button({ "data-volt-on-click": "showScale.set(!showScale.get())" }, "Toggle Scale"),
39
+
dom.blockquote({ "data-volt-if": "showScale", "data-volt-surge": "scale" }, "Scales up smoothly"),
40
+
),
41
+
dom.details(
42
+
null,
43
+
dom.summary(null, "Blur"),
44
+
dom.button({ "data-volt-on-click": "showBlur.set(!showBlur.get())" }, "Toggle Blur"),
45
+
dom.blockquote({ "data-volt-if": "showBlur", "data-volt-surge": "blur" }, "Blur effect transition"),
46
+
),
47
+
),
48
+
dom.section(
49
+
null,
50
+
dom.h3(null, "Custom Timing"),
51
+
dom.p(
52
+
null,
53
+
"Override transition duration and delay using dot notation.",
54
+
dom.small(null, "Syntax: data-volt-surge=\"preset.duration.delay\" (times in milliseconds)"),
55
+
),
56
+
dom.details(
57
+
null,
58
+
dom.summary(null, "Slow Fade (1000ms)"),
59
+
dom.button({ "data-volt-on-click": "showSlowFade.set(!showSlowFade.get())" }, "Toggle"),
60
+
dom.blockquote({ "data-volt-if": "showSlowFade", "data-volt-surge": "fade.1000" }, "Very slow fade"),
61
+
),
62
+
dom.details(
63
+
null,
64
+
dom.summary(null, "Delayed Slide (500ms + 200ms delay)"),
65
+
dom.button({ "data-volt-on-click": "showDelayedSlide.set(!showDelayedSlide.get())" }, "Toggle"),
66
+
dom.blockquote(
67
+
{ "data-volt-if": "showDelayedSlide", "data-volt-surge": "slide-down.500.200" },
68
+
"Slides with delay",
69
+
),
70
+
),
71
+
),
72
+
dom.section(
73
+
null,
74
+
dom.h3(null, "Different Enter/Leave Transitions"),
75
+
dom.p(
76
+
null,
77
+
"Specify different transitions for entering and leaving.",
78
+
dom.small(null, "Use data-volt-surge:enter and data-volt-surge:leave for granular control"),
79
+
),
80
+
dom.button({ "data-volt-on-click": "showGranular.set(!showGranular.get())" }, "Toggle Mixed Transition"),
81
+
dom.blockquote({
82
+
"data-volt-if": "showGranular",
83
+
"data-volt-surge:enter": "slide-down.400",
84
+
"data-volt-surge:leave": "fade.200",
85
+
}, "Slides in, fades out"),
86
+
),
87
+
dom.section(
88
+
null,
89
+
dom.h3(null, "Shift Plugin: Keyframe Animations"),
90
+
dom.p(
91
+
null,
92
+
"The shift plugin applies CSS keyframe animations for attention effects.",
93
+
dom.small(null, "Click buttons to trigger animations. Some run continuously, others on demand."),
94
+
),
95
+
dom.div(
96
+
{ style: "display: flex; gap: 0.5rem; flex-wrap: wrap;" },
97
+
dom.button({
98
+
"data-volt-on-click": "triggerBounce.set(triggerBounce.get() + 1)",
99
+
"data-volt-shift": "triggerBounce:bounce",
100
+
}, "Bounce"),
101
+
dom.button({
102
+
"data-volt-on-click": "triggerShake.set(triggerShake.get() + 1)",
103
+
"data-volt-shift": "triggerShake:shake",
104
+
}, "Shake"),
105
+
dom.button({ "data-volt-shift": "pulse" }, "Pulse (Continuous)"),
106
+
dom.button({
107
+
"data-volt-on-click": "triggerFlash.set(triggerFlash.get() + 1)",
108
+
"data-volt-shift": "triggerFlash:flash",
109
+
}, "Flash"),
110
+
),
111
+
dom.p(null, "Spinning gear: ", dom.span({ "data-volt-shift": "spin", style: "font-size: 2rem;" }, "⚙️")),
112
+
),
113
+
dom.section(
114
+
null,
115
+
dom.h3(null, "Custom Animation Settings"),
116
+
dom.p(
117
+
null,
118
+
"Override animation duration and iteration count.",
119
+
dom.small(null, "Syntax: data-volt-shift=\"animation.duration.iterations\""),
120
+
),
121
+
dom.div(
122
+
{ style: "display: flex; gap: 0.5rem; flex-wrap: wrap;" },
123
+
dom.button({
124
+
"data-volt-on-click": "triggerTripleBounce.set(triggerTripleBounce.get() + 1)",
125
+
"data-volt-shift": "triggerTripleBounce:bounce.800.3",
126
+
}, "Triple Bounce (800ms each)"),
127
+
dom.button({
128
+
"data-volt-on-click": "triggerLongShake.set(triggerLongShake.get() + 1)",
129
+
"data-volt-shift": "triggerLongShake:shake.1000.2",
130
+
}, "Long Shake (1000ms, 2x)"),
131
+
),
132
+
),
133
+
dom.section(
134
+
null,
135
+
dom.h3(null, "Combined Effects"),
136
+
dom.p(
137
+
null,
138
+
"Surge and shift can be combined for complex animation choreography.",
139
+
dom.small(null, "Toggle to see content that fades in, then bounces on mount"),
140
+
),
141
+
dom.button({ "data-volt-on-click": "showCombined.set(!showCombined.get())" }, "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!",
148
+
),
149
+
),
150
+
),
151
+
);
152
+
}
+1
lib/src/demo/utils.ts
+1
lib/src/demo/utils.ts
···
184
184
export const span: CreateFn<"span"> = (attrs?, ...children) => el("span", attrs, ...children);
185
185
export const small: CreateFn<"small"> = (attrs?, ...children) => el("small", attrs, ...children);
186
186
export const article: CreateFn<"article"> = (attrs?, ...children) => el("article", attrs, ...children);
187
+
export const aside: CreateFn<"aside"> = (attrs?, ...children) => el("aside", attrs, ...children);
187
188
export const section: CreateFn<"section"> = (attrs?, ...children) => el("section", attrs, ...children);
188
189
export const header: CreateFn<"header"> = (attrs?, ...children) => el("header", attrs, ...children);
189
190
export const footer: CreateFn<"footer"> = (attrs?, ...children) => el("footer", attrs, ...children);
+16
lib/src/index.ts
+16
lib/src/index.ts
···
35
35
registerTransition,
36
36
unregisterTransition,
37
37
} from "$core/transitions";
38
+
export {
39
+
namedViewTransition,
40
+
startViewTransition,
41
+
supportsViewTransitions,
42
+
withViewTransition,
43
+
} from "$core/view-transitions";
38
44
export { persistPlugin, registerStorageAdapter } from "$plugins/persist";
39
45
export { scrollPlugin } from "$plugins/scroll";
46
+
export {
47
+
getAnimation,
48
+
getRegisteredAnimations,
49
+
hasAnimation,
50
+
registerAnimation,
51
+
shiftPlugin,
52
+
unregisterAnimation,
53
+
} from "$plugins/shift";
40
54
export { surgePlugin } from "$plugins/surge";
41
55
export { urlPlugin } from "$plugins/url";
42
56
export type {
57
+
AnimationPreset,
43
58
ArcFunction,
44
59
AsyncEffectFunction,
45
60
AsyncEffectOptions,
···
67
82
TransitionPreset,
68
83
UidFunction,
69
84
UnwrapReactive,
85
+
ViewTransitionOptions,
70
86
} from "$types/volt";
+301
lib/src/plugins/shift.ts
+301
lib/src/plugins/shift.ts
···
1
+
/**
2
+
* Shift plugin for CSS keyframe animations
3
+
* Provides reusable animation presets that can be triggered on demand
4
+
*/
5
+
6
+
import { prefersReducedMotion } from "$core/transitions";
7
+
import type { Optional } from "$types/helpers";
8
+
import type { AnimationPreset, PluginContext, Signal } from "$types/volt";
9
+
10
+
/**
11
+
* Registry of animation presets
12
+
*/
13
+
const animationRegistry = new Map<string, AnimationPreset>();
14
+
15
+
/**
16
+
* Built-in animation presets with CSS keyframes
17
+
*/
18
+
const builtinAnimations: Record<string, AnimationPreset> = {
19
+
bounce: {
20
+
keyframes: [
21
+
{ offset: 0, transform: "translateY(0)" },
22
+
{ offset: 0.25, transform: "translateY(-20px)" },
23
+
{ offset: 0.5, transform: "translateY(0)" },
24
+
{ offset: 0.75, transform: "translateY(-10px)" },
25
+
{ offset: 1, transform: "translateY(0)" },
26
+
],
27
+
duration: 100,
28
+
iterations: 1,
29
+
timing: "ease",
30
+
},
31
+
shake: {
32
+
keyframes: [
33
+
{ offset: 0, transform: "translateX(0)" },
34
+
{ offset: 0.1, transform: "translateX(-10px)" },
35
+
{ offset: 0.2, transform: "translateX(10px)" },
36
+
{ offset: 0.3, transform: "translateX(-10px)" },
37
+
{ offset: 0.4, transform: "translateX(10px)" },
38
+
{ offset: 0.5, transform: "translateX(-10px)" },
39
+
{ offset: 0.6, transform: "translateX(10px)" },
40
+
{ offset: 0.7, transform: "translateX(-10px)" },
41
+
{ offset: 0.8, transform: "translateX(10px)" },
42
+
{ offset: 0.9, transform: "translateX(-10px)" },
43
+
{ offset: 1, transform: "translateX(0)" },
44
+
],
45
+
duration: 500,
46
+
iterations: 1,
47
+
timing: "ease",
48
+
},
49
+
pulse: {
50
+
keyframes: [{ offset: 0, transform: "scale(1)", opacity: "1" }, {
51
+
offset: 0.5,
52
+
transform: "scale(1.05)",
53
+
opacity: "0.9",
54
+
}, { offset: 1, transform: "scale(1)", opacity: "1" }],
55
+
duration: 1000,
56
+
iterations: Number.POSITIVE_INFINITY,
57
+
timing: "ease-in-out",
58
+
},
59
+
spin: {
60
+
keyframes: [{ offset: 0, transform: "rotate(0deg)" }, { offset: 1, transform: "rotate(360deg)" }],
61
+
duration: 1000,
62
+
iterations: Number.POSITIVE_INFINITY,
63
+
timing: "linear",
64
+
},
65
+
flash: {
66
+
keyframes: [{ offset: 0, opacity: "1" }, { offset: 0.25, opacity: "0" }, { offset: 0.5, opacity: "1" }, {
67
+
offset: 0.75,
68
+
opacity: "0",
69
+
}, { offset: 1, opacity: "1" }],
70
+
duration: 1000,
71
+
iterations: 1,
72
+
timing: "linear",
73
+
},
74
+
};
75
+
76
+
function initBuiltinAnimations(): void {
77
+
for (const [name, preset] of Object.entries(builtinAnimations)) {
78
+
animationRegistry.set(name, preset);
79
+
}
80
+
}
81
+
82
+
initBuiltinAnimations();
83
+
84
+
/**
85
+
* Register a custom animation preset.
86
+
* Allows users to define their own named animations in programmatic mode.
87
+
*
88
+
* @param name - Animation name (used in data-volt-shift="name")
89
+
* @param preset - Animation configuration with keyframes and timing
90
+
*
91
+
* @example
92
+
* ```typescript
93
+
* registerAnimation('wiggle', {
94
+
* keyframes: [
95
+
* { offset: 0, transform: 'rotate(0deg)' },
96
+
* { offset: 0.25, transform: 'rotate(-5deg)' },
97
+
* { offset: 0.75, transform: 'rotate(5deg)' },
98
+
* { offset: 1, transform: 'rotate(0deg)' }
99
+
* ],
100
+
* duration: 300,
101
+
* iterations: 2,
102
+
* timing: 'ease-in-out'
103
+
* });
104
+
* ```
105
+
*/
106
+
export function registerAnimation(name: string, preset: AnimationPreset): void {
107
+
if (animationRegistry.has(name) && Object.hasOwn(builtinAnimations, name)) {
108
+
console.warn(`[Volt] Overriding built-in animation preset: "${name}"`);
109
+
}
110
+
animationRegistry.set(name, preset);
111
+
}
112
+
113
+
/**
114
+
* Get an animation preset by name.
115
+
* Checks both custom and built-in presets.
116
+
*
117
+
* @param name - Preset name
118
+
* @returns Animation preset or undefined if not found
119
+
*/
120
+
export function getAnimation(name: string): Optional<AnimationPreset> {
121
+
return animationRegistry.get(name);
122
+
}
123
+
124
+
/**
125
+
* Check if an animation preset exists.
126
+
*
127
+
* @param name - Preset name
128
+
* @returns true if the preset is registered
129
+
*/
130
+
export function hasAnimation(name: string): boolean {
131
+
return animationRegistry.has(name);
132
+
}
133
+
134
+
/**
135
+
* Unregister a custom animation preset.
136
+
* Built-in presets cannot be unregistered.
137
+
*
138
+
* @param name - Preset name
139
+
* @returns true if the preset was removed, false otherwise
140
+
*/
141
+
export function unregisterAnimation(name: string): boolean {
142
+
if (Object.hasOwn(builtinAnimations, name)) {
143
+
console.warn(`[Volt] Cannot unregister built-in animation preset: "${name}"`);
144
+
return false;
145
+
}
146
+
return animationRegistry.delete(name);
147
+
}
148
+
149
+
/**
150
+
* Get all registered animation preset names.
151
+
*
152
+
* @returns Array of preset names
153
+
*/
154
+
export function getRegisteredAnimations(): string[] {
155
+
return [...animationRegistry.keys()];
156
+
}
157
+
158
+
type ParsedShiftValue = { animationName: string; duration?: number; iterations?: number; signalPath?: string };
159
+
160
+
/**
161
+
* Parse shift plugin value to extract configuration.
162
+
* Supports:
163
+
* - "animationName" - default animation
164
+
* - "animationName.duration" - custom duration
165
+
* - "animationName.duration.iterations" - custom duration and iterations
166
+
* - "signalPath:animationName" - watch signal with animation
167
+
* - "signalPath:animationName.duration.iterations" - watch signal with custom settings
168
+
*/
169
+
function parseShiftValue(value: string): Optional<ParsedShiftValue> {
170
+
const colonIndex = value.indexOf(":");
171
+
172
+
if (colonIndex !== -1) {
173
+
const signalPath = value.slice(0, colonIndex).trim();
174
+
const animationPart = value.slice(colonIndex + 1).trim();
175
+
const parsed = parseAnimationValue(animationPart);
176
+
177
+
if (!parsed) {
178
+
return undefined;
179
+
}
180
+
181
+
return { ...parsed, signalPath };
182
+
}
183
+
184
+
return parseAnimationValue(value);
185
+
}
186
+
187
+
function parseAnimationValue(value: string): Optional<ParsedShiftValue> {
188
+
const parts = value.split(".");
189
+
const animationName = parts[0]?.trim();
190
+
191
+
if (!animationName) {
192
+
return undefined;
193
+
}
194
+
195
+
const result: ParsedShiftValue = { animationName };
196
+
197
+
if (parts.length > 1) {
198
+
const duration = Number.parseInt(parts[1], 10);
199
+
if (!Number.isNaN(duration)) {
200
+
result.duration = duration;
201
+
}
202
+
}
203
+
204
+
if (parts.length > 2) {
205
+
const iterations = Number.parseInt(parts[2], 10);
206
+
if (!Number.isNaN(iterations)) {
207
+
result.iterations = iterations;
208
+
}
209
+
}
210
+
211
+
return result;
212
+
}
213
+
214
+
function applyAnimation(element: HTMLElement, preset: AnimationPreset, duration?: number, iterations?: number): void {
215
+
if (prefersReducedMotion()) {
216
+
return;
217
+
}
218
+
219
+
const effectiveDuration = duration ?? preset.duration;
220
+
const effectiveIterations = iterations ?? preset.iterations;
221
+
222
+
const animation = element.animate(preset.keyframes, {
223
+
duration: effectiveDuration,
224
+
iterations: effectiveIterations,
225
+
easing: preset.timing,
226
+
fill: "forwards",
227
+
});
228
+
229
+
animation.onfinish = () => {
230
+
animation.cancel();
231
+
};
232
+
}
233
+
234
+
/**
235
+
* Shift plugin handler.
236
+
* Provides CSS keyframe animations for elements.
237
+
*
238
+
* Syntax:
239
+
* - data-volt-shift="animationName" - Run animation with default settings
240
+
* - data-volt-shift="animationName.duration.iterations" - Custom duration and iterations
241
+
* - data-volt-shift="signalPath:animationName" - Watch signal to trigger animation
242
+
*
243
+
* @example
244
+
* ```html
245
+
* <!-- One-time animation on mount -->
246
+
* <button data-volt-shift="bounce">Click Me</button>
247
+
*
248
+
* <!-- Continuous pulse animation -->
249
+
* <div data-volt-shift="pulse">Loading...</div>
250
+
*
251
+
* <!-- Trigger animation based on signal -->
252
+
* <div data-volt-shift="error:shake">Error occurred!</div>
253
+
*
254
+
* <!-- Custom duration and iterations -->
255
+
* <div data-volt-shift="bounce.1000.3">Triple bounce!</div>
256
+
* ```
257
+
*/
258
+
export function shiftPlugin(ctx: PluginContext, value: string): void {
259
+
const el = ctx.element as HTMLElement;
260
+
261
+
const parsed = parseShiftValue(value);
262
+
if (!parsed) {
263
+
console.error(`[Volt] Invalid shift value: "${value}"`);
264
+
return;
265
+
}
266
+
267
+
const preset = getAnimation(parsed.animationName);
268
+
if (!preset) {
269
+
console.error(`[Volt] Unknown animation preset: "${parsed.animationName}"`);
270
+
return;
271
+
}
272
+
273
+
if (parsed.signalPath) {
274
+
const signal = ctx.findSignal(parsed.signalPath) as Optional<Signal<unknown>>;
275
+
if (!signal) {
276
+
console.error(`[Volt] Signal "${parsed.signalPath}" not found for shift binding`);
277
+
return;
278
+
}
279
+
280
+
let previousValue = signal.get();
281
+
282
+
const unsubscribe = signal.subscribe((value) => {
283
+
if (value !== previousValue && Boolean(value)) {
284
+
applyAnimation(el, preset, parsed.duration, parsed.iterations);
285
+
}
286
+
previousValue = value;
287
+
});
288
+
289
+
ctx.addCleanup(unsubscribe);
290
+
291
+
if (signal.get()) {
292
+
ctx.lifecycle.onMount(() => {
293
+
applyAnimation(el, preset, parsed.duration, parsed.iterations);
294
+
});
295
+
}
296
+
} else {
297
+
ctx.lifecycle.onMount(() => {
298
+
applyAnimation(el, preset, parsed.duration, parsed.iterations);
299
+
});
300
+
}
301
+
}
+3
-14
lib/src/plugins/surge.ts
+3
-14
lib/src/plugins/surge.ts
···
5
5
6
6
import { sleep } from "$core/shared";
7
7
import { applyOverrides, getEasing, parseTransitionValue, prefersReducedMotion } from "$core/transitions";
8
+
import { withViewTransition } from "$core/view-transitions";
8
9
import type { Optional } from "$types/helpers";
9
10
import type { PluginContext, Signal, TransitionPhase } from "$types/volt";
10
11
···
15
16
useViewTransitions: boolean;
16
17
};
17
18
18
-
function supportsViewTransitions(): boolean {
19
-
return typeof document !== "undefined" && "startViewTransition" in document;
20
-
}
21
-
22
-
function withViewTransition(callback: () => void): void {
23
-
if (supportsViewTransitions() && !prefersReducedMotion()) {
24
-
(document as Document & { startViewTransition: (callback: () => void) => void }).startViewTransition(callback);
25
-
} else {
26
-
callback();
27
-
}
28
-
}
29
-
30
19
function applyStyles(element: HTMLElement, styles: Record<string, string | number>): void {
31
20
for (const [property, value] of Object.entries(styles)) {
32
21
const cssProperty = property.replaceAll(/[A-Z]/g, (m) => `-${m.toLowerCase()}`);
···
98
87
if (phase.to) {
99
88
applyStyles(element, phase.to);
100
89
}
101
-
});
90
+
}, false);
102
91
} else {
103
92
if (phase.to) {
104
93
applyStyles(element, phase.to);
···
166
155
if (phase.to) {
167
156
applyStyles(element, phase.to);
168
157
}
169
-
});
158
+
}, false);
170
159
} else {
171
160
if (phase.to) {
172
161
applyStyles(element, phase.to);
+25
-40
lib/src/types/volt.d.ts
+25
-40
lib/src/types/volt.d.ts
···
497
497
};
498
498
499
499
/**
500
-
* Configuration for a single transition phase (enter or leave)
500
+
* Animation preset for CSS keyframe animations
501
501
*/
502
-
export type TransitionPhase = {
502
+
export type AnimationPreset = {
503
503
/**
504
-
* Initial CSS properties (applied immediately)
504
+
* Array of keyframes for the animation
505
505
*/
506
-
from?: Record<string, string | number>;
506
+
keyframes: Keyframe[];
507
507
508
508
/**
509
-
* Target CSS properties (animated to)
509
+
* Duration in milliseconds (default: varies by preset)
510
510
*/
511
-
to?: Record<string, string | number>;
511
+
duration: number;
512
512
513
513
/**
514
-
* Duration in milliseconds (default: 300)
514
+
* Number of iterations (use Infinity for infinite)
515
515
*/
516
-
duration?: number;
516
+
iterations: number;
517
517
518
518
/**
519
-
* Delay in milliseconds (default: 0)
519
+
* CSS timing function (default: "ease")
520
520
*/
521
-
delay?: number;
522
-
523
-
/**
524
-
* CSS easing function (default: 'ease')
525
-
*/
526
-
easing?: string;
527
-
528
-
/**
529
-
* CSS classes to apply during this phase
530
-
*/
531
-
classes?: string[];
521
+
timing: string;
532
522
};
533
523
534
524
/**
535
-
* Complete transition preset with enter and leave phases
525
+
* Options for configuring a view transition
536
526
*/
537
-
export type TransitionPreset = {
527
+
export type ViewTransitionOptions = {
538
528
/**
539
-
* Configuration for enter transition
540
-
*/
541
-
enter: TransitionPhase;
542
-
543
-
/**
544
-
* Configuration for leave transition
529
+
* Named view transition for specific element(s)
530
+
* Maps to view-transition-name CSS property
545
531
*/
546
-
leave: TransitionPhase;
547
-
};
532
+
name?: string;
548
533
549
-
/**
550
-
* Parsed transition value with preset and modifiers
551
-
*/
552
-
export type ParsedTransition = {
553
534
/**
554
-
* The transition preset to use
535
+
* Elements to apply named transitions to
536
+
* Each element will get a unique view-transition-name
555
537
*/
556
-
preset: TransitionPreset;
538
+
elements?: HTMLElement[];
557
539
558
540
/**
559
-
* Override duration from preset syntax (e.g., "fade.500")
541
+
* Skip transition if prefers-reduced-motion is enabled
542
+
* @default true
560
543
*/
561
-
duration?: number;
544
+
respectReducedMotion?: boolean;
562
545
563
546
/**
564
-
* Override delay from preset syntax (e.g., "fade.500.100")
547
+
* Force CSS fallback even if View Transitions API is supported
548
+
* Useful for testing or debugging
549
+
* @default false
565
550
*/
566
-
delay?: number;
551
+
forceFallback?: boolean;
567
552
};
+292
lib/test/core/view-transitions.test.ts
+292
lib/test/core/view-transitions.test.ts
···
1
+
import {
2
+
namedViewTransition,
3
+
startViewTransition,
4
+
supportsViewTransitions,
5
+
withViewTransition,
6
+
} from "$core/view-transitions";
7
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
8
+
9
+
describe("View Transitions API", () => {
10
+
let mockStartViewTransition: ReturnType<typeof vi.fn>;
11
+
let originalStartViewTransition: unknown;
12
+
let originalMatchMedia: unknown;
13
+
14
+
beforeEach(() => {
15
+
mockStartViewTransition = vi.fn((callback: () => void | Promise<void>) => {
16
+
const result = callback();
17
+
return {
18
+
finished: Promise.resolve(result).then(() => {}),
19
+
ready: Promise.resolve(),
20
+
updateCallbackDone: Promise.resolve(result).then(() => {}),
21
+
skipTransition: vi.fn(),
22
+
};
23
+
});
24
+
25
+
originalStartViewTransition = (document as Document & { startViewTransition?: unknown }).startViewTransition;
26
+
originalMatchMedia = globalThis.matchMedia;
27
+
28
+
(document as Document & { startViewTransition: typeof mockStartViewTransition }).startViewTransition =
29
+
mockStartViewTransition;
30
+
31
+
globalThis.matchMedia = vi.fn((query: string) => ({
32
+
matches: query === "(prefers-reduced-motion: reduce)" ? false : false,
33
+
media: query,
34
+
onchange: null,
35
+
addListener: vi.fn(),
36
+
removeListener: vi.fn(),
37
+
addEventListener: vi.fn(),
38
+
removeEventListener: vi.fn(),
39
+
dispatchEvent: vi.fn(),
40
+
})) as typeof globalThis.matchMedia;
41
+
});
42
+
43
+
afterEach(() => {
44
+
if (originalStartViewTransition === undefined) {
45
+
// @ts-expect-error mocking browser without view transitions API
46
+
delete (document as Document & { startViewTransition?: unknown }).startViewTransition;
47
+
} else {
48
+
// @ts-expect-error mocking view transitions API
49
+
(document as Document & { startViewTransition: unknown }).startViewTransition = originalStartViewTransition;
50
+
}
51
+
52
+
globalThis.matchMedia = originalMatchMedia as typeof globalThis.matchMedia;
53
+
54
+
vi.restoreAllMocks();
55
+
});
56
+
57
+
describe("supportsViewTransitions", () => {
58
+
it("should return true when View Transitions API is supported", () => {
59
+
expect(supportsViewTransitions()).toBe(true);
60
+
});
61
+
62
+
it("should return false when View Transitions API is not supported", () => {
63
+
// @ts-expect-error mocking browser without view transitions API
64
+
delete (document as Document & { startViewTransition?: unknown }).startViewTransition;
65
+
expect(supportsViewTransitions()).toBe(false);
66
+
});
67
+
});
68
+
69
+
describe("startViewTransition", () => {
70
+
it("should use View Transitions API when supported", async () => {
71
+
const callback = vi.fn();
72
+
73
+
await startViewTransition(callback);
74
+
75
+
expect(mockStartViewTransition).toHaveBeenCalledWith(callback);
76
+
expect(callback).toHaveBeenCalled();
77
+
});
78
+
79
+
it("should fallback to direct execution when API is not supported", async () => {
80
+
// @ts-expect-error mocking browser without view transitions API
81
+
delete (document as Document & { startViewTransition?: unknown }).startViewTransition;
82
+
83
+
const callback = vi.fn();
84
+
await startViewTransition(callback);
85
+
86
+
expect(callback).toHaveBeenCalled();
87
+
});
88
+
89
+
it("should skip transition when prefers-reduced-motion is enabled", async () => {
90
+
globalThis.matchMedia = vi.fn((query: string) => ({
91
+
matches: query === "(prefers-reduced-motion: reduce)" ? true : false,
92
+
media: query,
93
+
onchange: null,
94
+
addListener: vi.fn(),
95
+
removeListener: vi.fn(),
96
+
addEventListener: vi.fn(),
97
+
removeEventListener: vi.fn(),
98
+
dispatchEvent: vi.fn(),
99
+
})) as typeof globalThis.matchMedia;
100
+
101
+
const callback = vi.fn();
102
+
await startViewTransition(callback);
103
+
104
+
expect(mockStartViewTransition).not.toHaveBeenCalled();
105
+
expect(callback).toHaveBeenCalled();
106
+
});
107
+
108
+
it("should force fallback when forceFallback option is true", async () => {
109
+
const callback = vi.fn();
110
+
await startViewTransition(callback, { forceFallback: true });
111
+
112
+
expect(mockStartViewTransition).not.toHaveBeenCalled();
113
+
expect(callback).toHaveBeenCalled();
114
+
});
115
+
116
+
it("should respect reduced motion preference when respectReducedMotion is true", async () => {
117
+
globalThis.matchMedia = vi.fn((query: string) => ({
118
+
matches: query === "(prefers-reduced-motion: reduce)" ? true : false,
119
+
media: query,
120
+
onchange: null,
121
+
addListener: vi.fn(),
122
+
removeListener: vi.fn(),
123
+
addEventListener: vi.fn(),
124
+
removeEventListener: vi.fn(),
125
+
dispatchEvent: vi.fn(),
126
+
})) as typeof globalThis.matchMedia;
127
+
128
+
const callback = vi.fn();
129
+
await startViewTransition(callback, { respectReducedMotion: true });
130
+
131
+
expect(mockStartViewTransition).not.toHaveBeenCalled();
132
+
expect(callback).toHaveBeenCalled();
133
+
});
134
+
135
+
it("should use View Transitions API when respectReducedMotion is false even with reduced motion", async () => {
136
+
globalThis.matchMedia = vi.fn((query: string) => ({
137
+
matches: query === "(prefers-reduced-motion: reduce)" ? true : false,
138
+
media: query,
139
+
onchange: null,
140
+
addListener: vi.fn(),
141
+
removeListener: vi.fn(),
142
+
addEventListener: vi.fn(),
143
+
removeEventListener: vi.fn(),
144
+
dispatchEvent: vi.fn(),
145
+
})) as typeof globalThis.matchMedia;
146
+
147
+
const callback = vi.fn();
148
+
await startViewTransition(callback, { respectReducedMotion: false });
149
+
150
+
expect(mockStartViewTransition).toHaveBeenCalledWith(callback);
151
+
expect(callback).toHaveBeenCalled();
152
+
});
153
+
154
+
it("should apply and remove view-transition-name for named transitions", async () => {
155
+
const element = document.createElement("div");
156
+
const callback = vi.fn();
157
+
158
+
await startViewTransition(callback, { name: "test-transition", elements: [element] });
159
+
160
+
expect(callback).toHaveBeenCalled();
161
+
expect(element.style.viewTransitionName).toBe("");
162
+
});
163
+
164
+
it("should apply unique names for multiple elements", async () => {
165
+
const element1 = document.createElement("div");
166
+
const element2 = document.createElement("div");
167
+
const callback = vi.fn(() => {
168
+
expect(element1.style.viewTransitionName).toBe("test-transition-0");
169
+
expect(element2.style.viewTransitionName).toBe("test-transition-1");
170
+
});
171
+
172
+
await startViewTransition(callback, { name: "test-transition", elements: [element1, element2] });
173
+
174
+
expect(callback).toHaveBeenCalled();
175
+
expect(element1.style.viewTransitionName).toBe("");
176
+
expect(element2.style.viewTransitionName).toBe("");
177
+
});
178
+
179
+
it("should restore original view-transition-name values", async () => {
180
+
const element = document.createElement("div");
181
+
element.style.viewTransitionName = "original-name";
182
+
183
+
const callback = vi.fn(() => {
184
+
expect(element.style.viewTransitionName).toBe("test-transition");
185
+
});
186
+
187
+
await startViewTransition(callback, { name: "test-transition", elements: [element] });
188
+
189
+
expect(callback).toHaveBeenCalled();
190
+
expect(element.style.viewTransitionName).toBe("original-name");
191
+
});
192
+
193
+
it("should handle async callbacks", async () => {
194
+
const callback = vi.fn(async () => {
195
+
await new Promise((resolve) => setTimeout(resolve, 10));
196
+
});
197
+
198
+
await startViewTransition(callback);
199
+
200
+
expect(mockStartViewTransition).toHaveBeenCalledWith(callback);
201
+
expect(callback).toHaveBeenCalled();
202
+
});
203
+
});
204
+
205
+
describe("namedViewTransition", () => {
206
+
it("should apply named transition to single element", async () => {
207
+
const element = document.createElement("div");
208
+
const callback = vi.fn(() => {
209
+
expect(element.style.viewTransitionName).toBe("card-flip");
210
+
});
211
+
212
+
await namedViewTransition("card-flip", [element], callback);
213
+
214
+
expect(callback).toHaveBeenCalled();
215
+
expect(element.style.viewTransitionName).toBe("");
216
+
});
217
+
218
+
it("should apply named transition to multiple elements", async () => {
219
+
const element1 = document.createElement("div");
220
+
const element2 = document.createElement("div");
221
+
const callback = vi.fn(() => {
222
+
expect(element1.style.viewTransitionName).toBe("card-flip-0");
223
+
expect(element2.style.viewTransitionName).toBe("card-flip-1");
224
+
});
225
+
226
+
await namedViewTransition("card-flip", [element1, element2], callback);
227
+
228
+
expect(callback).toHaveBeenCalled();
229
+
expect(element1.style.viewTransitionName).toBe("");
230
+
expect(element2.style.viewTransitionName).toBe("");
231
+
});
232
+
});
233
+
234
+
describe("withViewTransition", () => {
235
+
it("should use View Transitions API when supported", () => {
236
+
const callback = vi.fn();
237
+
238
+
withViewTransition(callback);
239
+
240
+
expect(mockStartViewTransition).toHaveBeenCalledWith(callback);
241
+
expect(callback).toHaveBeenCalled();
242
+
});
243
+
244
+
it("should fallback to direct execution when API is not supported", () => {
245
+
// @ts-expect-error mocking browser without view transitions API
246
+
delete (document as Document & { startViewTransition?: unknown }).startViewTransition;
247
+
248
+
const callback = vi.fn();
249
+
withViewTransition(callback);
250
+
251
+
expect(callback).toHaveBeenCalled();
252
+
});
253
+
254
+
it("should skip transition when prefers-reduced-motion is enabled and respectReducedMotion is true", () => {
255
+
globalThis.matchMedia = vi.fn((query: string) => ({
256
+
matches: query === "(prefers-reduced-motion: reduce)" ? true : false,
257
+
media: query,
258
+
onchange: null,
259
+
addListener: vi.fn(),
260
+
removeListener: vi.fn(),
261
+
addEventListener: vi.fn(),
262
+
removeEventListener: vi.fn(),
263
+
dispatchEvent: vi.fn(),
264
+
})) as typeof globalThis.matchMedia;
265
+
266
+
const callback = vi.fn();
267
+
withViewTransition(callback, true);
268
+
269
+
expect(mockStartViewTransition).not.toHaveBeenCalled();
270
+
expect(callback).toHaveBeenCalled();
271
+
});
272
+
273
+
it("should use View Transitions API when respectReducedMotion is false", () => {
274
+
globalThis.matchMedia = vi.fn((query: string) => ({
275
+
matches: query === "(prefers-reduced-motion: reduce)" ? true : false,
276
+
media: query,
277
+
onchange: null,
278
+
addListener: vi.fn(),
279
+
removeListener: vi.fn(),
280
+
addEventListener: vi.fn(),
281
+
removeEventListener: vi.fn(),
282
+
dispatchEvent: vi.fn(),
283
+
})) as typeof globalThis.matchMedia;
284
+
285
+
const callback = vi.fn();
286
+
withViewTransition(callback, false);
287
+
288
+
expect(mockStartViewTransition).toHaveBeenCalledWith(callback);
289
+
expect(callback).toHaveBeenCalled();
290
+
});
291
+
});
292
+
});
+571
lib/test/integration/transitions.test.ts
+571
lib/test/integration/transitions.test.ts
···
1
+
import { shiftPlugin } from "$plugins/shift";
2
+
import { surgePlugin } from "$plugins/surge";
3
+
import type { TransitionPreset } from "$types/volt";
4
+
import { mount, registerPlugin, registerTransition, signal } from "$volt";
5
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
6
+
7
+
describe("integration: transitions", () => {
8
+
beforeEach(() => {
9
+
registerPlugin("surge", surgePlugin);
10
+
registerPlugin("shift", shiftPlugin);
11
+
globalThis.matchMedia = vi.fn().mockReturnValue({ matches: false });
12
+
});
13
+
14
+
afterEach(() => {
15
+
vi.restoreAllMocks();
16
+
});
17
+
18
+
describe("Surge with data-volt-if", () => {
19
+
it("should animate element in when condition becomes true", async () => {
20
+
vi.useFakeTimers();
21
+
22
+
const container = document.createElement("div");
23
+
container.innerHTML = `<div data-volt-if="show" data-volt-surge="fade">Content</div>`;
24
+
25
+
const show = signal(false);
26
+
mount(container, { show });
27
+
28
+
const comment = [...container.childNodes].find((node) => node.nodeType === 8);
29
+
expect(comment).toBeDefined();
30
+
31
+
show.set(true);
32
+
33
+
await vi.advanceTimersByTimeAsync(400);
34
+
35
+
const element = container.querySelector("div");
36
+
expect(element).toBeDefined();
37
+
if (element) {
38
+
expect(element.textContent).toContain("Content");
39
+
}
40
+
41
+
vi.useRealTimers();
42
+
});
43
+
44
+
it("should animate element out when condition becomes false", async () => {
45
+
vi.useFakeTimers();
46
+
47
+
const container = document.createElement("div");
48
+
container.innerHTML = `
49
+
<div data-volt-if="show" data-volt-surge="fade">
50
+
Content
51
+
</div>
52
+
`;
53
+
54
+
const show = signal(true);
55
+
mount(container, { show });
56
+
57
+
let element = container.querySelector("div");
58
+
expect(element).toBeDefined();
59
+
60
+
show.set(false);
61
+
62
+
await vi.advanceTimersByTimeAsync(400);
63
+
64
+
element = container.querySelector("div");
65
+
expect(element).toBeNull();
66
+
67
+
vi.useRealTimers();
68
+
});
69
+
70
+
it("should support custom enter/leave transitions", async () => {
71
+
vi.useFakeTimers();
72
+
73
+
const container = document.createElement("div");
74
+
container.innerHTML = `
75
+
<div
76
+
data-volt-if="show"
77
+
data-volt-surge:enter="slide-down"
78
+
data-volt-surge:leave="fade">
79
+
Content
80
+
</div>
81
+
`;
82
+
83
+
const show = signal(false);
84
+
mount(container, { show });
85
+
86
+
show.set(true);
87
+
await vi.advanceTimersByTimeAsync(400);
88
+
89
+
let element = container.querySelector("div");
90
+
expect(element).toBeDefined();
91
+
92
+
show.set(false);
93
+
await vi.advanceTimersByTimeAsync(400);
94
+
95
+
element = container.querySelector("div");
96
+
expect(element).toBeNull();
97
+
98
+
vi.useRealTimers();
99
+
});
100
+
101
+
it("should work with if/else pattern", async () => {
102
+
vi.useFakeTimers();
103
+
104
+
const container = document.createElement("div");
105
+
container.innerHTML = `
106
+
<div data-volt-if="show" data-volt-surge="fade">
107
+
Shown
108
+
</div>
109
+
<div data-volt-else data-volt-surge="fade">
110
+
Hidden
111
+
</div>
112
+
`;
113
+
114
+
const show = signal(true);
115
+
mount(container, { show });
116
+
117
+
await vi.advanceTimersByTimeAsync(50);
118
+
119
+
let shownEl = [...container.querySelectorAll("div")].find((el) => el.textContent?.includes("Shown"));
120
+
let hiddenEl = [...container.querySelectorAll("div")].find((el) => el.textContent?.includes("Hidden"));
121
+
122
+
expect(shownEl).toBeDefined();
123
+
expect(hiddenEl).toBeUndefined();
124
+
125
+
show.set(false);
126
+
await vi.advanceTimersByTimeAsync(400);
127
+
128
+
shownEl = [...container.querySelectorAll("div")].find((el) => el.textContent?.includes("Shown"));
129
+
hiddenEl = [...container.querySelectorAll("div")].find((el) => el.textContent?.includes("Hidden"));
130
+
131
+
expect(shownEl).toBeUndefined();
132
+
expect(hiddenEl).toBeDefined();
133
+
134
+
vi.useRealTimers();
135
+
});
136
+
137
+
it("should support duration and delay modifiers", async () => {
138
+
vi.useFakeTimers();
139
+
140
+
const container = document.createElement("div");
141
+
container.innerHTML = `
142
+
<div data-volt-if="show" data-volt-surge="fade.500.100">
143
+
Content
144
+
</div>
145
+
`;
146
+
147
+
const show = signal(false);
148
+
mount(container, { show });
149
+
150
+
show.set(true);
151
+
await vi.advanceTimersByTimeAsync(650);
152
+
153
+
const element = container.querySelector("div");
154
+
expect(element).toBeDefined();
155
+
156
+
vi.useRealTimers();
157
+
});
158
+
});
159
+
160
+
describe("Surge with data-volt-show", () => {
161
+
it("should toggle display property with transition", async () => {
162
+
vi.useFakeTimers();
163
+
164
+
const container = document.createElement("div");
165
+
const testEl = document.createElement("div");
166
+
testEl.dataset.voltShow = "visible";
167
+
testEl.dataset.voltSurge = "fade";
168
+
testEl.textContent = "Content";
169
+
container.append(testEl);
170
+
171
+
const visible = signal(true);
172
+
173
+
const element = testEl;
174
+
175
+
mount(container, { visible });
176
+
177
+
expect(element.style.display).not.toBe("none");
178
+
179
+
visible.set(false);
180
+
await vi.advanceTimersByTimeAsync(400);
181
+
182
+
expect(element.style.display).toBe("none");
183
+
184
+
visible.set(true);
185
+
await vi.advanceTimersByTimeAsync(400);
186
+
187
+
expect(element.style.display).not.toBe("none");
188
+
189
+
vi.useRealTimers();
190
+
});
191
+
192
+
it("should not start overlapping transitions", async () => {
193
+
vi.useFakeTimers();
194
+
195
+
const container = document.createElement("div");
196
+
container.innerHTML = `
197
+
<div data-volt-show="visible" data-volt-surge="fade">
198
+
Content
199
+
</div>
200
+
`;
201
+
202
+
const visible = signal(true);
203
+
mount(container, { visible });
204
+
205
+
const element = container.querySelector("div") as HTMLElement;
206
+
207
+
visible.set(false);
208
+
visible.set(true);
209
+
visible.set(false);
210
+
211
+
await vi.advanceTimersByTimeAsync(50);
212
+
213
+
expect(element).toBeDefined();
214
+
215
+
vi.useRealTimers();
216
+
});
217
+
});
218
+
219
+
describe("Surge signal-triggered mode", () => {
220
+
it("should watch signal and apply transitions", async () => {
221
+
vi.useFakeTimers();
222
+
223
+
const container = document.createElement("div");
224
+
const testEl = document.createElement("div");
225
+
testEl.dataset.voltSurge = "show:fade";
226
+
testEl.textContent = "Content";
227
+
container.append(testEl);
228
+
229
+
const show = signal(false);
230
+
231
+
const element = testEl;
232
+
233
+
mount(container, { show });
234
+
235
+
expect(element.style.display).toBe("none");
236
+
237
+
show.set(true);
238
+
await vi.advanceTimersByTimeAsync(400);
239
+
240
+
expect(element.style.display).not.toBe("none");
241
+
242
+
show.set(false);
243
+
await vi.advanceTimersByTimeAsync(400);
244
+
245
+
expect(element.style.display).toBe("none");
246
+
247
+
vi.useRealTimers();
248
+
});
249
+
250
+
it("should cleanup subscription on unmount", async () => {
251
+
const container = document.createElement("div");
252
+
const testEl = document.createElement("div");
253
+
testEl.dataset.voltSurge = "show:fade";
254
+
testEl.textContent = "Content";
255
+
container.append(testEl);
256
+
257
+
const show = signal(false);
258
+
const element = testEl;
259
+
260
+
const cleanup = mount(container, { show });
261
+
262
+
expect(element.style.display).toBe("none");
263
+
264
+
cleanup();
265
+
266
+
const initialDisplay = element.style.display;
267
+
show.set(true);
268
+
269
+
await new Promise((resolve) => {
270
+
setTimeout(resolve, 50);
271
+
});
272
+
273
+
expect(element.style.display).toBe(initialDisplay);
274
+
});
275
+
});
276
+
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", () => {
285
+
const container = document.createElement("div");
286
+
const testEl = document.createElement("div");
287
+
testEl.dataset.voltShift = "bounce";
288
+
testEl.textContent = "Content";
289
+
container.append(testEl);
290
+
291
+
const element = testEl;
292
+
293
+
mount(container, {});
294
+
295
+
expect(element.animate).toHaveBeenCalled();
296
+
});
297
+
298
+
it("should trigger animation based on signal", () => {
299
+
const container = document.createElement("div");
300
+
container.innerHTML = `
301
+
<button data-volt-shift="trigger:bounce">
302
+
Click me
303
+
</button>
304
+
`;
305
+
306
+
const trigger = signal(false);
307
+
mount(container, { trigger });
308
+
309
+
const button = container.querySelector("button") as HTMLElement;
310
+
expect(button.animate).not.toHaveBeenCalled();
311
+
312
+
trigger.set(true);
313
+
expect(button.animate).toHaveBeenCalled();
314
+
});
315
+
316
+
it("should support duration and iteration modifiers", () => {
317
+
const container = document.createElement("div");
318
+
const testEl = document.createElement("div");
319
+
testEl.dataset.voltShift = "bounce.1000.3";
320
+
testEl.textContent = "Content";
321
+
container.append(testEl);
322
+
323
+
const element = testEl;
324
+
325
+
mount(container, {});
326
+
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);
333
+
});
334
+
335
+
it("should cleanup signal subscription on unmount", () => {
336
+
const container = document.createElement("div");
337
+
container.innerHTML = `
338
+
<button data-volt-shift="trigger:bounce">
339
+
Click me
340
+
</button>
341
+
`;
342
+
343
+
const trigger = signal(false);
344
+
const cleanup = mount(container, { trigger });
345
+
346
+
cleanup();
347
+
348
+
const button = container.querySelector("button") as HTMLElement;
349
+
trigger.set(true);
350
+
351
+
expect(button.animate).not.toHaveBeenCalled();
352
+
});
353
+
});
354
+
355
+
describe("Custom presets", () => {
356
+
it("should use registered custom transition preset", async () => {
357
+
vi.useFakeTimers();
358
+
359
+
const customPreset: TransitionPreset = {
360
+
enter: {
361
+
from: { opacity: 0, transform: "scale(0.5)" },
362
+
to: { opacity: 1, transform: "scale(1)" },
363
+
duration: 200,
364
+
easing: "ease-out",
365
+
},
366
+
leave: {
367
+
from: { opacity: 1, transform: "scale(1)" },
368
+
to: { opacity: 0, transform: "scale(0.5)" },
369
+
duration: 200,
370
+
easing: "ease-in",
371
+
},
372
+
};
373
+
374
+
registerTransition("custom-scale", customPreset);
375
+
376
+
const container = document.createElement("div");
377
+
container.innerHTML = `
378
+
<div data-volt-if="show" data-volt-surge="custom-scale">
379
+
Content
380
+
</div>
381
+
`;
382
+
383
+
const show = signal(false);
384
+
mount(container, { show });
385
+
386
+
show.set(true);
387
+
await vi.advanceTimersByTimeAsync(300);
388
+
389
+
const element = container.querySelector("div");
390
+
expect(element).toBeDefined();
391
+
392
+
vi.useRealTimers();
393
+
});
394
+
});
395
+
396
+
describe("Accessibility: prefers-reduced-motion", () => {
397
+
it("should skip animations when user prefers reduced motion", async () => {
398
+
globalThis.matchMedia = vi.fn().mockReturnValue({ matches: true });
399
+
400
+
const container = document.createElement("div");
401
+
container.innerHTML = `
402
+
<div data-volt-if="show" data-volt-surge="fade">
403
+
Content
404
+
</div>
405
+
`;
406
+
407
+
const show = signal(false);
408
+
mount(container, { show });
409
+
410
+
show.set(true);
411
+
412
+
await new Promise((resolve) => {
413
+
setTimeout(resolve, 50);
414
+
});
415
+
416
+
const element = container.querySelector("div");
417
+
expect(element).toBeDefined();
418
+
});
419
+
});
420
+
421
+
describe("View Transitions API", () => {
422
+
it("should use View Transitions API when available", async () => {
423
+
const mockStartViewTransition = vi.fn((callback: () => void | Promise<void>) => {
424
+
const result = callback();
425
+
return {
426
+
finished: Promise.resolve(result).then(() => {}),
427
+
ready: Promise.resolve(),
428
+
updateCallbackDone: Promise.resolve(result).then(() => {}),
429
+
skipTransition: vi.fn(),
430
+
};
431
+
});
432
+
433
+
// @ts-expect-error - Adding View Transitions API mock
434
+
document.startViewTransition = mockStartViewTransition;
435
+
436
+
const { startViewTransition } = await import("$core/view-transitions");
437
+
438
+
await startViewTransition(() => {
439
+
const el = document.createElement("div");
440
+
el.textContent = "test";
441
+
});
442
+
443
+
expect(mockStartViewTransition).toHaveBeenCalled();
444
+
445
+
// @ts-expect-error - Cleanup mock
446
+
delete document.startViewTransition;
447
+
});
448
+
449
+
it("should fallback to CSS when View Transitions API not available", async () => {
450
+
// @ts-expect-error - Ensure View Transitions API is not available
451
+
delete document.startViewTransition;
452
+
453
+
vi.useFakeTimers();
454
+
455
+
const container = document.createElement("div");
456
+
container.innerHTML = `
457
+
<div data-volt-if="show" data-volt-surge="fade">
458
+
Content
459
+
</div>
460
+
`;
461
+
462
+
const show = signal(false);
463
+
mount(container, { show });
464
+
465
+
show.set(true);
466
+
await vi.advanceTimersByTimeAsync(400);
467
+
468
+
const element = container.querySelector("div");
469
+
expect(element).toBeDefined();
470
+
471
+
vi.useRealTimers();
472
+
});
473
+
});
474
+
475
+
describe("Memory leak prevention", () => {
476
+
it("should cleanup all transition-related subscriptions", async () => {
477
+
const container = document.createElement("div");
478
+
container.innerHTML = `
479
+
<div data-volt-if="show" data-volt-surge="fade">
480
+
Content 1
481
+
</div>
482
+
<div data-volt-show="visible" data-volt-surge="slide-down">
483
+
Content 2
484
+
</div>
485
+
<div data-volt-surge="trigger:scale">
486
+
Content 3
487
+
</div>
488
+
<button data-volt-shift="animTrigger:bounce">
489
+
Content 4
490
+
</button>
491
+
`;
492
+
493
+
const show = signal(false);
494
+
const visible = signal(true);
495
+
const trigger = signal(false);
496
+
const animTrigger = signal(false);
497
+
498
+
const cleanup = mount(container, { show, visible, trigger, animTrigger });
499
+
500
+
cleanup();
501
+
502
+
const initialHTML = container.innerHTML;
503
+
504
+
show.set(true);
505
+
visible.set(false);
506
+
trigger.set(true);
507
+
animTrigger.set(true);
508
+
509
+
await new Promise((resolve) => {
510
+
setTimeout(resolve, 50);
511
+
});
512
+
513
+
expect(container.innerHTML).toBe(initialHTML);
514
+
});
515
+
});
516
+
517
+
describe("Complex integration scenarios", () => {
518
+
it("should handle multiple animated elements simultaneously", async () => {
519
+
vi.useFakeTimers();
520
+
521
+
const container = document.createElement("div");
522
+
container.innerHTML = `
523
+
<div data-volt-if="show1" data-volt-surge="fade">Item 1</div>
524
+
<div data-volt-if="show2" data-volt-surge="slide-down">Item 2</div>
525
+
<div data-volt-if="show3" data-volt-surge="scale">Item 3</div>
526
+
`;
527
+
528
+
const show1 = signal(false);
529
+
const show2 = signal(false);
530
+
const show3 = signal(false);
531
+
532
+
mount(container, { show1, show2, show3 });
533
+
534
+
show1.set(true);
535
+
show2.set(true);
536
+
show3.set(true);
537
+
538
+
await vi.advanceTimersByTimeAsync(400);
539
+
540
+
const elements = container.querySelectorAll("div");
541
+
expect(elements.length).toBe(3);
542
+
543
+
vi.useRealTimers();
544
+
});
545
+
546
+
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
+
const container = document.createElement("div");
552
+
const testEl = document.createElement("div");
553
+
testEl.dataset.voltShow = "visible";
554
+
testEl.dataset.voltSurge = "fade";
555
+
testEl.dataset.voltShift = "bounce";
556
+
testEl.textContent = "Combined";
557
+
container.append(testEl);
558
+
559
+
const element = testEl;
560
+
561
+
const visible = signal(true);
562
+
mount(container, { visible });
563
+
564
+
await new Promise((resolve) => {
565
+
setTimeout(resolve, 100);
566
+
});
567
+
568
+
expect(element.animate).toHaveBeenCalled();
569
+
});
570
+
});
571
+
});
+361
lib/test/plugins/shift.test.ts
+361
lib/test/plugins/shift.test.ts
···
1
+
import { signal } from "$core/signal";
2
+
import {
3
+
getAnimation,
4
+
getRegisteredAnimations,
5
+
hasAnimation,
6
+
registerAnimation,
7
+
shiftPlugin,
8
+
unregisterAnimation,
9
+
} from "$plugins/shift";
10
+
import type { AnimationPreset, PluginContext } from "$types/volt";
11
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
12
+
13
+
describe("Shift Plugin", () => {
14
+
let container: HTMLDivElement;
15
+
let element: HTMLElement;
16
+
let mockContext: PluginContext;
17
+
let cleanups: Array<() => void>;
18
+
let onMountCallbacks: Array<() => void>;
19
+
20
+
beforeEach(() => {
21
+
container = document.createElement("div");
22
+
element = document.createElement("div");
23
+
element.textContent = "Test Content";
24
+
container.append(element);
25
+
document.body.append(container);
26
+
27
+
cleanups = [];
28
+
onMountCallbacks = [];
29
+
30
+
mockContext = {
31
+
element,
32
+
scope: {},
33
+
addCleanup: (fn) => {
34
+
cleanups.push(fn);
35
+
},
36
+
findSignal: vi.fn(),
37
+
evaluate: vi.fn(),
38
+
lifecycle: {
39
+
onMount: (cb) => {
40
+
onMountCallbacks.push(cb);
41
+
cb();
42
+
},
43
+
onUnmount: vi.fn(),
44
+
beforeBinding: vi.fn(),
45
+
afterBinding: vi.fn(),
46
+
},
47
+
};
48
+
49
+
globalThis.matchMedia = vi.fn().mockReturnValue({ matches: false });
50
+
51
+
element.animate = vi.fn((keyframes: Keyframe[], options?: KeyframeAnimationOptions) => {
52
+
return {
53
+
onfinish: null,
54
+
cancel: vi.fn(),
55
+
_keyframes: keyframes,
56
+
_options: options,
57
+
};
58
+
}) as unknown as typeof element.animate;
59
+
});
60
+
61
+
afterEach(() => {
62
+
for (const cleanup of cleanups) {
63
+
cleanup();
64
+
}
65
+
cleanups = [];
66
+
onMountCallbacks = [];
67
+
container.remove();
68
+
vi.restoreAllMocks();
69
+
});
70
+
71
+
describe("Animation Registry", () => {
72
+
it("should have built-in animation presets", () => {
73
+
expect(hasAnimation("bounce")).toBe(true);
74
+
expect(hasAnimation("shake")).toBe(true);
75
+
expect(hasAnimation("pulse")).toBe(true);
76
+
expect(hasAnimation("spin")).toBe(true);
77
+
expect(hasAnimation("flash")).toBe(true);
78
+
});
79
+
80
+
it("should register custom animation", () => {
81
+
const customAnimation: AnimationPreset = {
82
+
keyframes: [
83
+
{ offset: 0, transform: "scale(1)" },
84
+
{ offset: 1, transform: "scale(1.5)" },
85
+
],
86
+
duration: 500,
87
+
iterations: 1,
88
+
timing: "ease",
89
+
};
90
+
91
+
registerAnimation("custom", customAnimation);
92
+
expect(hasAnimation("custom")).toBe(true);
93
+
expect(getAnimation("custom")).toEqual(customAnimation);
94
+
});
95
+
96
+
it("should unregister custom animation", () => {
97
+
const customAnimation: AnimationPreset = {
98
+
keyframes: [{ offset: 0, opacity: "1" }, { offset: 1, opacity: "0" }],
99
+
duration: 300,
100
+
iterations: 1,
101
+
timing: "linear",
102
+
};
103
+
104
+
registerAnimation("temp", customAnimation);
105
+
expect(hasAnimation("temp")).toBe(true);
106
+
107
+
const result = unregisterAnimation("temp");
108
+
expect(result).toBe(true);
109
+
expect(hasAnimation("temp")).toBe(false);
110
+
});
111
+
112
+
it("should not unregister built-in animation", () => {
113
+
const result = unregisterAnimation("bounce");
114
+
expect(result).toBe(false);
115
+
expect(hasAnimation("bounce")).toBe(true);
116
+
});
117
+
118
+
it("should get all registered animations", () => {
119
+
const animations = getRegisteredAnimations();
120
+
expect(animations).toContain("bounce");
121
+
expect(animations).toContain("shake");
122
+
expect(animations).toContain("pulse");
123
+
expect(animations).toContain("spin");
124
+
expect(animations).toContain("flash");
125
+
});
126
+
127
+
it("should warn when overriding built-in animation", () => {
128
+
const consoleSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
129
+
const customAnimation: AnimationPreset = {
130
+
keyframes: [{ offset: 0, opacity: "1" }],
131
+
duration: 100,
132
+
iterations: 1,
133
+
timing: "ease",
134
+
};
135
+
136
+
registerAnimation("bounce", customAnimation);
137
+
expect(consoleSpy).toHaveBeenCalledWith(
138
+
expect.stringContaining('Overriding built-in animation preset: "bounce"'),
139
+
);
140
+
141
+
consoleSpy.mockRestore();
142
+
});
143
+
});
144
+
145
+
describe("Basic Animation Application", () => {
146
+
it("should apply animation on mount", () => {
147
+
shiftPlugin(mockContext, "bounce");
148
+
149
+
expect(element.animate).toHaveBeenCalled();
150
+
const animateCall = (element.animate as ReturnType<typeof vi.fn>).mock.calls[0];
151
+
expect(animateCall).toBeDefined();
152
+
});
153
+
154
+
it("should use default duration and iterations", () => {
155
+
shiftPlugin(mockContext, "bounce");
156
+
157
+
expect(element.animate).toHaveBeenCalled();
158
+
const animateMock = element.animate as unknown as ReturnType<typeof vi.fn>;
159
+
const options = animateMock.mock.calls[0]?.[1] as KeyframeAnimationOptions;
160
+
expect(options?.duration).toBe(100);
161
+
expect(options?.iterations).toBe(1);
162
+
});
163
+
164
+
it("should apply custom duration", () => {
165
+
shiftPlugin(mockContext, "bounce.1000");
166
+
167
+
expect(element.animate).toHaveBeenCalled();
168
+
const animateMock = element.animate as unknown as ReturnType<typeof vi.fn>;
169
+
const options = animateMock.mock.calls[0]?.[1] as KeyframeAnimationOptions;
170
+
expect(options?.duration).toBe(1000);
171
+
});
172
+
173
+
it("should apply custom duration and iterations", () => {
174
+
shiftPlugin(mockContext, "bounce.500.3");
175
+
176
+
expect(element.animate).toHaveBeenCalled();
177
+
const animateMock = element.animate as unknown as ReturnType<typeof vi.fn>;
178
+
const options = animateMock.mock.calls[0]?.[1] as KeyframeAnimationOptions;
179
+
expect(options?.duration).toBe(500);
180
+
expect(options?.iterations).toBe(3);
181
+
});
182
+
183
+
it("should handle unknown animation preset", () => {
184
+
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
185
+
186
+
shiftPlugin(mockContext, "unknown");
187
+
188
+
expect(element.animate).not.toHaveBeenCalled();
189
+
expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('Unknown animation preset: "unknown"'));
190
+
191
+
consoleSpy.mockRestore();
192
+
});
193
+
194
+
it("should handle invalid shift value", () => {
195
+
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
196
+
197
+
shiftPlugin(mockContext, "");
198
+
199
+
expect(element.animate).not.toHaveBeenCalled();
200
+
expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining("Invalid shift value"));
201
+
202
+
consoleSpy.mockRestore();
203
+
});
204
+
});
205
+
206
+
describe("Signal-Triggered Animations", () => {
207
+
it("should trigger animation when signal changes to truthy", () => {
208
+
const triggerSignal = signal(false);
209
+
mockContext.findSignal = vi.fn().mockReturnValue(triggerSignal);
210
+
211
+
shiftPlugin(mockContext, "trigger:bounce");
212
+
213
+
expect(element.animate).not.toHaveBeenCalled();
214
+
215
+
triggerSignal.set(true);
216
+
217
+
expect(element.animate).toHaveBeenCalled();
218
+
});
219
+
220
+
it("should not trigger animation when signal stays truthy", () => {
221
+
const triggerSignal = signal(true);
222
+
mockContext.findSignal = vi.fn().mockReturnValue(triggerSignal);
223
+
224
+
shiftPlugin(mockContext, "trigger:bounce");
225
+
226
+
expect(element.animate).toHaveBeenCalledTimes(1);
227
+
228
+
triggerSignal.set(true);
229
+
230
+
expect(element.animate).toHaveBeenCalledTimes(1);
231
+
});
232
+
233
+
it("should trigger animation on initial mount if signal is truthy", () => {
234
+
const triggerSignal = signal(true);
235
+
mockContext.findSignal = vi.fn().mockReturnValue(triggerSignal);
236
+
237
+
shiftPlugin(mockContext, "trigger:bounce");
238
+
239
+
expect(element.animate).toHaveBeenCalledTimes(1);
240
+
});
241
+
242
+
it("should handle signal not found", () => {
243
+
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
244
+
mockContext.findSignal = vi.fn().mockReturnValue(undefined);
245
+
246
+
shiftPlugin(mockContext, "missing:bounce");
247
+
248
+
expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('Signal "missing" not found'));
249
+
consoleSpy.mockRestore();
250
+
});
251
+
252
+
it("should support custom duration and iterations with signal trigger", () => {
253
+
const triggerSignal = signal(false);
254
+
mockContext.findSignal = vi.fn().mockReturnValue(triggerSignal);
255
+
256
+
shiftPlugin(mockContext, "trigger:bounce.800.2");
257
+
258
+
triggerSignal.set(true);
259
+
260
+
expect(element.animate).toHaveBeenCalled();
261
+
const animateMock = element.animate as unknown as ReturnType<typeof vi.fn>;
262
+
const options = animateMock.mock.calls[0]?.[1] as KeyframeAnimationOptions;
263
+
expect(options?.duration).toBe(800);
264
+
expect(options?.iterations).toBe(2);
265
+
});
266
+
});
267
+
268
+
describe("Accessibility", () => {
269
+
it("should respect prefers-reduced-motion", () => {
270
+
globalThis.matchMedia = vi.fn().mockReturnValue({ matches: true });
271
+
272
+
shiftPlugin(mockContext, "bounce");
273
+
274
+
expect(element.animate).not.toHaveBeenCalled();
275
+
});
276
+
277
+
it("should not animate when prefers-reduced-motion is active and signal triggers", () => {
278
+
globalThis.matchMedia = vi.fn().mockReturnValue({ matches: true });
279
+
280
+
const triggerSignal = signal(false);
281
+
mockContext.findSignal = vi.fn().mockReturnValue(triggerSignal);
282
+
283
+
shiftPlugin(mockContext, "trigger:bounce");
284
+
285
+
triggerSignal.set(true);
286
+
287
+
expect(element.animate).not.toHaveBeenCalled();
288
+
});
289
+
});
290
+
291
+
describe("Animation Cleanup", () => {
292
+
it("should cancel animation on finish", () => {
293
+
const mockAnimation = {
294
+
onfinish: null as (() => void) | null,
295
+
cancel: vi.fn(),
296
+
};
297
+
298
+
element.animate = vi.fn().mockReturnValue(mockAnimation);
299
+
300
+
shiftPlugin(mockContext, "bounce");
301
+
302
+
expect(mockAnimation.onfinish).toBeDefined();
303
+
304
+
mockAnimation.onfinish?.();
305
+
306
+
expect(mockAnimation.cancel).toHaveBeenCalled();
307
+
});
308
+
309
+
it("should cleanup signal subscription", () => {
310
+
const triggerSignal = signal(false);
311
+
const unsubscribe = vi.fn();
312
+
triggerSignal.subscribe = vi.fn().mockReturnValue(unsubscribe);
313
+
314
+
mockContext.findSignal = vi.fn().mockReturnValue(triggerSignal);
315
+
316
+
shiftPlugin(mockContext, "trigger:bounce");
317
+
318
+
expect(cleanups).toHaveLength(1);
319
+
320
+
cleanups[0]();
321
+
322
+
expect(unsubscribe).toHaveBeenCalled();
323
+
});
324
+
});
325
+
326
+
describe("Built-in Animations", () => {
327
+
it("should have bounce animation with correct keyframes", () => {
328
+
const bounce = getAnimation("bounce");
329
+
expect(bounce).toBeDefined();
330
+
expect(bounce?.keyframes.length).toBeGreaterThan(0);
331
+
expect(bounce?.duration).toBe(100);
332
+
expect(bounce?.iterations).toBe(1);
333
+
});
334
+
335
+
it("should have shake animation with correct keyframes", () => {
336
+
const shake = getAnimation("shake");
337
+
expect(shake).toBeDefined();
338
+
expect(shake?.keyframes.length).toBeGreaterThan(0);
339
+
expect(shake?.duration).toBe(500);
340
+
});
341
+
342
+
it("should have pulse animation with infinite iterations", () => {
343
+
const pulse = getAnimation("pulse");
344
+
expect(pulse).toBeDefined();
345
+
expect(pulse?.iterations).toBe(Number.POSITIVE_INFINITY);
346
+
});
347
+
348
+
it("should have spin animation with infinite iterations", () => {
349
+
const spin = getAnimation("spin");
350
+
expect(spin).toBeDefined();
351
+
expect(spin?.iterations).toBe(Number.POSITIVE_INFINITY);
352
+
expect(spin?.timing).toBe("linear");
353
+
});
354
+
355
+
it("should have flash animation", () => {
356
+
const flash = getAnimation("flash");
357
+
expect(flash).toBeDefined();
358
+
expect(flash?.duration).toBe(1000);
359
+
});
360
+
});
361
+
});