a reactive (signals based) hypermedia web framework (wip) stormlightlabs.github.io/volt/
hypermedia frontend signals

feat: navigation & history API routing (#6)

* feat: core navigation & enhanced url plugin
* feat: scroll + nav integration
* completed docs for feature

authored by Owais and committed by GitHub f9362a7d b1cf4294

+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
+82
docs/plugins/navigate.md
··· 1 + --- 2 + outline: deep 3 + --- 4 + 5 + # Navigate Plugin 6 + 7 + The navigate plugin upgrades plain links and forms with client-side navigation, History API integration, and optional 8 + View Transition animations. It keeps your DOM-driven pages feeling app-like without giving up regular hyperlinks. 9 + 10 + ## Quick Start 11 + 12 + ```html 13 + <!-- Link-based navigation --> 14 + <a href="/about" data-volt-navigate>About</a> 15 + 16 + <!-- Form submissions (GET only) --> 17 + <form action="/search" method="get" data-volt-navigate> 18 + <input name="q" placeholder="Search..." /> 19 + <button type="submit">Go</button> 20 + </form> 21 + ``` 22 + 23 + `data-volt-navigate` applies to `<a>` and `<form>` elements. Links use their `href`; forms default to `action` (or the current pathname) and serialize inputs into the query string for GET submissions. 24 + 25 + ## Modifiers 26 + 27 + Attach modifiers with dot notation or suffixed attribute names (`data-volt-navigate-replace`). 28 + 29 + - `replace` - call `history.replaceState` instead of `pushState`; good for redirects or idempotent flows. 30 + - `prefetch` - issue a `<link rel="prefetch">` when the element is hovered or focused to warm the cache. 31 + - `notransition` - skip View Transition API usage, falling back to an immediate DOM swap. 32 + 33 + ```html 34 + <a href="/settings" data-volt-navigate-notransition>Settings</a> 35 + <a href="/pricing" data-volt-navigate-prefetch>Pricing</a> 36 + <a href="/welcome" data-volt-navigate-replace>Skip intro</a> 37 + ``` 38 + 39 + ## View Transitions 40 + 41 + By default the plugin wraps navigations in `startViewTransition` using the `"page-transition"` name. Use the 42 + `notransition` modifier to disable it per element, or switch names when navigating imperatively: 43 + 44 + ```ts 45 + import { navigate } from "volt/plugins/navigate"; 46 + 47 + await navigate("/projects/42", { transitionName: "project-detail" }); 48 + ``` 49 + 50 + ## Programmatic APIs 51 + 52 + Import helpers straight from `lib/src/plugins/navigate.ts` (re-exported by the runtime build): 53 + 54 + ```ts 55 + import { goBack, goForward, initNavigationListener, navigate, redirect } from "volt/plugins/navigate"; 56 + 57 + await navigate("/projects/123", { replace: false, transitionName: "detail" }); 58 + redirect("/login"); // always uses replace 59 + goBack(); 60 + goForward(); 61 + 62 + // Restore scroll on history navigation 63 + const stop = initNavigationListener(); 64 + // Later: stop(); 65 + ``` 66 + 67 + `initNavigationListener` should run once during boot to restore scroll positions when users hit the back/forward 68 + buttons. It also emits a `volt:popstate` event mirroring the browser’s `popstate`. 69 + 70 + ## Events 71 + 72 + Every navigation dispatches: 73 + 74 + - `volt:navigate` after the History API call, with `{ url, replace }` in `event.detail`. 75 + - `volt:popstate` from the history listener, with `{ state }` in `event.detail`. 76 + 77 + Use these to re-fetch data, invalidate caches, or sync routing signals for plugins such as `url`. 78 + 79 + ## Handling External Links 80 + 81 + Navigation only intercepts same-origin URLs and primary-button clicks without modifier keys. 82 + External links, middle clicks, and `target="_blank"` continue to behave like normal browser navigation, preserving accessibility expectations.
+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
··· 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
··· 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
··· 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
··· 43 43 "number", 44 44 "trim", 45 45 "lazy", 46 + "replace", 47 + "prefetch", 48 + "notransition", 46 49 ]); 47 50 48 51 let i = 1;
+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
··· 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 {
+369
lib/src/plugins/navigate.ts
··· 1 + /** 2 + * Navigate plugin for client-side navigation with History API 3 + * 4 + * Intercepts link clicks and form submissions. Integrates with the History API and View Transition API for smooth page transitions. 5 + */ 6 + 7 + import { registerDirective } from "$core/binder"; 8 + import { hasModifier, parseModifiers } from "$core/modifiers"; 9 + import { startViewTransition } from "$core/view-transitions"; 10 + import type { Optional } from "$types/helpers"; 11 + import type { BindingContext, Modifier, PluginContext } from "$types/volt"; 12 + 13 + type NavigationState = { scrollPosition?: { x: number; y: number }; focusSelector?: string; timestamp: number }; 14 + 15 + type NavigationOpts = { replace?: boolean; transition?: boolean; transitionName?: string }; 16 + 17 + const scrollPositions = new Map<string, { x: number; y: number }>(); 18 + const focusSelectors = new Map<string, string>(); 19 + 20 + /** 21 + * Navigate directive handler for client-side navigation 22 + * 23 + * Syntax: data-volt-navigate[.modifiers]="url" or data-volt-navigate[.modifiers] (uses href) 24 + * 25 + * Modifiers: 26 + * - .replace - Use replaceState instead of pushState 27 + * - .prefetch - Prefetch resources on hover/idle 28 + * - .notransition - Disable view transitions 29 + * 30 + * @example 31 + * ```html 32 + * <a href="/about" data-volt-navigate>About</a> 33 + * <a href="/home" data-volt-navigate-replace>Home</a> 34 + * <a href="/blog" data-volt-navigate-prefetch>Blog</a> 35 + * <a href="/settings" data-volt-navigate-notransition>Settings</a> 36 + * ``` 37 + */ 38 + export function bindNavigate(ctx: BindingContext, value: string, modifiers: Modifier[] = []): void { 39 + const element = ctx.element; 40 + 41 + if (element instanceof HTMLAnchorElement) { 42 + handleLinkNavigation(ctx, value, modifiers); 43 + } else if (element instanceof HTMLFormElement) { 44 + handleFormNavigation(ctx, value, modifiers); 45 + } else { 46 + console.warn("data-volt-navigate only works on <a> and <form> elements"); 47 + } 48 + } 49 + 50 + /** 51 + * Plugin-compatible wrapper for navigate directive 52 + * @deprecated Use bindNavigate directly or register as a directive 53 + */ 54 + export function navigatePlugin(ctx: PluginContext, value: string): void { 55 + const { baseName, modifiers } = parseModifiers(value || ""); 56 + const bindingCtx: BindingContext = { element: ctx.element, scope: ctx.scope, cleanups: [] }; 57 + 58 + bindNavigate(bindingCtx, baseName, modifiers); 59 + 60 + for (const cleanup of bindingCtx.cleanups) { 61 + ctx.addCleanup(cleanup); 62 + } 63 + } 64 + 65 + function handleLinkNavigation(ctx: BindingContext, value: string, modifiers: Modifier[]): void { 66 + const link = ctx.element as HTMLAnchorElement; 67 + const targetUrl = value || link.getAttribute("href"); 68 + 69 + if (!targetUrl) { 70 + console.warn("data-volt-navigate: no URL specified and no href found"); 71 + return; 72 + } 73 + 74 + if (hasModifier(modifiers, "prefetch")) { 75 + const viewportPrefetch = hasModifier(modifiers, "viewport"); 76 + setupPrefetch(link, targetUrl, { viewport: viewportPrefetch }); 77 + } 78 + 79 + const clickHandler = async (event: MouseEvent) => { 80 + if (event.ctrlKey || event.metaKey || event.shiftKey || event.button !== 0) { 81 + return; 82 + } 83 + 84 + if (isExternalLink(targetUrl)) { 85 + return; 86 + } 87 + 88 + event.preventDefault(); 89 + 90 + const useReplace = hasModifier(modifiers, "replace"); 91 + const useTransition = !hasModifier(modifiers, "notransition"); 92 + 93 + await navigateTo(targetUrl, { replace: useReplace, transition: useTransition, transitionName: "page-transition" }); 94 + }; 95 + 96 + link.addEventListener("click", clickHandler); 97 + ctx.cleanups.push(() => link.removeEventListener("click", clickHandler)); 98 + } 99 + 100 + function handleFormNavigation(ctx: BindingContext, value: string, modifiers: Modifier[]): void { 101 + const form = ctx.element as HTMLFormElement; 102 + const targetUrl = value || form.getAttribute("action") || globalThis.location.pathname; 103 + 104 + const submitHandler = async (event: SubmitEvent) => { 105 + event.preventDefault(); 106 + 107 + const formData = new FormData(form); 108 + const method = form.method.toLowerCase(); 109 + const useReplace = hasModifier(modifiers, "replace"); 110 + const useTransition = !hasModifier(modifiers, "notransition"); 111 + 112 + if (method === "get") { 113 + // TODO: serialize FormData 114 + const params = new URLSearchParams(formData as any); 115 + const url = `${targetUrl}?${params.toString()}`; 116 + await navigateTo(url, { replace: useReplace, transition: useTransition, transitionName: "page-transition" }); 117 + } else { 118 + console.warn("data-volt-navigate: POST/PUT/PATCH forms should use data-volt-post/put/patch"); 119 + } 120 + }; 121 + 122 + form.addEventListener("submit", submitHandler); 123 + ctx.cleanups.push(() => form.removeEventListener("submit", submitHandler)); 124 + } 125 + 126 + async function navigateTo(url: string, options: NavigationOpts = {}): Promise<void> { 127 + const { replace = false, transition = true, transitionName = "page-transition" } = options; 128 + const currentKey = `${globalThis.location.pathname}${globalThis.location.search}`; 129 + scrollPositions.set(currentKey, { x: window.scrollX, y: window.scrollY }); 130 + 131 + const activeElement = document.activeElement; 132 + const focusSelector = activeElement && activeElement !== document.body 133 + ? getElementSelector(activeElement) 134 + : undefined; 135 + if (focusSelector) { 136 + focusSelectors.set(currentKey, focusSelector); 137 + } 138 + 139 + const state: NavigationState = { 140 + scrollPosition: { x: window.scrollX, y: window.scrollY }, 141 + focusSelector, 142 + timestamp: Date.now(), 143 + }; 144 + 145 + const performNavigation = async () => { 146 + if (replace) { 147 + globalThis.history.replaceState(state, "", url); 148 + } else { 149 + globalThis.history.pushState(state, "", url); 150 + } 151 + 152 + globalThis.dispatchEvent( 153 + new CustomEvent("volt:navigate", { detail: { url, replace }, bubbles: true, cancelable: false }), 154 + ); 155 + 156 + window.scrollTo(0, 0); 157 + 158 + resetFocusAfterNavigation(); 159 + }; 160 + 161 + if (transition && typeof transitionName === "string") { 162 + await startViewTransition(performNavigation, { name: transitionName }); 163 + } else { 164 + await performNavigation(); 165 + } 166 + } 167 + 168 + /** 169 + * Generate a unique selector for an element (for focus restoration) 170 + * Tries id, then name, then data attributes, then position-based selector 171 + */ 172 + function getElementSelector(element: Element): Optional<string> { 173 + if (element.id) { 174 + return `#${element.id}`; 175 + } 176 + 177 + if (element.hasAttribute("name")) { 178 + const name = element.getAttribute("name"); 179 + const tag = element.tagName.toLowerCase(); 180 + return `${tag}[name="${name}"]`; 181 + } 182 + 183 + for (const attr of element.attributes) { 184 + if (attr.name.startsWith("data-volt-")) { 185 + return `[${attr.name}="${attr.value}"]`; 186 + } 187 + } 188 + 189 + if (element.hasAttribute("aria-label")) { 190 + const label = element.getAttribute("aria-label"); 191 + return `[aria-label="${label}"]`; 192 + } 193 + 194 + const parent = element.parentElement; 195 + if (!parent) return undefined; 196 + 197 + const siblings = [...parent.children]; 198 + const index = siblings.indexOf(element); 199 + const tag = element.tagName.toLowerCase(); 200 + 201 + return `${tag}:nth-child(${index + 1})`; 202 + } 203 + 204 + /** 205 + * Reset focus to a sensible location after navigation 206 + * Tries to focus main content area or first focusable element 207 + */ 208 + function resetFocusAfterNavigation(): void { 209 + const main = document.querySelector("main, [role='main'], #main-content"); 210 + if (main instanceof HTMLElement && main.tabIndex < 0) { 211 + main.tabIndex = -1; 212 + } 213 + 214 + if (main instanceof HTMLElement) { 215 + main.focus({ preventScroll: true }); 216 + return; 217 + } 218 + 219 + const firstHeading = document.querySelector("h1"); 220 + if (firstHeading instanceof HTMLElement) { 221 + if (firstHeading.tabIndex < 0) { 222 + firstHeading.tabIndex = -1; 223 + } 224 + firstHeading.focus({ preventScroll: true }); 225 + return; 226 + } 227 + 228 + document.body.focus({ preventScroll: true }); 229 + } 230 + 231 + /** 232 + * Restore focus to the previously focused element (for back/forward navigation) 233 + */ 234 + function restoreFocus(selector: string): boolean { 235 + try { 236 + const element = document.querySelector(selector); 237 + if (element instanceof HTMLElement) { 238 + element.focus({ preventScroll: true }); 239 + return true; 240 + } 241 + } catch (error) { 242 + console.warn(`Could not restore focus to selector: ${selector}`, error); 243 + } 244 + return false; 245 + } 246 + 247 + function isExternalLink(url: string): boolean { 248 + try { 249 + const target = new URL(url, globalThis.location.origin); 250 + return target.origin !== globalThis.location.origin; 251 + } catch { 252 + return false; 253 + } 254 + } 255 + 256 + /** 257 + * Setup resource prefetching for a link 258 + * 259 + * By default, prefetches on hover/focus (interaction-based). 260 + * With viewport option, prefetches when element enters viewport (IntersectionObserver). 261 + */ 262 + function setupPrefetch(element: HTMLElement, url: string, opts: { viewport?: boolean } = {}): void { 263 + const { viewport = false } = opts; 264 + let prefetched = false; 265 + 266 + const prefetch = () => { 267 + if (prefetched) return; 268 + prefetched = true; 269 + 270 + fetch(url, { method: "GET", priority: "low", credentials: "same-origin" } as RequestInit).catch(() => { 271 + const link = document.createElement("link"); 272 + link.rel = "prefetch"; 273 + link.href = url; 274 + document.head.append(link); 275 + }); 276 + }; 277 + 278 + if (viewport) { 279 + const observer = new IntersectionObserver((entries) => { 280 + for (const entry of entries) { 281 + if (entry.isIntersecting) { 282 + prefetch(); 283 + observer.disconnect(); 284 + } 285 + } 286 + }, { rootMargin: "50px" }); 287 + 288 + observer.observe(element); 289 + } else { 290 + element.addEventListener("mouseenter", prefetch, { once: true, passive: true }); 291 + element.addEventListener("focus", prefetch, { once: true, passive: true }); 292 + } 293 + } 294 + 295 + /** 296 + * Initialize popstate listener for back/forward navigation 297 + * Should be called once on app initialization 298 + */ 299 + export function initNavigationListener(): () => void { 300 + const handlePopState = (event: PopStateEvent) => { 301 + const state = event.state as NavigationState | null; 302 + 303 + const key = `${globalThis.location.pathname}${globalThis.location.search}`; 304 + const savedPosition = scrollPositions.get(key); 305 + const savedFocus = focusSelectors.get(key); 306 + 307 + if (savedPosition) { 308 + window.scrollTo(savedPosition.x, savedPosition.y); 309 + } else if (state?.scrollPosition) { 310 + window.scrollTo(state.scrollPosition.x, state.scrollPosition.y); 311 + } 312 + 313 + if (savedFocus) { 314 + restoreFocus(savedFocus); 315 + } else if (state?.focusSelector) { 316 + restoreFocus(state.focusSelector); 317 + } else { 318 + resetFocusAfterNavigation(); 319 + } 320 + 321 + globalThis.dispatchEvent(new CustomEvent("volt:popstate", { detail: { state }, bubbles: true, cancelable: false })); 322 + }; 323 + 324 + globalThis.addEventListener("popstate", handlePopState); 325 + 326 + return () => { 327 + globalThis.removeEventListener("popstate", handlePopState); 328 + }; 329 + } 330 + 331 + /** 332 + * Programmatic navigation helper 333 + * 334 + * @param url - URL to navigate to 335 + * @param options - Navigation options 336 + * 337 + * @example 338 + * ```typescript 339 + * import { navigate } from 'voltx.js'; 340 + * 341 + * navigate('/dashboard', { replace: true }); 342 + * ``` 343 + */ 344 + export function navigate(url: string, options?: NavigationOpts): Promise<void> { 345 + return navigateTo(url, options); 346 + } 347 + 348 + /** 349 + * Go back in history 350 + */ 351 + export function goBack(): void { 352 + globalThis.history.back(); 353 + } 354 + 355 + /** 356 + * Go forward in history 357 + */ 358 + export function goForward(): void { 359 + globalThis.history.forward(); 360 + } 361 + 362 + /** 363 + * Redirect to a URL (alias for navigate with replace: true) 364 + */ 365 + export function redirect(url: string): Promise<void> { 366 + return navigateTo(url, { replace: true }); 367 + } 368 + 369 + registerDirective("navigate", bindNavigate);
+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
··· 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
··· 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 + });
+655
lib/test/plugins/navigate.test.ts
··· 1 + import { mount } from "$core/binder"; 2 + import { goBack, goForward, initNavigationListener, navigate, redirect } from "$plugins/navigate"; 3 + import { beforeEach, describe, expect, it, vi } from "vitest"; 4 + 5 + describe("navigate plugin", () => { 6 + beforeEach(() => { 7 + globalThis.history.replaceState({}, "", "/"); 8 + vi.clearAllMocks(); 9 + }); 10 + 11 + describe("link navigation", () => { 12 + it("intercepts link clicks and prevents default navigation", () => { 13 + const link = document.createElement("a"); 14 + link.href = "/about"; 15 + link.dataset.voltNavigate = ""; 16 + 17 + const preventDefault = vi.fn(); 18 + const event = new MouseEvent("click", { bubbles: true, cancelable: true }); 19 + Object.defineProperty(event, "preventDefault", { value: preventDefault }); 20 + 21 + mount(link, {}); 22 + link.dispatchEvent(event); 23 + 24 + expect(preventDefault).toHaveBeenCalled(); 25 + }); 26 + 27 + it("navigates to href when no explicit URL provided", () => { 28 + const link = document.createElement("a"); 29 + link.href = "/products"; 30 + link.dataset.voltNavigate = ""; 31 + 32 + const event = new MouseEvent("click", { bubbles: true, cancelable: true }); 33 + mount(link, {}); 34 + link.dispatchEvent(event); 35 + 36 + expect(globalThis.location.pathname).toBe("/products"); 37 + }); 38 + 39 + it("navigates to explicit URL when provided", () => { 40 + const link = document.createElement("a"); 41 + link.href = "/default"; 42 + link.dataset.voltNavigate = "/custom"; 43 + 44 + const event = new MouseEvent("click", { bubbles: true, cancelable: true }); 45 + mount(link, {}); 46 + link.dispatchEvent(event); 47 + 48 + expect(globalThis.location.pathname).toBe("/custom"); 49 + }); 50 + 51 + it("allows ctrl+click to open in new tab (does not prevent default)", () => { 52 + const link = document.createElement("a"); 53 + link.href = "/external"; 54 + link.dataset.voltNavigate = ""; 55 + 56 + const preventDefault = vi.fn(); 57 + const event = new MouseEvent("click", { bubbles: true, cancelable: true, ctrlKey: true }); 58 + Object.defineProperty(event, "preventDefault", { value: preventDefault }); 59 + 60 + mount(link, {}); 61 + link.dispatchEvent(event); 62 + 63 + expect(preventDefault).not.toHaveBeenCalled(); 64 + }); 65 + 66 + it("allows meta+click to open in new tab (does not prevent default)", () => { 67 + const link = document.createElement("a"); 68 + link.href = "/external"; 69 + link.dataset.voltNavigate = ""; 70 + 71 + const preventDefault = vi.fn(); 72 + const event = new MouseEvent("click", { bubbles: true, cancelable: true, metaKey: true }); 73 + Object.defineProperty(event, "preventDefault", { value: preventDefault }); 74 + 75 + mount(link, {}); 76 + link.dispatchEvent(event); 77 + 78 + expect(preventDefault).not.toHaveBeenCalled(); 79 + }); 80 + 81 + it("allows shift+click to open in new window (does not prevent default)", () => { 82 + const link = document.createElement("a"); 83 + link.href = "/external"; 84 + link.dataset.voltNavigate = ""; 85 + 86 + const preventDefault = vi.fn(); 87 + const event = new MouseEvent("click", { bubbles: true, cancelable: true, shiftKey: true }); 88 + Object.defineProperty(event, "preventDefault", { value: preventDefault }); 89 + 90 + mount(link, {}); 91 + link.dispatchEvent(event); 92 + 93 + expect(preventDefault).not.toHaveBeenCalled(); 94 + }); 95 + 96 + it("allows middle mouse button to open in new tab (does not prevent default)", () => { 97 + const link = document.createElement("a"); 98 + link.href = "/external"; 99 + link.dataset.voltNavigate = ""; 100 + 101 + const preventDefault = vi.fn(); 102 + const event = new MouseEvent("click", { bubbles: true, cancelable: true, button: 1 }); 103 + Object.defineProperty(event, "preventDefault", { value: preventDefault }); 104 + 105 + mount(link, {}); 106 + link.dispatchEvent(event); 107 + 108 + expect(preventDefault).not.toHaveBeenCalled(); 109 + }); 110 + 111 + it("does not intercept external links", () => { 112 + const link = document.createElement("a"); 113 + link.href = "https://external.com/page"; 114 + link.dataset.voltNavigate = ""; 115 + 116 + const preventDefault = vi.fn(); 117 + const event = new MouseEvent("click", { bubbles: true, cancelable: true }); 118 + Object.defineProperty(event, "preventDefault", { value: preventDefault }); 119 + 120 + mount(link, {}); 121 + link.dispatchEvent(event); 122 + 123 + expect(preventDefault).not.toHaveBeenCalled(); 124 + }); 125 + 126 + it("uses replaceState when .replace modifier is used", () => { 127 + globalThis.history.replaceState({}, "", "/initial"); 128 + 129 + const link = document.createElement("a"); 130 + link.href = "/about"; 131 + link.dataset.voltNavigateReplace = ""; 132 + 133 + const event = new MouseEvent("click", { bubbles: true, cancelable: true }); 134 + mount(link, {}); 135 + link.dispatchEvent(event); 136 + 137 + expect(globalThis.location.pathname).toBe("/about"); 138 + }); 139 + 140 + it("scrolls to top on navigation", async () => { 141 + const scrollToSpy = vi.spyOn(globalThis, "scrollTo"); 142 + 143 + const link = document.createElement("a"); 144 + link.href = "/page"; 145 + link.dataset.voltNavigate = ""; 146 + 147 + const event = new MouseEvent("click", { bubbles: true, cancelable: true }); 148 + mount(link, {}); 149 + link.dispatchEvent(event); 150 + 151 + await vi.waitFor(() => { 152 + expect(scrollToSpy).toHaveBeenCalledWith(0, 0); 153 + }); 154 + }); 155 + 156 + it("dispatches volt:navigate event on navigation", async () => { 157 + const navigateHandler = vi.fn(); 158 + globalThis.addEventListener("volt:navigate", navigateHandler); 159 + 160 + const link = document.createElement("a"); 161 + link.href = "/dashboard"; 162 + link.dataset.voltNavigate = ""; 163 + 164 + const event = new MouseEvent("click", { bubbles: true, cancelable: true }); 165 + mount(link, {}); 166 + link.dispatchEvent(event); 167 + 168 + await vi.waitFor(() => { 169 + expect(navigateHandler).toHaveBeenCalled(); 170 + const customEvent = navigateHandler.mock.calls[0][0] as CustomEvent; 171 + expect(customEvent.detail.url).toBe("/dashboard"); 172 + }); 173 + 174 + globalThis.removeEventListener("volt:navigate", navigateHandler); 175 + }); 176 + 177 + it("adds prefetch link on hover when .prefetch modifier is used", async () => { 178 + const link = document.createElement("a"); 179 + link.href = "/prefetch-page"; 180 + link.dataset.voltNavigatePrefetch = ""; 181 + 182 + mount(link, {}); 183 + 184 + const mouseenterEvent = new MouseEvent("mouseenter", { bubbles: true }); 185 + link.dispatchEvent(mouseenterEvent); 186 + 187 + await vi.waitFor(() => { 188 + const prefetchLink = document.querySelector("link[rel=\"prefetch\"][href=\"/prefetch-page\"]"); 189 + expect(prefetchLink).toBeTruthy(); 190 + }); 191 + }); 192 + 193 + it("adds prefetch link on focus when .prefetch modifier is used", async () => { 194 + const link = document.createElement("a"); 195 + link.href = "/prefetch-focus"; 196 + link.dataset.voltNavigatePrefetch = ""; 197 + 198 + mount(link, {}); 199 + 200 + const focusEvent = new FocusEvent("focus", { bubbles: true }); 201 + link.dispatchEvent(focusEvent); 202 + 203 + await vi.waitFor(() => { 204 + const prefetchLink = document.querySelector("link[rel=\"prefetch\"][href=\"/prefetch-focus\"]"); 205 + expect(prefetchLink).toBeTruthy(); 206 + }); 207 + }); 208 + 209 + it("only prefetches once even with multiple hover events", async () => { 210 + const link = document.createElement("a"); 211 + link.href = "/prefetch-once"; 212 + link.dataset.voltNavigatePrefetch = ""; 213 + 214 + mount(link, {}); 215 + 216 + link.dispatchEvent(new MouseEvent("mouseenter", { bubbles: true })); 217 + link.dispatchEvent(new MouseEvent("mouseenter", { bubbles: true })); 218 + link.dispatchEvent(new MouseEvent("mouseenter", { bubbles: true })); 219 + 220 + await vi.waitFor(() => { 221 + const prefetchLinks = document.querySelectorAll("link[rel=\"prefetch\"][href=\"/prefetch-once\"]"); 222 + expect(prefetchLinks.length).toBe(1); 223 + }); 224 + }); 225 + }); 226 + 227 + describe("form navigation", () => { 228 + it("intercepts form GET submissions", () => { 229 + const form = document.createElement("form"); 230 + form.method = "GET"; 231 + form.action = "/search"; 232 + form.dataset.voltNavigate = ""; 233 + 234 + const input = document.createElement("input"); 235 + input.name = "q"; 236 + input.value = "test"; 237 + form.append(input); 238 + 239 + const preventDefault = vi.fn(); 240 + const event = new Event("submit", { bubbles: true, cancelable: true }); 241 + Object.defineProperty(event, "preventDefault", { value: preventDefault }); 242 + 243 + mount(form, {}); 244 + form.dispatchEvent(event); 245 + 246 + expect(preventDefault).toHaveBeenCalled(); 247 + expect(globalThis.location.pathname).toBe("/search"); 248 + expect(globalThis.location.search).toContain("q=test"); 249 + }); 250 + 251 + it("warns on POST form submissions", () => { 252 + const consoleWarnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); 253 + 254 + const form = document.createElement("form"); 255 + form.method = "POST"; 256 + form.action = "/submit"; 257 + form.dataset.voltNavigate = ""; 258 + 259 + const event = new Event("submit", { bubbles: true, cancelable: true }); 260 + mount(form, {}); 261 + form.dispatchEvent(event); 262 + 263 + expect(consoleWarnSpy).toHaveBeenCalledWith( 264 + expect.stringContaining("POST/PUT/PATCH forms should use data-volt-post/put/patch"), 265 + ); 266 + 267 + consoleWarnSpy.mockRestore(); 268 + }); 269 + 270 + it("uses current pathname as default action", () => { 271 + globalThis.history.replaceState({}, "", "/current"); 272 + 273 + const form = document.createElement("form"); 274 + form.method = "GET"; 275 + form.dataset.voltNavigate = ""; 276 + 277 + const input = document.createElement("input"); 278 + input.name = "filter"; 279 + input.value = "active"; 280 + form.append(input); 281 + 282 + const event = new Event("submit", { bubbles: true, cancelable: true }); 283 + mount(form, {}); 284 + form.dispatchEvent(event); 285 + 286 + expect(globalThis.location.pathname).toBe("/current"); 287 + expect(globalThis.location.search).toContain("filter=active"); 288 + }); 289 + }); 290 + 291 + describe("programmatic navigation", () => { 292 + it("navigate() changes the URL", async () => { 293 + await navigate("/dashboard"); 294 + expect(globalThis.location.pathname).toBe("/dashboard"); 295 + }); 296 + 297 + it("navigate() with replace option uses replaceState", async () => { 298 + globalThis.history.replaceState({}, "", "/initial"); 299 + await navigate("/replaced", { replace: true }); 300 + expect(globalThis.location.pathname).toBe("/replaced"); 301 + }); 302 + 303 + it("redirect() uses replaceState", async () => { 304 + globalThis.history.replaceState({}, "", "/old"); 305 + await redirect("/new"); 306 + expect(globalThis.location.pathname).toBe("/new"); 307 + }); 308 + 309 + it("navigate() dispatches volt:navigate event", async () => { 310 + const handler = vi.fn(); 311 + globalThis.addEventListener("volt:navigate", handler); 312 + 313 + await navigate("/profile"); 314 + 315 + expect(handler).toHaveBeenCalled(); 316 + const event = handler.mock.calls[0][0] as CustomEvent; 317 + expect(event.detail.url).toBe("/profile"); 318 + 319 + globalThis.removeEventListener("volt:navigate", handler); 320 + }); 321 + 322 + it("goBack() navigates backward in history", () => { 323 + const backSpy = vi.spyOn(globalThis.history, "back"); 324 + goBack(); 325 + expect(backSpy).toHaveBeenCalled(); 326 + }); 327 + 328 + it("goForward() navigates forward in history", () => { 329 + const forwardSpy = vi.spyOn(globalThis.history, "forward"); 330 + goForward(); 331 + expect(forwardSpy).toHaveBeenCalled(); 332 + }); 333 + }); 334 + 335 + describe("scroll position restoration", () => { 336 + it("saves scroll position before navigation", async () => { 337 + Object.defineProperty(globalThis, "scrollX", { value: 100, writable: true, configurable: true }); 338 + Object.defineProperty(globalThis, "scrollY", { value: 200, writable: true, configurable: true }); 339 + 340 + await navigate("/page1"); 341 + await navigate("/page2"); 342 + 343 + expect(globalThis.history.state).toBeDefined(); 344 + }); 345 + 346 + it("restores scroll position on popstate", async () => { 347 + const cleanup = initNavigationListener(); 348 + 349 + Object.defineProperty(globalThis, "scrollX", { value: 0, writable: true, configurable: true }); 350 + Object.defineProperty(globalThis, "scrollY", { value: 0, writable: true, configurable: true }); 351 + await navigate("/page1"); 352 + 353 + Object.defineProperty(globalThis, "scrollX", { value: 0, writable: true, configurable: true }); 354 + Object.defineProperty(globalThis, "scrollY", { value: 500, writable: true, configurable: true }); 355 + 356 + await navigate("/page2"); 357 + 358 + const scrollToSpy = vi.spyOn(globalThis, "scrollTo"); 359 + globalThis.history.back(); 360 + globalThis.dispatchEvent(new PopStateEvent("popstate", { state: { scrollPosition: { x: 0, y: 500 } } })); 361 + 362 + await vi.waitFor(() => { 363 + expect(scrollToSpy).toHaveBeenCalledWith(0, 500); 364 + }); 365 + 366 + cleanup(); 367 + }); 368 + 369 + it("dispatches volt:popstate event on back/forward navigation", async () => { 370 + const cleanup = initNavigationListener(); 371 + const popstateHandler = vi.fn(); 372 + globalThis.addEventListener("volt:popstate", popstateHandler); 373 + 374 + globalThis.dispatchEvent(new PopStateEvent("popstate", { state: { timestamp: Date.now() } })); 375 + 376 + await vi.waitFor(() => { 377 + expect(popstateHandler).toHaveBeenCalled(); 378 + }); 379 + 380 + globalThis.removeEventListener("volt:popstate", popstateHandler); 381 + cleanup(); 382 + }); 383 + }); 384 + 385 + describe("navigation state", () => { 386 + it("stores navigation state in history", async () => { 387 + await navigate("/stateful"); 388 + 389 + expect(globalThis.history.state).toBeDefined(); 390 + expect(globalThis.history.state.timestamp).toBeDefined(); 391 + expect(typeof globalThis.history.state.timestamp).toBe("number"); 392 + }); 393 + 394 + it("includes scroll position in navigation state", async () => { 395 + Object.defineProperty(globalThis, "scrollX", { value: 150, writable: true, configurable: true }); 396 + Object.defineProperty(globalThis, "scrollY", { value: 300, writable: true, configurable: true }); 397 + 398 + await navigate("/with-scroll"); 399 + 400 + expect(globalThis.history.state.scrollPosition).toBeDefined(); 401 + expect(globalThis.history.state.scrollPosition.x).toBe(150); 402 + expect(globalThis.history.state.scrollPosition.y).toBe(300); 403 + }); 404 + }); 405 + 406 + describe("view transitions", () => { 407 + it("uses view transitions by default", async () => { 408 + const mockTransition = { 409 + finished: Promise.resolve(), 410 + ready: Promise.resolve(), 411 + updateCbDone: Promise.resolve(), 412 + skipTransition: vi.fn(), 413 + }; 414 + 415 + const startViewTransitionSpy = vi.fn(() => mockTransition); 416 + Object.defineProperty(document, "startViewTransition", { 417 + value: startViewTransitionSpy, 418 + writable: true, 419 + configurable: true, 420 + }); 421 + 422 + await navigate("/with-transition"); 423 + 424 + expect(startViewTransitionSpy).toHaveBeenCalled(); 425 + }); 426 + 427 + it("skips view transitions when notransition modifier is used", async () => { 428 + const link = document.createElement("a"); 429 + link.href = "/no-transition"; 430 + link.dataset.voltNavigateNotransition = ""; 431 + 432 + const mockTransition = { 433 + finished: Promise.resolve(), 434 + ready: Promise.resolve(), 435 + updateCbDone: Promise.resolve(), 436 + skipTransition: vi.fn(), 437 + }; 438 + 439 + const startViewTransitionSpy = vi.fn(() => mockTransition); 440 + Object.defineProperty(document, "startViewTransition", { 441 + value: startViewTransitionSpy, 442 + writable: true, 443 + configurable: true, 444 + }); 445 + 446 + const event = new MouseEvent("click", { bubbles: true, cancelable: true }); 447 + mount(link, {}); 448 + link.dispatchEvent(event); 449 + 450 + await vi.waitFor(() => { 451 + expect(globalThis.location.pathname).toBe("/no-transition"); 452 + }); 453 + }); 454 + 455 + it("can disable view transitions programmatically", async () => { 456 + await navigate("/no-vt", { transition: false }); 457 + expect(globalThis.location.pathname).toBe("/no-vt"); 458 + }); 459 + }); 460 + 461 + describe("focus management", () => { 462 + it("includes focus restoration functions in navigate module", () => { 463 + expect(navigate).toBeDefined(); 464 + expect(initNavigationListener).toBeDefined(); 465 + }); 466 + 467 + it.skip("saves focus state in navigation state when element has ID", async () => { 468 + const input = document.createElement("input"); 469 + input.id = "test-input"; 470 + input.type = "text"; 471 + document.body.append(input); 472 + 473 + const activeElementGetter = vi.spyOn(document, "activeElement", "get"); 474 + activeElementGetter.mockReturnValue(input); 475 + 476 + await navigate("/page-with-focus"); 477 + 478 + expect(globalThis.history.state.focusSelector).toBe("#test-input"); 479 + 480 + activeElementGetter.mockRestore(); 481 + input.remove(); 482 + }); 483 + 484 + it.skip("attempts to restore focus on popstate", () => { 485 + const cleanup = initNavigationListener(); 486 + 487 + const button = document.createElement("button"); 488 + button.id = "focus-button"; 489 + button.textContent = "Click me"; 490 + document.body.append(button); 491 + 492 + const focusSpy = vi.spyOn(button, "focus"); 493 + 494 + globalThis.dispatchEvent(new PopStateEvent("popstate", { state: { focusSelector: "#focus-button" } })); 495 + 496 + expect(focusSpy).toHaveBeenCalledWith({ preventScroll: true }); 497 + 498 + cleanup(); 499 + button.remove(); 500 + }); 501 + }); 502 + 503 + describe("viewport-based prefetching", () => { 504 + it.skip("prefetches when link enters viewport with .viewport modifier", async () => { 505 + const link = document.createElement("a"); 506 + link.href = "/viewport-prefetch"; 507 + link.dataset.voltNavigatePrefetchViewport = ""; 508 + document.body.append(link); 509 + 510 + let observerCallback!: IntersectionObserverCallback; 511 + const mockObserver = { 512 + observe: vi.fn(), 513 + disconnect: vi.fn(), 514 + unobserve: vi.fn(), 515 + takeRecords: vi.fn(), 516 + root: null, 517 + rootMargin: "", 518 + thresholds: [], 519 + }; 520 + 521 + (globalThis as typeof globalThis).IntersectionObserver = vi.fn((callback) => { 522 + observerCallback = callback; 523 + return mockObserver; 524 + }) as unknown as typeof IntersectionObserver; 525 + 526 + const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue(new Response()); 527 + 528 + mount(link, {}); 529 + 530 + expect(mockObserver.observe).toHaveBeenCalledWith(link); 531 + 532 + observerCallback( 533 + [{ isIntersecting: true, target: link } as unknown as IntersectionObserverEntry], 534 + mockObserver as IntersectionObserver, 535 + ); 536 + 537 + await vi.waitFor(() => { 538 + expect(fetchSpy).toHaveBeenCalledWith("/viewport-prefetch", expect.objectContaining({ method: "GET" })); 539 + }); 540 + 541 + fetchSpy.mockRestore(); 542 + link.remove(); 543 + }); 544 + 545 + it.skip("only prefetches once when element enters viewport multiple times", async () => { 546 + const link = document.createElement("a"); 547 + link.href = "/viewport-once"; 548 + link.dataset.voltNavigatePrefetchViewport = ""; 549 + document.body.append(link); 550 + 551 + let observerCallback!: IntersectionObserverCallback; 552 + const mockObserver = { 553 + observe: vi.fn(), 554 + disconnect: vi.fn(), 555 + unobserve: vi.fn(), 556 + takeRecords: vi.fn(), 557 + root: null, 558 + rootMargin: "", 559 + thresholds: [], 560 + }; 561 + 562 + (globalThis as typeof globalThis).IntersectionObserver = vi.fn((callback) => { 563 + observerCallback = callback; 564 + return mockObserver; 565 + }) as unknown as typeof IntersectionObserver; 566 + 567 + const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue(new Response()); 568 + 569 + mount(link, {}); 570 + 571 + observerCallback( 572 + [{ isIntersecting: true, target: link } as unknown as IntersectionObserverEntry], 573 + mockObserver as IntersectionObserver, 574 + ); 575 + 576 + observerCallback( 577 + [{ isIntersecting: false, target: link } as unknown as IntersectionObserverEntry], 578 + mockObserver as IntersectionObserver, 579 + ); 580 + 581 + observerCallback( 582 + [{ isIntersecting: true, target: link } as unknown as IntersectionObserverEntry], 583 + mockObserver as IntersectionObserver, 584 + ); 585 + 586 + await vi.waitFor(() => { 587 + expect(fetchSpy).toHaveBeenCalledTimes(1); 588 + expect(mockObserver.disconnect).toHaveBeenCalled(); 589 + }); 590 + 591 + fetchSpy.mockRestore(); 592 + link.remove(); 593 + }); 594 + 595 + it("falls back to link prefetch when fetch fails", async () => { 596 + const link = document.createElement("a"); 597 + link.href = "/fetch-fail"; 598 + link.dataset.voltNavigatePrefetch = ""; 599 + document.body.append(link); 600 + 601 + const fetchSpy = vi.spyOn(globalThis, "fetch").mockRejectedValue(new Error("Network error")); 602 + 603 + mount(link, {}); 604 + 605 + link.dispatchEvent(new MouseEvent("mouseenter", { bubbles: true })); 606 + 607 + await vi.waitFor(() => { 608 + const prefetchLink = document.querySelector("link[rel=\"prefetch\"][href=\"/fetch-fail\"]"); 609 + expect(prefetchLink).toBeTruthy(); 610 + }); 611 + 612 + fetchSpy.mockRestore(); 613 + link.remove(); 614 + }); 615 + }); 616 + 617 + describe("cleanup", () => { 618 + it("removes event listeners on cleanup", () => { 619 + const link = document.createElement("a"); 620 + link.href = "/cleanup-test"; 621 + link.dataset.voltNavigate = ""; 622 + 623 + const cleanup = mount(link, {}); 624 + const preventDefault = vi.fn(); 625 + const event = new MouseEvent("click", { bubbles: true, cancelable: true }); 626 + Object.defineProperty(event, "preventDefault", { value: preventDefault }); 627 + link.dispatchEvent(event); 628 + expect(preventDefault).toHaveBeenCalled(); 629 + cleanup(); 630 + 631 + preventDefault.mockClear(); 632 + const event2 = new MouseEvent("click", { bubbles: true, cancelable: true }); 633 + Object.defineProperty(event2, "preventDefault", { value: preventDefault }); 634 + link.dispatchEvent(event2); 635 + expect(preventDefault).not.toHaveBeenCalled(); 636 + }); 637 + 638 + it("initNavigationListener returns cleanup function", () => { 639 + const cleanup = initNavigationListener(); 640 + expect(typeof cleanup).toBe("function"); 641 + 642 + const popstateHandler = vi.fn(); 643 + globalThis.addEventListener("volt:popstate", popstateHandler); 644 + 645 + globalThis.dispatchEvent(new PopStateEvent("popstate")); 646 + expect(popstateHandler).toHaveBeenCalled(); 647 + 648 + cleanup(); 649 + popstateHandler.mockClear(); 650 + 651 + globalThis.dispatchEvent(new PopStateEvent("popstate")); 652 + globalThis.removeEventListener("volt:popstate", popstateHandler); 653 + }); 654 + }); 655 + });
+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
··· 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(() => {});