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

feat: Animation & Transitions (#1)

* feat: transition presets & surge plugin
* refactor http module
* build: update test action

authored by Owais and committed by GitHub 9affd470 5c98d191

Changed files
+1582 -48
.github
workflows
docs
lib
+6 -1
.github/workflows/test.yml
··· 1 1 name: Run tests and upload coverage 2 2 3 3 on: 4 - push 4 + push: 5 + branches: 6 + - main 7 + pull_request: 8 + branches: 9 + - main 5 10 6 11 jobs: 7 12 test:
+2 -2
ROADMAP.md
··· 80 80 **Outcome:** Volt.js supports rich declarative behaviors and event semantics built entirely on standard DOM APIs. 81 81 **Summary:** Introduced expressive attribute patterns and event modifiers for precise DOM and input control, for fine-grained declarative behavior entirely through standard DOM APIs. 82 82 83 - ## To-Do 84 - 85 83 ### Global State 86 84 87 85 **Goal:** Implement store/context pattern ··· 101 99 - Example: `data-volt-on-click="$pins.username.focus()"` 102 100 - `$arc(event, detail?)` - Dispatches a native CustomEvent from the current element. 103 101 - Example: `data-volt-on-click="$arc('user:save', { id })"` 102 + 103 + ## To-Do 104 104 105 105 ### Animation & Transitions 106 106
+7 -4
docs/.vitepress/config.ts
··· 18 18 }, 19 19 { 20 20 text: "Core Concepts", 21 - items: [{ text: "State Management", link: "/state" }, { text: "Bindings", link: "/bindings" }, { 22 - text: "Expressions", 23 - link: "/expressions", 24 - }, { text: "SSR & Lifecycle", link: "/lifecycle" }], 21 + items: [ 22 + { text: "State Management", link: "/state" }, 23 + { text: "Bindings", link: "/bindings" }, 24 + { text: "Expressions", link: "/expressions" }, 25 + { text: "SSR & Lifecycle", link: "/lifecycle" }, 26 + { text: "Animations & Transitions", link: "/animations" }, 27 + ], 25 28 }, 26 29 { text: "Tutorials", items: [{ text: "Counter", link: "/usage/counter" }] }, 27 30 {
+1
docs/animations.md
··· 1 + # Animations & Transitions
+30 -41
lib/src/core/http.ts
··· 4 4 * Provides HTTP request/response handling with DOM swapping capabilities for server-rendered HTML fragments and JSON responses. 5 5 */ 6 6 7 - import type { Optional } from "$types/helpers"; 7 + import type { Nullable, Optional } from "$types/helpers"; 8 8 import type { 9 9 BindingContext, 10 10 HttpMethod, ··· 18 18 } from "$types/volt"; 19 19 import { evaluate } from "./evaluator"; 20 20 import { sleep } from "./shared"; 21 + 22 + type IndicatorStrategy = "display" | "class"; 23 + 24 + type CapturedState = { 25 + focusPath: number[] | null; 26 + scrollPositions: Map<number[], { top: number; left: number }>; 27 + inputValues: Map<number[], string | boolean>; 28 + }; 29 + 30 + const indicatorStrategies = new WeakMap<Element, IndicatorStrategy>(); 21 31 22 32 /** 23 33 * Make an HTTP request and return the parsed response ··· 61 71 throw new Error(`HTTP request failed: ${error instanceof Error ? error.message : String(error)}`); 62 72 } 63 73 } 64 - 65 - type CapturedState = { 66 - focusPath: number[] | null; 67 - scrollPositions: Map<number[], { top: number; left: number }>; 68 - inputValues: Map<number[], string | boolean>; 69 - }; 70 74 71 75 /** 72 76 * Capture state that should be preserved during DOM swap ··· 80 84 } 81 85 82 86 const walker = document.createTreeWalker(root, NodeFilter.SHOW_ELEMENT); 83 - let currentNode: Node | null = walker.currentNode; 87 + let currentNode: Nullable<Node> = walker.currentNode; 84 88 85 89 while (currentNode) { 86 90 const el = currentNode as Element; ··· 111 115 */ 112 116 function getElementPath(el: Element, root: Element): number[] { 113 117 const path: number[] = []; 114 - let current: Element | null = el; 118 + let current: Nullable<Element> = el; 115 119 116 120 while (current && current !== root) { 117 - const parent: Element | null = current.parentElement; 121 + const parent: Nullable<Element> = current.parentElement; 118 122 if (!parent) break; 119 123 120 - const index = Array.from(parent.children).indexOf(current); 124 + const index = [...parent.children].indexOf(current); 121 125 if (index === -1) break; 122 126 123 127 path.unshift(index); ··· 127 131 return path; 128 132 } 129 133 130 - /** 131 - * Get element by path from root 132 - */ 133 - function getElementByPath(path: number[], root: Element): Element | null { 134 + function getElementByPath(path: number[], root: Element): Nullable<Element> { 134 135 let current: Element = root; 135 136 136 137 for (const index of path) { 137 - const children = Array.from(current.children); 138 + const children = [...current.children]; 138 139 if (index >= children.length) return null; 139 140 current = children[index]; 140 141 } ··· 142 143 return current; 143 144 } 144 145 145 - /** 146 - * Restore preserved state after DOM swap 147 - */ 148 146 function restoreState(root: Element, state: CapturedState): void { 149 147 if (state.focusPath) { 150 148 const element = getElementByPath(state.focusPath, root); ··· 328 326 return { trigger, target, swap, headers, retry, indicator }; 329 327 } 330 328 331 - /** 332 - * Get the default trigger event for an element 333 - */ 334 329 function getDefaultTrigger(el: Element): string { 335 330 if (el instanceof HTMLFormElement) { 336 331 return "submit"; ··· 348 343 * @param indicator - Optional indicator selector 349 344 */ 350 345 export function setLoadingState(el: Element, indicator?: string): void { 351 - el.setAttribute("data-volt-loading", "true"); 346 + (el as HTMLElement).dataset.voltLoading = "true"; 352 347 353 348 if (indicator) { 354 349 showIndicator(indicator); ··· 368 363 * @param indicator - Optional indicator selector 369 364 */ 370 365 export function setErrorState(el: Element, msg: string, indicator?: string): void { 371 - el.setAttribute("data-volt-error", msg); 366 + (el as HTMLElement).dataset.voltError = msg; 372 367 373 368 if (indicator) { 374 369 hideIndicator(indicator); ··· 389 384 * @param indicator - Optional indicator selector 390 385 */ 391 386 export function clearStates(el: Element, indicator?: string): void { 392 - el.removeAttribute("data-volt-loading"); 393 - el.removeAttribute("data-volt-error"); 394 - el.removeAttribute("data-volt-retry-attempt"); 387 + delete (el as HTMLElement).dataset.voltLoading; 388 + delete (el as HTMLElement).dataset.voltError; 389 + delete (el as HTMLElement).dataset.voltRetryAttempt; 395 390 396 391 if (indicator) { 397 392 hideIndicator(indicator); ··· 399 394 400 395 el.dispatchEvent(new CustomEvent("volt:success", { detail: { element: el }, bubbles: true, cancelable: false })); 401 396 } 402 - 403 - type IndicatorStrategy = "display" | "class"; 404 - 405 - const indicatorStrategies = new WeakMap<Element, IndicatorStrategy>(); 406 397 407 398 /** 408 399 * Detect the appropriate visibility strategy for an indicator element ··· 418 409 419 410 const htmlElement = el as HTMLElement; 420 411 const inlineDisplay = htmlElement.style.display; 421 - const computedDisplay = window.getComputedStyle(htmlElement).display; 412 + const computedDisplay = globalThis.getComputedStyle(htmlElement).display; 422 413 423 414 if (inlineDisplay === "none" || computedDisplay === "none") { 424 415 indicatorStrategies.set(el, "display"); 425 416 return "display"; 426 417 } 427 418 428 - const hasHiddenClass = Array.from(el.classList).some((cls) => cls.toLowerCase().includes("hidden")); 419 + const hasHiddenClass = [...el.classList].some((cls) => cls.toLowerCase().includes("hidden")); 429 420 if (hasHiddenClass) { 430 421 indicatorStrategies.set(el, "class"); 431 422 return "class"; ··· 445 436 if (strategy === "display") { 446 437 htmlElement.style.display = ""; 447 438 } else { 448 - const hiddenClass = Array.from(el.classList).find((cls) => cls.toLowerCase().includes("hidden")) || "hidden"; 439 + const hiddenClass = [...el.classList].find((cls) => cls.toLowerCase().includes("hidden")) || "hidden"; 449 440 el.classList.remove(hiddenClass); 450 441 } 451 442 } ··· 460 451 if (strategy === "display") { 461 452 htmlElement.style.display = "none"; 462 453 } else { 463 - const hiddenClass = Array.from(el.classList).find((cls) => cls.toLowerCase().includes("hidden")) || "hidden"; 454 + const hiddenClass = [...el.classList].find((cls) => cls.toLowerCase().includes("hidden")) || "hidden"; 464 455 el.classList.add(hiddenClass); 465 456 } 466 457 } ··· 597 588 for (let attempt = 0; attempt < maxAttempts; attempt++) { 598 589 try { 599 590 if (attempt > 0) { 600 - target.setAttribute("data-volt-retry-attempt", String(attempt)); 601 - target.setAttribute("data-volt-loading", "retrying"); 591 + (target as HTMLElement).dataset.voltRetryAttempt = String(attempt); 592 + (target as HTMLElement).dataset.voltLoading = "retrying"; 602 593 target.dispatchEvent( 603 594 new CustomEvent("volt:retry", { detail: { element: target, attempt }, bubbles: true, cancelable: false }), 604 595 ); ··· 678 669 679 670 let body: Optional<string | FormData>; 680 671 681 - if (method !== "GET" && method !== "DELETE") { 682 - if (ctx.element instanceof HTMLFormElement) { 683 - body = serializeForm(ctx.element); 684 - } 672 + if (method !== "GET" && method !== "DELETE" && ctx.element instanceof HTMLFormElement) { 673 + body = serializeForm(ctx.element); 685 674 } 686 675 687 676 await performRequest(ctx.element, method, resolvedUrl, config, body);
+311
lib/src/core/transitions.ts
··· 1 + /** 2 + * Transition preset system for surge plugin 3 + * Provides built-in transition presets and custom preset registration 4 + */ 5 + 6 + import type { Optional } from "$types/helpers"; 7 + import type { ParsedTransition, TransitionPhase, TransitionPreset } from "$types/volt"; 8 + 9 + /** 10 + * Registry of transition presets 11 + */ 12 + const transitionRegistry = new Map<string, TransitionPreset>(); 13 + 14 + /** 15 + * Built-in transition presets 16 + */ 17 + const builtinPresets: Record<string, TransitionPreset> = { 18 + fade: { 19 + enter: { from: { opacity: 0 }, to: { opacity: 1 }, duration: 300, easing: "ease" }, 20 + leave: { from: { opacity: 1 }, to: { opacity: 0 }, duration: 300, easing: "ease" }, 21 + }, 22 + "slide-up": { 23 + enter: { 24 + from: { opacity: 0, transform: "translateY(20px)" }, 25 + to: { opacity: 1, transform: "translateY(0)" }, 26 + duration: 300, 27 + easing: "ease-out", 28 + }, 29 + leave: { 30 + from: { opacity: 1, transform: "translateY(0)" }, 31 + to: { opacity: 0, transform: "translateY(-20px)" }, 32 + duration: 300, 33 + easing: "ease-in", 34 + }, 35 + }, 36 + "slide-down": { 37 + enter: { 38 + from: { opacity: 0, transform: "translateY(-20px)" }, 39 + to: { opacity: 1, transform: "translateY(0)" }, 40 + duration: 300, 41 + easing: "ease-out", 42 + }, 43 + leave: { 44 + from: { opacity: 1, transform: "translateY(0)" }, 45 + to: { opacity: 0, transform: "translateY(20px)" }, 46 + duration: 300, 47 + easing: "ease-in", 48 + }, 49 + }, 50 + "slide-left": { 51 + enter: { 52 + from: { opacity: 0, transform: "translateX(20px)" }, 53 + to: { opacity: 1, transform: "translateX(0)" }, 54 + duration: 300, 55 + easing: "ease-out", 56 + }, 57 + leave: { 58 + from: { opacity: 1, transform: "translateX(0)" }, 59 + to: { opacity: 0, transform: "translateX(-20px)" }, 60 + duration: 300, 61 + easing: "ease-in", 62 + }, 63 + }, 64 + "slide-right": { 65 + enter: { 66 + from: { opacity: 0, transform: "translateX(-20px)" }, 67 + to: { opacity: 1, transform: "translateX(0)" }, 68 + duration: 300, 69 + easing: "ease-out", 70 + }, 71 + leave: { 72 + from: { opacity: 1, transform: "translateX(0)" }, 73 + to: { opacity: 0, transform: "translateX(20px)" }, 74 + duration: 300, 75 + easing: "ease-in", 76 + }, 77 + }, 78 + scale: { 79 + enter: { 80 + from: { opacity: 0, transform: "scale(0.95)" }, 81 + to: { opacity: 1, transform: "scale(1)" }, 82 + duration: 300, 83 + easing: "ease-out", 84 + }, 85 + leave: { 86 + from: { opacity: 1, transform: "scale(1)" }, 87 + to: { opacity: 0, transform: "scale(0.95)" }, 88 + duration: 300, 89 + easing: "ease-in", 90 + }, 91 + }, 92 + blur: { 93 + enter: { 94 + from: { opacity: 0, filter: "blur(10px)" }, 95 + to: { opacity: 1, filter: "blur(0)" }, 96 + duration: 300, 97 + easing: "ease", 98 + }, 99 + leave: { 100 + from: { opacity: 1, filter: "blur(0)" }, 101 + to: { opacity: 0, filter: "blur(10px)" }, 102 + duration: 300, 103 + easing: "ease", 104 + }, 105 + }, 106 + }; 107 + 108 + function initBuiltinPresets(): void { 109 + for (const [name, preset] of Object.entries(builtinPresets)) { 110 + transitionRegistry.set(name, preset); 111 + } 112 + } 113 + 114 + initBuiltinPresets(); 115 + 116 + /** 117 + * Register a custom transition preset. 118 + * Allows users to define their own named transitions in programmatic mode. 119 + * 120 + * @param name - Preset name (used in data-volt-surge="name") 121 + * @param preset - Transition configuration with enter/leave phases 122 + * 123 + * @example 124 + * ```typescript 125 + * registerTransition('custom-slide', { 126 + * enter: { 127 + * from: { opacity: 0, transform: 'translateX(-100px)' }, 128 + * to: { opacity: 1, transform: 'translateX(0)' }, 129 + * duration: 400, 130 + * easing: 'cubic-bezier(0.4, 0, 0.2, 1)' 131 + * }, 132 + * leave: { 133 + * from: { opacity: 1, transform: 'translateX(0)' }, 134 + * to: { opacity: 0, transform: 'translateX(100px)' }, 135 + * duration: 300, 136 + * easing: 'ease-out' 137 + * } 138 + * }); 139 + * ``` 140 + */ 141 + export function registerTransition(name: string, preset: TransitionPreset): void { 142 + if (transitionRegistry.has(name) && Object.hasOwn(builtinPresets, name)) { 143 + console.warn(`[Volt] Overriding built-in transition preset: "${name}"`); 144 + } 145 + transitionRegistry.set(name, preset); 146 + } 147 + 148 + /** 149 + * Get a transition preset by name. 150 + * Checks both custom and built-in presets. 151 + * 152 + * @param name - Preset name 153 + * @returns Transition preset or undefined if not found 154 + */ 155 + export function getTransition(name: string): Optional<TransitionPreset> { 156 + return transitionRegistry.get(name); 157 + } 158 + 159 + /** 160 + * Check if a transition preset exists. 161 + * 162 + * @param name - Preset name 163 + * @returns true if the preset is registered 164 + */ 165 + export function hasTransition(name: string): boolean { 166 + return transitionRegistry.has(name); 167 + } 168 + 169 + /** 170 + * Unregister a custom transition preset. 171 + * Built-in presets cannot be unregistered. 172 + * 173 + * @param name - Preset name 174 + * @returns true if the preset was removed, false otherwise 175 + */ 176 + export function unregisterTransition(name: string): boolean { 177 + if (Object.hasOwn(builtinPresets, name)) { 178 + console.warn(`[Volt] Cannot unregister built-in transition preset: "${name}"`); 179 + return false; 180 + } 181 + return transitionRegistry.delete(name); 182 + } 183 + 184 + /** 185 + * Get all registered transition preset names. 186 + * 187 + * @returns Array of preset names 188 + */ 189 + export function getRegisteredTransitions(): string[] { 190 + return [...transitionRegistry.keys()]; 191 + } 192 + 193 + /** 194 + * Parse a transition value string into preset and modifiers. 195 + * Supports syntax: "presetName", "presetName.duration", "presetName.duration.delay" 196 + * 197 + * @param value - Transition value string 198 + * @returns Parsed transition with preset and optional duration/delay overrides 199 + * 200 + * @example 201 + * ```typescript 202 + * parseTransitionValue("fade") // { preset: fadePreset } 203 + * parseTransitionValue("fade.500") // { preset: fadePreset, duration: 500 } 204 + * parseTransitionValue("fade.500.100") // { preset: fadePreset, duration: 500, delay: 100 } 205 + * ``` 206 + */ 207 + export function parseTransitionValue(value: string): Optional<ParsedTransition> { 208 + const parts = value.split("."); 209 + const presetName = parts[0]?.trim(); 210 + 211 + if (!presetName) { 212 + return undefined; 213 + } 214 + 215 + const preset = getTransition(presetName); 216 + if (!preset) { 217 + console.error(`[Volt] Unknown transition preset: "${presetName}"`); 218 + return undefined; 219 + } 220 + 221 + const result: ParsedTransition = { preset }; 222 + 223 + if (parts.length > 1) { 224 + const duration = Number.parseInt(parts[1], 10); 225 + if (!Number.isNaN(duration)) { 226 + result.duration = duration; 227 + } 228 + } 229 + 230 + if (parts.length > 2) { 231 + const delay = Number.parseInt(parts[2], 10); 232 + if (!Number.isNaN(delay)) { 233 + result.delay = delay; 234 + } 235 + } 236 + 237 + return result; 238 + } 239 + 240 + /** 241 + * Common easing functions mapped to CSS easing values. 242 + * Users can also provide custom cubic-bezier strings directly. 243 + */ 244 + export const easings = { 245 + linear: "linear", 246 + ease: "ease", 247 + "ease-in": "ease-in", 248 + "ease-out": "ease-out", 249 + "ease-in-out": "ease-in-out", 250 + "ease-in-sine": "cubic-bezier(0.12, 0, 0.39, 0)", 251 + "ease-out-sine": "cubic-bezier(0.61, 1, 0.88, 1)", 252 + "ease-in-out-sine": "cubic-bezier(0.37, 0, 0.63, 1)", 253 + "ease-in-quad": "cubic-bezier(0.11, 0, 0.5, 0)", 254 + "ease-out-quad": "cubic-bezier(0.5, 1, 0.89, 1)", 255 + "ease-in-out-quad": "cubic-bezier(0.45, 0, 0.55, 1)", 256 + "ease-in-cubic": "cubic-bezier(0.32, 0, 0.67, 0)", 257 + "ease-out-cubic": "cubic-bezier(0.33, 1, 0.68, 1)", 258 + "ease-in-out-cubic": "cubic-bezier(0.65, 0, 0.35, 1)", 259 + "ease-in-quart": "cubic-bezier(0.5, 0, 0.75, 0)", 260 + "ease-out-quart": "cubic-bezier(0.25, 1, 0.5, 1)", 261 + "ease-in-out-quart": "cubic-bezier(0.76, 0, 0.24, 1)", 262 + "ease-in-quint": "cubic-bezier(0.64, 0, 0.78, 0)", 263 + "ease-out-quint": "cubic-bezier(0.22, 1, 0.36, 1)", 264 + "ease-in-out-quint": "cubic-bezier(0.83, 0, 0.17, 1)", 265 + "ease-in-expo": "cubic-bezier(0.7, 0, 0.84, 0)", 266 + "ease-out-expo": "cubic-bezier(0.16, 1, 0.3, 1)", 267 + "ease-in-out-expo": "cubic-bezier(0.87, 0, 0.13, 1)", 268 + "ease-in-circ": "cubic-bezier(0.55, 0, 1, 0.45)", 269 + "ease-out-circ": "cubic-bezier(0, 0.55, 0.45, 1)", 270 + "ease-in-out-circ": "cubic-bezier(0.85, 0, 0.15, 1)", 271 + "ease-in-back": "cubic-bezier(0.36, 0, 0.66, -0.56)", 272 + "ease-out-back": "cubic-bezier(0.34, 1.56, 0.64, 1)", 273 + "ease-in-out-back": "cubic-bezier(0.68, -0.6, 0.32, 1.6)", 274 + } as const; 275 + 276 + /** 277 + * Get the CSS easing value for a named easing function. 278 + * If the input is not a named easing, returns it as-is (for custom cubic-bezier). 279 + * 280 + * @param name - Easing name or custom cubic-bezier string 281 + * @returns CSS easing value 282 + */ 283 + export function getEasing(name: string): string { 284 + return (easings as Record<string, string>)[name] ?? name; 285 + } 286 + 287 + /** 288 + * Check if reduced motion is preferred by the user. 289 + * Respects prefers-reduced-motion media query for accessibility. 290 + * 291 + * @returns true if user prefers reduced motion 292 + */ 293 + export function prefersReducedMotion(): boolean { 294 + if (globalThis.window === undefined || !globalThis.matchMedia) { 295 + return false; 296 + } 297 + return globalThis.matchMedia("(prefers-reduced-motion: reduce)").matches; 298 + } 299 + 300 + /** 301 + * Apply duration/delay overrides to a transition phase. 302 + * Returns a new phase object with merged properties. 303 + * 304 + * @param phase - Original transition phase 305 + * @param duration - Optional duration override 306 + * @param delay - Optional delay override 307 + * @returns New phase with overrides applied 308 + */ 309 + export function applyOverrides(phase: TransitionPhase, duration?: number, delay?: number): TransitionPhase { 310 + return { ...phase, ...(duration !== undefined && { duration }), ...(delay !== undefined && { delay }) }; 311 + }
+16
lib/src/index.ts
··· 23 23 export { computed, effect, signal } from "$core/signal"; 24 24 export { deserializeScope, hydrate, isHydrated, isServerRendered, serializeScope } from "$core/ssr"; 25 25 export { getStore, registerStore } from "$core/store"; 26 + export { 27 + applyOverrides, 28 + easings, 29 + getEasing, 30 + getRegisteredTransitions, 31 + getTransition, 32 + hasTransition, 33 + parseTransitionValue, 34 + prefersReducedMotion, 35 + registerTransition, 36 + unregisterTransition, 37 + } from "$core/transitions"; 26 38 export { persistPlugin, registerStorageAdapter } from "$plugins/persist"; 27 39 export { scrollPlugin } from "$plugins/scroll"; 40 + export { surgePlugin } from "$plugins/surge"; 28 41 export { urlPlugin } from "$plugins/url"; 29 42 export type { 30 43 ArcFunction, ··· 39 52 HydrateResult, 40 53 IsReactive, 41 54 ParsedHttpConfig, 55 + ParsedTransition, 42 56 PinRegistry, 43 57 PluginContext, 44 58 PluginHandler, ··· 49 63 ScopeMetadata, 50 64 SerializedScope, 51 65 Signal, 66 + TransitionPhase, 67 + TransitionPreset, 52 68 UidFunction, 53 69 UnwrapReactive, 54 70 } from "$types/volt";
+373
lib/src/plugins/surge.ts
··· 1 + /** 2 + * Surge plugin for enter/leave transitions 3 + * Provides smooth animations when elements appear or disappear 4 + */ 5 + 6 + import { sleep } from "$core/shared"; 7 + import { applyOverrides, getEasing, parseTransitionValue, prefersReducedMotion } from "$core/transitions"; 8 + import type { Optional } from "$types/helpers"; 9 + import type { PluginContext, Signal, TransitionPhase } from "$types/volt"; 10 + 11 + type SurgeConfig = { 12 + enterPreset?: TransitionPhase; 13 + leavePreset?: TransitionPhase; 14 + signalPath?: string; 15 + useViewTransitions: boolean; 16 + }; 17 + 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 + function applyStyles(element: HTMLElement, styles: Record<string, string | number>): void { 31 + for (const [property, value] of Object.entries(styles)) { 32 + const cssProperty = property.replaceAll(/[A-Z]/g, (m) => `-${m.toLowerCase()}`); 33 + element.style.setProperty(cssProperty, String(value)); 34 + } 35 + } 36 + 37 + function applyClasses(el: HTMLElement, classes: string[]): void { 38 + for (const cls of classes) { 39 + el.classList.add(cls); 40 + } 41 + } 42 + 43 + function rmClasses(el: HTMLElement, classes: string[]): void { 44 + for (const cls of classes) { 45 + el.classList.remove(cls); 46 + } 47 + } 48 + 49 + async function execEnter(element: HTMLElement, phase: TransitionPhase, useViewTransitions: boolean): Promise<void> { 50 + const duration = phase.duration ?? 300; 51 + const delay = phase.delay ?? 0; 52 + const easing = getEasing(phase.easing ?? "ease"); 53 + 54 + if (prefersReducedMotion()) { 55 + if (phase.to) { 56 + applyStyles(element, phase.to); 57 + } 58 + if (phase.classes) { 59 + applyClasses(element, phase.classes); 60 + } 61 + return; 62 + } 63 + 64 + if (phase.from) { 65 + applyStyles(element, phase.from); 66 + } 67 + 68 + if (phase.classes) { 69 + applyClasses(element, phase.classes); 70 + } 71 + 72 + void element.offsetHeight; 73 + 74 + element.style.transition = `all ${duration}ms ${easing} ${delay}ms`; 75 + 76 + if (delay > 0) { 77 + await sleep(delay); 78 + } 79 + 80 + const transitionPromise = new Promise<void>((resolve) => { 81 + const handleTransitionEnd = (event: TransitionEvent) => { 82 + if (event.target === element) { 83 + element.removeEventListener("transitionend", handleTransitionEnd); 84 + resolve(); 85 + } 86 + }; 87 + 88 + element.addEventListener("transitionend", handleTransitionEnd); 89 + 90 + setTimeout(() => { 91 + element.removeEventListener("transitionend", handleTransitionEnd); 92 + resolve(); 93 + }, duration + delay + 50); 94 + }); 95 + 96 + if (useViewTransitions) { 97 + withViewTransition(() => { 98 + if (phase.to) { 99 + applyStyles(element, phase.to); 100 + } 101 + }); 102 + } else { 103 + if (phase.to) { 104 + applyStyles(element, phase.to); 105 + } 106 + } 107 + 108 + await transitionPromise; 109 + 110 + element.style.transition = ""; 111 + 112 + if (phase.classes) { 113 + rmClasses(element, phase.classes); 114 + } 115 + } 116 + 117 + async function execLeave(element: HTMLElement, phase: TransitionPhase, useViewTransitions: boolean): Promise<void> { 118 + const duration = phase.duration ?? 300; 119 + const delay = phase.delay ?? 0; 120 + const easing = getEasing(phase.easing ?? "ease"); 121 + 122 + if (prefersReducedMotion()) { 123 + if (phase.to) { 124 + applyStyles(element, phase.to); 125 + } 126 + if (phase.classes) { 127 + applyClasses(element, phase.classes); 128 + } 129 + return; 130 + } 131 + 132 + if (phase.from) { 133 + applyStyles(element, phase.from); 134 + } 135 + 136 + if (phase.classes) { 137 + applyClasses(element, phase.classes); 138 + } 139 + 140 + void element.offsetHeight; 141 + 142 + element.style.transition = `all ${duration}ms ${easing} ${delay}ms`; 143 + 144 + if (delay > 0) { 145 + await sleep(delay); 146 + } 147 + 148 + const transitionPromise = new Promise<void>((resolve) => { 149 + const handleTransitionEnd = (event: TransitionEvent) => { 150 + if (event.target === element) { 151 + element.removeEventListener("transitionend", handleTransitionEnd); 152 + resolve(); 153 + } 154 + }; 155 + 156 + element.addEventListener("transitionend", handleTransitionEnd); 157 + 158 + setTimeout(() => { 159 + element.removeEventListener("transitionend", handleTransitionEnd); 160 + resolve(); 161 + }, duration + delay + 50); 162 + }); 163 + 164 + if (useViewTransitions) { 165 + withViewTransition(() => { 166 + if (phase.to) { 167 + applyStyles(element, phase.to); 168 + } 169 + }); 170 + } else { 171 + if (phase.to) { 172 + applyStyles(element, phase.to); 173 + } 174 + } 175 + 176 + await transitionPromise; 177 + 178 + element.style.transition = ""; 179 + 180 + if (phase.classes) { 181 + rmClasses(element, phase.classes); 182 + } 183 + } 184 + 185 + /** 186 + * Parse surge plugin value to extract configuration 187 + * Supports: 188 + * - "presetName" - default preset 189 + * - "signalPath:presetName" - watch signal with preset 190 + * - "signalPath" - watch signal with default fade 191 + */ 192 + function parseSurgeValue(value: string): Optional<SurgeConfig> { 193 + const parts = value.split(":"); 194 + 195 + if (parts.length === 2) { 196 + const [signalPath, presetValue] = parts; 197 + const parsed = parseTransitionValue(presetValue.trim()); 198 + 199 + if (!parsed) { 200 + return undefined; 201 + } 202 + 203 + return { 204 + enterPreset: parsed.preset.enter, 205 + leavePreset: parsed.preset.leave, 206 + signalPath: signalPath.trim(), 207 + useViewTransitions: true, 208 + }; 209 + } 210 + 211 + const parsed = parseTransitionValue(value.trim()); 212 + if (!parsed) { 213 + return undefined; 214 + } 215 + 216 + return { enterPreset: parsed.preset.enter, leavePreset: parsed.preset.leave, useViewTransitions: true }; 217 + } 218 + 219 + function parsePhaseValue(value: string, phase: "enter" | "leave"): Optional<TransitionPhase> { 220 + const parsed = parseTransitionValue(value.trim()); 221 + if (!parsed) { 222 + return undefined; 223 + } 224 + const presetPhase = phase === "enter" ? parsed.preset.enter : parsed.preset.leave; 225 + return applyOverrides(presetPhase, parsed.duration, parsed.delay); 226 + } 227 + 228 + /** 229 + * Surge plugin handler. 230 + * Provides enter/leave transitions for elements. 231 + * 232 + * Syntax: 233 + * - data-volt-surge="presetName" - Default transition preset 234 + * - data-volt-surge="signalPath:presetName" - Watch signal for transitions 235 + * - data-volt-surge:enter="presetName" - Specific enter transition 236 + * - data-volt-surge:leave="presetName" - Specific leave transition 237 + * 238 + * @example 239 + * ```html 240 + * <!-- Explicit signal watching --> 241 + * <div data-volt-surge="show:fade">Content</div> 242 + * 243 + * <!-- Granular control --> 244 + * <div 245 + * data-volt-surge:enter="slide-down.500" 246 + * data-volt-surge:leave="fade.300"> 247 + * Content 248 + * </div> 249 + * ``` 250 + */ 251 + export function surgePlugin(ctx: PluginContext, value: string): void { 252 + const el = ctx.element as HTMLElement; 253 + 254 + if (value.includes(":")) { 255 + const [phase, presetValue] = value.split(":", 2); 256 + 257 + if (phase === "enter") { 258 + const enterPhase = parsePhaseValue(presetValue, "enter"); 259 + if (!enterPhase) { 260 + console.error(`[Volt] Invalid surge enter value: "${value}"`); 261 + return; 262 + } 263 + 264 + (el as HTMLElement & { _voltSurgeEnter?: TransitionPhase })._voltSurgeEnter = enterPhase; 265 + return; 266 + } 267 + 268 + if (phase === "leave") { 269 + const leavePhase = parsePhaseValue(presetValue, "leave"); 270 + if (!leavePhase) { 271 + console.error(`[Volt] Invalid surge leave value: "${value}"`); 272 + return; 273 + } 274 + 275 + (el as HTMLElement & { _voltSurgeLeave?: TransitionPhase })._voltSurgeLeave = leavePhase; 276 + return; 277 + } 278 + } 279 + 280 + const config = parseSurgeValue(value); 281 + if (!config) { 282 + console.error(`[Volt] Invalid surge value: "${value}"`); 283 + return; 284 + } 285 + 286 + if (!config.signalPath) { 287 + (el as HTMLElement & { _voltSurgeConfig?: SurgeConfig })._voltSurgeConfig = config; 288 + return; 289 + } 290 + 291 + const signal = ctx.findSignal(config.signalPath) as Optional<Signal<unknown>>; 292 + if (!signal) { 293 + console.error(`[Volt] Signal "${config.signalPath}" not found for surge binding`); 294 + return; 295 + } 296 + 297 + let isVisible = Boolean(signal.get()); 298 + let isTransitioning = false; 299 + 300 + if (!isVisible) { 301 + el.style.display = "none"; 302 + } 303 + 304 + const handleTransition = async (shouldShow: boolean) => { 305 + if (isTransitioning || shouldShow === isVisible) { 306 + return; 307 + } 308 + 309 + isTransitioning = true; 310 + 311 + if (shouldShow && config.enterPreset) { 312 + el.style.display = ""; 313 + await execEnter(el, config.enterPreset, config.useViewTransitions); 314 + isVisible = true; 315 + } else if (!shouldShow && config.leavePreset) { 316 + await execLeave(el, config.leavePreset, config.useViewTransitions); 317 + el.style.display = "none"; 318 + isVisible = false; 319 + } 320 + 321 + isTransitioning = false; 322 + }; 323 + 324 + const unsubscribe = signal.subscribe((value) => { 325 + const shouldShow = Boolean(value); 326 + void handleTransition(shouldShow); 327 + }); 328 + 329 + ctx.addCleanup(unsubscribe); 330 + } 331 + 332 + /** 333 + * @internal 334 + */ 335 + export async function executeSurgeEnter(element: HTMLElement): Promise<void> { 336 + const config = (element as HTMLElement & { _voltSurgeConfig?: SurgeConfig })._voltSurgeConfig; 337 + const customEnter = (element as HTMLElement & { _voltSurgeEnter?: TransitionPhase })._voltSurgeEnter; 338 + 339 + const enterPhase = customEnter ?? config?.enterPreset; 340 + if (!enterPhase) { 341 + return; 342 + } 343 + 344 + const useViewTransitions = config?.useViewTransitions ?? true; 345 + await execEnter(element, enterPhase, useViewTransitions); 346 + } 347 + 348 + /** 349 + * @internal 350 + */ 351 + export async function executeSurgeLeave(element: HTMLElement): Promise<void> { 352 + const config = (element as HTMLElement & { _voltSurgeConfig?: SurgeConfig })._voltSurgeConfig; 353 + const customLeave = (element as HTMLElement & { _voltSurgeLeave?: TransitionPhase })._voltSurgeLeave; 354 + 355 + const leavePhase = customLeave ?? config?.leavePreset; 356 + if (!leavePhase) { 357 + return; 358 + } 359 + 360 + const useViewTransitions = config?.useViewTransitions ?? true; 361 + await execLeave(element, leavePhase, useViewTransitions); 362 + } 363 + 364 + /** 365 + * @internal 366 + */ 367 + export function hasSurge(element: HTMLElement): boolean { 368 + const config = (element as HTMLElement & { _voltSurgeConfig?: SurgeConfig })._voltSurgeConfig; 369 + const customEnter = (element as HTMLElement & { _voltSurgeEnter?: TransitionPhase })._voltSurgeEnter; 370 + const customLeave = (element as HTMLElement & { _voltSurgeLeave?: TransitionPhase })._voltSurgeLeave; 371 + 372 + return Boolean(config || customEnter || customLeave); 373 + }
+140
lib/src/types/volt.d.ts
··· 425 425 * Function signature for $probe() - reactive observer 426 426 */ 427 427 export type ProbeFunction = (expression: string, callback: (value: unknown) => void) => CleanupFunction; 428 + 429 + /** 430 + * Configuration for a single transition phase (enter or leave) 431 + */ 432 + export type TransitionPhase = { 433 + /** 434 + * Initial CSS properties (applied immediately) 435 + */ 436 + from?: Record<string, string | number>; 437 + 438 + /** 439 + * Target CSS properties (animated to) 440 + */ 441 + to?: Record<string, string | number>; 442 + 443 + /** 444 + * Duration in milliseconds (default: 300) 445 + */ 446 + duration?: number; 447 + 448 + /** 449 + * Delay in milliseconds (default: 0) 450 + */ 451 + delay?: number; 452 + 453 + /** 454 + * CSS easing function (default: 'ease') 455 + */ 456 + easing?: string; 457 + 458 + /** 459 + * CSS classes to apply during this phase 460 + */ 461 + classes?: string[]; 462 + }; 463 + 464 + /** 465 + * Complete transition preset with enter and leave phases 466 + */ 467 + export type TransitionPreset = { 468 + /** 469 + * Configuration for enter transition 470 + */ 471 + enter: TransitionPhase; 472 + 473 + /** 474 + * Configuration for leave transition 475 + */ 476 + leave: TransitionPhase; 477 + }; 478 + 479 + /** 480 + * Parsed transition value with preset and modifiers 481 + */ 482 + export type ParsedTransition = { 483 + /** 484 + * The transition preset to use 485 + */ 486 + preset: TransitionPreset; 487 + 488 + /** 489 + * Override duration from preset syntax (e.g., "fade.500") 490 + */ 491 + duration?: number; 492 + 493 + /** 494 + * Override delay from preset syntax (e.g., "fade.500.100") 495 + */ 496 + delay?: number; 497 + }; 498 + 499 + /** 500 + * Configuration for a single transition phase (enter or leave) 501 + */ 502 + export type TransitionPhase = { 503 + /** 504 + * Initial CSS properties (applied immediately) 505 + */ 506 + from?: Record<string, string | number>; 507 + 508 + /** 509 + * Target CSS properties (animated to) 510 + */ 511 + to?: Record<string, string | number>; 512 + 513 + /** 514 + * Duration in milliseconds (default: 300) 515 + */ 516 + duration?: number; 517 + 518 + /** 519 + * Delay in milliseconds (default: 0) 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[]; 532 + }; 533 + 534 + /** 535 + * Complete transition preset with enter and leave phases 536 + */ 537 + export type TransitionPreset = { 538 + /** 539 + * Configuration for enter transition 540 + */ 541 + enter: TransitionPhase; 542 + 543 + /** 544 + * Configuration for leave transition 545 + */ 546 + leave: TransitionPhase; 547 + }; 548 + 549 + /** 550 + * Parsed transition value with preset and modifiers 551 + */ 552 + export type ParsedTransition = { 553 + /** 554 + * The transition preset to use 555 + */ 556 + preset: TransitionPreset; 557 + 558 + /** 559 + * Override duration from preset syntax (e.g., "fade.500") 560 + */ 561 + duration?: number; 562 + 563 + /** 564 + * Override delay from preset syntax (e.g., "fade.500.100") 565 + */ 566 + delay?: number; 567 + };
+301
lib/test/core/transitions.test.ts
··· 1 + import { 2 + applyOverrides, 3 + easings, 4 + getEasing, 5 + getRegisteredTransitions, 6 + getTransition, 7 + hasTransition, 8 + parseTransitionValue, 9 + prefersReducedMotion, 10 + registerTransition, 11 + unregisterTransition, 12 + } from "$core/transitions"; 13 + import type { TransitionPreset } from "$types/volt"; 14 + import { describe, expect, it, vi } from "vitest"; 15 + 16 + describe("Transition Preset System", () => { 17 + describe("Built-in Presets", () => { 18 + it("should have fade preset registered", () => { 19 + expect(hasTransition("fade")).toBe(true); 20 + const fade = getTransition("fade"); 21 + expect(fade).toBeDefined(); 22 + expect(fade?.enter.from).toEqual({ opacity: 0 }); 23 + expect(fade?.enter.to).toEqual({ opacity: 1 }); 24 + expect(fade?.leave.from).toEqual({ opacity: 1 }); 25 + expect(fade?.leave.to).toEqual({ opacity: 0 }); 26 + }); 27 + 28 + it.each([{ 29 + name: "slide-up", 30 + enterFrom: { opacity: 0, transform: "translateY(20px)" }, 31 + enterTo: { opacity: 1, transform: "translateY(0)" }, 32 + }, { 33 + name: "slide-down", 34 + enterFrom: { opacity: 0, transform: "translateY(-20px)" }, 35 + enterTo: { opacity: 1, transform: "translateY(0)" }, 36 + }, { 37 + name: "slide-left", 38 + enterFrom: { opacity: 0, transform: "translateX(20px)" }, 39 + enterTo: { opacity: 1, transform: "translateX(0)" }, 40 + }, { 41 + name: "slide-right", 42 + enterFrom: { opacity: 0, transform: "translateX(-20px)" }, 43 + enterTo: { opacity: 1, transform: "translateX(0)" }, 44 + }, { 45 + name: "scale", 46 + enterFrom: { opacity: 0, transform: "scale(0.95)" }, 47 + enterTo: { opacity: 1, transform: "scale(1)" }, 48 + }, { name: "blur", enterFrom: { opacity: 0, filter: "blur(10px)" }, enterTo: { opacity: 1, filter: "blur(0)" } }])( 49 + "should have $name preset registered", 50 + ({ name, enterFrom, enterTo }) => { 51 + expect(hasTransition(name)).toBe(true); 52 + const preset = getTransition(name); 53 + expect(preset).toBeDefined(); 54 + expect(preset?.enter.from).toEqual(enterFrom); 55 + expect(preset?.enter.to).toEqual(enterTo); 56 + }, 57 + ); 58 + 59 + it("should return all built-in preset names", () => { 60 + const presets = getRegisteredTransitions(); 61 + 62 + for (const preset of ["fade", "slide-up", "slide-down", "slide-left", "slide-right", "scale", "blur"]) { 63 + expect(presets).toContain(preset); 64 + } 65 + }); 66 + }); 67 + 68 + describe("Custom Preset Registration", () => { 69 + it("should register a custom transition preset", () => { 70 + const customPreset: TransitionPreset = { 71 + enter: { 72 + from: { opacity: 0, transform: "translateX(-100px)" }, 73 + to: { opacity: 1, transform: "translateX(0)" }, 74 + duration: 400, 75 + easing: "ease-out", 76 + }, 77 + leave: { 78 + from: { opacity: 1, transform: "translateX(0)" }, 79 + to: { opacity: 0, transform: "translateX(100px)" }, 80 + duration: 300, 81 + easing: "ease-in", 82 + }, 83 + }; 84 + 85 + registerTransition("custom-slide", customPreset); 86 + expect(hasTransition("custom-slide")).toBe(true); 87 + 88 + const retrieved = getTransition("custom-slide"); 89 + expect(retrieved).toEqual(customPreset); 90 + }); 91 + 92 + it("should unregister a custom preset", () => { 93 + const customPreset: TransitionPreset = { enter: { from: {}, to: {} }, leave: { from: {}, to: {} } }; 94 + 95 + registerTransition("temp-preset", customPreset); 96 + expect(hasTransition("temp-preset")).toBe(true); 97 + 98 + const result = unregisterTransition("temp-preset"); 99 + expect(result).toBe(true); 100 + expect(hasTransition("temp-preset")).toBe(false); 101 + }); 102 + 103 + it("should not unregister built-in presets", () => { 104 + const result = unregisterTransition("fade"); 105 + expect(result).toBe(false); 106 + expect(hasTransition("fade")).toBe(true); 107 + }); 108 + 109 + it("should warn when overriding built-in preset", () => { 110 + const consoleSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); 111 + const customPreset: TransitionPreset = { enter: { from: {}, to: {} }, leave: { from: {}, to: {} } }; 112 + 113 + registerTransition("fade", customPreset); 114 + expect(consoleSpy).toHaveBeenCalledWith("[Volt] Overriding built-in transition preset: \"fade\""); 115 + 116 + consoleSpy.mockRestore(); 117 + }); 118 + 119 + it("should return undefined for unknown preset", () => { 120 + const preset = getTransition("nonexistent"); 121 + expect(preset).toBeUndefined(); 122 + }); 123 + }); 124 + 125 + describe("Parse Transition Value", () => { 126 + it("should parse preset name only", () => { 127 + const parsed = parseTransitionValue("fade"); 128 + expect(parsed).toBeDefined(); 129 + expect(parsed?.preset).toEqual(getTransition("fade")); 130 + expect(parsed?.duration).toBeUndefined(); 131 + expect(parsed?.delay).toBeUndefined(); 132 + }); 133 + 134 + it("should parse preset name with duration", () => { 135 + const parsed = parseTransitionValue("fade.500"); 136 + expect(parsed).toBeDefined(); 137 + expect(parsed?.preset).toEqual(getTransition("fade")); 138 + expect(parsed?.duration).toBe(500); 139 + expect(parsed?.delay).toBeUndefined(); 140 + }); 141 + 142 + it("should parse preset name with duration and delay", () => { 143 + const parsed = parseTransitionValue("slide-down.600.100"); 144 + expect(parsed).toBeDefined(); 145 + expect(parsed?.preset).toEqual(getTransition("slide-down")); 146 + expect(parsed?.duration).toBe(600); 147 + expect(parsed?.delay).toBe(100); 148 + }); 149 + 150 + it("should handle whitespace", () => { 151 + const parsed = parseTransitionValue(" fade.500.100 "); 152 + expect(parsed).toBeDefined(); 153 + expect(parsed?.preset).toEqual(getTransition("fade")); 154 + expect(parsed?.duration).toBe(500); 155 + expect(parsed?.delay).toBe(100); 156 + }); 157 + 158 + it("should return undefined for empty string", () => { 159 + const parsed = parseTransitionValue(""); 160 + expect(parsed).toBeUndefined(); 161 + }); 162 + 163 + it("should return undefined for unknown preset", () => { 164 + const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {}); 165 + 166 + const parsed = parseTransitionValue("nonexistent"); 167 + expect(parsed).toBeUndefined(); 168 + expect(consoleSpy).toHaveBeenCalled(); 169 + 170 + consoleSpy.mockRestore(); 171 + }); 172 + 173 + it("should ignore invalid duration values", () => { 174 + const parsed = parseTransitionValue("fade.abc"); 175 + expect(parsed).toBeDefined(); 176 + expect(parsed?.duration).toBeUndefined(); 177 + }); 178 + 179 + it("should ignore invalid delay values", () => { 180 + const parsed = parseTransitionValue("fade.500.xyz"); 181 + expect(parsed).toBeDefined(); 182 + expect(parsed?.duration).toBe(500); 183 + expect(parsed?.delay).toBeUndefined(); 184 + }); 185 + }); 186 + 187 + describe("Easing Functions", () => { 188 + it("should return CSS easing for named easings", () => { 189 + for (const e of ["linear", "ease", "ease-in", "ease-out", "ease-in-out"]) { 190 + const res = getEasing(e); 191 + expect(res).toEqual(e); 192 + } 193 + }); 194 + 195 + it("should return cubic-bezier for named easing curves", () => { 196 + expect(getEasing("ease-in-sine")).toBe("cubic-bezier(0.12, 0, 0.39, 0)"); 197 + expect(getEasing("ease-out-sine")).toBe("cubic-bezier(0.61, 1, 0.88, 1)"); 198 + expect(getEasing("ease-in-quad")).toBe("cubic-bezier(0.11, 0, 0.5, 0)"); 199 + }); 200 + 201 + it("should return custom cubic-bezier as-is", () => { 202 + const custom = "cubic-bezier(0.25, 0.1, 0.25, 1)"; 203 + expect(getEasing(custom)).toBe(custom); 204 + }); 205 + 206 + it("should have all easing constants defined", () => { 207 + for ( 208 + const prop of [ 209 + "linear", 210 + "ease", 211 + "ease-in", 212 + "ease-out", 213 + "ease-in-out", 214 + "ease-in-back", 215 + "ease-out-back", 216 + "ease-in-out-back", 217 + ] 218 + ) { 219 + expect(easings).toHaveProperty(prop); 220 + } 221 + }); 222 + }); 223 + 224 + describe("Apply Overrides", () => { 225 + it("should apply duration override", () => { 226 + const phase = { from: { opacity: 0 }, to: { opacity: 1 }, duration: 300, delay: 0, easing: "ease" }; 227 + const overridden = applyOverrides(phase, 500); 228 + expect(overridden.duration).toBe(500); 229 + expect(overridden.delay).toBe(0); 230 + expect(overridden.from).toEqual({ opacity: 0 }); 231 + expect(overridden.to).toEqual({ opacity: 1 }); 232 + expect(overridden.easing).toBe("ease"); 233 + }); 234 + 235 + it("should apply delay override", () => { 236 + const phase = { from: { opacity: 0 }, to: { opacity: 1 }, duration: 300, delay: 0, easing: "ease" }; 237 + const overridden = applyOverrides(phase, undefined, 100); 238 + expect(overridden.duration).toBe(300); 239 + expect(overridden.delay).toBe(100); 240 + }); 241 + 242 + it("should apply both duration and delay overrides", () => { 243 + const phase = { from: { opacity: 0 }, to: { opacity: 1 }, duration: 300, delay: 0, easing: "ease" }; 244 + const overridden = applyOverrides(phase, 600, 200); 245 + expect(overridden.duration).toBe(600); 246 + expect(overridden.delay).toBe(200); 247 + }); 248 + 249 + it("should not mutate original phase", () => { 250 + const phase = { from: { opacity: 0 }, to: { opacity: 1 }, duration: 300, delay: 0, easing: "ease" }; 251 + const overridden = applyOverrides(phase, 500, 100); 252 + expect(phase.duration).toBe(300); 253 + expect(phase.delay).toBe(0); 254 + expect(overridden).not.toBe(phase); 255 + }); 256 + 257 + it("should preserve all properties when no overrides", () => { 258 + const phase = { 259 + from: { opacity: 0, transform: "translateY(20px)" }, 260 + to: { opacity: 1, transform: "translateY(0)" }, 261 + duration: 300, 262 + delay: 50, 263 + easing: "ease-out", 264 + classes: ["entering"], 265 + }; 266 + 267 + const overridden = applyOverrides(phase); 268 + expect(overridden).toEqual(phase); 269 + expect(overridden).not.toBe(phase); 270 + }); 271 + }); 272 + 273 + describe("Prefers Reduced Motion", () => { 274 + it("should return false when matchMedia is not available", () => { 275 + const originalMatchMedia = globalThis.matchMedia; 276 + // @ts-expect-error - Testing undefined case 277 + delete globalThis.matchMedia; 278 + 279 + expect(prefersReducedMotion()).toBe(false); 280 + 281 + globalThis.matchMedia = originalMatchMedia; 282 + }); 283 + 284 + it("should check prefers-reduced-motion media query", () => { 285 + const mockMatchMedia = vi.fn().mockReturnValue({ matches: true }); 286 + globalThis.matchMedia = mockMatchMedia; 287 + 288 + const result = prefersReducedMotion(); 289 + 290 + expect(mockMatchMedia).toHaveBeenCalledWith("(prefers-reduced-motion: reduce)"); 291 + expect(result).toBe(true); 292 + }); 293 + 294 + it("should return false when user does not prefer reduced motion", () => { 295 + const mockMatchMedia = vi.fn().mockReturnValue({ matches: false }); 296 + globalThis.matchMedia = mockMatchMedia; 297 + const result = prefersReducedMotion(); 298 + expect(result).toBe(false); 299 + }); 300 + }); 301 + });
+395
lib/test/plugins/surge.test.ts
··· 1 + import { signal } from "$core/signal"; 2 + import { registerTransition } from "$core/transitions"; 3 + import { executeSurgeEnter, executeSurgeLeave, hasSurge, surgePlugin } from "$plugins/surge"; 4 + import type { TransitionPreset } from "$types/volt"; 5 + import type { PluginContext } from "$types/volt"; 6 + import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; 7 + 8 + describe("Surge Plugin", () => { 9 + let container: HTMLDivElement; 10 + let element: HTMLElement; 11 + let mockContext: PluginContext; 12 + let cleanups: Array<() => void>; 13 + 14 + beforeEach(() => { 15 + container = document.createElement("div"); 16 + element = document.createElement("div"); 17 + element.textContent = "Test Content"; 18 + container.append(element); 19 + document.body.append(container); 20 + 21 + cleanups = []; 22 + 23 + mockContext = { 24 + element, 25 + scope: {}, 26 + addCleanup: (fn) => { 27 + cleanups.push(fn); 28 + }, 29 + findSignal: vi.fn(), 30 + evaluate: vi.fn(), 31 + lifecycle: { onMount: vi.fn(), onUnmount: vi.fn(), beforeBinding: vi.fn(), afterBinding: vi.fn() }, 32 + }; 33 + 34 + globalThis.matchMedia = vi.fn().mockReturnValue({ matches: false }); 35 + }); 36 + 37 + afterEach(() => { 38 + for (const cleanup of cleanups) { 39 + cleanup(); 40 + } 41 + cleanups = []; 42 + container.remove(); 43 + vi.restoreAllMocks(); 44 + }); 45 + 46 + describe("Configuration Storage", () => { 47 + it("should store config when no signal path provided", () => { 48 + surgePlugin(mockContext, "fade"); 49 + expect(hasSurge(element as HTMLElement)).toBe(true); 50 + }); 51 + 52 + it("should store enter-specific config", () => { 53 + surgePlugin(mockContext, "enter:slide-down"); 54 + const stored = (element as HTMLElement & { _voltSurgeEnter?: unknown })._voltSurgeEnter; 55 + expect(stored).toBeDefined(); 56 + }); 57 + 58 + it("should store leave-specific config", () => { 59 + surgePlugin(mockContext, "leave:fade.300"); 60 + const stored = (element as HTMLElement & { _voltSurgeLeave?: unknown })._voltSurgeLeave; 61 + expect(stored).toBeDefined(); 62 + }); 63 + }); 64 + 65 + describe("Signal Watching (Explicit Mode)", () => { 66 + it("should watch signal and show/hide element", async () => { 67 + vi.useFakeTimers(); 68 + 69 + const showSignal = signal(false); 70 + mockContext.findSignal = vi.fn().mockReturnValue(showSignal); 71 + mockContext.scope = { show: showSignal }; 72 + 73 + surgePlugin(mockContext, "show:fade"); 74 + 75 + expect(element.style.display).toBe("none"); 76 + 77 + showSignal.set(true); 78 + await vi.advanceTimersByTimeAsync(400); 79 + expect(element.style.display).not.toBe("none"); 80 + 81 + showSignal.set(false); 82 + await vi.advanceTimersByTimeAsync(400); 83 + expect(element.style.display).toBe("none"); 84 + 85 + vi.useRealTimers(); 86 + }); 87 + 88 + it("should apply transitions when showing element", async () => { 89 + const showSignal = signal(false); 90 + mockContext.findSignal = vi.fn().mockReturnValue(showSignal); 91 + 92 + surgePlugin(mockContext, "show:fade"); 93 + 94 + showSignal.set(true); 95 + 96 + await new Promise((resolve) => { 97 + setTimeout(resolve, 50); 98 + }); 99 + 100 + expect(element.style.display).not.toBe("none"); 101 + }); 102 + 103 + it("should cleanup subscription on unmount", () => { 104 + const showSignal = signal(true); 105 + mockContext.findSignal = vi.fn().mockReturnValue(showSignal); 106 + 107 + surgePlugin(mockContext, "show:fade"); 108 + 109 + expect(cleanups.length).toBeGreaterThan(0); 110 + 111 + for (const cleanup of cleanups) { 112 + cleanup(); 113 + } 114 + 115 + const initialDisplay = element.style.display; 116 + showSignal.set(false); 117 + 118 + expect(element.style.display).toBe(initialDisplay); 119 + }); 120 + 121 + it("should error when signal not found", () => { 122 + const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {}); 123 + mockContext.findSignal = vi.fn().mockReturnValue(void 0); 124 + 125 + surgePlugin(mockContext, "nonexistent:fade"); 126 + expect(consoleSpy).toHaveBeenCalledWith("[Volt] Signal \"nonexistent\" not found for surge binding"); 127 + 128 + consoleSpy.mockRestore(); 129 + }); 130 + 131 + it("should not transition if already in target state", async () => { 132 + const showSignal = signal(true); 133 + mockContext.findSignal = vi.fn().mockReturnValue(showSignal); 134 + 135 + surgePlugin(mockContext, "show:fade"); 136 + expect(element.style.display).not.toBe("none"); 137 + 138 + const initialStyles = element.style.cssText; 139 + showSignal.set(true); 140 + 141 + await new Promise((resolve) => { 142 + setTimeout(resolve, 50); 143 + }); 144 + 145 + expect(element.style.cssText).toBe(initialStyles); 146 + }); 147 + }); 148 + 149 + describe("Custom Presets", () => { 150 + it("should use custom registered preset", async () => { 151 + const customPreset: TransitionPreset = { 152 + enter: { 153 + from: { opacity: 0, transform: "scale(0.5)" }, 154 + to: { opacity: 1, transform: "scale(1)" }, 155 + duration: 200, 156 + easing: "ease-out", 157 + }, 158 + leave: { 159 + from: { opacity: 1, transform: "scale(1)" }, 160 + to: { opacity: 0, transform: "scale(0.5)" }, 161 + duration: 200, 162 + easing: "ease-in", 163 + }, 164 + }; 165 + 166 + registerTransition("custom-scale", customPreset); 167 + 168 + const showSignal = signal(false); 169 + mockContext.findSignal = vi.fn().mockReturnValue(showSignal); 170 + 171 + surgePlugin(mockContext, "show:custom-scale"); 172 + 173 + showSignal.set(true); 174 + 175 + await new Promise((resolve) => { 176 + setTimeout(resolve, 50); 177 + }); 178 + 179 + expect(element.style.display).not.toBe("none"); 180 + }); 181 + }); 182 + 183 + describe("Duration and Delay Overrides", () => { 184 + it("should parse duration override", () => { 185 + const showSignal = signal(false); 186 + mockContext.findSignal = vi.fn().mockReturnValue(showSignal); 187 + 188 + surgePlugin(mockContext, "show:fade.500"); 189 + 190 + expect(mockContext.findSignal).toHaveBeenCalledWith("show"); 191 + }); 192 + 193 + it("should parse duration and delay overrides", () => { 194 + const showSignal = signal(false); 195 + mockContext.findSignal = vi.fn().mockReturnValue(showSignal); 196 + 197 + surgePlugin(mockContext, "show:slide-down.600.100"); 198 + 199 + expect(mockContext.findSignal).toHaveBeenCalledWith("show"); 200 + }); 201 + }); 202 + 203 + describe("Error Handling", () => { 204 + it("should error on invalid surge value", () => { 205 + const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {}); 206 + surgePlugin(mockContext, "nonexistent-preset"); 207 + expect(consoleSpy).toHaveBeenCalledWith("[Volt] Unknown transition preset: \"nonexistent-preset\""); 208 + consoleSpy.mockRestore(); 209 + }); 210 + 211 + it("should error on invalid enter value", () => { 212 + const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {}); 213 + surgePlugin(mockContext, "enter:nonexistent"); 214 + expect(consoleSpy).toHaveBeenCalled(); 215 + consoleSpy.mockRestore(); 216 + }); 217 + 218 + it("should error on invalid leave value", () => { 219 + const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {}); 220 + surgePlugin(mockContext, "leave:nonexistent"); 221 + expect(consoleSpy).toHaveBeenCalled(); 222 + consoleSpy.mockRestore(); 223 + }); 224 + }); 225 + 226 + describe("Helper Functions", () => { 227 + describe("hasSurge", () => { 228 + it("should return true when surge config exists", () => { 229 + surgePlugin(mockContext, "fade"); 230 + expect(hasSurge(element as HTMLElement)).toBe(true); 231 + }); 232 + 233 + it("should return true when custom enter exists", () => { 234 + surgePlugin(mockContext, "enter:slide-down"); 235 + expect(hasSurge(element as HTMLElement)).toBe(true); 236 + }); 237 + 238 + it("should return true when custom leave exists", () => { 239 + surgePlugin(mockContext, "leave:fade"); 240 + expect(hasSurge(element as HTMLElement)).toBe(true); 241 + }); 242 + 243 + it("should return false when no surge config exists", () => { 244 + expect(hasSurge(element as HTMLElement)).toBe(false); 245 + }); 246 + }); 247 + 248 + describe("executeSurgeEnter", () => { 249 + it("should execute enter transition", async () => { 250 + surgePlugin(mockContext, "fade"); 251 + await executeSurgeEnter(element as HTMLElement); 252 + expect(element).toBeDefined(); 253 + }); 254 + 255 + it("should use custom enter if available", async () => { 256 + surgePlugin(mockContext, "enter:slide-down"); 257 + surgePlugin(mockContext, "leave:fade"); 258 + 259 + await executeSurgeEnter(element as HTMLElement); 260 + 261 + expect(element).toBeDefined(); 262 + }); 263 + 264 + it("should do nothing if no enter config", async () => { 265 + await executeSurgeEnter(element as HTMLElement); 266 + expect(element).toBeDefined(); 267 + }); 268 + }); 269 + 270 + describe("executeSurgeLeave", () => { 271 + it("should execute leave transition", async () => { 272 + surgePlugin(mockContext, "fade"); 273 + await executeSurgeLeave(element as HTMLElement); 274 + expect(element).toBeDefined(); 275 + }); 276 + 277 + it("should use custom leave if available", async () => { 278 + surgePlugin(mockContext, "enter:fade"); 279 + surgePlugin(mockContext, "leave:slide-up"); 280 + 281 + await executeSurgeLeave(element as HTMLElement); 282 + 283 + expect(element).toBeDefined(); 284 + }); 285 + 286 + it("should do nothing if no leave config", async () => { 287 + await executeSurgeLeave(element as HTMLElement); 288 + expect(element).toBeDefined(); 289 + }); 290 + }); 291 + }); 292 + 293 + describe("Accessibility", () => { 294 + it("should skip animations when prefers-reduced-motion is enabled", async () => { 295 + globalThis.matchMedia = vi.fn().mockReturnValue({ matches: true }); 296 + 297 + const showSignal = signal(false); 298 + mockContext.findSignal = vi.fn().mockReturnValue(showSignal); 299 + 300 + surgePlugin(mockContext, "show:fade"); 301 + 302 + showSignal.set(true); 303 + 304 + await new Promise((resolve) => { 305 + setTimeout(resolve, 50); 306 + }); 307 + 308 + expect(element.style.display).not.toBe("none"); 309 + }); 310 + }); 311 + 312 + describe("Transition Lifecycle", () => { 313 + it("should not start overlapping transitions", async () => { 314 + const showSignal = signal(false); 315 + mockContext.findSignal = vi.fn().mockReturnValue(showSignal); 316 + 317 + surgePlugin(mockContext, "show:fade"); 318 + 319 + showSignal.set(true); 320 + showSignal.set(false); 321 + showSignal.set(true); 322 + 323 + await new Promise((resolve) => { 324 + setTimeout(resolve, 100); 325 + }); 326 + 327 + expect(element).toBeDefined(); 328 + }); 329 + 330 + it("should cleanup transition styles after completion", async () => { 331 + const showSignal = signal(false); 332 + mockContext.findSignal = vi.fn().mockReturnValue(showSignal); 333 + 334 + registerTransition("test-fast", { 335 + enter: { from: { opacity: 0 }, to: { opacity: 1 }, duration: 10 }, 336 + leave: { from: { opacity: 1 }, to: { opacity: 0 }, duration: 10 }, 337 + }); 338 + 339 + surgePlugin(mockContext, "show:test-fast"); 340 + 341 + showSignal.set(true); 342 + 343 + await new Promise((resolve) => { 344 + setTimeout(resolve, 100); 345 + }); 346 + 347 + expect(element.style.transition).toBe(""); 348 + }); 349 + }); 350 + 351 + describe("View Transitions API", () => { 352 + it("should use View Transitions API when available", async () => { 353 + const mockStartViewTransition = vi.fn((callback) => { 354 + callback(); 355 + }); 356 + 357 + // @ts-expect-error - Adding View Transitions API mock 358 + document.startViewTransition = mockStartViewTransition; 359 + 360 + const showSignal = signal(false); 361 + mockContext.findSignal = vi.fn().mockReturnValue(showSignal); 362 + 363 + surgePlugin(mockContext, "show:fade"); 364 + 365 + showSignal.set(true); 366 + 367 + await new Promise((resolve) => { 368 + setTimeout(resolve, 50); 369 + }); 370 + 371 + expect(mockStartViewTransition).toHaveBeenCalled(); 372 + 373 + // @ts-expect-error - Cleanup mock 374 + delete document.startViewTransition; 375 + }); 376 + 377 + it("should fallback to CSS when View Transitions API not available", async () => { 378 + // @ts-expect-error - Ensure View Transitions API is not available 379 + delete document.startViewTransition; 380 + 381 + const showSignal = signal(false); 382 + mockContext.findSignal = vi.fn().mockReturnValue(showSignal); 383 + 384 + surgePlugin(mockContext, "show:fade"); 385 + 386 + showSignal.set(true); 387 + 388 + await new Promise((resolve) => { 389 + setTimeout(resolve, 50); 390 + }); 391 + 392 + expect(element.style.display).not.toBe("none"); 393 + }); 394 + }); 395 + });