+27
-44
ROADMAP.md
+27
-44
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
-
| v0.5.0 | | [History API Routing Plugin](#history-api-routing-plugin) |
21
-
| | | [Navigation & History Management](#navigation--history-management) |
20
+
| v0.5.0 | ✓ | [Navigation & History API Routing](#navigation--history-api-routing) |
22
21
| | ✓ | [Refactor](#evaluator--binder-hardening) |
23
-
| v0.6.0 | | [Background Requests & Reactive Polling](#background-requests--reactive-polling) |
24
-
| v0.7.0 | | [Streaming & Patch Engine](#streaming--patch-engine) |
25
-
| v0.8.0 | | PWA Capabilities |
26
-
| | | [Persistence & Offline](#persistence--offline) |
22
+
| | | Update demo to be a multi page application with routing plugin |
23
+
| v0.5.1 | | Support `voltx-` & `vx-` attributes: recommend `vx-` |
24
+
| v0.5.2 | | Switch to `data-voltx` |
25
+
| v0.5.3 | | [Background Requests & Reactive Polling](#background-requests--reactive-polling) |
26
+
| v0.5.4 | | [Streaming & Patch Engine](#streaming--patch-engine) |
27
+
| v0.5.5 | | PWA Capabilities |
28
+
| v0.5.6 | | [Persistence & Offline](#persistence--offline) |
29
+
| | | |
27
30
| v0.9.0 | | [Inspector & Developer Tools](#inspector--developer-tools) |
28
31
| v1.0.0 | | [Stable Release](#stable-release) |
29
32
···
58
61
### Backend Integration & HTTP Actions
59
62
60
63
**Goal:** Provide backend integration with declarative HTTP requests and responses.
61
-
**Outcome:** Volt.js can make backend requests and update the DOM
62
-
**Summary:** Declarative HTTP directives (data-volt-get|post|put|patch|delete) with swap strategies, loading indicators, error handling, and form serialization integrate Volt.js seamlessly with backend APIs.
64
+
**Outcome:** VoltX.js can make backend requests and update the DOM
65
+
**Summary:** Declarative HTTP directives (data-volt-get|post|put|patch|delete) with swap strategies, loading indicators, error handling, and form serialization integrate VoltX.js seamlessly with backend APIs.
63
66
64
67
### Markup Based Reactivity
65
68
···
75
78
76
79
### Reactive Attributes & Event Modifiers
77
80
78
-
**Goal:** Extend Volt.js with expressive attribute patterns and event options for fine-grained control.
79
-
**Outcome:** Volt.js supports rich declarative behaviors and event semantics built entirely on standard DOM APIs.
81
+
**Goal:** Extend VoltX.js with expressive attribute patterns and event options for fine-grained control.
82
+
**Outcome:** VoltX.js supports rich declarative behaviors and event semantics built entirely on standard DOM APIs.
80
83
**Summary:** Introduced expressive attribute patterns and event modifiers for precise DOM and input control, for fine-grained declarative behavior entirely through standard DOM APIs.
81
84
82
85
### Global State
83
86
84
87
**Goal:** Implement store/context pattern
85
-
**Outcome:** Volt.js provides intuitive global state management
88
+
**Outcome:** VoltX.js provides intuitive global state management
86
89
**Summary:** The scope injects helpers like `$origin`, `$scope`, `$pulse`, `$store`, `$uid`, `$probe`, `$pins`, and `$arc`, giving templates access to global state, microtask scheduling, deterministic IDs, element refs, and custom event dispatch without leaving declarative markup.
87
90
88
91
### Animation & Transitions
89
92
90
93
**Goal:** Add animation primitives for smooth UI transitions with Alpine/Datastar parity.
91
-
**Outcome:** Volt.js enables declarative animations and view transitions alongside reactivity.
94
+
**Outcome:** VoltX.js enables declarative animations and view transitions alongside reactivity.
92
95
**Summary:** The surge directive ships fade/slide/scale/blur presets with duration and delay overrides, per-phase enter/leave control, and easing helpers, while the shift plugin applies reusable keyframe animations—both composable with `data-volt-if`/`data-volt-show` as showcased in the animations demo.
93
96
97
+
### Navigation & History API Routing
98
+
99
+
**Goal:** Provide seamless client-side navigation with a first-class History API router.
100
+
**Outcome:** VoltX.js delivers accessible, stateful navigation with clean URLs and signal-driven routing.
101
+
**Summary:** Added seamless client-side navigation through a History API–powered router, enabling declarative routing with `data-volt-navigate` and `data-volt-url`, reactive URL synchronization, smooth transitions, scroll and focus restoration, dynamic route parsing, and full integration with signals and the View Transition API for accessible, stateful navigation and clean URLs.
102
+
94
103
## To-Do
95
104
96
105
### Streaming & Patch Engine
97
106
98
107
**Goal:** Enable real-time updates via SSE/WebSocket streaming with intelligent DOM patching.
99
-
**Outcome:** Volt.js can receive and apply live updates from the server
108
+
**Outcome:** VoltX.js can receive and apply live updates from the server
100
109
**Deliverables:**
101
110
- Server-Sent Events (SSE) integration
102
111
- `data-volt-flow` attribute for SSE endpoints
···
109
118
### Persistence & Offline
110
119
111
120
**Goal:** Introduce persistent storage and offline-first behaviors.
112
-
**Outcome:** Resilient state persistence and offline replay built into Volt.js.
121
+
**Outcome:** Resilient state persistence and offline replay built into VoltX.js.
113
122
**Deliverables:**
114
123
- ✓ Persistent signals (localStorage, sessionStorage, indexedDb)
115
124
- ✓ Storage plugin (`data-volt-persist`)
···
126
135
127
136
### Background Requests & Reactive Polling
128
137
129
-
**Goal:** Enable declarative background data fetching and periodic updates within the Volt.js runtime.
130
-
**Outcome:** Volt.js elements can fetch or refresh data automatically based on time, visibility, or reactive conditions.
138
+
**Goal:** Enable declarative background data fetching and periodic updates within the VoltX.js runtime.
139
+
**Outcome:** VoltX.js elements can fetch or refresh data automatically based on time, visibility, or reactive conditions.
131
140
**Deliverables:**
132
141
- `data-volt-visible` for fetching when an element enters the viewport (`IntersectionObserver`)
133
142
- `data-volt-fetch` attribute for declarative background requests
···
137
146
- Integration hooks for loading and pending states
138
147
- Background task scheduler with priority management
139
148
140
-
### Navigation & History Management
141
-
142
-
**Goal:** Introduce seamless client-side navigation and stateful history control using web standards.
143
-
**Outcome:** Volt.js provides enhanced navigation behavior with minimal overhead and full accessibility support.
144
-
**Deliverables:**
145
-
- `data-volt-navigate` for intercepting link and form actions
146
-
- Integration with the History API (`pushState`, `replaceState`, `popState`)
147
-
- Reactive synchronization of route and signal state
148
-
- Smooth page and fragment transitions coordinated with Volt’s signal system
149
-
- Native back/forward button support
150
-
- Scroll position persistence and restoration
151
-
- Preloading of linked resources on hover or idle
152
-
- `data-volt-url` for declarative history updates
153
-
- View Transition API integration for animated route changes
154
-
155
-
### History API Routing Plugin
156
-
157
-
**Goal:** Deliver a first-class path-based router that leverages the History API while staying signal-driven.
158
-
**Outcome:** Volt apps can opt into clean URLs (no hash) with back/forward support, nested segments, and SSR-friendly hydration.
159
-
**Deliverables:**
160
-
- `data-volt-url="history:signal"` mode with path + search preservation and optional base path configuration
161
-
- Route parsing utilities for dynamic params (e.g. `/blog/:slug`) and programmatic redirects
162
-
- Scroll restoration hooks and focus management aligned with `navigation` and `popstate` events
163
-
- Integration tests covering pushState navigation, deep links, and server-rendered bootstraps
164
-
- Documentation updates in `docs/usage/routing.md` contrasting hash vs. history strategies
165
-
166
149
### Inspector & Developer Tools
167
150
168
151
**Goal:** Improve developer experience and runtime introspection.
169
-
**Outcome:** First-class developer ergonomics; Volt.js is enjoyable to debug and extend.
152
+
**Outcome:** First-class developer ergonomics; VoltX.js is enjoyable to debug and extend.
170
153
**Deliverables:**
171
154
- Developer overlay for inspecting signals, subscriptions, and effects
172
155
- Dev logging toggle (`Volt.debug = true`)
···
180
163
### Stable Release
181
164
182
165
**Goal:** Prepare & ship the stable release
183
-
**Outcome:** Volt.js 1.0 is stable, documented, performant, and ready for production.
166
+
**Outcome:** VoltX.js 1.0 is stable, documented, performant, and ready for production.
184
167
**Deliverables:**
185
168
- ✓ Documentation site (VitePress)
186
169
- Full API reference with examples
+91
docs/plugins/shift.md
+91
docs/plugins/shift.md
···
1
+
---
2
+
outline: deep
3
+
---
4
+
5
+
# Shift Plugin
6
+
7
+
The shift plugin applies reusable CSS keyframe animations to any element. Use it for attention-grabbing nudges, loading
8
+
states, or signal-driven feedback without writing imperative animation code.
9
+
10
+
## Quick Start
11
+
12
+
```html
13
+
<!-- Run bounce once on mount -->
14
+
<button data-volt-shift="bounce">Click me</button>
15
+
16
+
<!-- Infinite pulse animation -->
17
+
<span data-volt-shift="pulse">Loading…</span>
18
+
```
19
+
20
+
When an element mounts the plugin pulls a preset from the animation registry and calls the Web Animations API with the
21
+
configured keyframes, duration, iterations, and easing. Users with `prefers-reduced-motion` skip the animation entirely.
22
+
23
+
## Built-in Presets
24
+
25
+
Volt ships with several presets you can reference immediately:
26
+
27
+
- `bounce`-snappy vertical movement for call-to-action buttons.
28
+
- `shake`-horizontal wiggle, ideal for error indicators.
29
+
- `pulse`-scale and opacity pulse that repeats forever.
30
+
- `spin`-continuous 360° rotation.
31
+
- `flash`-blinking opacity effect.
32
+
33
+
## Custom Duration and Iterations
34
+
35
+
Add dot-separated numbers after the preset to override timing settings.
36
+
37
+
```html
38
+
<!-- 1 second bounce repeated three times -->
39
+
<div data-volt-shift="bounce.1000.3">Triple bounce</div>
40
+
```
41
+
42
+
The first number is duration in milliseconds; the optional second number controls iteration count. Omitted values fall
43
+
back to the preset configuration.
44
+
45
+
## Reacting to Signals
46
+
47
+
Prefix the binding with a signal path to trigger the animation whenever the signal changes from its previous value to a
48
+
truthy value.
49
+
50
+
```html
51
+
<div data-volt-shift="form.error:shake">Please fix the highlighted fields</div>
52
+
```
53
+
54
+
For the snippet above:
55
+
56
+
- `form.error` is resolved via `ctx.findSignal`.
57
+
- The element animates the first time the signal evaluates truthy.
58
+
- Subsequent updates run the animation whenever the value toggles and remains truthy.
59
+
60
+
## Registering Custom Animations
61
+
62
+
Use the programmatic API to add, inspect, or remove presets.
63
+
64
+
```ts
65
+
import { getRegisteredAnimations, registerAnimation } from "volt/plugins/shift";
66
+
67
+
registerAnimation("wiggle", {
68
+
keyframes: [
69
+
{ offset: 0, transform: "rotate(0deg)" },
70
+
{ offset: 0.25, transform: "rotate(-5deg)" },
71
+
{ offset: 0.75, transform: "rotate(5deg)" },
72
+
{ offset: 1, transform: "rotate(0deg)" },
73
+
],
74
+
duration: 300,
75
+
iterations: 2,
76
+
timing: "ease-in-out",
77
+
});
78
+
79
+
console.log(getRegisteredAnimations()); // ["bounce", "shake", ..., "wiggle"]
80
+
```
81
+
82
+
Other helpers:
83
+
84
+
- `getAnimation(name)` - fetch the preset definition.
85
+
- `hasAnimation(name)` - check existence.
86
+
- `unregisterAnimation(name)` - remove custom presets (built-ins cannot be deleted).
87
+
88
+
## Cleanup
89
+
90
+
Shift automatically cancels the underlying `element.animate` call on completion and removes subscriptions registered via signals.
91
+
No additional teardown is required beyond VoltX’s normal plugin lifecycle.
+76
docs/plugins/surge.md
+76
docs/plugins/surge.md
···
1
+
---
2
+
outline: deep
3
+
---
4
+
5
+
# Surge Plugin
6
+
7
+
The surge plugin powers enter/leave transitions for conditional DOM. It combines CSS property interpolation, optional
8
+
View Transitions, and a signal-aware state machine to animate elements appearing or disappearing.
9
+
10
+
## Quick Start
11
+
12
+
```html
13
+
<section data-volt-surge="isOpen:fade">
14
+
<p>Panel content...</p>
15
+
</section>
16
+
```
17
+
18
+
- `isOpen` resolves to a signal. Falsy values hide the element (`display: none`).
19
+
- The `fade` preset runs when the signal flips to truthy and again in reverse when it returns to falsy.
20
+
21
+
## Presets and Overrides
22
+
23
+
`data-volt-surge="presetName"` attaches a preset from the transition registry without watching a signal. Combine with
24
+
granular variants when you need independent enter/leave control:
25
+
26
+
```html
27
+
<article
28
+
data-volt-surge="show:slide-down.400"
29
+
data-volt-surge:enter="fade.200"
30
+
data-volt-surge:leave="scale-down.250">
31
+
...
32
+
</article>
33
+
```
34
+
35
+
- `data-volt-surge:enter` and `:leave` use the same parsing logic as the core transition helpers (`duration.delay`
36
+
suffixes honored via `parseTransitionValue` and `applyOverrides`).
37
+
- When both shorthand and phase-specific attributes exist, the phase-specific value wins.
38
+
39
+
## Signal Lifecycle
40
+
41
+
When bound to a signal the plugin:
42
+
43
+
1. Checks the initial signal value. Falsy values hide the element immediately.
44
+
2. Subscribes to the signal and debounces concurrent transitions so rapid toggles stay smooth.
45
+
3. Uses `execEnter`/`execLeave` helpers to apply styles, classes, delays, and easing.
46
+
4. Cleans up the subscription when the element unmounts.
47
+
48
+
The underlying transition promise resolves before the element is marked visible or hidden, ensuring sequential updates
49
+
remain ordered.
50
+
51
+
## View Transitions Integration
52
+
53
+
Surge participates in the View Transition API whenever the preset’s config opts in (default). Calling variants like
54
+
`slide-down.400` runs inside `withViewTransition`, making swapping sections feel native. Add `:notransition` to your
55
+
navigate bindings if you need to avoid double animations when combining plugins.
56
+
57
+
## Manual Execution
58
+
59
+
Volt’s runtime calls the internal helpers automatically for keyed iterations and DOM diffs. If you render content
60
+
manually, you can trigger the same behavior:
61
+
62
+
```ts
63
+
import { executeSurgeEnter, executeSurgeLeave, hasSurge } from "volt/plugins/surge";
64
+
65
+
if (hasSurge(el)) {
66
+
await executeSurgeEnter(el);
67
+
}
68
+
```
69
+
70
+
`hasSurge` checks whether the element owns any surge metadata (signal config or phase overrides) before attempting an
71
+
explicit enter/leave.
72
+
73
+
## Reduced Motion
74
+
75
+
When the user prefers reduced motion the plugin skips transitions, applies the `to` styles or classes immediately, and
76
+
avoids firing View Transition effects. This keeps the animation accessible without extra work on your part.
+78
docs/plugins/url.md
+78
docs/plugins/url.md
···
1
+
---
2
+
outline: deep
3
+
---
4
+
5
+
# URL Plugin
6
+
7
+
The url plugin bridges VoltXsignals with the browser’s address bar. Use it to hydrate page state from query parameters, mirror form inputs into the URL, or power hash/history based routing.
8
+
9
+
## Quick Start
10
+
11
+
```html
12
+
<!-- Populate a signal on mount -->
13
+
<div data-volt-url="read:filters.category"></div>
14
+
15
+
<!-- Two-way sync between location.search and a signal -->
16
+
<input name="q" data-volt-url="sync:searchQuery" />
17
+
```
18
+
19
+
Each binding follows `mode:signalPath[:basePath]`. The plugin resolves the signal via `ctx.findSignal` and wires it to one of the strategies below.
20
+
21
+
## Modes
22
+
23
+
### `read`
24
+
25
+
One-way hydration. On mount the plugin reads `?signalPath=value` and assigns it to the signal. Later signal updates do not modify the URL.
26
+
27
+
```html
28
+
<div data-volt-url="read:filters.status"></div>
29
+
```
30
+
31
+
### `sync`
32
+
33
+
Bidirectional query-string sync. The plugin:
34
+
35
+
1. Seeds the signal from `?signalPath=...`.
36
+
2. Subscribes to the signal and pushes URL updates (debounced) via `history.pushState`.
37
+
3. Listens for `popstate` to keep the signal in sync when the user navigates back/forward.
38
+
39
+
The `serializeValue`/`deserializeValue` helpers support strings, numbers, booleans, JSON payloads, and empty values.
40
+
41
+
```html
42
+
<input placeholder="Search…" data-volt-url="sync:search" />
43
+
```
44
+
45
+
When the input changes the URL updates to `?search=...`. Clearing the input removes the parameter.
46
+
47
+
### `hash`
48
+
49
+
Two-way binding to `window.location.hash`. Useful for simple client-side routing or tab selection:
50
+
51
+
```html
52
+
<nav data-volt-url="hash:activeTab"></nav>
53
+
```
54
+
55
+
- Updates to the signal call `history.pushState` with the new hash.
56
+
- `hashchange` events hydrate the signal when users edit the URL manually.
57
+
58
+
### `history`
59
+
60
+
Full routing synchronization with `pathname + search`. Optionally trim a base path when syncing:
61
+
62
+
```html
63
+
<main data-volt-url="history:route:/app"></main>
64
+
```
65
+
66
+
- The signal receives `/` when the user is at `/app`.
67
+
- Pushing a new value updates the URL and dispatches `volt:navigate`.
68
+
- Browser back/forward emits `volt:popstate` and refreshes the signal.
69
+
70
+
Combine this mode with the navigate plugin to keep a global router signal in lockstep with address bar changes.
71
+
72
+
## Handling Missing Signals
73
+
74
+
If the plugin cannot resolve `signalPath` it logs a descriptive error and aborts. Ensure your scope exports naming matches when wiring bindings.
75
+
76
+
## Cleanup
77
+
78
+
Each mode registers the necessary event listeners (`popstate`, `hashchange`, `volt:navigate`) and unsubscribes during cleanup, so no manual teardown is required. All timers are also cleared to prevent stale updates.
+282
-20
docs/usage/routing.md
+282
-20
docs/usage/routing.md
···
2
2
3
3
Client-side routing lets VoltX applications feel like multi-page sites without full page reloads.
4
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.
5
+
This guide walks through building both hash-based and History API routers that swap entire page sections while preserving the advantages of VoltX's signal system.
7
6
8
7
## Why?
9
8
10
9
- **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
10
- **Shareable URLs:** Users can refresh or share a link such as `/#/pricing` and land directly on the same view.
12
11
- **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.
12
+
- **Simple integration:** No extra router dependency is required. Register the plugin once and opt-in per signal.
14
13
15
14
> The plugin also supports synchronising signals with query parameters (`read:` and `sync:` modes).
16
15
> For multi-page navigation the `hash:` mode is the simplest option because it avoids server configuration and works on static hosting.
17
16
18
-
## How?
17
+
## Getting Started
19
18
20
-
1. Install Volt normally (see [Installation](../installation.md)).
21
-
2. Register the plugin before calling `charge()` or `mount()`:
19
+
1. Install Volt normally (see [Installation](../installation)).
20
+
2. Register the plugin before calling `charge()` or `mount()`.
21
+
Choose the import style that matches your setup:
22
22
23
23
```html
24
+
<!-- CDN / script-tag usage -->
24
25
<script type="module">
25
-
import {
26
-
charge,
27
-
registerPlugin,
28
-
urlPlugin,
29
-
} from 'https://unpkg.com/voltx.js@latest/dist/volt.js';
26
+
import { charge, registerPlugin, urlPlugin } from "https://unpkg.com/voltx.js@latest/dist/volt.js";
30
27
31
-
registerPlugin('url', urlPlugin);
28
+
registerPlugin("url", urlPlugin);
32
29
charge();
33
30
</script>
34
31
```
35
32
36
-
3. In your markup, opt a signal into hash synchronisation with `data-volt-url="hash:signalName"`.
33
+
```ts
34
+
// src/main.ts — bundled projects
35
+
import { charge, initNavigationListener, registerPlugin, urlPlugin } from "voltx.js";
36
+
37
+
registerPlugin("url", urlPlugin);
38
+
initNavigationListener(); // restores scroll/focus when using history routing
39
+
40
+
charge();
41
+
```
42
+
43
+
3. In your markup, opt a signal into URL synchronisation, for example `data-volt-url="hash:route"` or `data-volt-url="history:path"`.
44
+
45
+
## URL modes at a glance
46
+
47
+
| Mode | Binding example | Sync direction | Use Case |
48
+
| -------- | -------------------------------------- | --------------------------- | ------------------------------------------------------------------------------ |
49
+
| `read` | `data-volt-url="read:filter"` | URL ➝ signal on first mount | Hydrate initial state from a query param without mutating the URL afterwards. |
50
+
| `sync` | `data-volt-url="sync:sort"` | Bidirectional | Mirror a filter, tab, or feature flag in the query string. |
51
+
| `hash` | `data-volt-url="hash:route"` | Bidirectional | Build hash-based navigation that works on static hosts. |
52
+
| `history`| `data-volt-url="history:path:/app"` | Bidirectional | Reflect clean History API routes; strip a base path such as `/app` when needed.|
53
+
54
+
> Mix and match bindings inside the same scope.
55
+
> It's common to pair `history:path` for the main route with `sync:` bindings for search filters or sort order.
37
56
38
57
## Building a multi-page shell
39
58
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.
59
+
The example below delivers a three-page marketing site entirely on the client.
60
+
Each "page" is a section that only renders when the current route matches its slug.
42
61
43
62
```html
44
63
<main
···
65
84
<section data-volt-if="route === 'pricing'">
66
85
<h1>Pricing</h1>
67
86
<ul>
68
-
<li>Starter — $0</li>
69
-
<li>Team — $29</li>
70
-
<li>Enterprise — Contact us</li>
87
+
<li>Starter - $0</li>
88
+
<li>Team - $29</li>
89
+
<li>Enterprise - Contact us</li>
71
90
</ul>
72
91
</section>
73
92
···
149
168
150
169
Now `#/pricing?preview=true` keeps both the route and a feature flag in sync with the URL.
151
170
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.
171
+
Use `read:` instead of `sync:` when you only need to hydrate the initial value from the URL without mutating it.
172
+
173
+
## History API Routing
174
+
175
+
VoltX supports true History API routing via the `history:` mode on the url plugin and the `navigate` directive for SPA-style navigation with pushState/replaceState.
176
+
177
+
### Using history mode
178
+
179
+
The `history:` mode syncs a signal with the browser pathname and search params, updating the URL via `history.pushState()` without page reloads:
180
+
181
+
```html
182
+
<div
183
+
data-volt
184
+
data-volt-state='{"currentPath": "/"}'
185
+
data-volt-url="history:currentPath">
186
+
<nav>
187
+
<a href="/about" data-volt-navigate>About</a>
188
+
<a href="/pricing" data-volt-navigate>Pricing</a>
189
+
</nav>
190
+
</div>
191
+
```
192
+
193
+
Make sure `initNavigationListener()` runs once during boot (see the bundler example above). It restores scroll positions and focus when users navigate with the browser controls.
194
+
195
+
Links with `data-volt-navigate` intercept clicks and use pushState instead of full navigation. The `currentPath` signal stays synchronized with the URL, enabling declarative rendering based on pathname.
196
+
197
+
### Base paths and nested apps
198
+
199
+
When your app is served from a subdirectory, provide the base path as the third argument:
200
+
201
+
```html
202
+
<div
203
+
data-volt
204
+
data-volt-state='{"currentPath": "/"}'
205
+
data-volt-url="history:currentPath:/docs">
206
+
<!-- routes now read "/pricing" instead of "/docs/pricing" -->
207
+
</div>
208
+
```
209
+
210
+
Volt automatically strips `/docs` from the signal value while keeping the full URL intact.
211
+
212
+
### Link Interception
213
+
214
+
The navigate directive ships with VoltX so there is no extra plugin registration required. It handles:
215
+
216
+
- Click interception on anchor tags (respects Ctrl/Cmd+click for new tabs)
217
+
- Form GET submission as navigation
218
+
- Back/forward button support via popstate events
219
+
- Automatic scroll position restoration
220
+
- Optional View Transition API integration
221
+
222
+
Use modifiers for control:
223
+
224
+
- `data-volt-navigate.replace` - Use replaceState instead of pushState
225
+
- `data-volt-navigate.prefetch` - Prefetch on hover or focus
226
+
- `data-volt-navigate.prefetch.viewport` - Prefetch when entering viewport
227
+
- `data-volt-navigate.notransition` - Skip View Transitions
228
+
229
+
### Programmatic navigation
230
+
231
+
Import `navigate()`, `redirect()`, `goBack()`, or `goForward()` for JavaScript-driven routing:
232
+
233
+
```typescript
234
+
import { navigate, redirect } from "voltx.js";
235
+
236
+
await navigate("/dashboard"); // Pushes state
237
+
await redirect("/login"); // Replaces state
238
+
```
239
+
240
+
Both functions return Promises that resolve after navigation completes, supporting View Transitions when available.
241
+
242
+
### Navigation events
243
+
244
+
History navigations emit custom events you can react to without polling:
245
+
246
+
```ts
247
+
globalThis.addEventListener("volt:navigate", (event) => {
248
+
const { url, route } = event.detail; // route is present when dispatched by the url plugin
249
+
console.debug("navigated to", url, route ?? "");
250
+
});
251
+
252
+
globalThis.addEventListener("volt:popstate", (event) => {
253
+
refreshDataFor(event.detail.route);
254
+
});
255
+
```
256
+
257
+
Use these hooks to trigger data fetching, analytics, or other side effects whenever the active route changes.
258
+
259
+
## Hash vs History Routing
260
+
261
+
| Feature | Hash Mode | History Mode |
262
+
| ------------------ | ------------------------ | ------------------------------ |
263
+
| URL format | `/#/page` | `/page` |
264
+
| Server config | None required | Requires catch-all route |
265
+
| Browser history | Yes | Yes |
266
+
| SEO friendly | Limited | Full |
267
+
| Deep linking | Yes | Yes |
268
+
| Static hosting | Perfect | Needs fallback to index.html |
269
+
| Back/forward | Automatic via hashchange | Automatic via popstate |
270
+
| Scroll restoration | Manual | Automatic with navigate plugin |
271
+
272
+
**Choose hash mode** when deploying to static hosting (GitHub Pages, Netlify without redirects) or when server configuration is unavailable.
273
+
274
+
**Choose history mode** when you control server routing and want cleaner URLs for SEO and user experience. Configure your server to serve `index.html` for all routes.
275
+
276
+
## Route Parameters
277
+
278
+
VoltX provides pattern matching utilities for extracting dynamic segments from URLs.
279
+
280
+
### Pattern syntax
281
+
282
+
Route patterns support:
283
+
284
+
- Named parameters: `/blog/:slug`
285
+
- Optional parameters: `/blog/:category/:slug?`
286
+
- Wildcard parameters: `/files/*path`
287
+
- Multiple parameters: `/users/:userId/posts/:postId`
288
+
289
+
### Using route utilities
290
+
291
+
Import `matchRoute()`, `extractParams()`, or `buildPath()` from voltx.js to work with route patterns:
292
+
293
+
```typescript
294
+
import { matchRoute, extractParams, buildPath } from "voltx.js";
295
+
296
+
const match = matchRoute("/blog/:slug", "/blog/hello-world");
297
+
// { path: '/blog/hello-world', params: { slug: 'hello-world' }, pattern: '/blog/:slug' }
298
+
299
+
const params = extractParams("/users/:id", "/users/42"); // { id: '42' }
300
+
const url = buildPath("/blog/:slug", { slug: "new-post" }); // '/blog/new-post'
301
+
```
302
+
303
+
Combine these with computed signals to derive route information declaratively. For example, use `matchRoute()` in a computed signal that watches the url plugin's signal to extract parameters whenever the route changes.
304
+
305
+
### Declarative parameter extraction
306
+
307
+
Rather than calling route utilities in methods, create computed signals that derive route data:
308
+
309
+
```html
310
+
<div
311
+
data-volt
312
+
data-volt-state='{"path": "/"}'
313
+
data-volt-url="history:path"
314
+
data-volt-computed:blogSlug="path.startsWith('/blog/') ? path.split('/')[2] : null">
315
+
<article data-volt-if="blogSlug">
316
+
<h1 data-volt-text="'Post: ' + blogSlug"></h1>
317
+
</article>
318
+
</div>
319
+
```
320
+
321
+
For more complex routing needs, register a custom method or use the programmatic API with the router utilities.
322
+
323
+
## Data fetching on navigation
324
+
325
+
Combine routing signals with `asyncEffect` to load data whenever the active path changes.
326
+
Abort signals prevent stale responses from updating the UI if the user navigates away mid-request.
327
+
328
+
```ts
329
+
import { asyncEffect, matchRoute, registerPlugin, signal, urlPlugin } from "voltx.js";
330
+
331
+
const path = signal("/");
332
+
const blogPost = signal(null);
333
+
const loading = signal(false);
334
+
335
+
registerPlugin("url", urlPlugin);
336
+
337
+
asyncEffect(
338
+
async (abortSignal) => {
339
+
const match = matchRoute("/blog/:slug", path.get());
340
+
if (!match) {
341
+
blogPost.set(null);
342
+
return;
343
+
}
344
+
345
+
loading.set(true);
346
+
try {
347
+
const response = await fetch(`/api/posts/${match.params.slug}`, { signal: abortSignal });
348
+
if (!response.ok) throw new Error("Failed to load post");
349
+
blogPost.set(await response.json());
350
+
} finally {
351
+
loading.set(false);
352
+
}
353
+
},
354
+
[path],
355
+
{ abortable: true },
356
+
);
357
+
```
358
+
359
+
Bind `blogPost` and `loading` into your template (`data-volt-if="blogPost"` etc.) to show the fetched content once it arrives.
360
+
361
+
## View Transitions
362
+
363
+
The navigate directive automatically integrates with the View Transitions API when available, providing smooth cross-fade animations between page navigations.
364
+
365
+
### Automatic transitions
366
+
367
+
By default, all navigations triggered via `data-volt-navigate` or the `navigate()` function use View Transitions with a transition name of `"page-transition"`. The browser handles the animation automatically.
368
+
369
+
### Customizing transitions
370
+
371
+
Control transition behavior with CSS using view-transition pseudo-elements:
372
+
373
+
```css
374
+
::view-transition-old(root),
375
+
::view-transition-new(root) {
376
+
animation-duration: 0.3s;
377
+
}
378
+
379
+
::view-transition-old(root) {
380
+
animation-name: fade-out;
381
+
}
382
+
383
+
::view-transition-new(root) {
384
+
animation-name: fade-in;
385
+
}
386
+
```
387
+
388
+
Disable transitions per-navigation using the `.notransition` modifier or pass `transition: false` to programmatic navigation functions.
389
+
390
+
## Focus Management & Accessibility
391
+
392
+
The navigate plugin includes automatic focus management for keyboard navigation and screen reader users.
393
+
394
+
On forward navigation, focus moves to the main content area (searches for `<main>`, `[role="main"]`, or `#main-content`) or the first `<h1>` heading. On back/forward navigation, focus is restored to the previously focused element when possible.
395
+
396
+
This ensures users navigating via keyboard don't lose their position in the document after navigation.
397
+
398
+
View Transitions are automatically skipped in browsers without support or when `prefers-reduced-motion` is enabled. Navigation continues to work normally without visual transitions.
399
+
400
+
## Scroll Restoration
401
+
402
+
Scroll positions are automatically saved before navigation and restored when using the browser back/forward buttons.
403
+
The navigate plugin maintains a map of scroll positions keyed by pathname.
404
+
405
+
For custom scroll containers, use the scroll plugin's history mode:
406
+
407
+
```html
408
+
<div data-volt-scroll="history" style="overflow-y: auto;">
409
+
<!-- scrollable content -->
410
+
</div>
411
+
```
412
+
413
+
This automatically saves and restores the scroll position of the container across navigations.
152
414
153
415
## Progressive Enhancement
154
416
155
417
- 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"`).
418
+
- Prefetch data when a link becomes visible by combining navigation events with `asyncEffect` or the `data-volt-navigate.prefetch` modifier.
419
+
- Use the scroll plugin's history mode for tall pages (`data-volt-scroll="history"`).
+3
lib/src/core/modifiers.ts
+3
lib/src/core/modifiers.ts
+305
lib/src/core/router.ts
+305
lib/src/core/router.ts
···
1
+
/**
2
+
* Route utilities for pattern matching and parameter extraction
3
+
*
4
+
* Provides utilities for dynamic route matching with support for:
5
+
* - Named parameters: /blog/:slug
6
+
* - Wildcard parameters: /files/*path
7
+
* - Optional parameters: /blog/:slug?
8
+
* - Multiple parameters: /users/:userId/posts/:postId
9
+
*/
10
+
11
+
import type { Optional } from "$types/helpers";
12
+
13
+
/**
14
+
* Route match result containing extracted parameters
15
+
*/
16
+
export type RouteMatch = { path: string; params: Record<string, string>; pattern: string };
17
+
18
+
/**
19
+
* Compiled route pattern for efficient matching
20
+
*/
21
+
type CompiledRoute = {
22
+
pattern: string;
23
+
regex: RegExp;
24
+
keys: Array<{ name: string; optional: boolean; wildcard: boolean }>;
25
+
};
26
+
27
+
const routeCache = new Map<string, CompiledRoute>();
28
+
29
+
/**
30
+
* Compile a route pattern into a regex for efficient matching
31
+
*
32
+
* Supported patterns:
33
+
* - /blog/:slug - Named parameter
34
+
* - /blog/:slug? - Optional parameter
35
+
* - /files/*path - Wildcard (matches rest of path)
36
+
* - /users/:userId/posts/:postId - Multiple parameters
37
+
*
38
+
* @param pattern - Route pattern to compile
39
+
* @returns Compiled route with regex and parameter keys
40
+
*
41
+
* @example
42
+
* ```typescript
43
+
* const route = compileRoute('/blog/:slug');
44
+
* const match = route.regex.exec('/blog/hello-world');
45
+
* // match[1] === 'hello-world'
46
+
* ```
47
+
*/
48
+
export function compileRoute(pattern: string): CompiledRoute {
49
+
if (routeCache.has(pattern)) {
50
+
return routeCache.get(pattern)!;
51
+
}
52
+
53
+
const keys: Array<{ name: string; optional: boolean; wildcard: boolean }> = [];
54
+
55
+
// Build regex pattern by processing each part
56
+
let regexPattern = "";
57
+
let i = 0;
58
+
59
+
while (i < pattern.length) {
60
+
// Check for parameter :name or :name?
61
+
if (pattern[i] === ":") {
62
+
const paramMatch = pattern.slice(i).match(/^:(\w+)(\?)?/);
63
+
if (paramMatch) {
64
+
const [fullMatch, name, optional] = paramMatch;
65
+
keys.push({ name, optional: Boolean(optional), wildcard: false });
66
+
67
+
if (optional) {
68
+
// For optional params, include the preceding / in the optional group
69
+
// Remove trailing / from regexPattern if present
70
+
if (regexPattern.endsWith("/")) {
71
+
regexPattern = regexPattern.slice(0, -1);
72
+
}
73
+
regexPattern += "(?:/([^/?]+))?";
74
+
} else {
75
+
// Required params: just the capture group (/ already processed)
76
+
regexPattern += "([^/?]+)";
77
+
}
78
+
79
+
i += fullMatch.length;
80
+
continue;
81
+
}
82
+
}
83
+
84
+
// Check for wildcard *name
85
+
if (pattern[i] === "*") {
86
+
const wildcardMatch = pattern.slice(i).match(/^\*(\w+)/);
87
+
if (wildcardMatch) {
88
+
const [fullMatch, name] = wildcardMatch;
89
+
keys.push({ name, optional: false, wildcard: true });
90
+
regexPattern += "(.*)";
91
+
i += fullMatch.length;
92
+
continue;
93
+
}
94
+
}
95
+
96
+
// Escape special regex characters for literal matching
97
+
const char = pattern[i];
98
+
if (".+?^${}()|[]\\".includes(char)) {
99
+
regexPattern += `\\${char}`;
100
+
} else {
101
+
regexPattern += char;
102
+
}
103
+
i++;
104
+
}
105
+
106
+
// Create regex with anchors
107
+
const regex = new RegExp(`^${regexPattern}$`);
108
+
109
+
const compiled: CompiledRoute = { pattern, regex, keys };
110
+
routeCache.set(pattern, compiled);
111
+
112
+
return compiled;
113
+
}
114
+
115
+
/**
116
+
* Match a path against a route pattern and extract parameters
117
+
*
118
+
* @param pattern - Route pattern (e.g., '/blog/:slug')
119
+
* @param path - Path to match (e.g., '/blog/hello-world')
120
+
* @returns RouteMatch with extracted params, or undefined if no match
121
+
*
122
+
* @example
123
+
* ```typescript
124
+
* const match = matchRoute('/blog/:slug', '/blog/hello-world');
125
+
* // { path: '/blog/hello-world', params: { slug: 'hello-world' }, pattern: '/blog/:slug' }
126
+
*
127
+
* const noMatch = matchRoute('/blog/:slug', '/about');
128
+
* // undefined
129
+
* ```
130
+
*/
131
+
export function matchRoute(pattern: string, path: string): Optional<RouteMatch> {
132
+
const compiled = compileRoute(pattern);
133
+
const match = compiled.regex.exec(path);
134
+
135
+
if (!match) {
136
+
return undefined;
137
+
}
138
+
139
+
const params: Record<string, string> = {};
140
+
141
+
for (const [index, key] of compiled.keys.entries()) {
142
+
const value = match[index + 1];
143
+
if (value !== undefined) {
144
+
params[key.name] = decodeURIComponent(value);
145
+
}
146
+
}
147
+
148
+
return { path, params, pattern };
149
+
}
150
+
151
+
/**
152
+
* Match a path against multiple route patterns and return the first match
153
+
*
154
+
* @param patterns - Array of route patterns to try
155
+
* @param path - Path to match
156
+
* @returns First matching RouteMatch, or undefined if no match
157
+
*
158
+
* @example
159
+
* ```typescript
160
+
* const routes = ['/blog/:slug', '/users/:id', '/about'];
161
+
* const match = matchRoutes(routes, '/users/123');
162
+
* // { path: '/users/123', params: { id: '123' }, pattern: '/users/:id' }
163
+
* ```
164
+
*/
165
+
export function matchRoutes(patterns: string[], path: string): Optional<RouteMatch> {
166
+
for (const pattern of patterns) {
167
+
const match = matchRoute(pattern, path);
168
+
if (match) {
169
+
return match;
170
+
}
171
+
}
172
+
return undefined;
173
+
}
174
+
175
+
/**
176
+
* Extract parameters from a path using a route pattern
177
+
*
178
+
* @param pattern - Route pattern with parameters
179
+
* @param path - Path to extract from
180
+
* @returns Object with extracted parameters, or empty object if no match
181
+
*
182
+
* @example
183
+
* ```typescript
184
+
* const params = extractParams('/blog/:slug', '/blog/hello-world');
185
+
* // { slug: 'hello-world' }
186
+
*
187
+
* const params2 = extractParams('/users/:userId/posts/:postId', '/users/42/posts/123');
188
+
* // { userId: '42', postId: '123' }
189
+
* ```
190
+
*/
191
+
export function extractParams(pattern: string, path: string): Record<string, string> {
192
+
const match = matchRoute(pattern, path);
193
+
return match ? match.params : {};
194
+
}
195
+
196
+
/**
197
+
* Build a path from a pattern by replacing parameters
198
+
*
199
+
* @param pattern - Route pattern with parameters
200
+
* @param params - Parameters to insert
201
+
* @returns Built path with parameters replaced
202
+
*
203
+
* @example
204
+
* ```typescript
205
+
* const path = buildPath('/blog/:slug', { slug: 'hello-world' });
206
+
* // '/blog/hello-world'
207
+
*
208
+
* const path2 = buildPath('/users/:userId/posts/:postId', { userId: '42', postId: '123' });
209
+
* // '/users/42/posts/123'
210
+
* ```
211
+
*/
212
+
export function buildPath(pattern: string, params: Record<string, string>): string {
213
+
let path = pattern;
214
+
215
+
// Replace named parameters
216
+
for (const [key, value] of Object.entries(params)) {
217
+
const encoded = encodeURIComponent(value);
218
+
path = path.replace(`:${key}?`, encoded).replace(`:${key}`, encoded);
219
+
}
220
+
221
+
// Remove optional parameters that weren't provided
222
+
path = path.replaceAll(/:(\w+)\?/g, "");
223
+
224
+
// Replace wildcards
225
+
for (const [key, value] of Object.entries(params)) {
226
+
path = path.replace(`*${key}`, value);
227
+
}
228
+
229
+
return path;
230
+
}
231
+
232
+
/**
233
+
* Check if a path matches a route pattern
234
+
*
235
+
* @param pattern - Route pattern
236
+
* @param path - Path to check
237
+
* @returns true if path matches pattern
238
+
*
239
+
* @example
240
+
* ```typescript
241
+
* isMatch('/blog/:slug', '/blog/hello-world'); // true
242
+
* isMatch('/blog/:slug', '/about'); // false
243
+
* ```
244
+
*/
245
+
export function isMatch(pattern: string, path: string): boolean {
246
+
return matchRoute(pattern, path) !== undefined;
247
+
}
248
+
249
+
/**
250
+
* Normalize a path by removing trailing slashes and ensuring leading slash
251
+
*
252
+
* @param path - Path to normalize
253
+
* @returns Normalized path
254
+
*
255
+
* @example
256
+
* ```typescript
257
+
* normalizePath('/blog/'); // '/blog'
258
+
* normalizePath('about'); // '/about'
259
+
* normalizePath('/'); // '/'
260
+
* ```
261
+
*/
262
+
export function normalizePath(path: string): string {
263
+
// Ensure leading slash
264
+
if (!path.startsWith("/")) {
265
+
path = `/${path}`;
266
+
}
267
+
268
+
// Remove trailing slash (except for root)
269
+
if (path.length > 1 && path.endsWith("/")) {
270
+
path = path.slice(0, -1);
271
+
}
272
+
273
+
return path;
274
+
}
275
+
276
+
/**
277
+
* Parse a URL into path and search params
278
+
*
279
+
* @param url - URL to parse (can be relative or absolute)
280
+
* @returns Object with path and searchParams
281
+
*
282
+
* @example
283
+
* ```typescript
284
+
* parseUrl('/blog?page=2&sort=date');
285
+
* // { path: '/blog', searchParams: URLSearchParams { 'page' => '2', 'sort' => 'date' } }
286
+
* ```
287
+
*/
288
+
export function parseUrl(url: string): { path: string; searchParams: URLSearchParams } {
289
+
try {
290
+
const urlObj = new URL(url, globalThis.location.origin);
291
+
return { path: urlObj.pathname, searchParams: urlObj.searchParams };
292
+
} catch {
293
+
// If URL parsing fails, treat as relative path
294
+
const [path, search] = url.split("?");
295
+
return { path: path || "/", searchParams: new URLSearchParams(search || "") };
296
+
}
297
+
}
298
+
299
+
/**
300
+
* Clear the route compilation cache
301
+
* Useful for testing or when patterns change dynamically
302
+
*/
303
+
export function clearRouteCache(): void {
304
+
routeCache.clear();
305
+
}
+13
lib/src/index.ts
+13
lib/src/index.ts
···
19
19
} from "$core/lifecycle";
20
20
export { clearPlugins, getRegisteredPlugins, hasPlugin, registerPlugin, unregisterPlugin } from "$core/plugin";
21
21
export { isReactive, reactive, toRaw } from "$core/reactive";
22
+
export {
23
+
buildPath,
24
+
clearRouteCache,
25
+
compileRoute,
26
+
extractParams,
27
+
isMatch,
28
+
matchRoute,
29
+
matchRoutes,
30
+
normalizePath,
31
+
parseUrl,
32
+
} from "$core/router";
33
+
export type { RouteMatch } from "$core/router";
22
34
export { getScopeMetadata } from "$core/scope-metadata";
23
35
export { computed, effect, signal } from "$core/signal";
24
36
export { deserializeScope, hydrate, isHydrated, isServerRendered, serializeScope } from "$core/ssr";
···
41
53
supportsViewTransitions,
42
54
withViewTransition,
43
55
} from "$core/view-transitions";
56
+
export { goBack, goForward, initNavigationListener, navigate, navigatePlugin, redirect } from "$plugins/navigate";
44
57
export { persistPlugin, registerStorageAdapter } from "$plugins/persist";
45
58
export { scrollPlugin } from "$plugins/scroll";
46
59
export {
+42
-2
lib/src/plugins/scroll.ts
+42
-2
lib/src/plugins/scroll.ts
···
8
8
/**
9
9
* Scroll plugin handler to manage various scroll-related behaviors.
10
10
*
11
-
* Syntax: data-volt-scroll="mode:signalPath"
11
+
* Syntax: data-volt-scroll="mode:signalPath" or data-volt-scroll="mode"
12
12
* Modes:
13
13
* - restore:signalPath - Save/restore scroll position
14
14
* - scrollTo:signalPath - Scroll to element when signal changes
15
15
* - spy:signalPath - Update signal when element is visible
16
16
* - smooth:signalPath - Enable smooth scrolling behavior
17
+
* - history - Integrate with navigation history (auto save/restore on navigation)
17
18
*/
18
19
export function scrollPlugin(ctx: PluginContext, value: string): void {
20
+
if (value === "history") {
21
+
handleScrollHistory(ctx);
22
+
return;
23
+
}
24
+
19
25
const parts = value.split(":");
20
26
if (parts.length !== 2) {
21
-
console.error(`Invalid scroll binding: "${value}". Expected format: "mode:signalPath"`);
27
+
console.error(`Invalid scroll binding: "${value}". Expected format: "mode:signalPath" or "history"`);
22
28
return;
23
29
}
24
30
···
157
163
element.style.scrollBehavior = "";
158
164
});
159
165
}
166
+
167
+
/**
168
+
* Integrate scroll position with browser history
169
+
* Automatically saves and restores scroll position on navigation
170
+
* Works with volt:navigate and volt:popstate events
171
+
*/
172
+
function handleScrollHistory(ctx: PluginContext): void {
173
+
const element = ctx.element as HTMLElement;
174
+
const scrollPositions = new Map<string, number>();
175
+
176
+
const handleNavigate = () => {
177
+
const key = `${globalThis.location.pathname}${globalThis.location.search}`;
178
+
scrollPositions.set(key, element.scrollTop);
179
+
};
180
+
181
+
const handlePopstate = () => {
182
+
const key = `${globalThis.location.pathname}${globalThis.location.search}`;
183
+
const savedPosition = scrollPositions.get(key);
184
+
185
+
if (savedPosition !== undefined) {
186
+
requestAnimationFrame(() => {
187
+
element.scrollTop = savedPosition;
188
+
});
189
+
}
190
+
};
191
+
192
+
globalThis.addEventListener("volt:navigate", handleNavigate);
193
+
globalThis.addEventListener("volt:popstate", handlePopstate);
194
+
195
+
ctx.addCleanup(() => {
196
+
globalThis.removeEventListener("volt:navigate", handleNavigate);
197
+
globalThis.removeEventListener("volt:popstate", handlePopstate);
198
+
});
199
+
}
+81
-5
lib/src/plugins/url.ts
+81
-5
lib/src/plugins/url.ts
···
9
9
10
10
/**
11
11
* URL plugin handler.
12
-
* Synchronizes signal values with URL parameters and hash.
12
+
* Synchronizes signal values with URL parameters, hash, and full history state.
13
13
*
14
-
* Syntax: data-volt-url="mode:signalPath"
14
+
* Syntax: data-volt-url="mode:signalPath" or data-volt-url="mode:signalPath:basePath"
15
15
* Modes:
16
16
* - read:signalPath - Read URL param into signal on mount (one-way)
17
17
* - sync:signalPath - Bidirectional sync between signal and URL param
18
18
* - hash:signalPath - Sync with hash portion for routing
19
+
* - history:signalPath[:basePath] - Sync with full path + search (History API routing)
19
20
*/
20
21
export function urlPlugin(ctx: PluginContext, value: string): void {
21
22
const parts = value.split(":");
22
-
if (parts.length !== 2) {
23
-
console.error(`Invalid url binding: "${value}". Expected format: "mode:signalPath"`);
23
+
if (parts.length < 2) {
24
+
console.error(`Invalid url binding: "${value}". Expected format: "mode:signalPath[:basePath]"`);
24
25
return;
25
26
}
26
27
27
-
const [mode, signalPath] = parts.map((p) => p.trim());
28
+
const [mode, signalPath, basePath] = parts.map((p) => p.trim());
28
29
29
30
switch (mode) {
30
31
case "read": {
···
37
38
}
38
39
case "hash": {
39
40
handleHashRouting(ctx, signalPath);
41
+
break;
42
+
}
43
+
case "history": {
44
+
handleHistoryRouting(ctx, signalPath, basePath);
40
45
break;
41
46
}
42
47
default: {
···
215
220
return value;
216
221
}
217
222
}
223
+
224
+
/**
225
+
* Sync signal with full path + search params for History API routing.
226
+
* Bidirectional sync between signal and window.location.pathname + search.
227
+
*
228
+
* @param ctx - Plugin context
229
+
* @param signalPath - Signal path to sync
230
+
* @param basePath - Optional base path to strip from routes (e.g., "/app")
231
+
*/
232
+
function handleHistoryRouting(ctx: PluginContext, signalPath: string, basePath?: string): void {
233
+
const signal = ctx.findSignal(signalPath);
234
+
if (!signal) {
235
+
console.error(`Signal "${signalPath}" not found for history routing`);
236
+
return;
237
+
}
238
+
239
+
const base = basePath || "";
240
+
const getCurrentRoute = (): string => {
241
+
const fullPath = globalThis.location.pathname + globalThis.location.search;
242
+
if (base && fullPath.startsWith(base)) {
243
+
return fullPath.slice(base.length) || "/";
244
+
}
245
+
return fullPath;
246
+
};
247
+
248
+
const currentRoute = getCurrentRoute();
249
+
if (currentRoute) {
250
+
(signal as Signal<string>).set(currentRoute);
251
+
}
252
+
253
+
let isUpdatingFromHistory = false;
254
+
255
+
const updateUrl = (value: unknown) => {
256
+
if (isUpdatingFromHistory) return;
257
+
258
+
const route = String(value ?? "/");
259
+
const fullPath = base ? `${base}${route}` : route;
260
+
261
+
if (globalThis.location.pathname + globalThis.location.search !== fullPath) {
262
+
globalThis.history.pushState({}, "", fullPath);
263
+
globalThis.dispatchEvent(
264
+
new CustomEvent("volt:navigate", { detail: { url: fullPath, route }, bubbles: true, cancelable: false }),
265
+
);
266
+
}
267
+
};
268
+
269
+
const handlePopState = () => {
270
+
isUpdatingFromHistory = true;
271
+
const route = getCurrentRoute();
272
+
(signal as Signal<string>).set(route);
273
+
globalThis.dispatchEvent(new CustomEvent("volt:popstate", { detail: { route }, bubbles: true, cancelable: false }));
274
+
isUpdatingFromHistory = false;
275
+
};
276
+
277
+
const handleNavigate = () => {
278
+
isUpdatingFromHistory = true;
279
+
const route = getCurrentRoute();
280
+
(signal as Signal<string>).set(route);
281
+
isUpdatingFromHistory = false;
282
+
};
283
+
284
+
const unsubscribe = signal.subscribe(updateUrl);
285
+
globalThis.addEventListener("popstate", handlePopState);
286
+
globalThis.addEventListener("volt:navigate", handleNavigate);
287
+
288
+
ctx.addCleanup(() => {
289
+
unsubscribe();
290
+
globalThis.removeEventListener("popstate", handlePopState);
291
+
globalThis.removeEventListener("volt:navigate", handleNavigate);
292
+
});
293
+
}
+400
lib/test/core/router.test.ts
+400
lib/test/core/router.test.ts
···
1
+
import {
2
+
buildPath,
3
+
clearRouteCache,
4
+
compileRoute,
5
+
extractParams,
6
+
isMatch,
7
+
matchRoute,
8
+
matchRoutes,
9
+
normalizePath,
10
+
parseUrl,
11
+
} from "$core/router";
12
+
import { beforeEach, describe, expect, it } from "vitest";
13
+
14
+
describe("router utilities", () => {
15
+
beforeEach(() => {
16
+
clearRouteCache();
17
+
});
18
+
19
+
describe("compileRoute", () => {
20
+
it("compiles a simple pattern with single parameter", () => {
21
+
const compiled = compileRoute("/blog/:slug");
22
+
expect(compiled.pattern).toBe("/blog/:slug");
23
+
expect(compiled.keys).toHaveLength(1);
24
+
expect(compiled.keys[0]).toEqual({ name: "slug", optional: false, wildcard: false });
25
+
});
26
+
27
+
it("compiles a pattern with multiple parameters", () => {
28
+
const compiled = compileRoute("/users/:userId/posts/:postId");
29
+
expect(compiled.keys).toHaveLength(2);
30
+
expect(compiled.keys[0]).toEqual({ name: "userId", optional: false, wildcard: false });
31
+
expect(compiled.keys[1]).toEqual({ name: "postId", optional: false, wildcard: false });
32
+
});
33
+
34
+
it("compiles a pattern with optional parameter", () => {
35
+
const compiled = compileRoute("/blog/:slug?");
36
+
expect(compiled.keys).toHaveLength(1);
37
+
expect(compiled.keys[0]).toEqual({ name: "slug", optional: true, wildcard: false });
38
+
});
39
+
40
+
it("compiles a pattern with wildcard parameter", () => {
41
+
const compiled = compileRoute("/files/*path");
42
+
expect(compiled.keys).toHaveLength(1);
43
+
expect(compiled.keys[0]).toEqual({ name: "path", optional: false, wildcard: true });
44
+
});
45
+
46
+
it("caches compiled routes", () => {
47
+
const first = compileRoute("/blog/:slug");
48
+
const second = compileRoute("/blog/:slug");
49
+
expect(first).toBe(second);
50
+
});
51
+
52
+
it("creates valid regex for pattern matching", () => {
53
+
const compiled = compileRoute("/blog/:slug");
54
+
expect(compiled.regex.test("/blog/hello-world")).toBe(true);
55
+
expect(compiled.regex.test("/about")).toBe(false);
56
+
});
57
+
});
58
+
59
+
describe("matchRoute", () => {
60
+
it("matches a simple route with one parameter", () => {
61
+
const match = matchRoute("/blog/:slug", "/blog/hello-world");
62
+
expect(match).toEqual({ path: "/blog/hello-world", params: { slug: "hello-world" }, pattern: "/blog/:slug" });
63
+
});
64
+
65
+
it("returns undefined for non-matching routes", () => {
66
+
const match = matchRoute("/blog/:slug", "/about");
67
+
expect(match).toBeUndefined();
68
+
});
69
+
70
+
it("matches routes with multiple parameters", () => {
71
+
const match = matchRoute("/users/:userId/posts/:postId", "/users/42/posts/123");
72
+
expect(match).toBeDefined();
73
+
expect(match!.params).toEqual({ userId: "42", postId: "123" });
74
+
});
75
+
76
+
it("matches optional parameters when present", () => {
77
+
const match = matchRoute("/blog/:category/:slug?", "/blog/tech/hello");
78
+
expect(match).toBeDefined();
79
+
expect(match!.params).toEqual({ category: "tech", slug: "hello" });
80
+
});
81
+
82
+
it("matches optional parameters when absent", () => {
83
+
const match = matchRoute("/blog/:category/:slug?", "/blog/tech");
84
+
expect(match).toBeDefined();
85
+
expect(match!.params).toEqual({ category: "tech" });
86
+
});
87
+
88
+
it("matches wildcard parameters", () => {
89
+
const match = matchRoute("/files/*path", "/files/docs/guide/intro.md");
90
+
expect(match).toBeDefined();
91
+
expect(match!.params).toEqual({ path: "docs/guide/intro.md" });
92
+
});
93
+
94
+
it("decodes URI components in parameters", () => {
95
+
const match = matchRoute("/blog/:slug", "/blog/hello%20world");
96
+
expect(match).toBeDefined();
97
+
expect(match!.params.slug).toBe("hello world");
98
+
});
99
+
100
+
it("handles static routes without parameters", () => {
101
+
const match = matchRoute("/about", "/about");
102
+
expect(match).toEqual({ path: "/about", params: {}, pattern: "/about" });
103
+
});
104
+
105
+
it("does not match partial paths", () => {
106
+
const match = matchRoute("/blog/:slug", "/blog/hello/extra");
107
+
expect(match).toBeUndefined();
108
+
});
109
+
110
+
it("handles routes with special characters", () => {
111
+
const match = matchRoute("/api/v1/users/:id", "/api/v1/users/42");
112
+
expect(match).toBeDefined();
113
+
expect(match!.params).toEqual({ id: "42" });
114
+
});
115
+
});
116
+
117
+
describe("matchRoutes", () => {
118
+
it("returns first matching route", () => {
119
+
const patterns = ["/about", "/blog/:slug", "/users/:id"];
120
+
const match = matchRoutes(patterns, "/blog/hello-world");
121
+
expect(match).toBeDefined();
122
+
expect(match!.pattern).toBe("/blog/:slug");
123
+
expect(match!.params).toEqual({ slug: "hello-world" });
124
+
});
125
+
126
+
it("tries patterns in order", () => {
127
+
const patterns = ["/blog/:slug", "/blog/featured"];
128
+
const match = matchRoutes(patterns, "/blog/featured");
129
+
expect(match).toBeDefined();
130
+
expect(match!.pattern).toBe("/blog/:slug");
131
+
expect(match!.params).toEqual({ slug: "featured" });
132
+
});
133
+
134
+
it("returns undefined when no routes match", () => {
135
+
const patterns = ["/about", "/blog/:slug"];
136
+
const match = matchRoutes(patterns, "/products");
137
+
expect(match).toBeUndefined();
138
+
});
139
+
140
+
it("handles empty pattern array", () => {
141
+
const match = matchRoutes([], "/any-path");
142
+
expect(match).toBeUndefined();
143
+
});
144
+
});
145
+
146
+
describe("extractParams", () => {
147
+
it("extracts parameters from matching route", () => {
148
+
const params = extractParams("/blog/:slug", "/blog/hello-world");
149
+
expect(params).toEqual({ slug: "hello-world" });
150
+
});
151
+
152
+
it("returns empty object for non-matching route", () => {
153
+
const params = extractParams("/blog/:slug", "/about");
154
+
expect(params).toEqual({});
155
+
});
156
+
157
+
it("extracts multiple parameters", () => {
158
+
const params = extractParams("/users/:userId/posts/:postId", "/users/42/posts/123");
159
+
expect(params).toEqual({ userId: "42", postId: "123" });
160
+
});
161
+
162
+
it("handles optional parameters", () => {
163
+
const params1 = extractParams("/blog/:category/:slug?", "/blog/tech/hello");
164
+
expect(params1).toEqual({ category: "tech", slug: "hello" });
165
+
166
+
const params2 = extractParams("/blog/:category/:slug?", "/blog/tech");
167
+
expect(params2).toEqual({ category: "tech" });
168
+
});
169
+
170
+
it("extracts wildcard parameters", () => {
171
+
const params = extractParams("/files/*path", "/files/docs/guide.md");
172
+
expect(params).toEqual({ path: "docs/guide.md" });
173
+
});
174
+
});
175
+
176
+
describe("buildPath", () => {
177
+
it("builds path from pattern with single parameter", () => {
178
+
const path = buildPath("/blog/:slug", { slug: "hello-world" });
179
+
expect(path).toBe("/blog/hello-world");
180
+
});
181
+
182
+
it("builds path from pattern with multiple parameters", () => {
183
+
const path = buildPath("/users/:userId/posts/:postId", { userId: "42", postId: "123" });
184
+
expect(path).toBe("/users/42/posts/123");
185
+
});
186
+
187
+
it("URL-encodes parameter values", () => {
188
+
const path = buildPath("/blog/:slug", { slug: "hello world" });
189
+
expect(path).toBe("/blog/hello%20world");
190
+
});
191
+
192
+
it("handles optional parameters when provided", () => {
193
+
const path = buildPath("/blog/:category/:slug?", { category: "tech", slug: "hello" });
194
+
expect(path).toBe("/blog/tech/hello");
195
+
});
196
+
197
+
it("removes optional parameters when not provided", () => {
198
+
const path = buildPath("/blog/:category/:slug?", { category: "tech" });
199
+
expect(path).toBe("/blog/tech/");
200
+
});
201
+
202
+
it("builds path with wildcard parameters", () => {
203
+
const path = buildPath("/files/*path", { path: "docs/guide.md" });
204
+
expect(path).toBe("/files/docs/guide.md");
205
+
});
206
+
207
+
it("handles special characters in parameters", () => {
208
+
const path = buildPath("/search/:query", { query: "hello+world" });
209
+
expect(path).toBe("/search/hello%2Bworld");
210
+
});
211
+
212
+
it("leaves unmatched placeholders as-is", () => {
213
+
const path = buildPath("/users/:userId/posts/:postId", { userId: "42" });
214
+
expect(path).toContain(":postId");
215
+
});
216
+
});
217
+
218
+
describe("isMatch", () => {
219
+
it("returns true for matching routes", () => {
220
+
expect(isMatch("/blog/:slug", "/blog/hello-world")).toBe(true);
221
+
});
222
+
223
+
it("returns false for non-matching routes", () => {
224
+
expect(isMatch("/blog/:slug", "/about")).toBe(false);
225
+
});
226
+
227
+
it("works with complex patterns", () => {
228
+
expect(isMatch("/users/:userId/posts/:postId", "/users/42/posts/123")).toBe(true);
229
+
expect(isMatch("/users/:userId/posts/:postId", "/users/42")).toBe(false);
230
+
});
231
+
232
+
it("handles optional parameters", () => {
233
+
expect(isMatch("/blog/:category/:slug?", "/blog/tech/hello")).toBe(true);
234
+
expect(isMatch("/blog/:category/:slug?", "/blog/tech")).toBe(true);
235
+
});
236
+
237
+
it("handles wildcards", () => {
238
+
expect(isMatch("/files/*path", "/files/docs/guide/intro.md")).toBe(true);
239
+
});
240
+
});
241
+
242
+
describe("normalizePath", () => {
243
+
it("ensures leading slash", () => {
244
+
expect(normalizePath("about")).toBe("/about");
245
+
expect(normalizePath("blog/hello")).toBe("/blog/hello");
246
+
});
247
+
248
+
it("removes trailing slash", () => {
249
+
expect(normalizePath("/blog/")).toBe("/blog");
250
+
expect(normalizePath("/about/us/")).toBe("/about/us");
251
+
});
252
+
253
+
it("preserves root path", () => {
254
+
expect(normalizePath("/")).toBe("/");
255
+
});
256
+
257
+
it("handles already normalized paths", () => {
258
+
expect(normalizePath("/blog")).toBe("/blog");
259
+
});
260
+
261
+
it("handles empty string", () => {
262
+
expect(normalizePath("")).toBe("/");
263
+
});
264
+
265
+
it("normalizes complex paths", () => {
266
+
expect(normalizePath("users/42/posts/")).toBe("/users/42/posts");
267
+
});
268
+
});
269
+
270
+
describe("parseUrl", () => {
271
+
it("parses path from simple URL", () => {
272
+
const parsed = parseUrl("/blog");
273
+
expect(parsed.path).toBe("/blog");
274
+
expect(parsed.searchParams.toString()).toBe("");
275
+
});
276
+
277
+
it("parses path and search params", () => {
278
+
const parsed = parseUrl("/blog?page=2&sort=date");
279
+
expect(parsed.path).toBe("/blog");
280
+
expect(parsed.searchParams.get("page")).toBe("2");
281
+
expect(parsed.searchParams.get("sort")).toBe("date");
282
+
});
283
+
284
+
it("handles absolute URLs", () => {
285
+
const parsed = parseUrl("https://example.com/blog?page=2");
286
+
expect(parsed.path).toBe("/blog");
287
+
expect(parsed.searchParams.get("page")).toBe("2");
288
+
});
289
+
290
+
it("handles URLs without search params", () => {
291
+
const parsed = parseUrl("/users/42/posts/123");
292
+
expect(parsed.path).toBe("/users/42/posts/123");
293
+
expect(parsed.searchParams.toString()).toBe("");
294
+
});
295
+
296
+
it("handles root path", () => {
297
+
const parsed = parseUrl("/");
298
+
expect(parsed.path).toBe("/");
299
+
});
300
+
301
+
it("handles URL with hash", () => {
302
+
const parsed = parseUrl("/blog?page=2#comments");
303
+
expect(parsed.path).toBe("/blog");
304
+
expect(parsed.searchParams.get("page")).toBe("2");
305
+
});
306
+
307
+
it("handles malformed URLs gracefully", () => {
308
+
const parsed = parseUrl("not-a-url?with=params");
309
+
expect(parsed.path).toBe("/not-a-url");
310
+
expect(parsed.searchParams.get("with")).toBe("params");
311
+
});
312
+
313
+
it("handles URL with multiple query params", () => {
314
+
const parsed = parseUrl("/search?q=test&category=tech&sort=date");
315
+
expect(parsed.searchParams.get("q")).toBe("test");
316
+
expect(parsed.searchParams.get("category")).toBe("tech");
317
+
expect(parsed.searchParams.get("sort")).toBe("date");
318
+
});
319
+
});
320
+
321
+
describe("clearRouteCache", () => {
322
+
it("clears the compilation cache", () => {
323
+
const first = compileRoute("/blog/:slug");
324
+
clearRouteCache();
325
+
const second = compileRoute("/blog/:slug");
326
+
327
+
expect(first).not.toBe(second);
328
+
expect(first.pattern).toBe(second.pattern);
329
+
expect(first.keys).toEqual(second.keys);
330
+
});
331
+
});
332
+
333
+
describe("complex routing scenarios", () => {
334
+
it("handles nested optional parameters", () => {
335
+
const match = matchRoute("/blog/:category?/:slug?", "/blog");
336
+
expect(match?.params).toEqual({});
337
+
});
338
+
339
+
it("handles routes with dots in path", () => {
340
+
const match = matchRoute("/files/:filename", "/files/document.pdf");
341
+
expect(match?.params).toEqual({ filename: "document.pdf" });
342
+
});
343
+
344
+
it("handles routes with hyphens and underscores", () => {
345
+
const match = matchRoute("/api/:resource_type", "/api/user-profile");
346
+
expect(match?.params).toEqual({ resource_type: "user-profile" });
347
+
});
348
+
349
+
it("matches exact routes before parameterized routes", () => {
350
+
const patterns = ["/blog/featured", "/blog/:slug"];
351
+
const match1 = matchRoutes(patterns, "/blog/featured");
352
+
expect(match1?.pattern).toBe("/blog/featured");
353
+
354
+
const match2 = matchRoutes(patterns, "/blog/other");
355
+
expect(match2?.pattern).toBe("/blog/:slug");
356
+
});
357
+
358
+
it("handles building paths with missing optional params", () => {
359
+
const path = buildPath("/blog/:year?/:month?/:slug", { slug: "hello" });
360
+
expect(path).not.toContain(":year");
361
+
expect(path).not.toContain(":month");
362
+
});
363
+
364
+
it("handles URL encoding in both directions", () => {
365
+
const encoded = buildPath("/search/:query", { query: "hello world" });
366
+
expect(encoded).toBe("/search/hello%20world");
367
+
368
+
const match = matchRoute("/search/:query", encoded);
369
+
expect(match?.params.query).toBe("hello world");
370
+
});
371
+
});
372
+
373
+
describe("edge cases", () => {
374
+
it("handles empty pattern", () => {
375
+
const match = matchRoute("", "/any-path");
376
+
expect(match).toBeUndefined();
377
+
});
378
+
379
+
it("handles pattern with only slashes", () => {
380
+
const match = matchRoute("///", "/");
381
+
expect(match).toBeUndefined();
382
+
});
383
+
384
+
it("handles path with trailing query string", () => {
385
+
const match = matchRoute("/blog/:slug", "/blog/hello?extra=param");
386
+
expect(match).toBeUndefined();
387
+
});
388
+
389
+
it("handles numeric parameter values", () => {
390
+
const match = matchRoute("/users/:id", "/users/12345");
391
+
expect(match?.params.id).toBe("12345");
392
+
expect(typeof match?.params.id).toBe("string");
393
+
});
394
+
395
+
it("preserves parameter order", () => {
396
+
const params = extractParams("/a/:first/b/:second/c/:third", "/a/1/b/2/c/3");
397
+
expect(Object.keys(params)).toEqual(["first", "second", "third"]);
398
+
});
399
+
});
400
+
});
+122
-25
lib/test/plugins/scroll.test.ts
+122
-25
lib/test/plugins/scroll.test.ts
···
17
17
18
18
const scrollPos = signal(250);
19
19
mount(element, { scrollPos });
20
-
21
20
expect(element.scrollTop).toBe(250);
22
21
});
23
22
···
42
41
43
42
const scrollPos = signal("not a number" as unknown as number);
44
43
mount(element, { scrollPos });
45
-
46
44
expect(element.scrollTop).toBe(0);
47
45
});
48
46
···
108
106
109
107
const targetId = signal("otherSection");
110
108
mount(element, { targetId });
111
-
112
109
expect(scrollIntoViewMock).not.toHaveBeenCalled();
113
110
});
114
111
···
122
119
123
120
const targetId = signal("section1");
124
121
mount(element, { targetId });
125
-
126
122
expect(scrollIntoViewMock).toHaveBeenCalledOnce();
127
123
});
128
124
});
···
133
129
element.dataset.voltScroll = "spy:isVisible";
134
130
135
131
const isVisible = signal(false);
136
-
137
132
let observerCallback!: IntersectionObserverCallback;
138
133
const mockObserver = {
139
134
observe: vi.fn(),
···
151
146
}) as unknown as typeof IntersectionObserver;
152
147
153
148
mount(element, { isVisible });
154
-
155
149
expect(mockObserver.observe).toHaveBeenCalledWith(element);
156
-
157
150
observerCallback(
158
151
[{ isIntersecting: true, target: element } as unknown as IntersectionObserverEntry],
159
152
mockObserver as IntersectionObserver,
160
153
);
161
154
162
155
expect(isVisible.get()).toBe(true);
163
-
164
156
observerCallback(
165
157
[{ isIntersecting: false, target: element } as unknown as IntersectionObserverEntry],
166
158
mockObserver as IntersectionObserver,
167
159
);
168
-
169
160
expect(isVisible.get()).toBe(false);
170
161
});
171
162
···
174
165
element.dataset.voltScroll = "spy:isVisible";
175
166
176
167
const isVisible = signal(false);
177
-
178
168
const mockObserver = {
179
169
observe: vi.fn(),
180
170
disconnect: vi.fn(),
···
185
175
thresholds: [],
186
176
};
187
177
188
-
(globalThis as typeof globalThis).IntersectionObserver = vi.fn(() => {
189
-
return mockObserver;
190
-
}) as unknown as typeof IntersectionObserver;
178
+
(globalThis as typeof globalThis).IntersectionObserver = vi.fn(() =>
179
+
mockObserver
180
+
) as unknown as typeof IntersectionObserver;
191
181
192
182
const cleanup = mount(element, { isVisible });
193
-
194
183
cleanup();
195
-
196
184
expect(mockObserver.disconnect).toHaveBeenCalled();
197
185
});
198
186
});
···
204
192
205
193
const smoothScroll = signal(true);
206
194
mount(element, { smoothScroll });
207
-
208
195
expect(element.style.scrollBehavior).toBe("smooth");
209
196
});
210
197
···
214
201
215
202
const smoothScroll = signal("smooth");
216
203
mount(element, { smoothScroll });
217
-
218
204
expect(element.style.scrollBehavior).toBe("smooth");
219
205
});
220
206
···
260
246
261
247
const smoothScroll = signal(true);
262
248
const cleanup = mount(element, { smoothScroll });
249
+
expect(element.style.scrollBehavior).toBe("smooth");
250
+
cleanup();
251
+
expect(element.style.scrollBehavior).toBe("");
252
+
});
253
+
});
254
+
255
+
describe("history mode", () => {
256
+
it("saves scroll position on volt:navigate event", () => {
257
+
const element = document.createElement("div");
258
+
element.dataset.voltScroll = "history";
259
+
Object.defineProperty(element, "scrollTop", { writable: true, value: 150 });
260
+
261
+
mount(element, {});
262
+
globalThis.dispatchEvent(new CustomEvent("volt:navigate", { detail: { url: "/page1" } }));
263
+
expect(element.dataset.voltScroll).toBe("history");
264
+
});
265
+
266
+
it("restores scroll position on volt:popstate event", async () => {
267
+
const element = document.createElement("div");
268
+
element.dataset.voltScroll = "history";
269
+
270
+
let currentScrollTop = 0;
271
+
Object.defineProperty(element, "scrollTop", {
272
+
get() {
273
+
return currentScrollTop;
274
+
},
275
+
set(value) {
276
+
currentScrollTop = value;
277
+
},
278
+
configurable: true,
279
+
});
280
+
281
+
mount(element, {});
282
+
283
+
globalThis.history.replaceState({}, "", "/page1");
284
+
element.scrollTop = 300;
285
+
globalThis.dispatchEvent(new CustomEvent("volt:navigate", { detail: { url: "/page1" } }));
286
+
287
+
globalThis.history.replaceState({}, "", "/page2");
288
+
element.scrollTop = 0;
289
+
globalThis.dispatchEvent(new CustomEvent("volt:navigate", { detail: { url: "/page2" } }));
290
+
291
+
globalThis.history.replaceState({}, "", "/page1");
292
+
element.scrollTop = 0;
293
+
globalThis.dispatchEvent(new CustomEvent("volt:popstate", { detail: { state: {} } }));
294
+
295
+
await new Promise((resolve) => requestAnimationFrame(resolve));
296
+
297
+
expect(element.scrollTop).toBe(300);
298
+
});
299
+
300
+
it("handles multiple navigation cycles correctly", async () => {
301
+
const element = document.createElement("div");
302
+
element.dataset.voltScroll = "history";
263
303
264
-
expect(element.style.scrollBehavior).toBe("smooth");
304
+
let currentScrollTop = 0;
305
+
Object.defineProperty(element, "scrollTop", {
306
+
get() {
307
+
return currentScrollTop;
308
+
},
309
+
set(value) {
310
+
currentScrollTop = value;
311
+
},
312
+
configurable: true,
313
+
});
314
+
315
+
mount(element, {});
316
+
317
+
globalThis.history.replaceState({}, "", "/page1");
318
+
element.scrollTop = 100;
319
+
globalThis.dispatchEvent(new CustomEvent("volt:navigate", { detail: { url: "/page1" } }));
320
+
321
+
globalThis.history.replaceState({}, "", "/page2");
322
+
element.scrollTop = 200;
323
+
globalThis.dispatchEvent(new CustomEvent("volt:navigate", { detail: { url: "/page2" } }));
324
+
325
+
globalThis.history.replaceState({}, "", "/page3");
326
+
element.scrollTop = 300;
327
+
globalThis.dispatchEvent(new CustomEvent("volt:navigate", { detail: { url: "/page3" } }));
328
+
329
+
globalThis.history.replaceState({}, "", "/page2");
330
+
element.scrollTop = 0;
331
+
globalThis.dispatchEvent(new CustomEvent("volt:popstate", { detail: { state: {} } }));
332
+
await new Promise((resolve) => requestAnimationFrame(resolve));
333
+
expect(element.scrollTop).toBe(200);
334
+
335
+
globalThis.history.replaceState({}, "", "/page1");
336
+
element.scrollTop = 0;
337
+
globalThis.dispatchEvent(new CustomEvent("volt:popstate", { detail: { state: {} } }));
338
+
await new Promise((resolve) => requestAnimationFrame(resolve));
339
+
expect(element.scrollTop).toBe(100);
340
+
});
341
+
342
+
it("cleans up event listeners on unmount", () => {
343
+
const element = document.createElement("div");
344
+
element.dataset.voltScroll = "history";
345
+
346
+
const cleanup = mount(element, {});
347
+
348
+
Object.defineProperty(element, "scrollTop", { writable: true, value: 100 });
349
+
globalThis.dispatchEvent(new CustomEvent("volt:navigate"));
265
350
266
351
cleanup();
267
352
268
-
expect(element.style.scrollBehavior).toBe("");
353
+
Object.defineProperty(element, "scrollTop", { writable: true, value: 200 });
354
+
globalThis.dispatchEvent(new CustomEvent("volt:navigate"));
355
+
356
+
expect(element.scrollTop).toBe(200);
357
+
});
358
+
359
+
it("does not restore scroll position if not previously saved", async () => {
360
+
const element = document.createElement("div");
361
+
element.dataset.voltScroll = "history";
362
+
Object.defineProperty(element, "scrollTop", { writable: true, value: 50 });
363
+
364
+
mount(element, {});
365
+
366
+
globalThis.history.replaceState({}, "", "/new-page");
367
+
globalThis.dispatchEvent(new CustomEvent("volt:popstate", { detail: { state: {} } }));
368
+
369
+
await new Promise((resolve) => requestAnimationFrame(resolve));
370
+
371
+
expect(element.scrollTop).toBe(50);
269
372
});
270
373
});
271
374
···
276
379
element.dataset.voltScroll = "invalidformat";
277
380
278
381
mount(element, {});
279
-
280
382
expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining("Invalid scroll binding"));
281
-
282
383
errorSpy.mockRestore();
283
384
});
284
385
···
288
389
element.dataset.voltScroll = "unknown:signal";
289
390
290
391
mount(element, {});
291
-
292
392
expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining("Unknown scroll mode: \"unknown\""));
293
-
294
393
errorSpy.mockRestore();
295
394
});
296
395
···
300
399
element.dataset.voltScroll = "restore:nonexistent";
301
400
302
401
mount(element, {});
303
-
304
402
expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining("Signal \"nonexistent\" not found"));
305
-
306
403
errorSpy.mockRestore();
307
404
});
308
405
});
+269
lib/test/plugins/url.test.ts
+269
lib/test/plugins/url.test.ts
···
275
275
});
276
276
});
277
277
278
+
describe("history mode", () => {
279
+
it("reads current pathname and search into signal on mount", () => {
280
+
globalThis.history.replaceState({}, "", "/products?category=electronics");
281
+
282
+
const element = document.createElement("div");
283
+
element.dataset.voltUrl = "history:route";
284
+
285
+
const route = signal("");
286
+
mount(element, { route });
287
+
288
+
expect(route.get()).toBe("/products?category=electronics");
289
+
});
290
+
291
+
it("initializes to root path when on root", () => {
292
+
globalThis.history.replaceState({}, "", "/");
293
+
294
+
const element = document.createElement("div");
295
+
element.dataset.voltUrl = "history:route";
296
+
297
+
const route = signal("");
298
+
mount(element, { route });
299
+
300
+
expect(route.get()).toBe("/");
301
+
});
302
+
303
+
it("updates URL when signal changes", () => {
304
+
globalThis.history.replaceState({}, "", "/");
305
+
306
+
const element = document.createElement("div");
307
+
element.dataset.voltUrl = "history:route";
308
+
309
+
const route = signal("");
310
+
mount(element, { route });
311
+
312
+
route.set("/dashboard");
313
+
314
+
expect(globalThis.location.pathname).toBe("/dashboard");
315
+
});
316
+
317
+
it("preserves search params when updating path", () => {
318
+
globalThis.history.replaceState({}, "", "/");
319
+
320
+
const element = document.createElement("div");
321
+
element.dataset.voltUrl = "history:route";
322
+
323
+
const route = signal("");
324
+
mount(element, { route });
325
+
326
+
route.set("/search?q=test&page=2");
327
+
328
+
expect(globalThis.location.pathname).toBe("/search");
329
+
expect(globalThis.location.search).toBe("?q=test&page=2");
330
+
});
331
+
332
+
it("handles base path configuration", () => {
333
+
globalThis.history.replaceState({}, "", "/app/dashboard");
334
+
335
+
const element = document.createElement("div");
336
+
element.dataset.voltUrl = "history:route:/app";
337
+
338
+
const route = signal("");
339
+
mount(element, { route });
340
+
341
+
expect(route.get()).toBe("/dashboard");
342
+
});
343
+
344
+
it("prepends base path when updating URL", () => {
345
+
globalThis.history.replaceState({}, "", "/app");
346
+
347
+
const element = document.createElement("div");
348
+
element.dataset.voltUrl = "history:route:/app";
349
+
350
+
const route = signal("");
351
+
mount(element, { route });
352
+
353
+
route.set("/settings");
354
+
355
+
expect(globalThis.location.pathname).toBe("/app/settings");
356
+
});
357
+
358
+
it("handles base path with trailing slash", () => {
359
+
globalThis.history.replaceState({}, "", "/myapp/profile");
360
+
361
+
const element = document.createElement("div");
362
+
element.dataset.voltUrl = "history:route:/myapp";
363
+
364
+
const route = signal("");
365
+
mount(element, { route });
366
+
367
+
expect(route.get()).toBe("/profile");
368
+
});
369
+
370
+
it("returns root when on base path", () => {
371
+
globalThis.history.replaceState({}, "", "/app");
372
+
373
+
const element = document.createElement("div");
374
+
element.dataset.voltUrl = "history:route:/app";
375
+
376
+
const route = signal("");
377
+
mount(element, { route });
378
+
379
+
expect(route.get()).toBe("/");
380
+
});
381
+
382
+
it("dispatches volt:navigate event when signal changes", () => {
383
+
const navigateHandler = vi.fn();
384
+
globalThis.addEventListener("volt:navigate", navigateHandler);
385
+
386
+
const element = document.createElement("div");
387
+
element.dataset.voltUrl = "history:route";
388
+
389
+
const route = signal("/");
390
+
mount(element, { route });
391
+
392
+
route.set("/about");
393
+
394
+
expect(navigateHandler).toHaveBeenCalled();
395
+
const event = navigateHandler.mock.calls[0][0] as CustomEvent;
396
+
expect(event.detail.url).toBe("/about");
397
+
expect(event.detail.route).toBe("/about");
398
+
399
+
globalThis.removeEventListener("volt:navigate", navigateHandler);
400
+
});
401
+
402
+
it("handles popstate events from browser navigation", () => {
403
+
globalThis.history.replaceState({}, "", "/page1");
404
+
405
+
const element = document.createElement("div");
406
+
element.dataset.voltUrl = "history:route";
407
+
408
+
const route = signal("");
409
+
mount(element, { route });
410
+
411
+
expect(route.get()).toBe("/page1");
412
+
413
+
globalThis.history.replaceState({}, "", "/page2");
414
+
globalThis.dispatchEvent(new PopStateEvent("popstate"));
415
+
416
+
expect(route.get()).toBe("/page2");
417
+
});
418
+
419
+
it("dispatches volt:popstate event on back/forward navigation", () => {
420
+
const popstateHandler = vi.fn();
421
+
globalThis.addEventListener("volt:popstate", popstateHandler);
422
+
423
+
const element = document.createElement("div");
424
+
element.dataset.voltUrl = "history:route";
425
+
426
+
const route = signal("/");
427
+
mount(element, { route });
428
+
429
+
globalThis.history.replaceState({}, "", "/other");
430
+
globalThis.dispatchEvent(new PopStateEvent("popstate"));
431
+
432
+
expect(popstateHandler).toHaveBeenCalled();
433
+
const event = popstateHandler.mock.calls[0][0] as CustomEvent;
434
+
expect(event.detail.route).toBe("/other");
435
+
436
+
globalThis.removeEventListener("volt:popstate", popstateHandler);
437
+
});
438
+
439
+
it("syncs with volt:navigate events from navigate plugin", () => {
440
+
const element = document.createElement("div");
441
+
element.dataset.voltUrl = "history:route";
442
+
443
+
const route = signal("/");
444
+
mount(element, { route });
445
+
446
+
globalThis.history.pushState({}, "", "/external-nav");
447
+
globalThis.dispatchEvent(new CustomEvent("volt:navigate", { detail: { url: "/external-nav" } }));
448
+
expect(route.get()).toBe("/external-nav");
449
+
});
450
+
451
+
it("does not update URL when already at target route", () => {
452
+
globalThis.history.replaceState({}, "", "/current");
453
+
454
+
const pushStateSpy = vi.spyOn(globalThis.history, "pushState");
455
+
456
+
const element = document.createElement("div");
457
+
element.dataset.voltUrl = "history:route";
458
+
459
+
const route = signal("");
460
+
mount(element, { route });
461
+
462
+
pushStateSpy.mockClear();
463
+
464
+
route.set("/current");
465
+
466
+
expect(pushStateSpy).not.toHaveBeenCalled();
467
+
468
+
pushStateSpy.mockRestore();
469
+
});
470
+
471
+
it("prevents infinite loops between signal and URL updates", () => {
472
+
const element = document.createElement("div");
473
+
element.dataset.voltUrl = "history:route";
474
+
475
+
const route = signal("/");
476
+
const subscribeSpy = vi.fn();
477
+
route.subscribe(subscribeSpy);
478
+
479
+
mount(element, { route });
480
+
481
+
subscribeSpy.mockClear();
482
+
483
+
route.set("/test");
484
+
485
+
globalThis.dispatchEvent(new PopStateEvent("popstate"));
486
+
487
+
expect(subscribeSpy).toHaveBeenCalledTimes(1);
488
+
});
489
+
490
+
it("cleans up listeners on unmount", () => {
491
+
globalThis.history.replaceState({}, "", "/initial");
492
+
493
+
const element = document.createElement("div");
494
+
element.dataset.voltUrl = "history:route";
495
+
496
+
const route = signal("");
497
+
const cleanup = mount(element, { route });
498
+
499
+
expect(route.get()).toBe("/initial");
500
+
501
+
cleanup();
502
+
503
+
globalThis.history.replaceState({}, "", "/changed");
504
+
globalThis.dispatchEvent(new PopStateEvent("popstate"));
505
+
506
+
expect(route.get()).toBe("/initial");
507
+
});
508
+
509
+
it("handles complex routes with multiple path segments", () => {
510
+
globalThis.history.replaceState({}, "", "/blog/2024/introducing-volt");
511
+
512
+
const element = document.createElement("div");
513
+
element.dataset.voltUrl = "history:route";
514
+
515
+
const route = signal("");
516
+
mount(element, { route });
517
+
518
+
expect(route.get()).toBe("/blog/2024/introducing-volt");
519
+
});
520
+
521
+
it("handles routes with query parameters", () => {
522
+
globalThis.history.replaceState({}, "", "/search?q=reactive&lang=ts");
523
+
524
+
const element = document.createElement("div");
525
+
element.dataset.voltUrl = "history:route";
526
+
527
+
const route = signal("");
528
+
mount(element, { route });
529
+
530
+
expect(route.get()).toBe("/search?q=reactive&lang=ts");
531
+
});
532
+
533
+
it("logs error when signal not found", () => {
534
+
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
535
+
536
+
const element = document.createElement("div");
537
+
element.dataset.voltUrl = "history:nonexistent";
538
+
539
+
mount(element, {});
540
+
541
+
expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining("Signal \"nonexistent\" not found"));
542
+
543
+
errorSpy.mockRestore();
544
+
});
545
+
});
546
+
278
547
describe("error handling", () => {
279
548
it("logs error for invalid binding format", () => {
280
549
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});