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

feat: Animation & Transition Docs & Examples (#2)

* feat: updated core bindings & implemented shift plugin
* added hash routing doc
* feat: View Transitions API integration
* feat: completed animations with docs & demo
* chore: bump to 0.4.0

authored by Owais and committed by GitHub 270fa892 9affd470

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