diff --git a/ROADMAP.md b/ROADMAP.md index 2602b04..d27b315 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -17,13 +17,16 @@ | v0.2.0 | ✓ | [Reactive Attributes & Event Modifiers](#reactive-attributes--event-modifiers) | | v0.3.0 | ✓ | [Global State](#global-state) | | v0.4.0 | ✓ | [Animation & Transitions](#animation--transitions) | -| v0.5.0 | | [History API Routing Plugin](#history-api-routing-plugin) | -| | | [Navigation & History Management](#navigation--history-management) | +| v0.5.0 | ✓ | [Navigation & History API Routing](#navigation--history-api-routing) | | | ✓ | [Refactor](#evaluator--binder-hardening) | -| v0.6.0 | | [Background Requests & Reactive Polling](#background-requests--reactive-polling) | -| v0.7.0 | | [Streaming & Patch Engine](#streaming--patch-engine) | -| v0.8.0 | | PWA Capabilities | -| | | [Persistence & Offline](#persistence--offline) | +| | | Update demo to be a multi page application with routing plugin | +| v0.5.1 | | Support `voltx-` & `vx-` attributes: recommend `vx-` | +| v0.5.2 | | Switch to `data-voltx` | +| v0.5.3 | | [Background Requests & Reactive Polling](#background-requests--reactive-polling) | +| v0.5.4 | | [Streaming & Patch Engine](#streaming--patch-engine) | +| v0.5.5 | | PWA Capabilities | +| v0.5.6 | | [Persistence & Offline](#persistence--offline) | +| | | | | v0.9.0 | | [Inspector & Developer Tools](#inspector--developer-tools) | | v1.0.0 | | [Stable Release](#stable-release) | @@ -58,8 +61,8 @@ _NOTE_: `data-x-*` is now `data-volt-*` ### Backend Integration & HTTP Actions **Goal:** Provide backend integration with declarative HTTP requests and responses. -**Outcome:** Volt.js can make backend requests and update the DOM -**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. +**Outcome:** VoltX.js can make backend requests and update the DOM +**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. ### Markup Based Reactivity @@ -75,28 +78,34 @@ _NOTE_: `data-x-*` is now `data-volt-*` ### Reactive Attributes & Event Modifiers -**Goal:** Extend Volt.js with expressive attribute patterns and event options for fine-grained control. -**Outcome:** Volt.js supports rich declarative behaviors and event semantics built entirely on standard DOM APIs. +**Goal:** Extend VoltX.js with expressive attribute patterns and event options for fine-grained control. +**Outcome:** VoltX.js supports rich declarative behaviors and event semantics built entirely on standard DOM APIs. **Summary:** Introduced expressive attribute patterns and event modifiers for precise DOM and input control, for fine-grained declarative behavior entirely through standard DOM APIs. ### Global State **Goal:** Implement store/context pattern -**Outcome:** Volt.js provides intuitive global state management +**Outcome:** VoltX.js provides intuitive global state management **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. ### Animation & Transitions **Goal:** Add animation primitives for smooth UI transitions with Alpine/Datastar parity. -**Outcome:** Volt.js enables declarative animations and view transitions alongside reactivity. +**Outcome:** VoltX.js enables declarative animations and view transitions alongside reactivity. **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. +### Navigation & History API Routing + +**Goal:** Provide seamless client-side navigation with a first-class History API router. +**Outcome:** VoltX.js delivers accessible, stateful navigation with clean URLs and signal-driven routing. +**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. + ## To-Do ### Streaming & Patch Engine **Goal:** Enable real-time updates via SSE/WebSocket streaming with intelligent DOM patching. -**Outcome:** Volt.js can receive and apply live updates from the server +**Outcome:** VoltX.js can receive and apply live updates from the server **Deliverables:** - Server-Sent Events (SSE) integration - `data-volt-flow` attribute for SSE endpoints @@ -109,7 +118,7 @@ _NOTE_: `data-x-*` is now `data-volt-*` ### Persistence & Offline **Goal:** Introduce persistent storage and offline-first behaviors. -**Outcome:** Resilient state persistence and offline replay built into Volt.js. +**Outcome:** Resilient state persistence and offline replay built into VoltX.js. **Deliverables:** - ✓ Persistent signals (localStorage, sessionStorage, indexedDb) - ✓ Storage plugin (`data-volt-persist`) @@ -126,8 +135,8 @@ _NOTE_: `data-x-*` is now `data-volt-*` ### Background Requests & Reactive Polling -**Goal:** Enable declarative background data fetching and periodic updates within the Volt.js runtime. -**Outcome:** Volt.js elements can fetch or refresh data automatically based on time, visibility, or reactive conditions. +**Goal:** Enable declarative background data fetching and periodic updates within the VoltX.js runtime. +**Outcome:** VoltX.js elements can fetch or refresh data automatically based on time, visibility, or reactive conditions. **Deliverables:** - `data-volt-visible` for fetching when an element enters the viewport (`IntersectionObserver`) - `data-volt-fetch` attribute for declarative background requests @@ -137,36 +146,10 @@ _NOTE_: `data-x-*` is now `data-volt-*` - Integration hooks for loading and pending states - Background task scheduler with priority management -### Navigation & History Management - -**Goal:** Introduce seamless client-side navigation and stateful history control using web standards. -**Outcome:** Volt.js provides enhanced navigation behavior with minimal overhead and full accessibility support. -**Deliverables:** - - `data-volt-navigate` for intercepting link and form actions - - Integration with the History API (`pushState`, `replaceState`, `popState`) - - Reactive synchronization of route and signal state - - Smooth page and fragment transitions coordinated with Volt’s signal system - - Native back/forward button support - - Scroll position persistence and restoration - - Preloading of linked resources on hover or idle - - `data-volt-url` for declarative history updates - - View Transition API integration for animated route changes - -### History API Routing Plugin - -**Goal:** Deliver a first-class path-based router that leverages the History API while staying signal-driven. -**Outcome:** Volt apps can opt into clean URLs (no hash) with back/forward support, nested segments, and SSR-friendly hydration. -**Deliverables:** - - `data-volt-url="history:signal"` mode with path + search preservation and optional base path configuration - - Route parsing utilities for dynamic params (e.g. `/blog/:slug`) and programmatic redirects - - Scroll restoration hooks and focus management aligned with `navigation` and `popstate` events - - Integration tests covering pushState navigation, deep links, and server-rendered bootstraps - - Documentation updates in `docs/usage/routing.md` contrasting hash vs. history strategies - ### Inspector & Developer Tools **Goal:** Improve developer experience and runtime introspection. -**Outcome:** First-class developer ergonomics; Volt.js is enjoyable to debug and extend. +**Outcome:** First-class developer ergonomics; VoltX.js is enjoyable to debug and extend. **Deliverables:** - Developer overlay for inspecting signals, subscriptions, and effects - Dev logging toggle (`Volt.debug = true`) @@ -180,7 +163,7 @@ _NOTE_: `data-x-*` is now `data-volt-*` ### Stable Release **Goal:** Prepare & ship the stable release -**Outcome:** Volt.js 1.0 is stable, documented, performant, and ready for production. +**Outcome:** VoltX.js 1.0 is stable, documented, performant, and ready for production. **Deliverables:** - ✓ Documentation site (VitePress) - Full API reference with examples diff --git a/docs/plugins/navigate.md b/docs/plugins/navigate.md new file mode 100644 index 0000000..8f7a5da --- /dev/null +++ b/docs/plugins/navigate.md @@ -0,0 +1,82 @@ +--- +outline: deep +--- + +# Navigate Plugin + +The navigate plugin upgrades plain links and forms with client-side navigation, History API integration, and optional +View Transition animations. It keeps your DOM-driven pages feeling app-like without giving up regular hyperlinks. + +## Quick Start + +```html + +About + + +
+ + +
+``` + +`data-volt-navigate` applies to `` and `
` elements. Links use their `href`; forms default to `action` (or the current pathname) and serialize inputs into the query string for GET submissions. + +## Modifiers + +Attach modifiers with dot notation or suffixed attribute names (`data-volt-navigate-replace`). + +- `replace` - call `history.replaceState` instead of `pushState`; good for redirects or idempotent flows. +- `prefetch` - issue a `` when the element is hovered or focused to warm the cache. +- `notransition` - skip View Transition API usage, falling back to an immediate DOM swap. + +```html +Settings +Pricing +Skip intro +``` + +## View Transitions + +By default the plugin wraps navigations in `startViewTransition` using the `"page-transition"` name. Use the +`notransition` modifier to disable it per element, or switch names when navigating imperatively: + +```ts +import { navigate } from "volt/plugins/navigate"; + +await navigate("/projects/42", { transitionName: "project-detail" }); +``` + +## Programmatic APIs + +Import helpers straight from `lib/src/plugins/navigate.ts` (re-exported by the runtime build): + +```ts +import { goBack, goForward, initNavigationListener, navigate, redirect } from "volt/plugins/navigate"; + +await navigate("/projects/123", { replace: false, transitionName: "detail" }); +redirect("/login"); // always uses replace +goBack(); +goForward(); + +// Restore scroll on history navigation +const stop = initNavigationListener(); +// Later: stop(); +``` + +`initNavigationListener` should run once during boot to restore scroll positions when users hit the back/forward +buttons. It also emits a `volt:popstate` event mirroring the browser’s `popstate`. + +## Events + +Every navigation dispatches: + +- `volt:navigate` after the History API call, with `{ url, replace }` in `event.detail`. +- `volt:popstate` from the history listener, with `{ state }` in `event.detail`. + +Use these to re-fetch data, invalidate caches, or sync routing signals for plugins such as `url`. + +## Handling External Links + +Navigation only intercepts same-origin URLs and primary-button clicks without modifier keys. +External links, middle clicks, and `target="_blank"` continue to behave like normal browser navigation, preserving accessibility expectations. diff --git a/docs/plugins/shift.md b/docs/plugins/shift.md new file mode 100644 index 0000000..61a46a4 --- /dev/null +++ b/docs/plugins/shift.md @@ -0,0 +1,91 @@ +--- +outline: deep +--- + +# Shift Plugin + +The shift plugin applies reusable CSS keyframe animations to any element. Use it for attention-grabbing nudges, loading +states, or signal-driven feedback without writing imperative animation code. + +## Quick Start + +```html + + + + +Loading… +``` + +When an element mounts the plugin pulls a preset from the animation registry and calls the Web Animations API with the +configured keyframes, duration, iterations, and easing. Users with `prefers-reduced-motion` skip the animation entirely. + +## Built-in Presets + +Volt ships with several presets you can reference immediately: + +- `bounce`-snappy vertical movement for call-to-action buttons. +- `shake`-horizontal wiggle, ideal for error indicators. +- `pulse`-scale and opacity pulse that repeats forever. +- `spin`-continuous 360° rotation. +- `flash`-blinking opacity effect. + +## Custom Duration and Iterations + +Add dot-separated numbers after the preset to override timing settings. + +```html + +
Triple bounce
+``` + +The first number is duration in milliseconds; the optional second number controls iteration count. Omitted values fall +back to the preset configuration. + +## Reacting to Signals + +Prefix the binding with a signal path to trigger the animation whenever the signal changes from its previous value to a +truthy value. + +```html +
Please fix the highlighted fields
+``` + +For the snippet above: + +- `form.error` is resolved via `ctx.findSignal`. +- The element animates the first time the signal evaluates truthy. +- Subsequent updates run the animation whenever the value toggles and remains truthy. + +## Registering Custom Animations + +Use the programmatic API to add, inspect, or remove presets. + +```ts +import { getRegisteredAnimations, registerAnimation } from "volt/plugins/shift"; + +registerAnimation("wiggle", { + keyframes: [ + { offset: 0, transform: "rotate(0deg)" }, + { offset: 0.25, transform: "rotate(-5deg)" }, + { offset: 0.75, transform: "rotate(5deg)" }, + { offset: 1, transform: "rotate(0deg)" }, + ], + duration: 300, + iterations: 2, + timing: "ease-in-out", +}); + +console.log(getRegisteredAnimations()); // ["bounce", "shake", ..., "wiggle"] +``` + +Other helpers: + +- `getAnimation(name)` - fetch the preset definition. +- `hasAnimation(name)` - check existence. +- `unregisterAnimation(name)` - remove custom presets (built-ins cannot be deleted). + +## Cleanup + +Shift automatically cancels the underlying `element.animate` call on completion and removes subscriptions registered via signals. +No additional teardown is required beyond VoltX’s normal plugin lifecycle. diff --git a/docs/plugins/surge.md b/docs/plugins/surge.md new file mode 100644 index 0000000..c1bfa4f --- /dev/null +++ b/docs/plugins/surge.md @@ -0,0 +1,76 @@ +--- +outline: deep +--- + +# Surge Plugin + +The surge plugin powers enter/leave transitions for conditional DOM. It combines CSS property interpolation, optional +View Transitions, and a signal-aware state machine to animate elements appearing or disappearing. + +## Quick Start + +```html +
+

Panel content...

+
+``` + +- `isOpen` resolves to a signal. Falsy values hide the element (`display: none`). +- The `fade` preset runs when the signal flips to truthy and again in reverse when it returns to falsy. + +## Presets and Overrides + +`data-volt-surge="presetName"` attaches a preset from the transition registry without watching a signal. Combine with +granular variants when you need independent enter/leave control: + +```html +
+ ... +
+``` + +- `data-volt-surge:enter` and `:leave` use the same parsing logic as the core transition helpers (`duration.delay` + suffixes honored via `parseTransitionValue` and `applyOverrides`). +- When both shorthand and phase-specific attributes exist, the phase-specific value wins. + +## Signal Lifecycle + +When bound to a signal the plugin: + +1. Checks the initial signal value. Falsy values hide the element immediately. +2. Subscribes to the signal and debounces concurrent transitions so rapid toggles stay smooth. +3. Uses `execEnter`/`execLeave` helpers to apply styles, classes, delays, and easing. +4. Cleans up the subscription when the element unmounts. + +The underlying transition promise resolves before the element is marked visible or hidden, ensuring sequential updates +remain ordered. + +## View Transitions Integration + +Surge participates in the View Transition API whenever the preset’s config opts in (default). Calling variants like +`slide-down.400` runs inside `withViewTransition`, making swapping sections feel native. Add `:notransition` to your +navigate bindings if you need to avoid double animations when combining plugins. + +## Manual Execution + +Volt’s runtime calls the internal helpers automatically for keyed iterations and DOM diffs. If you render content +manually, you can trigger the same behavior: + +```ts +import { executeSurgeEnter, executeSurgeLeave, hasSurge } from "volt/plugins/surge"; + +if (hasSurge(el)) { + await executeSurgeEnter(el); +} +``` + +`hasSurge` checks whether the element owns any surge metadata (signal config or phase overrides) before attempting an +explicit enter/leave. + +## Reduced Motion + +When the user prefers reduced motion the plugin skips transitions, applies the `to` styles or classes immediately, and +avoids firing View Transition effects. This keeps the animation accessible without extra work on your part. diff --git a/docs/plugins/url.md b/docs/plugins/url.md new file mode 100644 index 0000000..291f141 --- /dev/null +++ b/docs/plugins/url.md @@ -0,0 +1,78 @@ +--- +outline: deep +--- + +# URL Plugin + +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. + +## Quick Start + +```html + +
+ + + +``` + +Each binding follows `mode:signalPath[:basePath]`. The plugin resolves the signal via `ctx.findSignal` and wires it to one of the strategies below. + +## Modes + +### `read` + +One-way hydration. On mount the plugin reads `?signalPath=value` and assigns it to the signal. Later signal updates do not modify the URL. + +```html +
+``` + +### `sync` + +Bidirectional query-string sync. The plugin: + +1. Seeds the signal from `?signalPath=...`. +2. Subscribes to the signal and pushes URL updates (debounced) via `history.pushState`. +3. Listens for `popstate` to keep the signal in sync when the user navigates back/forward. + +The `serializeValue`/`deserializeValue` helpers support strings, numbers, booleans, JSON payloads, and empty values. + +```html + +``` + +When the input changes the URL updates to `?search=...`. Clearing the input removes the parameter. + +### `hash` + +Two-way binding to `window.location.hash`. Useful for simple client-side routing or tab selection: + +```html + +``` + +- Updates to the signal call `history.pushState` with the new hash. +- `hashchange` events hydrate the signal when users edit the URL manually. + +### `history` + +Full routing synchronization with `pathname + search`. Optionally trim a base path when syncing: + +```html +
+``` + +- The signal receives `/` when the user is at `/app`. +- Pushing a new value updates the URL and dispatches `volt:navigate`. +- Browser back/forward emits `volt:popstate` and refreshes the signal. + +Combine this mode with the navigate plugin to keep a global router signal in lockstep with address bar changes. + +## Handling Missing Signals + +If the plugin cannot resolve `signalPath` it logs a descriptive error and aborts. Ensure your scope exports naming matches when wiring bindings. + +## Cleanup + +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. diff --git a/docs/usage/routing.md b/docs/usage/routing.md index e489130..5223de4 100644 --- a/docs/usage/routing.md +++ b/docs/usage/routing.md @@ -2,43 +2,62 @@ Client-side routing lets VoltX applications feel like multi-page sites without full page reloads. The `url` plugin keeps a signal in sync with the browser URL so your application can react declaratively to route changes. -This guide walks through building a hash-based router that swaps entire page sections while preserving the advantages -of VoltX's signal system. +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. ## Why? - **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. - **Shareable URLs:** Users can refresh or share a link such as `/#/pricing` and land directly on the same view. - **Declarative rendering:** Routing is just another signal; templates choose what to display with conditional bindings like `data-volt-if` or `data-volt-show`. -- **Simple integration:** No extra router dependency is required—register the plugin once and opt-in per signal. +- **Simple integration:** No extra router dependency is required. Register the plugin once and opt-in per signal. > The plugin also supports synchronising signals with query parameters (`read:` and `sync:` modes). > For multi-page navigation the `hash:` mode is the simplest option because it avoids server configuration and works on static hosting. -## How? +## Getting Started -1. Install Volt normally (see [Installation](../installation.md)). -2. Register the plugin before calling `charge()` or `mount()`: +1. Install Volt normally (see [Installation](../installation)). +2. Register the plugin before calling `charge()` or `mount()`. + Choose the import style that matches your setup: ```html + ``` -3. In your markup, opt a signal into hash synchronisation with `data-volt-url="hash:signalName"`. + ```ts + // src/main.ts — bundled projects + import { charge, initNavigationListener, registerPlugin, urlPlugin } from "voltx.js"; + + registerPlugin("url", urlPlugin); + initNavigationListener(); // restores scroll/focus when using history routing + + charge(); + ``` + +3. In your markup, opt a signal into URL synchronisation, for example `data-volt-url="hash:route"` or `data-volt-url="history:path"`. + +## URL modes at a glance + +| Mode | Binding example | Sync direction | Use Case | +| -------- | -------------------------------------- | --------------------------- | ------------------------------------------------------------------------------ | +| `read` | `data-volt-url="read:filter"` | URL ➝ signal on first mount | Hydrate initial state from a query param without mutating the URL afterwards. | +| `sync` | `data-volt-url="sync:sort"` | Bidirectional | Mirror a filter, tab, or feature flag in the query string. | +| `hash` | `data-volt-url="hash:route"` | Bidirectional | Build hash-based navigation that works on static hosts. | +| `history`| `data-volt-url="history:path:/app"` | Bidirectional | Reflect clean History API routes; strip a base path such as `/app` when needed.| + +> Mix and match bindings inside the same scope. +> It's common to pair `history:path` for the main route with `sync:` bindings for search filters or sort order. ## Building a multi-page shell -The example below delivers a three-page marketing site entirely on the client. Each "page" is a section that only renders -when the current route matches its slug. +The example below delivers a three-page marketing site entirely on the client. +Each "page" is a section that only renders when the current route matches its slug. ```html

Pricing

@@ -149,9 +168,252 @@ For example: Now `#/pricing?preview=true` keeps both the route and a feature flag in sync with the URL. 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. +Use `read:` instead of `sync:` when you only need to hydrate the initial value from the URL without mutating it. + +## History API Routing + +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. + +### Using history mode + +The `history:` mode syncs a signal with the browser pathname and search params, updating the URL via `history.pushState()` without page reloads: + +```html +
+ +
+``` + +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. + +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. + +### Base paths and nested apps + +When your app is served from a subdirectory, provide the base path as the third argument: + +```html +
+ +
+``` + +Volt automatically strips `/docs` from the signal value while keeping the full URL intact. + +### Link Interception + +The navigate directive ships with VoltX so there is no extra plugin registration required. It handles: + +- Click interception on anchor tags (respects Ctrl/Cmd+click for new tabs) +- Form GET submission as navigation +- Back/forward button support via popstate events +- Automatic scroll position restoration +- Optional View Transition API integration + +Use modifiers for control: + +- `data-volt-navigate.replace` - Use replaceState instead of pushState +- `data-volt-navigate.prefetch` - Prefetch on hover or focus +- `data-volt-navigate.prefetch.viewport` - Prefetch when entering viewport +- `data-volt-navigate.notransition` - Skip View Transitions + +### Programmatic navigation + +Import `navigate()`, `redirect()`, `goBack()`, or `goForward()` for JavaScript-driven routing: + +```typescript +import { navigate, redirect } from "voltx.js"; + +await navigate("/dashboard"); // Pushes state +await redirect("/login"); // Replaces state +``` + +Both functions return Promises that resolve after navigation completes, supporting View Transitions when available. + +### Navigation events + +History navigations emit custom events you can react to without polling: + +```ts +globalThis.addEventListener("volt:navigate", (event) => { + const { url, route } = event.detail; // route is present when dispatched by the url plugin + console.debug("navigated to", url, route ?? ""); +}); + +globalThis.addEventListener("volt:popstate", (event) => { + refreshDataFor(event.detail.route); +}); +``` + +Use these hooks to trigger data fetching, analytics, or other side effects whenever the active route changes. + +## Hash vs History Routing + +| Feature | Hash Mode | History Mode | +| ------------------ | ------------------------ | ------------------------------ | +| URL format | `/#/page` | `/page` | +| Server config | None required | Requires catch-all route | +| Browser history | Yes | Yes | +| SEO friendly | Limited | Full | +| Deep linking | Yes | Yes | +| Static hosting | Perfect | Needs fallback to index.html | +| Back/forward | Automatic via hashchange | Automatic via popstate | +| Scroll restoration | Manual | Automatic with navigate plugin | + +**Choose hash mode** when deploying to static hosting (GitHub Pages, Netlify without redirects) or when server configuration is unavailable. + +**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. + +## Route Parameters + +VoltX provides pattern matching utilities for extracting dynamic segments from URLs. + +### Pattern syntax + +Route patterns support: + +- Named parameters: `/blog/:slug` +- Optional parameters: `/blog/:category/:slug?` +- Wildcard parameters: `/files/*path` +- Multiple parameters: `/users/:userId/posts/:postId` + +### Using route utilities + +Import `matchRoute()`, `extractParams()`, or `buildPath()` from voltx.js to work with route patterns: + +```typescript +import { matchRoute, extractParams, buildPath } from "voltx.js"; + +const match = matchRoute("/blog/:slug", "/blog/hello-world"); +// { path: '/blog/hello-world', params: { slug: 'hello-world' }, pattern: '/blog/:slug' } + +const params = extractParams("/users/:id", "/users/42"); // { id: '42' } +const url = buildPath("/blog/:slug", { slug: "new-post" }); // '/blog/new-post' +``` + +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. + +### Declarative parameter extraction + +Rather than calling route utilities in methods, create computed signals that derive route data: + +```html +
+
+

+
+
+``` + +For more complex routing needs, register a custom method or use the programmatic API with the router utilities. + +## Data fetching on navigation + +Combine routing signals with `asyncEffect` to load data whenever the active path changes. +Abort signals prevent stale responses from updating the UI if the user navigates away mid-request. + +```ts +import { asyncEffect, matchRoute, registerPlugin, signal, urlPlugin } from "voltx.js"; + +const path = signal("/"); +const blogPost = signal(null); +const loading = signal(false); + +registerPlugin("url", urlPlugin); + +asyncEffect( + async (abortSignal) => { + const match = matchRoute("/blog/:slug", path.get()); + if (!match) { + blogPost.set(null); + return; + } + + loading.set(true); + try { + const response = await fetch(`/api/posts/${match.params.slug}`, { signal: abortSignal }); + if (!response.ok) throw new Error("Failed to load post"); + blogPost.set(await response.json()); + } finally { + loading.set(false); + } + }, + [path], + { abortable: true }, +); +``` + +Bind `blogPost` and `loading` into your template (`data-volt-if="blogPost"` etc.) to show the fetched content once it arrives. + +## View Transitions + +The navigate directive automatically integrates with the View Transitions API when available, providing smooth cross-fade animations between page navigations. + +### Automatic transitions + +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. + +### Customizing transitions + +Control transition behavior with CSS using view-transition pseudo-elements: + +```css +::view-transition-old(root), +::view-transition-new(root) { + animation-duration: 0.3s; +} + +::view-transition-old(root) { + animation-name: fade-out; +} + +::view-transition-new(root) { + animation-name: fade-in; +} +``` + +Disable transitions per-navigation using the `.notransition` modifier or pass `transition: false` to programmatic navigation functions. + +## Focus Management & Accessibility + +The navigate plugin includes automatic focus management for keyboard navigation and screen reader users. + +On forward navigation, focus moves to the main content area (searches for `
`, `[role="main"]`, or `#main-content`) or the first `

` heading. On back/forward navigation, focus is restored to the previously focused element when possible. + +This ensures users navigating via keyboard don't lose their position in the document after navigation. + +View Transitions are automatically skipped in browsers without support or when `prefers-reduced-motion` is enabled. Navigation continues to work normally without visual transitions. + +## Scroll Restoration + +Scroll positions are automatically saved before navigation and restored when using the browser back/forward buttons. +The navigate plugin maintains a map of scroll positions keyed by pathname. + +For custom scroll containers, use the scroll plugin's history mode: + +```html +
+ +
+``` + +This automatically saves and restores the scroll position of the container across navigations. ## Progressive Enhancement - Always provide semantic HTML in each section so the site remains usable without JavaScript or when crawled. -- Consider prefetching data when a link becomes visible: attach a watcher to `route` and trigger fetch logic from the programmatic API. -- Use `scrollPlugin` for auto-scrolling on navigation if you have tall pages (`data-volt-scroll="route"`). +- Prefetch data when a link becomes visible by combining navigation events with `asyncEffect` or the `data-volt-navigate.prefetch` modifier. +- Use the scroll plugin's history mode for tall pages (`data-volt-scroll="history"`). diff --git a/lib/src/core/modifiers.ts b/lib/src/core/modifiers.ts index 9163ebb..5c63d05 100644 --- a/lib/src/core/modifiers.ts +++ b/lib/src/core/modifiers.ts @@ -43,6 +43,9 @@ export function parseModifiers(attrName: string): ParsedAttribute { "number", "trim", "lazy", + "replace", + "prefetch", + "notransition", ]); let i = 1; diff --git a/lib/src/core/router.ts b/lib/src/core/router.ts new file mode 100644 index 0000000..9ed5c2b --- /dev/null +++ b/lib/src/core/router.ts @@ -0,0 +1,305 @@ +/** + * Route utilities for pattern matching and parameter extraction + * + * Provides utilities for dynamic route matching with support for: + * - Named parameters: /blog/:slug + * - Wildcard parameters: /files/*path + * - Optional parameters: /blog/:slug? + * - Multiple parameters: /users/:userId/posts/:postId + */ + +import type { Optional } from "$types/helpers"; + +/** + * Route match result containing extracted parameters + */ +export type RouteMatch = { path: string; params: Record; pattern: string }; + +/** + * Compiled route pattern for efficient matching + */ +type CompiledRoute = { + pattern: string; + regex: RegExp; + keys: Array<{ name: string; optional: boolean; wildcard: boolean }>; +}; + +const routeCache = new Map(); + +/** + * Compile a route pattern into a regex for efficient matching + * + * Supported patterns: + * - /blog/:slug - Named parameter + * - /blog/:slug? - Optional parameter + * - /files/*path - Wildcard (matches rest of path) + * - /users/:userId/posts/:postId - Multiple parameters + * + * @param pattern - Route pattern to compile + * @returns Compiled route with regex and parameter keys + * + * @example + * ```typescript + * const route = compileRoute('/blog/:slug'); + * const match = route.regex.exec('/blog/hello-world'); + * // match[1] === 'hello-world' + * ``` + */ +export function compileRoute(pattern: string): CompiledRoute { + if (routeCache.has(pattern)) { + return routeCache.get(pattern)!; + } + + const keys: Array<{ name: string; optional: boolean; wildcard: boolean }> = []; + + // Build regex pattern by processing each part + let regexPattern = ""; + let i = 0; + + while (i < pattern.length) { + // Check for parameter :name or :name? + if (pattern[i] === ":") { + const paramMatch = pattern.slice(i).match(/^:(\w+)(\?)?/); + if (paramMatch) { + const [fullMatch, name, optional] = paramMatch; + keys.push({ name, optional: Boolean(optional), wildcard: false }); + + if (optional) { + // For optional params, include the preceding / in the optional group + // Remove trailing / from regexPattern if present + if (regexPattern.endsWith("/")) { + regexPattern = regexPattern.slice(0, -1); + } + regexPattern += "(?:/([^/?]+))?"; + } else { + // Required params: just the capture group (/ already processed) + regexPattern += "([^/?]+)"; + } + + i += fullMatch.length; + continue; + } + } + + // Check for wildcard *name + if (pattern[i] === "*") { + const wildcardMatch = pattern.slice(i).match(/^\*(\w+)/); + if (wildcardMatch) { + const [fullMatch, name] = wildcardMatch; + keys.push({ name, optional: false, wildcard: true }); + regexPattern += "(.*)"; + i += fullMatch.length; + continue; + } + } + + // Escape special regex characters for literal matching + const char = pattern[i]; + if (".+?^${}()|[]\\".includes(char)) { + regexPattern += `\\${char}`; + } else { + regexPattern += char; + } + i++; + } + + // Create regex with anchors + const regex = new RegExp(`^${regexPattern}$`); + + const compiled: CompiledRoute = { pattern, regex, keys }; + routeCache.set(pattern, compiled); + + return compiled; +} + +/** + * Match a path against a route pattern and extract parameters + * + * @param pattern - Route pattern (e.g., '/blog/:slug') + * @param path - Path to match (e.g., '/blog/hello-world') + * @returns RouteMatch with extracted params, or undefined if no match + * + * @example + * ```typescript + * const match = matchRoute('/blog/:slug', '/blog/hello-world'); + * // { path: '/blog/hello-world', params: { slug: 'hello-world' }, pattern: '/blog/:slug' } + * + * const noMatch = matchRoute('/blog/:slug', '/about'); + * // undefined + * ``` + */ +export function matchRoute(pattern: string, path: string): Optional { + const compiled = compileRoute(pattern); + const match = compiled.regex.exec(path); + + if (!match) { + return undefined; + } + + const params: Record = {}; + + for (const [index, key] of compiled.keys.entries()) { + const value = match[index + 1]; + if (value !== undefined) { + params[key.name] = decodeURIComponent(value); + } + } + + return { path, params, pattern }; +} + +/** + * Match a path against multiple route patterns and return the first match + * + * @param patterns - Array of route patterns to try + * @param path - Path to match + * @returns First matching RouteMatch, or undefined if no match + * + * @example + * ```typescript + * const routes = ['/blog/:slug', '/users/:id', '/about']; + * const match = matchRoutes(routes, '/users/123'); + * // { path: '/users/123', params: { id: '123' }, pattern: '/users/:id' } + * ``` + */ +export function matchRoutes(patterns: string[], path: string): Optional { + for (const pattern of patterns) { + const match = matchRoute(pattern, path); + if (match) { + return match; + } + } + return undefined; +} + +/** + * Extract parameters from a path using a route pattern + * + * @param pattern - Route pattern with parameters + * @param path - Path to extract from + * @returns Object with extracted parameters, or empty object if no match + * + * @example + * ```typescript + * const params = extractParams('/blog/:slug', '/blog/hello-world'); + * // { slug: 'hello-world' } + * + * const params2 = extractParams('/users/:userId/posts/:postId', '/users/42/posts/123'); + * // { userId: '42', postId: '123' } + * ``` + */ +export function extractParams(pattern: string, path: string): Record { + const match = matchRoute(pattern, path); + return match ? match.params : {}; +} + +/** + * Build a path from a pattern by replacing parameters + * + * @param pattern - Route pattern with parameters + * @param params - Parameters to insert + * @returns Built path with parameters replaced + * + * @example + * ```typescript + * const path = buildPath('/blog/:slug', { slug: 'hello-world' }); + * // '/blog/hello-world' + * + * const path2 = buildPath('/users/:userId/posts/:postId', { userId: '42', postId: '123' }); + * // '/users/42/posts/123' + * ``` + */ +export function buildPath(pattern: string, params: Record): string { + let path = pattern; + + // Replace named parameters + for (const [key, value] of Object.entries(params)) { + const encoded = encodeURIComponent(value); + path = path.replace(`:${key}?`, encoded).replace(`:${key}`, encoded); + } + + // Remove optional parameters that weren't provided + path = path.replaceAll(/:(\w+)\?/g, ""); + + // Replace wildcards + for (const [key, value] of Object.entries(params)) { + path = path.replace(`*${key}`, value); + } + + return path; +} + +/** + * Check if a path matches a route pattern + * + * @param pattern - Route pattern + * @param path - Path to check + * @returns true if path matches pattern + * + * @example + * ```typescript + * isMatch('/blog/:slug', '/blog/hello-world'); // true + * isMatch('/blog/:slug', '/about'); // false + * ``` + */ +export function isMatch(pattern: string, path: string): boolean { + return matchRoute(pattern, path) !== undefined; +} + +/** + * Normalize a path by removing trailing slashes and ensuring leading slash + * + * @param path - Path to normalize + * @returns Normalized path + * + * @example + * ```typescript + * normalizePath('/blog/'); // '/blog' + * normalizePath('about'); // '/about' + * normalizePath('/'); // '/' + * ``` + */ +export function normalizePath(path: string): string { + // Ensure leading slash + if (!path.startsWith("/")) { + path = `/${path}`; + } + + // Remove trailing slash (except for root) + if (path.length > 1 && path.endsWith("/")) { + path = path.slice(0, -1); + } + + return path; +} + +/** + * Parse a URL into path and search params + * + * @param url - URL to parse (can be relative or absolute) + * @returns Object with path and searchParams + * + * @example + * ```typescript + * parseUrl('/blog?page=2&sort=date'); + * // { path: '/blog', searchParams: URLSearchParams { 'page' => '2', 'sort' => 'date' } } + * ``` + */ +export function parseUrl(url: string): { path: string; searchParams: URLSearchParams } { + try { + const urlObj = new URL(url, globalThis.location.origin); + return { path: urlObj.pathname, searchParams: urlObj.searchParams }; + } catch { + // If URL parsing fails, treat as relative path + const [path, search] = url.split("?"); + return { path: path || "/", searchParams: new URLSearchParams(search || "") }; + } +} + +/** + * Clear the route compilation cache + * Useful for testing or when patterns change dynamically + */ +export function clearRouteCache(): void { + routeCache.clear(); +} diff --git a/lib/src/index.ts b/lib/src/index.ts index 4a60e98..979b0e7 100644 --- a/lib/src/index.ts +++ b/lib/src/index.ts @@ -19,6 +19,18 @@ export { } from "$core/lifecycle"; export { clearPlugins, getRegisteredPlugins, hasPlugin, registerPlugin, unregisterPlugin } from "$core/plugin"; export { isReactive, reactive, toRaw } from "$core/reactive"; +export { + buildPath, + clearRouteCache, + compileRoute, + extractParams, + isMatch, + matchRoute, + matchRoutes, + normalizePath, + parseUrl, +} from "$core/router"; +export type { RouteMatch } from "$core/router"; export { getScopeMetadata } from "$core/scope-metadata"; export { computed, effect, signal } from "$core/signal"; export { deserializeScope, hydrate, isHydrated, isServerRendered, serializeScope } from "$core/ssr"; @@ -41,6 +53,7 @@ export { supportsViewTransitions, withViewTransition, } from "$core/view-transitions"; +export { goBack, goForward, initNavigationListener, navigate, navigatePlugin, redirect } from "$plugins/navigate"; export { persistPlugin, registerStorageAdapter } from "$plugins/persist"; export { scrollPlugin } from "$plugins/scroll"; export { diff --git a/lib/src/plugins/navigate.ts b/lib/src/plugins/navigate.ts new file mode 100644 index 0000000..d677e99 --- /dev/null +++ b/lib/src/plugins/navigate.ts @@ -0,0 +1,369 @@ +/** + * Navigate plugin for client-side navigation with History API + * + * Intercepts link clicks and form submissions. Integrates with the History API and View Transition API for smooth page transitions. + */ + +import { registerDirective } from "$core/binder"; +import { hasModifier, parseModifiers } from "$core/modifiers"; +import { startViewTransition } from "$core/view-transitions"; +import type { Optional } from "$types/helpers"; +import type { BindingContext, Modifier, PluginContext } from "$types/volt"; + +type NavigationState = { scrollPosition?: { x: number; y: number }; focusSelector?: string; timestamp: number }; + +type NavigationOpts = { replace?: boolean; transition?: boolean; transitionName?: string }; + +const scrollPositions = new Map(); +const focusSelectors = new Map(); + +/** + * Navigate directive handler for client-side navigation + * + * Syntax: data-volt-navigate[.modifiers]="url" or data-volt-navigate[.modifiers] (uses href) + * + * Modifiers: + * - .replace - Use replaceState instead of pushState + * - .prefetch - Prefetch resources on hover/idle + * - .notransition - Disable view transitions + * + * @example + * ```html + * About + * Home + * Blog + * Settings + * ``` + */ +export function bindNavigate(ctx: BindingContext, value: string, modifiers: Modifier[] = []): void { + const element = ctx.element; + + if (element instanceof HTMLAnchorElement) { + handleLinkNavigation(ctx, value, modifiers); + } else if (element instanceof HTMLFormElement) { + handleFormNavigation(ctx, value, modifiers); + } else { + console.warn("data-volt-navigate only works on and elements"); + } +} + +/** + * Plugin-compatible wrapper for navigate directive + * @deprecated Use bindNavigate directly or register as a directive + */ +export function navigatePlugin(ctx: PluginContext, value: string): void { + const { baseName, modifiers } = parseModifiers(value || ""); + const bindingCtx: BindingContext = { element: ctx.element, scope: ctx.scope, cleanups: [] }; + + bindNavigate(bindingCtx, baseName, modifiers); + + for (const cleanup of bindingCtx.cleanups) { + ctx.addCleanup(cleanup); + } +} + +function handleLinkNavigation(ctx: BindingContext, value: string, modifiers: Modifier[]): void { + const link = ctx.element as HTMLAnchorElement; + const targetUrl = value || link.getAttribute("href"); + + if (!targetUrl) { + console.warn("data-volt-navigate: no URL specified and no href found"); + return; + } + + if (hasModifier(modifiers, "prefetch")) { + const viewportPrefetch = hasModifier(modifiers, "viewport"); + setupPrefetch(link, targetUrl, { viewport: viewportPrefetch }); + } + + const clickHandler = async (event: MouseEvent) => { + if (event.ctrlKey || event.metaKey || event.shiftKey || event.button !== 0) { + return; + } + + if (isExternalLink(targetUrl)) { + return; + } + + event.preventDefault(); + + const useReplace = hasModifier(modifiers, "replace"); + const useTransition = !hasModifier(modifiers, "notransition"); + + await navigateTo(targetUrl, { replace: useReplace, transition: useTransition, transitionName: "page-transition" }); + }; + + link.addEventListener("click", clickHandler); + ctx.cleanups.push(() => link.removeEventListener("click", clickHandler)); +} + +function handleFormNavigation(ctx: BindingContext, value: string, modifiers: Modifier[]): void { + const form = ctx.element as HTMLFormElement; + const targetUrl = value || form.getAttribute("action") || globalThis.location.pathname; + + const submitHandler = async (event: SubmitEvent) => { + event.preventDefault(); + + const formData = new FormData(form); + const method = form.method.toLowerCase(); + const useReplace = hasModifier(modifiers, "replace"); + const useTransition = !hasModifier(modifiers, "notransition"); + + if (method === "get") { + // TODO: serialize FormData + const params = new URLSearchParams(formData as any); + const url = `${targetUrl}?${params.toString()}`; + await navigateTo(url, { replace: useReplace, transition: useTransition, transitionName: "page-transition" }); + } else { + console.warn("data-volt-navigate: POST/PUT/PATCH forms should use data-volt-post/put/patch"); + } + }; + + form.addEventListener("submit", submitHandler); + ctx.cleanups.push(() => form.removeEventListener("submit", submitHandler)); +} + +async function navigateTo(url: string, options: NavigationOpts = {}): Promise { + const { replace = false, transition = true, transitionName = "page-transition" } = options; + const currentKey = `${globalThis.location.pathname}${globalThis.location.search}`; + scrollPositions.set(currentKey, { x: window.scrollX, y: window.scrollY }); + + const activeElement = document.activeElement; + const focusSelector = activeElement && activeElement !== document.body + ? getElementSelector(activeElement) + : undefined; + if (focusSelector) { + focusSelectors.set(currentKey, focusSelector); + } + + const state: NavigationState = { + scrollPosition: { x: window.scrollX, y: window.scrollY }, + focusSelector, + timestamp: Date.now(), + }; + + const performNavigation = async () => { + if (replace) { + globalThis.history.replaceState(state, "", url); + } else { + globalThis.history.pushState(state, "", url); + } + + globalThis.dispatchEvent( + new CustomEvent("volt:navigate", { detail: { url, replace }, bubbles: true, cancelable: false }), + ); + + window.scrollTo(0, 0); + + resetFocusAfterNavigation(); + }; + + if (transition && typeof transitionName === "string") { + await startViewTransition(performNavigation, { name: transitionName }); + } else { + await performNavigation(); + } +} + +/** + * Generate a unique selector for an element (for focus restoration) + * Tries id, then name, then data attributes, then position-based selector + */ +function getElementSelector(element: Element): Optional { + if (element.id) { + return `#${element.id}`; + } + + if (element.hasAttribute("name")) { + const name = element.getAttribute("name"); + const tag = element.tagName.toLowerCase(); + return `${tag}[name="${name}"]`; + } + + for (const attr of element.attributes) { + if (attr.name.startsWith("data-volt-")) { + return `[${attr.name}="${attr.value}"]`; + } + } + + if (element.hasAttribute("aria-label")) { + const label = element.getAttribute("aria-label"); + return `[aria-label="${label}"]`; + } + + const parent = element.parentElement; + if (!parent) return undefined; + + const siblings = [...parent.children]; + const index = siblings.indexOf(element); + const tag = element.tagName.toLowerCase(); + + return `${tag}:nth-child(${index + 1})`; +} + +/** + * Reset focus to a sensible location after navigation + * Tries to focus main content area or first focusable element + */ +function resetFocusAfterNavigation(): void { + const main = document.querySelector("main, [role='main'], #main-content"); + if (main instanceof HTMLElement && main.tabIndex < 0) { + main.tabIndex = -1; + } + + if (main instanceof HTMLElement) { + main.focus({ preventScroll: true }); + return; + } + + const firstHeading = document.querySelector("h1"); + if (firstHeading instanceof HTMLElement) { + if (firstHeading.tabIndex < 0) { + firstHeading.tabIndex = -1; + } + firstHeading.focus({ preventScroll: true }); + return; + } + + document.body.focus({ preventScroll: true }); +} + +/** + * Restore focus to the previously focused element (for back/forward navigation) + */ +function restoreFocus(selector: string): boolean { + try { + const element = document.querySelector(selector); + if (element instanceof HTMLElement) { + element.focus({ preventScroll: true }); + return true; + } + } catch (error) { + console.warn(`Could not restore focus to selector: ${selector}`, error); + } + return false; +} + +function isExternalLink(url: string): boolean { + try { + const target = new URL(url, globalThis.location.origin); + return target.origin !== globalThis.location.origin; + } catch { + return false; + } +} + +/** + * Setup resource prefetching for a link + * + * By default, prefetches on hover/focus (interaction-based). + * With viewport option, prefetches when element enters viewport (IntersectionObserver). + */ +function setupPrefetch(element: HTMLElement, url: string, opts: { viewport?: boolean } = {}): void { + const { viewport = false } = opts; + let prefetched = false; + + const prefetch = () => { + if (prefetched) return; + prefetched = true; + + fetch(url, { method: "GET", priority: "low", credentials: "same-origin" } as RequestInit).catch(() => { + const link = document.createElement("link"); + link.rel = "prefetch"; + link.href = url; + document.head.append(link); + }); + }; + + if (viewport) { + const observer = new IntersectionObserver((entries) => { + for (const entry of entries) { + if (entry.isIntersecting) { + prefetch(); + observer.disconnect(); + } + } + }, { rootMargin: "50px" }); + + observer.observe(element); + } else { + element.addEventListener("mouseenter", prefetch, { once: true, passive: true }); + element.addEventListener("focus", prefetch, { once: true, passive: true }); + } +} + +/** + * Initialize popstate listener for back/forward navigation + * Should be called once on app initialization + */ +export function initNavigationListener(): () => void { + const handlePopState = (event: PopStateEvent) => { + const state = event.state as NavigationState | null; + + const key = `${globalThis.location.pathname}${globalThis.location.search}`; + const savedPosition = scrollPositions.get(key); + const savedFocus = focusSelectors.get(key); + + if (savedPosition) { + window.scrollTo(savedPosition.x, savedPosition.y); + } else if (state?.scrollPosition) { + window.scrollTo(state.scrollPosition.x, state.scrollPosition.y); + } + + if (savedFocus) { + restoreFocus(savedFocus); + } else if (state?.focusSelector) { + restoreFocus(state.focusSelector); + } else { + resetFocusAfterNavigation(); + } + + globalThis.dispatchEvent(new CustomEvent("volt:popstate", { detail: { state }, bubbles: true, cancelable: false })); + }; + + globalThis.addEventListener("popstate", handlePopState); + + return () => { + globalThis.removeEventListener("popstate", handlePopState); + }; +} + +/** + * Programmatic navigation helper + * + * @param url - URL to navigate to + * @param options - Navigation options + * + * @example + * ```typescript + * import { navigate } from 'voltx.js'; + * + * navigate('/dashboard', { replace: true }); + * ``` + */ +export function navigate(url: string, options?: NavigationOpts): Promise { + return navigateTo(url, options); +} + +/** + * Go back in history + */ +export function goBack(): void { + globalThis.history.back(); +} + +/** + * Go forward in history + */ +export function goForward(): void { + globalThis.history.forward(); +} + +/** + * Redirect to a URL (alias for navigate with replace: true) + */ +export function redirect(url: string): Promise { + return navigateTo(url, { replace: true }); +} + +registerDirective("navigate", bindNavigate); diff --git a/lib/src/plugins/scroll.ts b/lib/src/plugins/scroll.ts index 97fc690..915eb27 100644 --- a/lib/src/plugins/scroll.ts +++ b/lib/src/plugins/scroll.ts @@ -8,17 +8,23 @@ import type { PluginContext, Signal } from "$types/volt"; /** * Scroll plugin handler to manage various scroll-related behaviors. * - * Syntax: data-volt-scroll="mode:signalPath" + * Syntax: data-volt-scroll="mode:signalPath" or data-volt-scroll="mode" * Modes: * - restore:signalPath - Save/restore scroll position * - scrollTo:signalPath - Scroll to element when signal changes * - spy:signalPath - Update signal when element is visible * - smooth:signalPath - Enable smooth scrolling behavior + * - history - Integrate with navigation history (auto save/restore on navigation) */ export function scrollPlugin(ctx: PluginContext, value: string): void { + if (value === "history") { + handleScrollHistory(ctx); + return; + } + const parts = value.split(":"); if (parts.length !== 2) { - console.error(`Invalid scroll binding: "${value}". Expected format: "mode:signalPath"`); + console.error(`Invalid scroll binding: "${value}". Expected format: "mode:signalPath" or "history"`); return; } @@ -157,3 +163,37 @@ function handleSmoothScroll(ctx: PluginContext, signalPath: string): void { element.style.scrollBehavior = ""; }); } + +/** + * Integrate scroll position with browser history + * Automatically saves and restores scroll position on navigation + * Works with volt:navigate and volt:popstate events + */ +function handleScrollHistory(ctx: PluginContext): void { + const element = ctx.element as HTMLElement; + const scrollPositions = new Map(); + + const handleNavigate = () => { + const key = `${globalThis.location.pathname}${globalThis.location.search}`; + scrollPositions.set(key, element.scrollTop); + }; + + const handlePopstate = () => { + const key = `${globalThis.location.pathname}${globalThis.location.search}`; + const savedPosition = scrollPositions.get(key); + + if (savedPosition !== undefined) { + requestAnimationFrame(() => { + element.scrollTop = savedPosition; + }); + } + }; + + globalThis.addEventListener("volt:navigate", handleNavigate); + globalThis.addEventListener("volt:popstate", handlePopstate); + + ctx.addCleanup(() => { + globalThis.removeEventListener("volt:navigate", handleNavigate); + globalThis.removeEventListener("volt:popstate", handlePopstate); + }); +} diff --git a/lib/src/plugins/url.ts b/lib/src/plugins/url.ts index f534c05..9f82d14 100644 --- a/lib/src/plugins/url.ts +++ b/lib/src/plugins/url.ts @@ -9,22 +9,23 @@ import type { PluginContext, Signal } from "$types/volt"; /** * URL plugin handler. - * Synchronizes signal values with URL parameters and hash. + * Synchronizes signal values with URL parameters, hash, and full history state. * - * Syntax: data-volt-url="mode:signalPath" + * Syntax: data-volt-url="mode:signalPath" or data-volt-url="mode:signalPath:basePath" * Modes: * - read:signalPath - Read URL param into signal on mount (one-way) * - sync:signalPath - Bidirectional sync between signal and URL param * - hash:signalPath - Sync with hash portion for routing + * - history:signalPath[:basePath] - Sync with full path + search (History API routing) */ export function urlPlugin(ctx: PluginContext, value: string): void { const parts = value.split(":"); - if (parts.length !== 2) { - console.error(`Invalid url binding: "${value}". Expected format: "mode:signalPath"`); + if (parts.length < 2) { + console.error(`Invalid url binding: "${value}". Expected format: "mode:signalPath[:basePath]"`); return; } - const [mode, signalPath] = parts.map((p) => p.trim()); + const [mode, signalPath, basePath] = parts.map((p) => p.trim()); switch (mode) { case "read": { @@ -39,6 +40,10 @@ export function urlPlugin(ctx: PluginContext, value: string): void { handleHashRouting(ctx, signalPath); break; } + case "history": { + handleHistoryRouting(ctx, signalPath, basePath); + break; + } default: { console.error(`Unknown url mode: "${mode}"`); } @@ -215,3 +220,74 @@ function deserializeValue(value: string): unknown { return value; } } + +/** + * Sync signal with full path + search params for History API routing. + * Bidirectional sync between signal and window.location.pathname + search. + * + * @param ctx - Plugin context + * @param signalPath - Signal path to sync + * @param basePath - Optional base path to strip from routes (e.g., "/app") + */ +function handleHistoryRouting(ctx: PluginContext, signalPath: string, basePath?: string): void { + const signal = ctx.findSignal(signalPath); + if (!signal) { + console.error(`Signal "${signalPath}" not found for history routing`); + return; + } + + const base = basePath || ""; + const getCurrentRoute = (): string => { + const fullPath = globalThis.location.pathname + globalThis.location.search; + if (base && fullPath.startsWith(base)) { + return fullPath.slice(base.length) || "/"; + } + return fullPath; + }; + + const currentRoute = getCurrentRoute(); + if (currentRoute) { + (signal as Signal).set(currentRoute); + } + + let isUpdatingFromHistory = false; + + const updateUrl = (value: unknown) => { + if (isUpdatingFromHistory) return; + + const route = String(value ?? "/"); + const fullPath = base ? `${base}${route}` : route; + + if (globalThis.location.pathname + globalThis.location.search !== fullPath) { + globalThis.history.pushState({}, "", fullPath); + globalThis.dispatchEvent( + new CustomEvent("volt:navigate", { detail: { url: fullPath, route }, bubbles: true, cancelable: false }), + ); + } + }; + + const handlePopState = () => { + isUpdatingFromHistory = true; + const route = getCurrentRoute(); + (signal as Signal).set(route); + globalThis.dispatchEvent(new CustomEvent("volt:popstate", { detail: { route }, bubbles: true, cancelable: false })); + isUpdatingFromHistory = false; + }; + + const handleNavigate = () => { + isUpdatingFromHistory = true; + const route = getCurrentRoute(); + (signal as Signal).set(route); + isUpdatingFromHistory = false; + }; + + const unsubscribe = signal.subscribe(updateUrl); + globalThis.addEventListener("popstate", handlePopState); + globalThis.addEventListener("volt:navigate", handleNavigate); + + ctx.addCleanup(() => { + unsubscribe(); + globalThis.removeEventListener("popstate", handlePopState); + globalThis.removeEventListener("volt:navigate", handleNavigate); + }); +} diff --git a/lib/test/core/router.test.ts b/lib/test/core/router.test.ts new file mode 100644 index 0000000..ce61b7f --- /dev/null +++ b/lib/test/core/router.test.ts @@ -0,0 +1,400 @@ +import { + buildPath, + clearRouteCache, + compileRoute, + extractParams, + isMatch, + matchRoute, + matchRoutes, + normalizePath, + parseUrl, +} from "$core/router"; +import { beforeEach, describe, expect, it } from "vitest"; + +describe("router utilities", () => { + beforeEach(() => { + clearRouteCache(); + }); + + describe("compileRoute", () => { + it("compiles a simple pattern with single parameter", () => { + const compiled = compileRoute("/blog/:slug"); + expect(compiled.pattern).toBe("/blog/:slug"); + expect(compiled.keys).toHaveLength(1); + expect(compiled.keys[0]).toEqual({ name: "slug", optional: false, wildcard: false }); + }); + + it("compiles a pattern with multiple parameters", () => { + const compiled = compileRoute("/users/:userId/posts/:postId"); + expect(compiled.keys).toHaveLength(2); + expect(compiled.keys[0]).toEqual({ name: "userId", optional: false, wildcard: false }); + expect(compiled.keys[1]).toEqual({ name: "postId", optional: false, wildcard: false }); + }); + + it("compiles a pattern with optional parameter", () => { + const compiled = compileRoute("/blog/:slug?"); + expect(compiled.keys).toHaveLength(1); + expect(compiled.keys[0]).toEqual({ name: "slug", optional: true, wildcard: false }); + }); + + it("compiles a pattern with wildcard parameter", () => { + const compiled = compileRoute("/files/*path"); + expect(compiled.keys).toHaveLength(1); + expect(compiled.keys[0]).toEqual({ name: "path", optional: false, wildcard: true }); + }); + + it("caches compiled routes", () => { + const first = compileRoute("/blog/:slug"); + const second = compileRoute("/blog/:slug"); + expect(first).toBe(second); + }); + + it("creates valid regex for pattern matching", () => { + const compiled = compileRoute("/blog/:slug"); + expect(compiled.regex.test("/blog/hello-world")).toBe(true); + expect(compiled.regex.test("/about")).toBe(false); + }); + }); + + describe("matchRoute", () => { + it("matches a simple route with one parameter", () => { + const match = matchRoute("/blog/:slug", "/blog/hello-world"); + expect(match).toEqual({ path: "/blog/hello-world", params: { slug: "hello-world" }, pattern: "/blog/:slug" }); + }); + + it("returns undefined for non-matching routes", () => { + const match = matchRoute("/blog/:slug", "/about"); + expect(match).toBeUndefined(); + }); + + it("matches routes with multiple parameters", () => { + const match = matchRoute("/users/:userId/posts/:postId", "/users/42/posts/123"); + expect(match).toBeDefined(); + expect(match!.params).toEqual({ userId: "42", postId: "123" }); + }); + + it("matches optional parameters when present", () => { + const match = matchRoute("/blog/:category/:slug?", "/blog/tech/hello"); + expect(match).toBeDefined(); + expect(match!.params).toEqual({ category: "tech", slug: "hello" }); + }); + + it("matches optional parameters when absent", () => { + const match = matchRoute("/blog/:category/:slug?", "/blog/tech"); + expect(match).toBeDefined(); + expect(match!.params).toEqual({ category: "tech" }); + }); + + it("matches wildcard parameters", () => { + const match = matchRoute("/files/*path", "/files/docs/guide/intro.md"); + expect(match).toBeDefined(); + expect(match!.params).toEqual({ path: "docs/guide/intro.md" }); + }); + + it("decodes URI components in parameters", () => { + const match = matchRoute("/blog/:slug", "/blog/hello%20world"); + expect(match).toBeDefined(); + expect(match!.params.slug).toBe("hello world"); + }); + + it("handles static routes without parameters", () => { + const match = matchRoute("/about", "/about"); + expect(match).toEqual({ path: "/about", params: {}, pattern: "/about" }); + }); + + it("does not match partial paths", () => { + const match = matchRoute("/blog/:slug", "/blog/hello/extra"); + expect(match).toBeUndefined(); + }); + + it("handles routes with special characters", () => { + const match = matchRoute("/api/v1/users/:id", "/api/v1/users/42"); + expect(match).toBeDefined(); + expect(match!.params).toEqual({ id: "42" }); + }); + }); + + describe("matchRoutes", () => { + it("returns first matching route", () => { + const patterns = ["/about", "/blog/:slug", "/users/:id"]; + const match = matchRoutes(patterns, "/blog/hello-world"); + expect(match).toBeDefined(); + expect(match!.pattern).toBe("/blog/:slug"); + expect(match!.params).toEqual({ slug: "hello-world" }); + }); + + it("tries patterns in order", () => { + const patterns = ["/blog/:slug", "/blog/featured"]; + const match = matchRoutes(patterns, "/blog/featured"); + expect(match).toBeDefined(); + expect(match!.pattern).toBe("/blog/:slug"); + expect(match!.params).toEqual({ slug: "featured" }); + }); + + it("returns undefined when no routes match", () => { + const patterns = ["/about", "/blog/:slug"]; + const match = matchRoutes(patterns, "/products"); + expect(match).toBeUndefined(); + }); + + it("handles empty pattern array", () => { + const match = matchRoutes([], "/any-path"); + expect(match).toBeUndefined(); + }); + }); + + describe("extractParams", () => { + it("extracts parameters from matching route", () => { + const params = extractParams("/blog/:slug", "/blog/hello-world"); + expect(params).toEqual({ slug: "hello-world" }); + }); + + it("returns empty object for non-matching route", () => { + const params = extractParams("/blog/:slug", "/about"); + expect(params).toEqual({}); + }); + + it("extracts multiple parameters", () => { + const params = extractParams("/users/:userId/posts/:postId", "/users/42/posts/123"); + expect(params).toEqual({ userId: "42", postId: "123" }); + }); + + it("handles optional parameters", () => { + const params1 = extractParams("/blog/:category/:slug?", "/blog/tech/hello"); + expect(params1).toEqual({ category: "tech", slug: "hello" }); + + const params2 = extractParams("/blog/:category/:slug?", "/blog/tech"); + expect(params2).toEqual({ category: "tech" }); + }); + + it("extracts wildcard parameters", () => { + const params = extractParams("/files/*path", "/files/docs/guide.md"); + expect(params).toEqual({ path: "docs/guide.md" }); + }); + }); + + describe("buildPath", () => { + it("builds path from pattern with single parameter", () => { + const path = buildPath("/blog/:slug", { slug: "hello-world" }); + expect(path).toBe("/blog/hello-world"); + }); + + it("builds path from pattern with multiple parameters", () => { + const path = buildPath("/users/:userId/posts/:postId", { userId: "42", postId: "123" }); + expect(path).toBe("/users/42/posts/123"); + }); + + it("URL-encodes parameter values", () => { + const path = buildPath("/blog/:slug", { slug: "hello world" }); + expect(path).toBe("/blog/hello%20world"); + }); + + it("handles optional parameters when provided", () => { + const path = buildPath("/blog/:category/:slug?", { category: "tech", slug: "hello" }); + expect(path).toBe("/blog/tech/hello"); + }); + + it("removes optional parameters when not provided", () => { + const path = buildPath("/blog/:category/:slug?", { category: "tech" }); + expect(path).toBe("/blog/tech/"); + }); + + it("builds path with wildcard parameters", () => { + const path = buildPath("/files/*path", { path: "docs/guide.md" }); + expect(path).toBe("/files/docs/guide.md"); + }); + + it("handles special characters in parameters", () => { + const path = buildPath("/search/:query", { query: "hello+world" }); + expect(path).toBe("/search/hello%2Bworld"); + }); + + it("leaves unmatched placeholders as-is", () => { + const path = buildPath("/users/:userId/posts/:postId", { userId: "42" }); + expect(path).toContain(":postId"); + }); + }); + + describe("isMatch", () => { + it("returns true for matching routes", () => { + expect(isMatch("/blog/:slug", "/blog/hello-world")).toBe(true); + }); + + it("returns false for non-matching routes", () => { + expect(isMatch("/blog/:slug", "/about")).toBe(false); + }); + + it("works with complex patterns", () => { + expect(isMatch("/users/:userId/posts/:postId", "/users/42/posts/123")).toBe(true); + expect(isMatch("/users/:userId/posts/:postId", "/users/42")).toBe(false); + }); + + it("handles optional parameters", () => { + expect(isMatch("/blog/:category/:slug?", "/blog/tech/hello")).toBe(true); + expect(isMatch("/blog/:category/:slug?", "/blog/tech")).toBe(true); + }); + + it("handles wildcards", () => { + expect(isMatch("/files/*path", "/files/docs/guide/intro.md")).toBe(true); + }); + }); + + describe("normalizePath", () => { + it("ensures leading slash", () => { + expect(normalizePath("about")).toBe("/about"); + expect(normalizePath("blog/hello")).toBe("/blog/hello"); + }); + + it("removes trailing slash", () => { + expect(normalizePath("/blog/")).toBe("/blog"); + expect(normalizePath("/about/us/")).toBe("/about/us"); + }); + + it("preserves root path", () => { + expect(normalizePath("/")).toBe("/"); + }); + + it("handles already normalized paths", () => { + expect(normalizePath("/blog")).toBe("/blog"); + }); + + it("handles empty string", () => { + expect(normalizePath("")).toBe("/"); + }); + + it("normalizes complex paths", () => { + expect(normalizePath("users/42/posts/")).toBe("/users/42/posts"); + }); + }); + + describe("parseUrl", () => { + it("parses path from simple URL", () => { + const parsed = parseUrl("/blog"); + expect(parsed.path).toBe("/blog"); + expect(parsed.searchParams.toString()).toBe(""); + }); + + it("parses path and search params", () => { + const parsed = parseUrl("/blog?page=2&sort=date"); + expect(parsed.path).toBe("/blog"); + expect(parsed.searchParams.get("page")).toBe("2"); + expect(parsed.searchParams.get("sort")).toBe("date"); + }); + + it("handles absolute URLs", () => { + const parsed = parseUrl("https://example.com/blog?page=2"); + expect(parsed.path).toBe("/blog"); + expect(parsed.searchParams.get("page")).toBe("2"); + }); + + it("handles URLs without search params", () => { + const parsed = parseUrl("/users/42/posts/123"); + expect(parsed.path).toBe("/users/42/posts/123"); + expect(parsed.searchParams.toString()).toBe(""); + }); + + it("handles root path", () => { + const parsed = parseUrl("/"); + expect(parsed.path).toBe("/"); + }); + + it("handles URL with hash", () => { + const parsed = parseUrl("/blog?page=2#comments"); + expect(parsed.path).toBe("/blog"); + expect(parsed.searchParams.get("page")).toBe("2"); + }); + + it("handles malformed URLs gracefully", () => { + const parsed = parseUrl("not-a-url?with=params"); + expect(parsed.path).toBe("/not-a-url"); + expect(parsed.searchParams.get("with")).toBe("params"); + }); + + it("handles URL with multiple query params", () => { + const parsed = parseUrl("/search?q=test&category=tech&sort=date"); + expect(parsed.searchParams.get("q")).toBe("test"); + expect(parsed.searchParams.get("category")).toBe("tech"); + expect(parsed.searchParams.get("sort")).toBe("date"); + }); + }); + + describe("clearRouteCache", () => { + it("clears the compilation cache", () => { + const first = compileRoute("/blog/:slug"); + clearRouteCache(); + const second = compileRoute("/blog/:slug"); + + expect(first).not.toBe(second); + expect(first.pattern).toBe(second.pattern); + expect(first.keys).toEqual(second.keys); + }); + }); + + describe("complex routing scenarios", () => { + it("handles nested optional parameters", () => { + const match = matchRoute("/blog/:category?/:slug?", "/blog"); + expect(match?.params).toEqual({}); + }); + + it("handles routes with dots in path", () => { + const match = matchRoute("/files/:filename", "/files/document.pdf"); + expect(match?.params).toEqual({ filename: "document.pdf" }); + }); + + it("handles routes with hyphens and underscores", () => { + const match = matchRoute("/api/:resource_type", "/api/user-profile"); + expect(match?.params).toEqual({ resource_type: "user-profile" }); + }); + + it("matches exact routes before parameterized routes", () => { + const patterns = ["/blog/featured", "/blog/:slug"]; + const match1 = matchRoutes(patterns, "/blog/featured"); + expect(match1?.pattern).toBe("/blog/featured"); + + const match2 = matchRoutes(patterns, "/blog/other"); + expect(match2?.pattern).toBe("/blog/:slug"); + }); + + it("handles building paths with missing optional params", () => { + const path = buildPath("/blog/:year?/:month?/:slug", { slug: "hello" }); + expect(path).not.toContain(":year"); + expect(path).not.toContain(":month"); + }); + + it("handles URL encoding in both directions", () => { + const encoded = buildPath("/search/:query", { query: "hello world" }); + expect(encoded).toBe("/search/hello%20world"); + + const match = matchRoute("/search/:query", encoded); + expect(match?.params.query).toBe("hello world"); + }); + }); + + describe("edge cases", () => { + it("handles empty pattern", () => { + const match = matchRoute("", "/any-path"); + expect(match).toBeUndefined(); + }); + + it("handles pattern with only slashes", () => { + const match = matchRoute("///", "/"); + expect(match).toBeUndefined(); + }); + + it("handles path with trailing query string", () => { + const match = matchRoute("/blog/:slug", "/blog/hello?extra=param"); + expect(match).toBeUndefined(); + }); + + it("handles numeric parameter values", () => { + const match = matchRoute("/users/:id", "/users/12345"); + expect(match?.params.id).toBe("12345"); + expect(typeof match?.params.id).toBe("string"); + }); + + it("preserves parameter order", () => { + const params = extractParams("/a/:first/b/:second/c/:third", "/a/1/b/2/c/3"); + expect(Object.keys(params)).toEqual(["first", "second", "third"]); + }); + }); +}); diff --git a/lib/test/plugins/navigate.test.ts b/lib/test/plugins/navigate.test.ts new file mode 100644 index 0000000..9122942 --- /dev/null +++ b/lib/test/plugins/navigate.test.ts @@ -0,0 +1,655 @@ +import { mount } from "$core/binder"; +import { goBack, goForward, initNavigationListener, navigate, redirect } from "$plugins/navigate"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +describe("navigate plugin", () => { + beforeEach(() => { + globalThis.history.replaceState({}, "", "/"); + vi.clearAllMocks(); + }); + + describe("link navigation", () => { + it("intercepts link clicks and prevents default navigation", () => { + const link = document.createElement("a"); + link.href = "/about"; + link.dataset.voltNavigate = ""; + + const preventDefault = vi.fn(); + const event = new MouseEvent("click", { bubbles: true, cancelable: true }); + Object.defineProperty(event, "preventDefault", { value: preventDefault }); + + mount(link, {}); + link.dispatchEvent(event); + + expect(preventDefault).toHaveBeenCalled(); + }); + + it("navigates to href when no explicit URL provided", () => { + const link = document.createElement("a"); + link.href = "/products"; + link.dataset.voltNavigate = ""; + + const event = new MouseEvent("click", { bubbles: true, cancelable: true }); + mount(link, {}); + link.dispatchEvent(event); + + expect(globalThis.location.pathname).toBe("/products"); + }); + + it("navigates to explicit URL when provided", () => { + const link = document.createElement("a"); + link.href = "/default"; + link.dataset.voltNavigate = "/custom"; + + const event = new MouseEvent("click", { bubbles: true, cancelable: true }); + mount(link, {}); + link.dispatchEvent(event); + + expect(globalThis.location.pathname).toBe("/custom"); + }); + + it("allows ctrl+click to open in new tab (does not prevent default)", () => { + const link = document.createElement("a"); + link.href = "/external"; + link.dataset.voltNavigate = ""; + + const preventDefault = vi.fn(); + const event = new MouseEvent("click", { bubbles: true, cancelable: true, ctrlKey: true }); + Object.defineProperty(event, "preventDefault", { value: preventDefault }); + + mount(link, {}); + link.dispatchEvent(event); + + expect(preventDefault).not.toHaveBeenCalled(); + }); + + it("allows meta+click to open in new tab (does not prevent default)", () => { + const link = document.createElement("a"); + link.href = "/external"; + link.dataset.voltNavigate = ""; + + const preventDefault = vi.fn(); + const event = new MouseEvent("click", { bubbles: true, cancelable: true, metaKey: true }); + Object.defineProperty(event, "preventDefault", { value: preventDefault }); + + mount(link, {}); + link.dispatchEvent(event); + + expect(preventDefault).not.toHaveBeenCalled(); + }); + + it("allows shift+click to open in new window (does not prevent default)", () => { + const link = document.createElement("a"); + link.href = "/external"; + link.dataset.voltNavigate = ""; + + const preventDefault = vi.fn(); + const event = new MouseEvent("click", { bubbles: true, cancelable: true, shiftKey: true }); + Object.defineProperty(event, "preventDefault", { value: preventDefault }); + + mount(link, {}); + link.dispatchEvent(event); + + expect(preventDefault).not.toHaveBeenCalled(); + }); + + it("allows middle mouse button to open in new tab (does not prevent default)", () => { + const link = document.createElement("a"); + link.href = "/external"; + link.dataset.voltNavigate = ""; + + const preventDefault = vi.fn(); + const event = new MouseEvent("click", { bubbles: true, cancelable: true, button: 1 }); + Object.defineProperty(event, "preventDefault", { value: preventDefault }); + + mount(link, {}); + link.dispatchEvent(event); + + expect(preventDefault).not.toHaveBeenCalled(); + }); + + it("does not intercept external links", () => { + const link = document.createElement("a"); + link.href = "https://external.com/page"; + link.dataset.voltNavigate = ""; + + const preventDefault = vi.fn(); + const event = new MouseEvent("click", { bubbles: true, cancelable: true }); + Object.defineProperty(event, "preventDefault", { value: preventDefault }); + + mount(link, {}); + link.dispatchEvent(event); + + expect(preventDefault).not.toHaveBeenCalled(); + }); + + it("uses replaceState when .replace modifier is used", () => { + globalThis.history.replaceState({}, "", "/initial"); + + const link = document.createElement("a"); + link.href = "/about"; + link.dataset.voltNavigateReplace = ""; + + const event = new MouseEvent("click", { bubbles: true, cancelable: true }); + mount(link, {}); + link.dispatchEvent(event); + + expect(globalThis.location.pathname).toBe("/about"); + }); + + it("scrolls to top on navigation", async () => { + const scrollToSpy = vi.spyOn(globalThis, "scrollTo"); + + const link = document.createElement("a"); + link.href = "/page"; + link.dataset.voltNavigate = ""; + + const event = new MouseEvent("click", { bubbles: true, cancelable: true }); + mount(link, {}); + link.dispatchEvent(event); + + await vi.waitFor(() => { + expect(scrollToSpy).toHaveBeenCalledWith(0, 0); + }); + }); + + it("dispatches volt:navigate event on navigation", async () => { + const navigateHandler = vi.fn(); + globalThis.addEventListener("volt:navigate", navigateHandler); + + const link = document.createElement("a"); + link.href = "/dashboard"; + link.dataset.voltNavigate = ""; + + const event = new MouseEvent("click", { bubbles: true, cancelable: true }); + mount(link, {}); + link.dispatchEvent(event); + + await vi.waitFor(() => { + expect(navigateHandler).toHaveBeenCalled(); + const customEvent = navigateHandler.mock.calls[0][0] as CustomEvent; + expect(customEvent.detail.url).toBe("/dashboard"); + }); + + globalThis.removeEventListener("volt:navigate", navigateHandler); + }); + + it("adds prefetch link on hover when .prefetch modifier is used", async () => { + const link = document.createElement("a"); + link.href = "/prefetch-page"; + link.dataset.voltNavigatePrefetch = ""; + + mount(link, {}); + + const mouseenterEvent = new MouseEvent("mouseenter", { bubbles: true }); + link.dispatchEvent(mouseenterEvent); + + await vi.waitFor(() => { + const prefetchLink = document.querySelector("link[rel=\"prefetch\"][href=\"/prefetch-page\"]"); + expect(prefetchLink).toBeTruthy(); + }); + }); + + it("adds prefetch link on focus when .prefetch modifier is used", async () => { + const link = document.createElement("a"); + link.href = "/prefetch-focus"; + link.dataset.voltNavigatePrefetch = ""; + + mount(link, {}); + + const focusEvent = new FocusEvent("focus", { bubbles: true }); + link.dispatchEvent(focusEvent); + + await vi.waitFor(() => { + const prefetchLink = document.querySelector("link[rel=\"prefetch\"][href=\"/prefetch-focus\"]"); + expect(prefetchLink).toBeTruthy(); + }); + }); + + it("only prefetches once even with multiple hover events", async () => { + const link = document.createElement("a"); + link.href = "/prefetch-once"; + link.dataset.voltNavigatePrefetch = ""; + + mount(link, {}); + + link.dispatchEvent(new MouseEvent("mouseenter", { bubbles: true })); + link.dispatchEvent(new MouseEvent("mouseenter", { bubbles: true })); + link.dispatchEvent(new MouseEvent("mouseenter", { bubbles: true })); + + await vi.waitFor(() => { + const prefetchLinks = document.querySelectorAll("link[rel=\"prefetch\"][href=\"/prefetch-once\"]"); + expect(prefetchLinks.length).toBe(1); + }); + }); + }); + + describe("form navigation", () => { + it("intercepts form GET submissions", () => { + const form = document.createElement("form"); + form.method = "GET"; + form.action = "/search"; + form.dataset.voltNavigate = ""; + + const input = document.createElement("input"); + input.name = "q"; + input.value = "test"; + form.append(input); + + const preventDefault = vi.fn(); + const event = new Event("submit", { bubbles: true, cancelable: true }); + Object.defineProperty(event, "preventDefault", { value: preventDefault }); + + mount(form, {}); + form.dispatchEvent(event); + + expect(preventDefault).toHaveBeenCalled(); + expect(globalThis.location.pathname).toBe("/search"); + expect(globalThis.location.search).toContain("q=test"); + }); + + it("warns on POST form submissions", () => { + const consoleWarnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + + const form = document.createElement("form"); + form.method = "POST"; + form.action = "/submit"; + form.dataset.voltNavigate = ""; + + const event = new Event("submit", { bubbles: true, cancelable: true }); + mount(form, {}); + form.dispatchEvent(event); + + expect(consoleWarnSpy).toHaveBeenCalledWith( + expect.stringContaining("POST/PUT/PATCH forms should use data-volt-post/put/patch"), + ); + + consoleWarnSpy.mockRestore(); + }); + + it("uses current pathname as default action", () => { + globalThis.history.replaceState({}, "", "/current"); + + const form = document.createElement("form"); + form.method = "GET"; + form.dataset.voltNavigate = ""; + + const input = document.createElement("input"); + input.name = "filter"; + input.value = "active"; + form.append(input); + + const event = new Event("submit", { bubbles: true, cancelable: true }); + mount(form, {}); + form.dispatchEvent(event); + + expect(globalThis.location.pathname).toBe("/current"); + expect(globalThis.location.search).toContain("filter=active"); + }); + }); + + describe("programmatic navigation", () => { + it("navigate() changes the URL", async () => { + await navigate("/dashboard"); + expect(globalThis.location.pathname).toBe("/dashboard"); + }); + + it("navigate() with replace option uses replaceState", async () => { + globalThis.history.replaceState({}, "", "/initial"); + await navigate("/replaced", { replace: true }); + expect(globalThis.location.pathname).toBe("/replaced"); + }); + + it("redirect() uses replaceState", async () => { + globalThis.history.replaceState({}, "", "/old"); + await redirect("/new"); + expect(globalThis.location.pathname).toBe("/new"); + }); + + it("navigate() dispatches volt:navigate event", async () => { + const handler = vi.fn(); + globalThis.addEventListener("volt:navigate", handler); + + await navigate("/profile"); + + expect(handler).toHaveBeenCalled(); + const event = handler.mock.calls[0][0] as CustomEvent; + expect(event.detail.url).toBe("/profile"); + + globalThis.removeEventListener("volt:navigate", handler); + }); + + it("goBack() navigates backward in history", () => { + const backSpy = vi.spyOn(globalThis.history, "back"); + goBack(); + expect(backSpy).toHaveBeenCalled(); + }); + + it("goForward() navigates forward in history", () => { + const forwardSpy = vi.spyOn(globalThis.history, "forward"); + goForward(); + expect(forwardSpy).toHaveBeenCalled(); + }); + }); + + describe("scroll position restoration", () => { + it("saves scroll position before navigation", async () => { + Object.defineProperty(globalThis, "scrollX", { value: 100, writable: true, configurable: true }); + Object.defineProperty(globalThis, "scrollY", { value: 200, writable: true, configurable: true }); + + await navigate("/page1"); + await navigate("/page2"); + + expect(globalThis.history.state).toBeDefined(); + }); + + it("restores scroll position on popstate", async () => { + const cleanup = initNavigationListener(); + + Object.defineProperty(globalThis, "scrollX", { value: 0, writable: true, configurable: true }); + Object.defineProperty(globalThis, "scrollY", { value: 0, writable: true, configurable: true }); + await navigate("/page1"); + + Object.defineProperty(globalThis, "scrollX", { value: 0, writable: true, configurable: true }); + Object.defineProperty(globalThis, "scrollY", { value: 500, writable: true, configurable: true }); + + await navigate("/page2"); + + const scrollToSpy = vi.spyOn(globalThis, "scrollTo"); + globalThis.history.back(); + globalThis.dispatchEvent(new PopStateEvent("popstate", { state: { scrollPosition: { x: 0, y: 500 } } })); + + await vi.waitFor(() => { + expect(scrollToSpy).toHaveBeenCalledWith(0, 500); + }); + + cleanup(); + }); + + it("dispatches volt:popstate event on back/forward navigation", async () => { + const cleanup = initNavigationListener(); + const popstateHandler = vi.fn(); + globalThis.addEventListener("volt:popstate", popstateHandler); + + globalThis.dispatchEvent(new PopStateEvent("popstate", { state: { timestamp: Date.now() } })); + + await vi.waitFor(() => { + expect(popstateHandler).toHaveBeenCalled(); + }); + + globalThis.removeEventListener("volt:popstate", popstateHandler); + cleanup(); + }); + }); + + describe("navigation state", () => { + it("stores navigation state in history", async () => { + await navigate("/stateful"); + + expect(globalThis.history.state).toBeDefined(); + expect(globalThis.history.state.timestamp).toBeDefined(); + expect(typeof globalThis.history.state.timestamp).toBe("number"); + }); + + it("includes scroll position in navigation state", async () => { + Object.defineProperty(globalThis, "scrollX", { value: 150, writable: true, configurable: true }); + Object.defineProperty(globalThis, "scrollY", { value: 300, writable: true, configurable: true }); + + await navigate("/with-scroll"); + + expect(globalThis.history.state.scrollPosition).toBeDefined(); + expect(globalThis.history.state.scrollPosition.x).toBe(150); + expect(globalThis.history.state.scrollPosition.y).toBe(300); + }); + }); + + describe("view transitions", () => { + it("uses view transitions by default", async () => { + const mockTransition = { + finished: Promise.resolve(), + ready: Promise.resolve(), + updateCbDone: Promise.resolve(), + skipTransition: vi.fn(), + }; + + const startViewTransitionSpy = vi.fn(() => mockTransition); + Object.defineProperty(document, "startViewTransition", { + value: startViewTransitionSpy, + writable: true, + configurable: true, + }); + + await navigate("/with-transition"); + + expect(startViewTransitionSpy).toHaveBeenCalled(); + }); + + it("skips view transitions when notransition modifier is used", async () => { + const link = document.createElement("a"); + link.href = "/no-transition"; + link.dataset.voltNavigateNotransition = ""; + + const mockTransition = { + finished: Promise.resolve(), + ready: Promise.resolve(), + updateCbDone: Promise.resolve(), + skipTransition: vi.fn(), + }; + + const startViewTransitionSpy = vi.fn(() => mockTransition); + Object.defineProperty(document, "startViewTransition", { + value: startViewTransitionSpy, + writable: true, + configurable: true, + }); + + const event = new MouseEvent("click", { bubbles: true, cancelable: true }); + mount(link, {}); + link.dispatchEvent(event); + + await vi.waitFor(() => { + expect(globalThis.location.pathname).toBe("/no-transition"); + }); + }); + + it("can disable view transitions programmatically", async () => { + await navigate("/no-vt", { transition: false }); + expect(globalThis.location.pathname).toBe("/no-vt"); + }); + }); + + describe("focus management", () => { + it("includes focus restoration functions in navigate module", () => { + expect(navigate).toBeDefined(); + expect(initNavigationListener).toBeDefined(); + }); + + it.skip("saves focus state in navigation state when element has ID", async () => { + const input = document.createElement("input"); + input.id = "test-input"; + input.type = "text"; + document.body.append(input); + + const activeElementGetter = vi.spyOn(document, "activeElement", "get"); + activeElementGetter.mockReturnValue(input); + + await navigate("/page-with-focus"); + + expect(globalThis.history.state.focusSelector).toBe("#test-input"); + + activeElementGetter.mockRestore(); + input.remove(); + }); + + it.skip("attempts to restore focus on popstate", () => { + const cleanup = initNavigationListener(); + + const button = document.createElement("button"); + button.id = "focus-button"; + button.textContent = "Click me"; + document.body.append(button); + + const focusSpy = vi.spyOn(button, "focus"); + + globalThis.dispatchEvent(new PopStateEvent("popstate", { state: { focusSelector: "#focus-button" } })); + + expect(focusSpy).toHaveBeenCalledWith({ preventScroll: true }); + + cleanup(); + button.remove(); + }); + }); + + describe("viewport-based prefetching", () => { + it.skip("prefetches when link enters viewport with .viewport modifier", async () => { + const link = document.createElement("a"); + link.href = "/viewport-prefetch"; + link.dataset.voltNavigatePrefetchViewport = ""; + document.body.append(link); + + let observerCallback!: IntersectionObserverCallback; + const mockObserver = { + observe: vi.fn(), + disconnect: vi.fn(), + unobserve: vi.fn(), + takeRecords: vi.fn(), + root: null, + rootMargin: "", + thresholds: [], + }; + + (globalThis as typeof globalThis).IntersectionObserver = vi.fn((callback) => { + observerCallback = callback; + return mockObserver; + }) as unknown as typeof IntersectionObserver; + + const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue(new Response()); + + mount(link, {}); + + expect(mockObserver.observe).toHaveBeenCalledWith(link); + + observerCallback( + [{ isIntersecting: true, target: link } as unknown as IntersectionObserverEntry], + mockObserver as IntersectionObserver, + ); + + await vi.waitFor(() => { + expect(fetchSpy).toHaveBeenCalledWith("/viewport-prefetch", expect.objectContaining({ method: "GET" })); + }); + + fetchSpy.mockRestore(); + link.remove(); + }); + + it.skip("only prefetches once when element enters viewport multiple times", async () => { + const link = document.createElement("a"); + link.href = "/viewport-once"; + link.dataset.voltNavigatePrefetchViewport = ""; + document.body.append(link); + + let observerCallback!: IntersectionObserverCallback; + const mockObserver = { + observe: vi.fn(), + disconnect: vi.fn(), + unobserve: vi.fn(), + takeRecords: vi.fn(), + root: null, + rootMargin: "", + thresholds: [], + }; + + (globalThis as typeof globalThis).IntersectionObserver = vi.fn((callback) => { + observerCallback = callback; + return mockObserver; + }) as unknown as typeof IntersectionObserver; + + const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue(new Response()); + + mount(link, {}); + + observerCallback( + [{ isIntersecting: true, target: link } as unknown as IntersectionObserverEntry], + mockObserver as IntersectionObserver, + ); + + observerCallback( + [{ isIntersecting: false, target: link } as unknown as IntersectionObserverEntry], + mockObserver as IntersectionObserver, + ); + + observerCallback( + [{ isIntersecting: true, target: link } as unknown as IntersectionObserverEntry], + mockObserver as IntersectionObserver, + ); + + await vi.waitFor(() => { + expect(fetchSpy).toHaveBeenCalledTimes(1); + expect(mockObserver.disconnect).toHaveBeenCalled(); + }); + + fetchSpy.mockRestore(); + link.remove(); + }); + + it("falls back to link prefetch when fetch fails", async () => { + const link = document.createElement("a"); + link.href = "/fetch-fail"; + link.dataset.voltNavigatePrefetch = ""; + document.body.append(link); + + const fetchSpy = vi.spyOn(globalThis, "fetch").mockRejectedValue(new Error("Network error")); + + mount(link, {}); + + link.dispatchEvent(new MouseEvent("mouseenter", { bubbles: true })); + + await vi.waitFor(() => { + const prefetchLink = document.querySelector("link[rel=\"prefetch\"][href=\"/fetch-fail\"]"); + expect(prefetchLink).toBeTruthy(); + }); + + fetchSpy.mockRestore(); + link.remove(); + }); + }); + + describe("cleanup", () => { + it("removes event listeners on cleanup", () => { + const link = document.createElement("a"); + link.href = "/cleanup-test"; + link.dataset.voltNavigate = ""; + + const cleanup = mount(link, {}); + const preventDefault = vi.fn(); + const event = new MouseEvent("click", { bubbles: true, cancelable: true }); + Object.defineProperty(event, "preventDefault", { value: preventDefault }); + link.dispatchEvent(event); + expect(preventDefault).toHaveBeenCalled(); + cleanup(); + + preventDefault.mockClear(); + const event2 = new MouseEvent("click", { bubbles: true, cancelable: true }); + Object.defineProperty(event2, "preventDefault", { value: preventDefault }); + link.dispatchEvent(event2); + expect(preventDefault).not.toHaveBeenCalled(); + }); + + it("initNavigationListener returns cleanup function", () => { + const cleanup = initNavigationListener(); + expect(typeof cleanup).toBe("function"); + + const popstateHandler = vi.fn(); + globalThis.addEventListener("volt:popstate", popstateHandler); + + globalThis.dispatchEvent(new PopStateEvent("popstate")); + expect(popstateHandler).toHaveBeenCalled(); + + cleanup(); + popstateHandler.mockClear(); + + globalThis.dispatchEvent(new PopStateEvent("popstate")); + globalThis.removeEventListener("volt:popstate", popstateHandler); + }); + }); +}); diff --git a/lib/test/plugins/scroll.test.ts b/lib/test/plugins/scroll.test.ts index afda307..dbe44d1 100644 --- a/lib/test/plugins/scroll.test.ts +++ b/lib/test/plugins/scroll.test.ts @@ -17,7 +17,6 @@ describe("scroll plugin", () => { const scrollPos = signal(250); mount(element, { scrollPos }); - expect(element.scrollTop).toBe(250); }); @@ -42,7 +41,6 @@ describe("scroll plugin", () => { const scrollPos = signal("not a number" as unknown as number); mount(element, { scrollPos }); - expect(element.scrollTop).toBe(0); }); @@ -108,7 +106,6 @@ describe("scroll plugin", () => { const targetId = signal("otherSection"); mount(element, { targetId }); - expect(scrollIntoViewMock).not.toHaveBeenCalled(); }); @@ -122,7 +119,6 @@ describe("scroll plugin", () => { const targetId = signal("section1"); mount(element, { targetId }); - expect(scrollIntoViewMock).toHaveBeenCalledOnce(); }); }); @@ -133,7 +129,6 @@ describe("scroll plugin", () => { element.dataset.voltScroll = "spy:isVisible"; const isVisible = signal(false); - let observerCallback!: IntersectionObserverCallback; const mockObserver = { observe: vi.fn(), @@ -151,21 +146,17 @@ describe("scroll plugin", () => { }) as unknown as typeof IntersectionObserver; mount(element, { isVisible }); - expect(mockObserver.observe).toHaveBeenCalledWith(element); - observerCallback( [{ isIntersecting: true, target: element } as unknown as IntersectionObserverEntry], mockObserver as IntersectionObserver, ); expect(isVisible.get()).toBe(true); - observerCallback( [{ isIntersecting: false, target: element } as unknown as IntersectionObserverEntry], mockObserver as IntersectionObserver, ); - expect(isVisible.get()).toBe(false); }); @@ -174,7 +165,6 @@ describe("scroll plugin", () => { element.dataset.voltScroll = "spy:isVisible"; const isVisible = signal(false); - const mockObserver = { observe: vi.fn(), disconnect: vi.fn(), @@ -185,14 +175,12 @@ describe("scroll plugin", () => { thresholds: [], }; - (globalThis as typeof globalThis).IntersectionObserver = vi.fn(() => { - return mockObserver; - }) as unknown as typeof IntersectionObserver; + (globalThis as typeof globalThis).IntersectionObserver = vi.fn(() => + mockObserver + ) as unknown as typeof IntersectionObserver; const cleanup = mount(element, { isVisible }); - cleanup(); - expect(mockObserver.disconnect).toHaveBeenCalled(); }); }); @@ -204,7 +192,6 @@ describe("scroll plugin", () => { const smoothScroll = signal(true); mount(element, { smoothScroll }); - expect(element.style.scrollBehavior).toBe("smooth"); }); @@ -214,7 +201,6 @@ describe("scroll plugin", () => { const smoothScroll = signal("smooth"); mount(element, { smoothScroll }); - expect(element.style.scrollBehavior).toBe("smooth"); }); @@ -260,12 +246,129 @@ describe("scroll plugin", () => { const smoothScroll = signal(true); const cleanup = mount(element, { smoothScroll }); - expect(element.style.scrollBehavior).toBe("smooth"); + cleanup(); + expect(element.style.scrollBehavior).toBe(""); + }); + }); + + describe("history mode", () => { + it("saves scroll position on volt:navigate event", () => { + const element = document.createElement("div"); + element.dataset.voltScroll = "history"; + Object.defineProperty(element, "scrollTop", { writable: true, value: 150 }); + + mount(element, {}); + globalThis.dispatchEvent(new CustomEvent("volt:navigate", { detail: { url: "/page1" } })); + expect(element.dataset.voltScroll).toBe("history"); + }); + + it("restores scroll position on volt:popstate event", async () => { + const element = document.createElement("div"); + element.dataset.voltScroll = "history"; + + let currentScrollTop = 0; + Object.defineProperty(element, "scrollTop", { + get() { + return currentScrollTop; + }, + set(value) { + currentScrollTop = value; + }, + configurable: true, + }); + + mount(element, {}); + + globalThis.history.replaceState({}, "", "/page1"); + element.scrollTop = 300; + globalThis.dispatchEvent(new CustomEvent("volt:navigate", { detail: { url: "/page1" } })); + + globalThis.history.replaceState({}, "", "/page2"); + element.scrollTop = 0; + globalThis.dispatchEvent(new CustomEvent("volt:navigate", { detail: { url: "/page2" } })); + + globalThis.history.replaceState({}, "", "/page1"); + element.scrollTop = 0; + globalThis.dispatchEvent(new CustomEvent("volt:popstate", { detail: { state: {} } })); + + await new Promise((resolve) => requestAnimationFrame(resolve)); + + expect(element.scrollTop).toBe(300); + }); + + it("handles multiple navigation cycles correctly", async () => { + const element = document.createElement("div"); + element.dataset.voltScroll = "history"; + + let currentScrollTop = 0; + Object.defineProperty(element, "scrollTop", { + get() { + return currentScrollTop; + }, + set(value) { + currentScrollTop = value; + }, + configurable: true, + }); + + mount(element, {}); + + globalThis.history.replaceState({}, "", "/page1"); + element.scrollTop = 100; + globalThis.dispatchEvent(new CustomEvent("volt:navigate", { detail: { url: "/page1" } })); + + globalThis.history.replaceState({}, "", "/page2"); + element.scrollTop = 200; + globalThis.dispatchEvent(new CustomEvent("volt:navigate", { detail: { url: "/page2" } })); + + globalThis.history.replaceState({}, "", "/page3"); + element.scrollTop = 300; + globalThis.dispatchEvent(new CustomEvent("volt:navigate", { detail: { url: "/page3" } })); + + globalThis.history.replaceState({}, "", "/page2"); + element.scrollTop = 0; + globalThis.dispatchEvent(new CustomEvent("volt:popstate", { detail: { state: {} } })); + await new Promise((resolve) => requestAnimationFrame(resolve)); + expect(element.scrollTop).toBe(200); + + globalThis.history.replaceState({}, "", "/page1"); + element.scrollTop = 0; + globalThis.dispatchEvent(new CustomEvent("volt:popstate", { detail: { state: {} } })); + await new Promise((resolve) => requestAnimationFrame(resolve)); + expect(element.scrollTop).toBe(100); + }); + + it("cleans up event listeners on unmount", () => { + const element = document.createElement("div"); + element.dataset.voltScroll = "history"; + + const cleanup = mount(element, {}); + + Object.defineProperty(element, "scrollTop", { writable: true, value: 100 }); + globalThis.dispatchEvent(new CustomEvent("volt:navigate")); cleanup(); - expect(element.style.scrollBehavior).toBe(""); + Object.defineProperty(element, "scrollTop", { writable: true, value: 200 }); + globalThis.dispatchEvent(new CustomEvent("volt:navigate")); + + expect(element.scrollTop).toBe(200); + }); + + it("does not restore scroll position if not previously saved", async () => { + const element = document.createElement("div"); + element.dataset.voltScroll = "history"; + Object.defineProperty(element, "scrollTop", { writable: true, value: 50 }); + + mount(element, {}); + + globalThis.history.replaceState({}, "", "/new-page"); + globalThis.dispatchEvent(new CustomEvent("volt:popstate", { detail: { state: {} } })); + + await new Promise((resolve) => requestAnimationFrame(resolve)); + + expect(element.scrollTop).toBe(50); }); }); @@ -276,9 +379,7 @@ describe("scroll plugin", () => { element.dataset.voltScroll = "invalidformat"; mount(element, {}); - expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining("Invalid scroll binding")); - errorSpy.mockRestore(); }); @@ -288,9 +389,7 @@ describe("scroll plugin", () => { element.dataset.voltScroll = "unknown:signal"; mount(element, {}); - expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining("Unknown scroll mode: \"unknown\"")); - errorSpy.mockRestore(); }); @@ -300,9 +399,7 @@ describe("scroll plugin", () => { element.dataset.voltScroll = "restore:nonexistent"; mount(element, {}); - expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining("Signal \"nonexistent\" not found")); - errorSpy.mockRestore(); }); }); diff --git a/lib/test/plugins/url.test.ts b/lib/test/plugins/url.test.ts index 9d1a49c..47721aa 100644 --- a/lib/test/plugins/url.test.ts +++ b/lib/test/plugins/url.test.ts @@ -275,6 +275,275 @@ describe("url plugin", () => { }); }); + describe("history mode", () => { + it("reads current pathname and search into signal on mount", () => { + globalThis.history.replaceState({}, "", "/products?category=electronics"); + + const element = document.createElement("div"); + element.dataset.voltUrl = "history:route"; + + const route = signal(""); + mount(element, { route }); + + expect(route.get()).toBe("/products?category=electronics"); + }); + + it("initializes to root path when on root", () => { + globalThis.history.replaceState({}, "", "/"); + + const element = document.createElement("div"); + element.dataset.voltUrl = "history:route"; + + const route = signal(""); + mount(element, { route }); + + expect(route.get()).toBe("/"); + }); + + it("updates URL when signal changes", () => { + globalThis.history.replaceState({}, "", "/"); + + const element = document.createElement("div"); + element.dataset.voltUrl = "history:route"; + + const route = signal(""); + mount(element, { route }); + + route.set("/dashboard"); + + expect(globalThis.location.pathname).toBe("/dashboard"); + }); + + it("preserves search params when updating path", () => { + globalThis.history.replaceState({}, "", "/"); + + const element = document.createElement("div"); + element.dataset.voltUrl = "history:route"; + + const route = signal(""); + mount(element, { route }); + + route.set("/search?q=test&page=2"); + + expect(globalThis.location.pathname).toBe("/search"); + expect(globalThis.location.search).toBe("?q=test&page=2"); + }); + + it("handles base path configuration", () => { + globalThis.history.replaceState({}, "", "/app/dashboard"); + + const element = document.createElement("div"); + element.dataset.voltUrl = "history:route:/app"; + + const route = signal(""); + mount(element, { route }); + + expect(route.get()).toBe("/dashboard"); + }); + + it("prepends base path when updating URL", () => { + globalThis.history.replaceState({}, "", "/app"); + + const element = document.createElement("div"); + element.dataset.voltUrl = "history:route:/app"; + + const route = signal(""); + mount(element, { route }); + + route.set("/settings"); + + expect(globalThis.location.pathname).toBe("/app/settings"); + }); + + it("handles base path with trailing slash", () => { + globalThis.history.replaceState({}, "", "/myapp/profile"); + + const element = document.createElement("div"); + element.dataset.voltUrl = "history:route:/myapp"; + + const route = signal(""); + mount(element, { route }); + + expect(route.get()).toBe("/profile"); + }); + + it("returns root when on base path", () => { + globalThis.history.replaceState({}, "", "/app"); + + const element = document.createElement("div"); + element.dataset.voltUrl = "history:route:/app"; + + const route = signal(""); + mount(element, { route }); + + expect(route.get()).toBe("/"); + }); + + it("dispatches volt:navigate event when signal changes", () => { + const navigateHandler = vi.fn(); + globalThis.addEventListener("volt:navigate", navigateHandler); + + const element = document.createElement("div"); + element.dataset.voltUrl = "history:route"; + + const route = signal("/"); + mount(element, { route }); + + route.set("/about"); + + expect(navigateHandler).toHaveBeenCalled(); + const event = navigateHandler.mock.calls[0][0] as CustomEvent; + expect(event.detail.url).toBe("/about"); + expect(event.detail.route).toBe("/about"); + + globalThis.removeEventListener("volt:navigate", navigateHandler); + }); + + it("handles popstate events from browser navigation", () => { + globalThis.history.replaceState({}, "", "/page1"); + + const element = document.createElement("div"); + element.dataset.voltUrl = "history:route"; + + const route = signal(""); + mount(element, { route }); + + expect(route.get()).toBe("/page1"); + + globalThis.history.replaceState({}, "", "/page2"); + globalThis.dispatchEvent(new PopStateEvent("popstate")); + + expect(route.get()).toBe("/page2"); + }); + + it("dispatches volt:popstate event on back/forward navigation", () => { + const popstateHandler = vi.fn(); + globalThis.addEventListener("volt:popstate", popstateHandler); + + const element = document.createElement("div"); + element.dataset.voltUrl = "history:route"; + + const route = signal("/"); + mount(element, { route }); + + globalThis.history.replaceState({}, "", "/other"); + globalThis.dispatchEvent(new PopStateEvent("popstate")); + + expect(popstateHandler).toHaveBeenCalled(); + const event = popstateHandler.mock.calls[0][0] as CustomEvent; + expect(event.detail.route).toBe("/other"); + + globalThis.removeEventListener("volt:popstate", popstateHandler); + }); + + it("syncs with volt:navigate events from navigate plugin", () => { + const element = document.createElement("div"); + element.dataset.voltUrl = "history:route"; + + const route = signal("/"); + mount(element, { route }); + + globalThis.history.pushState({}, "", "/external-nav"); + globalThis.dispatchEvent(new CustomEvent("volt:navigate", { detail: { url: "/external-nav" } })); + expect(route.get()).toBe("/external-nav"); + }); + + it("does not update URL when already at target route", () => { + globalThis.history.replaceState({}, "", "/current"); + + const pushStateSpy = vi.spyOn(globalThis.history, "pushState"); + + const element = document.createElement("div"); + element.dataset.voltUrl = "history:route"; + + const route = signal(""); + mount(element, { route }); + + pushStateSpy.mockClear(); + + route.set("/current"); + + expect(pushStateSpy).not.toHaveBeenCalled(); + + pushStateSpy.mockRestore(); + }); + + it("prevents infinite loops between signal and URL updates", () => { + const element = document.createElement("div"); + element.dataset.voltUrl = "history:route"; + + const route = signal("/"); + const subscribeSpy = vi.fn(); + route.subscribe(subscribeSpy); + + mount(element, { route }); + + subscribeSpy.mockClear(); + + route.set("/test"); + + globalThis.dispatchEvent(new PopStateEvent("popstate")); + + expect(subscribeSpy).toHaveBeenCalledTimes(1); + }); + + it("cleans up listeners on unmount", () => { + globalThis.history.replaceState({}, "", "/initial"); + + const element = document.createElement("div"); + element.dataset.voltUrl = "history:route"; + + const route = signal(""); + const cleanup = mount(element, { route }); + + expect(route.get()).toBe("/initial"); + + cleanup(); + + globalThis.history.replaceState({}, "", "/changed"); + globalThis.dispatchEvent(new PopStateEvent("popstate")); + + expect(route.get()).toBe("/initial"); + }); + + it("handles complex routes with multiple path segments", () => { + globalThis.history.replaceState({}, "", "/blog/2024/introducing-volt"); + + const element = document.createElement("div"); + element.dataset.voltUrl = "history:route"; + + const route = signal(""); + mount(element, { route }); + + expect(route.get()).toBe("/blog/2024/introducing-volt"); + }); + + it("handles routes with query parameters", () => { + globalThis.history.replaceState({}, "", "/search?q=reactive&lang=ts"); + + const element = document.createElement("div"); + element.dataset.voltUrl = "history:route"; + + const route = signal(""); + mount(element, { route }); + + expect(route.get()).toBe("/search?q=reactive&lang=ts"); + }); + + it("logs error when signal not found", () => { + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + + const element = document.createElement("div"); + element.dataset.voltUrl = "history:nonexistent"; + + mount(element, {}); + + expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining("Signal \"nonexistent\" not found")); + + errorSpy.mockRestore(); + }); + }); + describe("error handling", () => { it("logs error for invalid binding format", () => { const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});