From 9affd470e1c952f5e06972269477013c15b59d00 Mon Sep 17 00:00:00 2001 From: Owais <71664048+desertthunder@users.noreply.github.com> Date: Tue, 21 Oct 2025 08:52:08 -0500 Subject: [PATCH] feat: Animation & Transitions (#1) * feat: transition presets & surge plugin * refactor http module * build: update test action --- .github/workflows/test.yml | 7 +- ROADMAP.md | 4 +- docs/.vitepress/config.ts | 11 +- docs/animations.md | 1 + lib/src/core/http.ts | 71 +++--- lib/src/core/transitions.ts | 311 +++++++++++++++++++++++ lib/src/index.ts | 16 ++ lib/src/plugins/surge.ts | 373 ++++++++++++++++++++++++++++ lib/src/types/volt.d.ts | 140 +++++++++++ lib/test/core/transitions.test.ts | 301 +++++++++++++++++++++++ lib/test/plugins/surge.test.ts | 395 ++++++++++++++++++++++++++++++ 11 files changed, 1582 insertions(+), 48 deletions(-) create mode 100644 docs/animations.md create mode 100644 lib/src/core/transitions.ts create mode 100644 lib/src/plugins/surge.ts create mode 100644 lib/test/core/transitions.test.ts create mode 100644 lib/test/plugins/surge.test.ts diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index e985b32..f263f21 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -1,7 +1,12 @@ name: Run tests and upload coverage on: - push + push: + branches: + - main + pull_request: + branches: + - main jobs: test: diff --git a/ROADMAP.md b/ROADMAP.md index c9254b2..82919af 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -80,8 +80,6 @@ _NOTE_: `data-x-*` is now `data-volt-*` **Outcome:** Volt.js supports rich declarative behaviors and event semantics built entirely on standard DOM APIs. **Summary:** Introduced expressive attribute patterns and event modifiers for precise DOM and input control, for fine-grained declarative behavior entirely through standard DOM APIs. -## To-Do - ### Global State **Goal:** Implement store/context pattern @@ -102,6 +100,8 @@ _NOTE_: `data-x-*` is now `data-volt-*` - `$arc(event, detail?)` - Dispatches a native CustomEvent from the current element. - Example: `data-volt-on-click="$arc('user:save', { id })"` +## To-Do + ### Animation & Transitions **Goal:** Add animation primitives for smooth UI transitions with Alpine/Datastar parity. diff --git a/docs/.vitepress/config.ts b/docs/.vitepress/config.ts index b537d15..445a987 100644 --- a/docs/.vitepress/config.ts +++ b/docs/.vitepress/config.ts @@ -18,10 +18,13 @@ export default defineConfig({ }, { text: "Core Concepts", - items: [{ text: "State Management", link: "/state" }, { text: "Bindings", link: "/bindings" }, { - text: "Expressions", - link: "/expressions", - }, { text: "SSR & Lifecycle", link: "/lifecycle" }], + items: [ + { text: "State Management", link: "/state" }, + { text: "Bindings", link: "/bindings" }, + { text: "Expressions", link: "/expressions" }, + { text: "SSR & Lifecycle", link: "/lifecycle" }, + { text: "Animations & Transitions", link: "/animations" }, + ], }, { text: "Tutorials", items: [{ text: "Counter", link: "/usage/counter" }] }, { diff --git a/docs/animations.md b/docs/animations.md new file mode 100644 index 0000000..a90b369 --- /dev/null +++ b/docs/animations.md @@ -0,0 +1 @@ +# Animations & Transitions diff --git a/lib/src/core/http.ts b/lib/src/core/http.ts index 1d4a190..a1a0c89 100644 --- a/lib/src/core/http.ts +++ b/lib/src/core/http.ts @@ -4,7 +4,7 @@ * Provides HTTP request/response handling with DOM swapping capabilities for server-rendered HTML fragments and JSON responses. */ -import type { Optional } from "$types/helpers"; +import type { Nullable, Optional } from "$types/helpers"; import type { BindingContext, HttpMethod, @@ -19,6 +19,16 @@ import type { import { evaluate } from "./evaluator"; import { sleep } from "./shared"; +type IndicatorStrategy = "display" | "class"; + +type CapturedState = { + focusPath: number[] | null; + scrollPositions: Map; + inputValues: Map; +}; + +const indicatorStrategies = new WeakMap(); + /** * Make an HTTP request and return the parsed response * @@ -62,12 +72,6 @@ export async function request(conf: RequestConfig): Promise { } } -type CapturedState = { - focusPath: number[] | null; - scrollPositions: Map; - inputValues: Map; -}; - /** * Capture state that should be preserved during DOM swap */ @@ -80,7 +84,7 @@ function captureState(root: Element): CapturedState { } const walker = document.createTreeWalker(root, NodeFilter.SHOW_ELEMENT); - let currentNode: Node | null = walker.currentNode; + let currentNode: Nullable = walker.currentNode; while (currentNode) { const el = currentNode as Element; @@ -111,13 +115,13 @@ function captureState(root: Element): CapturedState { */ function getElementPath(el: Element, root: Element): number[] { const path: number[] = []; - let current: Element | null = el; + let current: Nullable = el; while (current && current !== root) { - const parent: Element | null = current.parentElement; + const parent: Nullable = current.parentElement; if (!parent) break; - const index = Array.from(parent.children).indexOf(current); + const index = [...parent.children].indexOf(current); if (index === -1) break; path.unshift(index); @@ -127,14 +131,11 @@ function getElementPath(el: Element, root: Element): number[] { return path; } -/** - * Get element by path from root - */ -function getElementByPath(path: number[], root: Element): Element | null { +function getElementByPath(path: number[], root: Element): Nullable { let current: Element = root; for (const index of path) { - const children = Array.from(current.children); + const children = [...current.children]; if (index >= children.length) return null; current = children[index]; } @@ -142,9 +143,6 @@ function getElementByPath(path: number[], root: Element): Element | null { return current; } -/** - * Restore preserved state after DOM swap - */ function restoreState(root: Element, state: CapturedState): void { if (state.focusPath) { const element = getElementByPath(state.focusPath, root); @@ -328,9 +326,6 @@ export function parseHttpConfig(el: Element, scope: Scope): ParsedHttpConfig { return { trigger, target, swap, headers, retry, indicator }; } -/** - * Get the default trigger event for an element - */ function getDefaultTrigger(el: Element): string { if (el instanceof HTMLFormElement) { return "submit"; @@ -348,7 +343,7 @@ function getDefaultTrigger(el: Element): string { * @param indicator - Optional indicator selector */ export function setLoadingState(el: Element, indicator?: string): void { - el.setAttribute("data-volt-loading", "true"); + (el as HTMLElement).dataset.voltLoading = "true"; if (indicator) { showIndicator(indicator); @@ -368,7 +363,7 @@ export function setLoadingState(el: Element, indicator?: string): void { * @param indicator - Optional indicator selector */ export function setErrorState(el: Element, msg: string, indicator?: string): void { - el.setAttribute("data-volt-error", msg); + (el as HTMLElement).dataset.voltError = msg; if (indicator) { hideIndicator(indicator); @@ -389,9 +384,9 @@ export function setErrorState(el: Element, msg: string, indicator?: string): voi * @param indicator - Optional indicator selector */ export function clearStates(el: Element, indicator?: string): void { - el.removeAttribute("data-volt-loading"); - el.removeAttribute("data-volt-error"); - el.removeAttribute("data-volt-retry-attempt"); + delete (el as HTMLElement).dataset.voltLoading; + delete (el as HTMLElement).dataset.voltError; + delete (el as HTMLElement).dataset.voltRetryAttempt; if (indicator) { hideIndicator(indicator); @@ -400,10 +395,6 @@ export function clearStates(el: Element, indicator?: string): void { el.dispatchEvent(new CustomEvent("volt:success", { detail: { element: el }, bubbles: true, cancelable: false })); } -type IndicatorStrategy = "display" | "class"; - -const indicatorStrategies = new WeakMap(); - /** * Detect the appropriate visibility strategy for an indicator element * @@ -418,14 +409,14 @@ function detectIndicatorStrategy(el: Element): IndicatorStrategy { const htmlElement = el as HTMLElement; const inlineDisplay = htmlElement.style.display; - const computedDisplay = window.getComputedStyle(htmlElement).display; + const computedDisplay = globalThis.getComputedStyle(htmlElement).display; if (inlineDisplay === "none" || computedDisplay === "none") { indicatorStrategies.set(el, "display"); return "display"; } - const hasHiddenClass = Array.from(el.classList).some((cls) => cls.toLowerCase().includes("hidden")); + const hasHiddenClass = [...el.classList].some((cls) => cls.toLowerCase().includes("hidden")); if (hasHiddenClass) { indicatorStrategies.set(el, "class"); return "class"; @@ -445,7 +436,7 @@ function showIndicatorElement(el: Element): void { if (strategy === "display") { htmlElement.style.display = ""; } else { - const hiddenClass = Array.from(el.classList).find((cls) => cls.toLowerCase().includes("hidden")) || "hidden"; + const hiddenClass = [...el.classList].find((cls) => cls.toLowerCase().includes("hidden")) || "hidden"; el.classList.remove(hiddenClass); } } @@ -460,7 +451,7 @@ function hideIndicatorElement(el: Element): void { if (strategy === "display") { htmlElement.style.display = "none"; } else { - const hiddenClass = Array.from(el.classList).find((cls) => cls.toLowerCase().includes("hidden")) || "hidden"; + const hiddenClass = [...el.classList].find((cls) => cls.toLowerCase().includes("hidden")) || "hidden"; el.classList.add(hiddenClass); } } @@ -597,8 +588,8 @@ async function performRequest( for (let attempt = 0; attempt < maxAttempts; attempt++) { try { if (attempt > 0) { - target.setAttribute("data-volt-retry-attempt", String(attempt)); - target.setAttribute("data-volt-loading", "retrying"); + (target as HTMLElement).dataset.voltRetryAttempt = String(attempt); + (target as HTMLElement).dataset.voltLoading = "retrying"; target.dispatchEvent( new CustomEvent("volt:retry", { detail: { element: target, attempt }, bubbles: true, cancelable: false }), ); @@ -678,10 +669,8 @@ function bindHttpMethod(ctx: BindingContext | PluginContext, method: HttpMethod, let body: Optional; - if (method !== "GET" && method !== "DELETE") { - if (ctx.element instanceof HTMLFormElement) { - body = serializeForm(ctx.element); - } + if (method !== "GET" && method !== "DELETE" && ctx.element instanceof HTMLFormElement) { + body = serializeForm(ctx.element); } await performRequest(ctx.element, method, resolvedUrl, config, body); diff --git a/lib/src/core/transitions.ts b/lib/src/core/transitions.ts new file mode 100644 index 0000000..9da28dd --- /dev/null +++ b/lib/src/core/transitions.ts @@ -0,0 +1,311 @@ +/** + * Transition preset system for surge plugin + * Provides built-in transition presets and custom preset registration + */ + +import type { Optional } from "$types/helpers"; +import type { ParsedTransition, TransitionPhase, TransitionPreset } from "$types/volt"; + +/** + * Registry of transition presets + */ +const transitionRegistry = new Map(); + +/** + * Built-in transition presets + */ +const builtinPresets: Record = { + fade: { + enter: { from: { opacity: 0 }, to: { opacity: 1 }, duration: 300, easing: "ease" }, + leave: { from: { opacity: 1 }, to: { opacity: 0 }, duration: 300, easing: "ease" }, + }, + "slide-up": { + enter: { + from: { opacity: 0, transform: "translateY(20px)" }, + to: { opacity: 1, transform: "translateY(0)" }, + duration: 300, + easing: "ease-out", + }, + leave: { + from: { opacity: 1, transform: "translateY(0)" }, + to: { opacity: 0, transform: "translateY(-20px)" }, + duration: 300, + easing: "ease-in", + }, + }, + "slide-down": { + enter: { + from: { opacity: 0, transform: "translateY(-20px)" }, + to: { opacity: 1, transform: "translateY(0)" }, + duration: 300, + easing: "ease-out", + }, + leave: { + from: { opacity: 1, transform: "translateY(0)" }, + to: { opacity: 0, transform: "translateY(20px)" }, + duration: 300, + easing: "ease-in", + }, + }, + "slide-left": { + enter: { + from: { opacity: 0, transform: "translateX(20px)" }, + to: { opacity: 1, transform: "translateX(0)" }, + duration: 300, + easing: "ease-out", + }, + leave: { + from: { opacity: 1, transform: "translateX(0)" }, + to: { opacity: 0, transform: "translateX(-20px)" }, + duration: 300, + easing: "ease-in", + }, + }, + "slide-right": { + enter: { + from: { opacity: 0, transform: "translateX(-20px)" }, + to: { opacity: 1, transform: "translateX(0)" }, + duration: 300, + easing: "ease-out", + }, + leave: { + from: { opacity: 1, transform: "translateX(0)" }, + to: { opacity: 0, transform: "translateX(20px)" }, + duration: 300, + easing: "ease-in", + }, + }, + scale: { + enter: { + from: { opacity: 0, transform: "scale(0.95)" }, + to: { opacity: 1, transform: "scale(1)" }, + duration: 300, + easing: "ease-out", + }, + leave: { + from: { opacity: 1, transform: "scale(1)" }, + to: { opacity: 0, transform: "scale(0.95)" }, + duration: 300, + easing: "ease-in", + }, + }, + blur: { + enter: { + from: { opacity: 0, filter: "blur(10px)" }, + to: { opacity: 1, filter: "blur(0)" }, + duration: 300, + easing: "ease", + }, + leave: { + from: { opacity: 1, filter: "blur(0)" }, + to: { opacity: 0, filter: "blur(10px)" }, + duration: 300, + easing: "ease", + }, + }, +}; + +function initBuiltinPresets(): void { + for (const [name, preset] of Object.entries(builtinPresets)) { + transitionRegistry.set(name, preset); + } +} + +initBuiltinPresets(); + +/** + * Register a custom transition preset. + * Allows users to define their own named transitions in programmatic mode. + * + * @param name - Preset name (used in data-volt-surge="name") + * @param preset - Transition configuration with enter/leave phases + * + * @example + * ```typescript + * registerTransition('custom-slide', { + * enter: { + * from: { opacity: 0, transform: 'translateX(-100px)' }, + * to: { opacity: 1, transform: 'translateX(0)' }, + * duration: 400, + * easing: 'cubic-bezier(0.4, 0, 0.2, 1)' + * }, + * leave: { + * from: { opacity: 1, transform: 'translateX(0)' }, + * to: { opacity: 0, transform: 'translateX(100px)' }, + * duration: 300, + * easing: 'ease-out' + * } + * }); + * ``` + */ +export function registerTransition(name: string, preset: TransitionPreset): void { + if (transitionRegistry.has(name) && Object.hasOwn(builtinPresets, name)) { + console.warn(`[Volt] Overriding built-in transition preset: "${name}"`); + } + transitionRegistry.set(name, preset); +} + +/** + * Get a transition preset by name. + * Checks both custom and built-in presets. + * + * @param name - Preset name + * @returns Transition preset or undefined if not found + */ +export function getTransition(name: string): Optional { + return transitionRegistry.get(name); +} + +/** + * Check if a transition preset exists. + * + * @param name - Preset name + * @returns true if the preset is registered + */ +export function hasTransition(name: string): boolean { + return transitionRegistry.has(name); +} + +/** + * Unregister a custom transition preset. + * Built-in presets cannot be unregistered. + * + * @param name - Preset name + * @returns true if the preset was removed, false otherwise + */ +export function unregisterTransition(name: string): boolean { + if (Object.hasOwn(builtinPresets, name)) { + console.warn(`[Volt] Cannot unregister built-in transition preset: "${name}"`); + return false; + } + return transitionRegistry.delete(name); +} + +/** + * Get all registered transition preset names. + * + * @returns Array of preset names + */ +export function getRegisteredTransitions(): string[] { + return [...transitionRegistry.keys()]; +} + +/** + * Parse a transition value string into preset and modifiers. + * Supports syntax: "presetName", "presetName.duration", "presetName.duration.delay" + * + * @param value - Transition value string + * @returns Parsed transition with preset and optional duration/delay overrides + * + * @example + * ```typescript + * parseTransitionValue("fade") // { preset: fadePreset } + * parseTransitionValue("fade.500") // { preset: fadePreset, duration: 500 } + * parseTransitionValue("fade.500.100") // { preset: fadePreset, duration: 500, delay: 100 } + * ``` + */ +export function parseTransitionValue(value: string): Optional { + const parts = value.split("."); + const presetName = parts[0]?.trim(); + + if (!presetName) { + return undefined; + } + + const preset = getTransition(presetName); + if (!preset) { + console.error(`[Volt] Unknown transition preset: "${presetName}"`); + return undefined; + } + + const result: ParsedTransition = { preset }; + + if (parts.length > 1) { + const duration = Number.parseInt(parts[1], 10); + if (!Number.isNaN(duration)) { + result.duration = duration; + } + } + + if (parts.length > 2) { + const delay = Number.parseInt(parts[2], 10); + if (!Number.isNaN(delay)) { + result.delay = delay; + } + } + + return result; +} + +/** + * Common easing functions mapped to CSS easing values. + * Users can also provide custom cubic-bezier strings directly. + */ +export const easings = { + linear: "linear", + ease: "ease", + "ease-in": "ease-in", + "ease-out": "ease-out", + "ease-in-out": "ease-in-out", + "ease-in-sine": "cubic-bezier(0.12, 0, 0.39, 0)", + "ease-out-sine": "cubic-bezier(0.61, 1, 0.88, 1)", + "ease-in-out-sine": "cubic-bezier(0.37, 0, 0.63, 1)", + "ease-in-quad": "cubic-bezier(0.11, 0, 0.5, 0)", + "ease-out-quad": "cubic-bezier(0.5, 1, 0.89, 1)", + "ease-in-out-quad": "cubic-bezier(0.45, 0, 0.55, 1)", + "ease-in-cubic": "cubic-bezier(0.32, 0, 0.67, 0)", + "ease-out-cubic": "cubic-bezier(0.33, 1, 0.68, 1)", + "ease-in-out-cubic": "cubic-bezier(0.65, 0, 0.35, 1)", + "ease-in-quart": "cubic-bezier(0.5, 0, 0.75, 0)", + "ease-out-quart": "cubic-bezier(0.25, 1, 0.5, 1)", + "ease-in-out-quart": "cubic-bezier(0.76, 0, 0.24, 1)", + "ease-in-quint": "cubic-bezier(0.64, 0, 0.78, 0)", + "ease-out-quint": "cubic-bezier(0.22, 1, 0.36, 1)", + "ease-in-out-quint": "cubic-bezier(0.83, 0, 0.17, 1)", + "ease-in-expo": "cubic-bezier(0.7, 0, 0.84, 0)", + "ease-out-expo": "cubic-bezier(0.16, 1, 0.3, 1)", + "ease-in-out-expo": "cubic-bezier(0.87, 0, 0.13, 1)", + "ease-in-circ": "cubic-bezier(0.55, 0, 1, 0.45)", + "ease-out-circ": "cubic-bezier(0, 0.55, 0.45, 1)", + "ease-in-out-circ": "cubic-bezier(0.85, 0, 0.15, 1)", + "ease-in-back": "cubic-bezier(0.36, 0, 0.66, -0.56)", + "ease-out-back": "cubic-bezier(0.34, 1.56, 0.64, 1)", + "ease-in-out-back": "cubic-bezier(0.68, -0.6, 0.32, 1.6)", +} as const; + +/** + * Get the CSS easing value for a named easing function. + * If the input is not a named easing, returns it as-is (for custom cubic-bezier). + * + * @param name - Easing name or custom cubic-bezier string + * @returns CSS easing value + */ +export function getEasing(name: string): string { + return (easings as Record)[name] ?? name; +} + +/** + * Check if reduced motion is preferred by the user. + * Respects prefers-reduced-motion media query for accessibility. + * + * @returns true if user prefers reduced motion + */ +export function prefersReducedMotion(): boolean { + if (globalThis.window === undefined || !globalThis.matchMedia) { + return false; + } + return globalThis.matchMedia("(prefers-reduced-motion: reduce)").matches; +} + +/** + * Apply duration/delay overrides to a transition phase. + * Returns a new phase object with merged properties. + * + * @param phase - Original transition phase + * @param duration - Optional duration override + * @param delay - Optional delay override + * @returns New phase with overrides applied + */ +export function applyOverrides(phase: TransitionPhase, duration?: number, delay?: number): TransitionPhase { + return { ...phase, ...(duration !== undefined && { duration }), ...(delay !== undefined && { delay }) }; +} diff --git a/lib/src/index.ts b/lib/src/index.ts index 18a5f13..27cfa33 100644 --- a/lib/src/index.ts +++ b/lib/src/index.ts @@ -23,8 +23,21 @@ export { getScopeMetadata } from "$core/scope-metadata"; export { computed, effect, signal } from "$core/signal"; export { deserializeScope, hydrate, isHydrated, isServerRendered, serializeScope } from "$core/ssr"; export { getStore, registerStore } from "$core/store"; +export { + applyOverrides, + easings, + getEasing, + getRegisteredTransitions, + getTransition, + hasTransition, + parseTransitionValue, + prefersReducedMotion, + registerTransition, + unregisterTransition, +} from "$core/transitions"; export { persistPlugin, registerStorageAdapter } from "$plugins/persist"; export { scrollPlugin } from "$plugins/scroll"; +export { surgePlugin } from "$plugins/surge"; export { urlPlugin } from "$plugins/url"; export type { ArcFunction, @@ -39,6 +52,7 @@ export type { HydrateResult, IsReactive, ParsedHttpConfig, + ParsedTransition, PinRegistry, PluginContext, PluginHandler, @@ -49,6 +63,8 @@ export type { ScopeMetadata, SerializedScope, Signal, + TransitionPhase, + TransitionPreset, UidFunction, UnwrapReactive, } from "$types/volt"; diff --git a/lib/src/plugins/surge.ts b/lib/src/plugins/surge.ts new file mode 100644 index 0000000..1a92c92 --- /dev/null +++ b/lib/src/plugins/surge.ts @@ -0,0 +1,373 @@ +/** + * Surge plugin for enter/leave transitions + * Provides smooth animations when elements appear or disappear + */ + +import { sleep } from "$core/shared"; +import { applyOverrides, getEasing, parseTransitionValue, prefersReducedMotion } from "$core/transitions"; +import type { Optional } from "$types/helpers"; +import type { PluginContext, Signal, TransitionPhase } from "$types/volt"; + +type SurgeConfig = { + enterPreset?: TransitionPhase; + leavePreset?: TransitionPhase; + signalPath?: string; + useViewTransitions: boolean; +}; + +function supportsViewTransitions(): boolean { + return typeof document !== "undefined" && "startViewTransition" in document; +} + +function withViewTransition(callback: () => void): void { + if (supportsViewTransitions() && !prefersReducedMotion()) { + (document as Document & { startViewTransition: (callback: () => void) => void }).startViewTransition(callback); + } else { + callback(); + } +} + +function applyStyles(element: HTMLElement, styles: Record): void { + for (const [property, value] of Object.entries(styles)) { + const cssProperty = property.replaceAll(/[A-Z]/g, (m) => `-${m.toLowerCase()}`); + element.style.setProperty(cssProperty, String(value)); + } +} + +function applyClasses(el: HTMLElement, classes: string[]): void { + for (const cls of classes) { + el.classList.add(cls); + } +} + +function rmClasses(el: HTMLElement, classes: string[]): void { + for (const cls of classes) { + el.classList.remove(cls); + } +} + +async function execEnter(element: HTMLElement, phase: TransitionPhase, useViewTransitions: boolean): Promise { + const duration = phase.duration ?? 300; + const delay = phase.delay ?? 0; + const easing = getEasing(phase.easing ?? "ease"); + + if (prefersReducedMotion()) { + if (phase.to) { + applyStyles(element, phase.to); + } + if (phase.classes) { + applyClasses(element, phase.classes); + } + return; + } + + if (phase.from) { + applyStyles(element, phase.from); + } + + if (phase.classes) { + applyClasses(element, phase.classes); + } + + void element.offsetHeight; + + element.style.transition = `all ${duration}ms ${easing} ${delay}ms`; + + if (delay > 0) { + await sleep(delay); + } + + const transitionPromise = new Promise((resolve) => { + const handleTransitionEnd = (event: TransitionEvent) => { + if (event.target === element) { + element.removeEventListener("transitionend", handleTransitionEnd); + resolve(); + } + }; + + element.addEventListener("transitionend", handleTransitionEnd); + + setTimeout(() => { + element.removeEventListener("transitionend", handleTransitionEnd); + resolve(); + }, duration + delay + 50); + }); + + if (useViewTransitions) { + withViewTransition(() => { + if (phase.to) { + applyStyles(element, phase.to); + } + }); + } else { + if (phase.to) { + applyStyles(element, phase.to); + } + } + + await transitionPromise; + + element.style.transition = ""; + + if (phase.classes) { + rmClasses(element, phase.classes); + } +} + +async function execLeave(element: HTMLElement, phase: TransitionPhase, useViewTransitions: boolean): Promise { + const duration = phase.duration ?? 300; + const delay = phase.delay ?? 0; + const easing = getEasing(phase.easing ?? "ease"); + + if (prefersReducedMotion()) { + if (phase.to) { + applyStyles(element, phase.to); + } + if (phase.classes) { + applyClasses(element, phase.classes); + } + return; + } + + if (phase.from) { + applyStyles(element, phase.from); + } + + if (phase.classes) { + applyClasses(element, phase.classes); + } + + void element.offsetHeight; + + element.style.transition = `all ${duration}ms ${easing} ${delay}ms`; + + if (delay > 0) { + await sleep(delay); + } + + const transitionPromise = new Promise((resolve) => { + const handleTransitionEnd = (event: TransitionEvent) => { + if (event.target === element) { + element.removeEventListener("transitionend", handleTransitionEnd); + resolve(); + } + }; + + element.addEventListener("transitionend", handleTransitionEnd); + + setTimeout(() => { + element.removeEventListener("transitionend", handleTransitionEnd); + resolve(); + }, duration + delay + 50); + }); + + if (useViewTransitions) { + withViewTransition(() => { + if (phase.to) { + applyStyles(element, phase.to); + } + }); + } else { + if (phase.to) { + applyStyles(element, phase.to); + } + } + + await transitionPromise; + + element.style.transition = ""; + + if (phase.classes) { + rmClasses(element, phase.classes); + } +} + +/** + * Parse surge plugin value to extract configuration + * Supports: + * - "presetName" - default preset + * - "signalPath:presetName" - watch signal with preset + * - "signalPath" - watch signal with default fade + */ +function parseSurgeValue(value: string): Optional { + const parts = value.split(":"); + + if (parts.length === 2) { + const [signalPath, presetValue] = parts; + const parsed = parseTransitionValue(presetValue.trim()); + + if (!parsed) { + return undefined; + } + + return { + enterPreset: parsed.preset.enter, + leavePreset: parsed.preset.leave, + signalPath: signalPath.trim(), + useViewTransitions: true, + }; + } + + const parsed = parseTransitionValue(value.trim()); + if (!parsed) { + return undefined; + } + + return { enterPreset: parsed.preset.enter, leavePreset: parsed.preset.leave, useViewTransitions: true }; +} + +function parsePhaseValue(value: string, phase: "enter" | "leave"): Optional { + const parsed = parseTransitionValue(value.trim()); + if (!parsed) { + return undefined; + } + const presetPhase = phase === "enter" ? parsed.preset.enter : parsed.preset.leave; + return applyOverrides(presetPhase, parsed.duration, parsed.delay); +} + +/** + * Surge plugin handler. + * Provides enter/leave transitions for elements. + * + * Syntax: + * - data-volt-surge="presetName" - Default transition preset + * - data-volt-surge="signalPath:presetName" - Watch signal for transitions + * - data-volt-surge:enter="presetName" - Specific enter transition + * - data-volt-surge:leave="presetName" - Specific leave transition + * + * @example + * ```html + * + *
Content
+ * + * + *
+ * Content + *
+ * ``` + */ +export function surgePlugin(ctx: PluginContext, value: string): void { + const el = ctx.element as HTMLElement; + + if (value.includes(":")) { + const [phase, presetValue] = value.split(":", 2); + + if (phase === "enter") { + const enterPhase = parsePhaseValue(presetValue, "enter"); + if (!enterPhase) { + console.error(`[Volt] Invalid surge enter value: "${value}"`); + return; + } + + (el as HTMLElement & { _voltSurgeEnter?: TransitionPhase })._voltSurgeEnter = enterPhase; + return; + } + + if (phase === "leave") { + const leavePhase = parsePhaseValue(presetValue, "leave"); + if (!leavePhase) { + console.error(`[Volt] Invalid surge leave value: "${value}"`); + return; + } + + (el as HTMLElement & { _voltSurgeLeave?: TransitionPhase })._voltSurgeLeave = leavePhase; + return; + } + } + + const config = parseSurgeValue(value); + if (!config) { + console.error(`[Volt] Invalid surge value: "${value}"`); + return; + } + + if (!config.signalPath) { + (el as HTMLElement & { _voltSurgeConfig?: SurgeConfig })._voltSurgeConfig = config; + return; + } + + const signal = ctx.findSignal(config.signalPath) as Optional>; + if (!signal) { + console.error(`[Volt] Signal "${config.signalPath}" not found for surge binding`); + return; + } + + let isVisible = Boolean(signal.get()); + let isTransitioning = false; + + if (!isVisible) { + el.style.display = "none"; + } + + const handleTransition = async (shouldShow: boolean) => { + if (isTransitioning || shouldShow === isVisible) { + return; + } + + isTransitioning = true; + + if (shouldShow && config.enterPreset) { + el.style.display = ""; + await execEnter(el, config.enterPreset, config.useViewTransitions); + isVisible = true; + } else if (!shouldShow && config.leavePreset) { + await execLeave(el, config.leavePreset, config.useViewTransitions); + el.style.display = "none"; + isVisible = false; + } + + isTransitioning = false; + }; + + const unsubscribe = signal.subscribe((value) => { + const shouldShow = Boolean(value); + void handleTransition(shouldShow); + }); + + ctx.addCleanup(unsubscribe); +} + +/** + * @internal + */ +export async function executeSurgeEnter(element: HTMLElement): Promise { + const config = (element as HTMLElement & { _voltSurgeConfig?: SurgeConfig })._voltSurgeConfig; + const customEnter = (element as HTMLElement & { _voltSurgeEnter?: TransitionPhase })._voltSurgeEnter; + + const enterPhase = customEnter ?? config?.enterPreset; + if (!enterPhase) { + return; + } + + const useViewTransitions = config?.useViewTransitions ?? true; + await execEnter(element, enterPhase, useViewTransitions); +} + +/** + * @internal + */ +export async function executeSurgeLeave(element: HTMLElement): Promise { + const config = (element as HTMLElement & { _voltSurgeConfig?: SurgeConfig })._voltSurgeConfig; + const customLeave = (element as HTMLElement & { _voltSurgeLeave?: TransitionPhase })._voltSurgeLeave; + + const leavePhase = customLeave ?? config?.leavePreset; + if (!leavePhase) { + return; + } + + const useViewTransitions = config?.useViewTransitions ?? true; + await execLeave(element, leavePhase, useViewTransitions); +} + +/** + * @internal + */ +export function hasSurge(element: HTMLElement): boolean { + const config = (element as HTMLElement & { _voltSurgeConfig?: SurgeConfig })._voltSurgeConfig; + const customEnter = (element as HTMLElement & { _voltSurgeEnter?: TransitionPhase })._voltSurgeEnter; + const customLeave = (element as HTMLElement & { _voltSurgeLeave?: TransitionPhase })._voltSurgeLeave; + + return Boolean(config || customEnter || customLeave); +} diff --git a/lib/src/types/volt.d.ts b/lib/src/types/volt.d.ts index fe1ea17..3a92f06 100644 --- a/lib/src/types/volt.d.ts +++ b/lib/src/types/volt.d.ts @@ -425,3 +425,143 @@ export type ArcFunction = (eventName: string, detail?: unknown) => void; * Function signature for $probe() - reactive observer */ export type ProbeFunction = (expression: string, callback: (value: unknown) => void) => CleanupFunction; + +/** + * Configuration for a single transition phase (enter or leave) + */ +export type TransitionPhase = { + /** + * Initial CSS properties (applied immediately) + */ + from?: Record; + + /** + * Target CSS properties (animated to) + */ + to?: Record; + + /** + * Duration in milliseconds (default: 300) + */ + duration?: number; + + /** + * Delay in milliseconds (default: 0) + */ + delay?: number; + + /** + * CSS easing function (default: 'ease') + */ + easing?: string; + + /** + * CSS classes to apply during this phase + */ + classes?: string[]; +}; + +/** + * Complete transition preset with enter and leave phases + */ +export type TransitionPreset = { + /** + * Configuration for enter transition + */ + enter: TransitionPhase; + + /** + * Configuration for leave transition + */ + leave: TransitionPhase; +}; + +/** + * Parsed transition value with preset and modifiers + */ +export type ParsedTransition = { + /** + * The transition preset to use + */ + preset: TransitionPreset; + + /** + * Override duration from preset syntax (e.g., "fade.500") + */ + duration?: number; + + /** + * Override delay from preset syntax (e.g., "fade.500.100") + */ + delay?: number; +}; + +/** + * Configuration for a single transition phase (enter or leave) + */ +export type TransitionPhase = { + /** + * Initial CSS properties (applied immediately) + */ + from?: Record; + + /** + * Target CSS properties (animated to) + */ + to?: Record; + + /** + * Duration in milliseconds (default: 300) + */ + duration?: number; + + /** + * Delay in milliseconds (default: 0) + */ + delay?: number; + + /** + * CSS easing function (default: 'ease') + */ + easing?: string; + + /** + * CSS classes to apply during this phase + */ + classes?: string[]; +}; + +/** + * Complete transition preset with enter and leave phases + */ +export type TransitionPreset = { + /** + * Configuration for enter transition + */ + enter: TransitionPhase; + + /** + * Configuration for leave transition + */ + leave: TransitionPhase; +}; + +/** + * Parsed transition value with preset and modifiers + */ +export type ParsedTransition = { + /** + * The transition preset to use + */ + preset: TransitionPreset; + + /** + * Override duration from preset syntax (e.g., "fade.500") + */ + duration?: number; + + /** + * Override delay from preset syntax (e.g., "fade.500.100") + */ + delay?: number; +}; diff --git a/lib/test/core/transitions.test.ts b/lib/test/core/transitions.test.ts new file mode 100644 index 0000000..a738c2b --- /dev/null +++ b/lib/test/core/transitions.test.ts @@ -0,0 +1,301 @@ +import { + applyOverrides, + easings, + getEasing, + getRegisteredTransitions, + getTransition, + hasTransition, + parseTransitionValue, + prefersReducedMotion, + registerTransition, + unregisterTransition, +} from "$core/transitions"; +import type { TransitionPreset } from "$types/volt"; +import { describe, expect, it, vi } from "vitest"; + +describe("Transition Preset System", () => { + describe("Built-in Presets", () => { + it("should have fade preset registered", () => { + expect(hasTransition("fade")).toBe(true); + const fade = getTransition("fade"); + expect(fade).toBeDefined(); + expect(fade?.enter.from).toEqual({ opacity: 0 }); + expect(fade?.enter.to).toEqual({ opacity: 1 }); + expect(fade?.leave.from).toEqual({ opacity: 1 }); + expect(fade?.leave.to).toEqual({ opacity: 0 }); + }); + + it.each([{ + name: "slide-up", + enterFrom: { opacity: 0, transform: "translateY(20px)" }, + enterTo: { opacity: 1, transform: "translateY(0)" }, + }, { + name: "slide-down", + enterFrom: { opacity: 0, transform: "translateY(-20px)" }, + enterTo: { opacity: 1, transform: "translateY(0)" }, + }, { + name: "slide-left", + enterFrom: { opacity: 0, transform: "translateX(20px)" }, + enterTo: { opacity: 1, transform: "translateX(0)" }, + }, { + name: "slide-right", + enterFrom: { opacity: 0, transform: "translateX(-20px)" }, + enterTo: { opacity: 1, transform: "translateX(0)" }, + }, { + name: "scale", + enterFrom: { opacity: 0, transform: "scale(0.95)" }, + enterTo: { opacity: 1, transform: "scale(1)" }, + }, { name: "blur", enterFrom: { opacity: 0, filter: "blur(10px)" }, enterTo: { opacity: 1, filter: "blur(0)" } }])( + "should have $name preset registered", + ({ name, enterFrom, enterTo }) => { + expect(hasTransition(name)).toBe(true); + const preset = getTransition(name); + expect(preset).toBeDefined(); + expect(preset?.enter.from).toEqual(enterFrom); + expect(preset?.enter.to).toEqual(enterTo); + }, + ); + + it("should return all built-in preset names", () => { + const presets = getRegisteredTransitions(); + + for (const preset of ["fade", "slide-up", "slide-down", "slide-left", "slide-right", "scale", "blur"]) { + expect(presets).toContain(preset); + } + }); + }); + + describe("Custom Preset Registration", () => { + it("should register a custom transition preset", () => { + const customPreset: TransitionPreset = { + enter: { + from: { opacity: 0, transform: "translateX(-100px)" }, + to: { opacity: 1, transform: "translateX(0)" }, + duration: 400, + easing: "ease-out", + }, + leave: { + from: { opacity: 1, transform: "translateX(0)" }, + to: { opacity: 0, transform: "translateX(100px)" }, + duration: 300, + easing: "ease-in", + }, + }; + + registerTransition("custom-slide", customPreset); + expect(hasTransition("custom-slide")).toBe(true); + + const retrieved = getTransition("custom-slide"); + expect(retrieved).toEqual(customPreset); + }); + + it("should unregister a custom preset", () => { + const customPreset: TransitionPreset = { enter: { from: {}, to: {} }, leave: { from: {}, to: {} } }; + + registerTransition("temp-preset", customPreset); + expect(hasTransition("temp-preset")).toBe(true); + + const result = unregisterTransition("temp-preset"); + expect(result).toBe(true); + expect(hasTransition("temp-preset")).toBe(false); + }); + + it("should not unregister built-in presets", () => { + const result = unregisterTransition("fade"); + expect(result).toBe(false); + expect(hasTransition("fade")).toBe(true); + }); + + it("should warn when overriding built-in preset", () => { + const consoleSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + const customPreset: TransitionPreset = { enter: { from: {}, to: {} }, leave: { from: {}, to: {} } }; + + registerTransition("fade", customPreset); + expect(consoleSpy).toHaveBeenCalledWith("[Volt] Overriding built-in transition preset: \"fade\""); + + consoleSpy.mockRestore(); + }); + + it("should return undefined for unknown preset", () => { + const preset = getTransition("nonexistent"); + expect(preset).toBeUndefined(); + }); + }); + + describe("Parse Transition Value", () => { + it("should parse preset name only", () => { + const parsed = parseTransitionValue("fade"); + expect(parsed).toBeDefined(); + expect(parsed?.preset).toEqual(getTransition("fade")); + expect(parsed?.duration).toBeUndefined(); + expect(parsed?.delay).toBeUndefined(); + }); + + it("should parse preset name with duration", () => { + const parsed = parseTransitionValue("fade.500"); + expect(parsed).toBeDefined(); + expect(parsed?.preset).toEqual(getTransition("fade")); + expect(parsed?.duration).toBe(500); + expect(parsed?.delay).toBeUndefined(); + }); + + it("should parse preset name with duration and delay", () => { + const parsed = parseTransitionValue("slide-down.600.100"); + expect(parsed).toBeDefined(); + expect(parsed?.preset).toEqual(getTransition("slide-down")); + expect(parsed?.duration).toBe(600); + expect(parsed?.delay).toBe(100); + }); + + it("should handle whitespace", () => { + const parsed = parseTransitionValue(" fade.500.100 "); + expect(parsed).toBeDefined(); + expect(parsed?.preset).toEqual(getTransition("fade")); + expect(parsed?.duration).toBe(500); + expect(parsed?.delay).toBe(100); + }); + + it("should return undefined for empty string", () => { + const parsed = parseTransitionValue(""); + expect(parsed).toBeUndefined(); + }); + + it("should return undefined for unknown preset", () => { + const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + + const parsed = parseTransitionValue("nonexistent"); + expect(parsed).toBeUndefined(); + expect(consoleSpy).toHaveBeenCalled(); + + consoleSpy.mockRestore(); + }); + + it("should ignore invalid duration values", () => { + const parsed = parseTransitionValue("fade.abc"); + expect(parsed).toBeDefined(); + expect(parsed?.duration).toBeUndefined(); + }); + + it("should ignore invalid delay values", () => { + const parsed = parseTransitionValue("fade.500.xyz"); + expect(parsed).toBeDefined(); + expect(parsed?.duration).toBe(500); + expect(parsed?.delay).toBeUndefined(); + }); + }); + + describe("Easing Functions", () => { + it("should return CSS easing for named easings", () => { + for (const e of ["linear", "ease", "ease-in", "ease-out", "ease-in-out"]) { + const res = getEasing(e); + expect(res).toEqual(e); + } + }); + + it("should return cubic-bezier for named easing curves", () => { + expect(getEasing("ease-in-sine")).toBe("cubic-bezier(0.12, 0, 0.39, 0)"); + expect(getEasing("ease-out-sine")).toBe("cubic-bezier(0.61, 1, 0.88, 1)"); + expect(getEasing("ease-in-quad")).toBe("cubic-bezier(0.11, 0, 0.5, 0)"); + }); + + it("should return custom cubic-bezier as-is", () => { + const custom = "cubic-bezier(0.25, 0.1, 0.25, 1)"; + expect(getEasing(custom)).toBe(custom); + }); + + it("should have all easing constants defined", () => { + for ( + const prop of [ + "linear", + "ease", + "ease-in", + "ease-out", + "ease-in-out", + "ease-in-back", + "ease-out-back", + "ease-in-out-back", + ] + ) { + expect(easings).toHaveProperty(prop); + } + }); + }); + + describe("Apply Overrides", () => { + it("should apply duration override", () => { + const phase = { from: { opacity: 0 }, to: { opacity: 1 }, duration: 300, delay: 0, easing: "ease" }; + const overridden = applyOverrides(phase, 500); + expect(overridden.duration).toBe(500); + expect(overridden.delay).toBe(0); + expect(overridden.from).toEqual({ opacity: 0 }); + expect(overridden.to).toEqual({ opacity: 1 }); + expect(overridden.easing).toBe("ease"); + }); + + it("should apply delay override", () => { + const phase = { from: { opacity: 0 }, to: { opacity: 1 }, duration: 300, delay: 0, easing: "ease" }; + const overridden = applyOverrides(phase, undefined, 100); + expect(overridden.duration).toBe(300); + expect(overridden.delay).toBe(100); + }); + + it("should apply both duration and delay overrides", () => { + const phase = { from: { opacity: 0 }, to: { opacity: 1 }, duration: 300, delay: 0, easing: "ease" }; + const overridden = applyOverrides(phase, 600, 200); + expect(overridden.duration).toBe(600); + expect(overridden.delay).toBe(200); + }); + + it("should not mutate original phase", () => { + const phase = { from: { opacity: 0 }, to: { opacity: 1 }, duration: 300, delay: 0, easing: "ease" }; + const overridden = applyOverrides(phase, 500, 100); + expect(phase.duration).toBe(300); + expect(phase.delay).toBe(0); + expect(overridden).not.toBe(phase); + }); + + it("should preserve all properties when no overrides", () => { + const phase = { + from: { opacity: 0, transform: "translateY(20px)" }, + to: { opacity: 1, transform: "translateY(0)" }, + duration: 300, + delay: 50, + easing: "ease-out", + classes: ["entering"], + }; + + const overridden = applyOverrides(phase); + expect(overridden).toEqual(phase); + expect(overridden).not.toBe(phase); + }); + }); + + describe("Prefers Reduced Motion", () => { + it("should return false when matchMedia is not available", () => { + const originalMatchMedia = globalThis.matchMedia; + // @ts-expect-error - Testing undefined case + delete globalThis.matchMedia; + + expect(prefersReducedMotion()).toBe(false); + + globalThis.matchMedia = originalMatchMedia; + }); + + it("should check prefers-reduced-motion media query", () => { + const mockMatchMedia = vi.fn().mockReturnValue({ matches: true }); + globalThis.matchMedia = mockMatchMedia; + + const result = prefersReducedMotion(); + + expect(mockMatchMedia).toHaveBeenCalledWith("(prefers-reduced-motion: reduce)"); + expect(result).toBe(true); + }); + + it("should return false when user does not prefer reduced motion", () => { + const mockMatchMedia = vi.fn().mockReturnValue({ matches: false }); + globalThis.matchMedia = mockMatchMedia; + const result = prefersReducedMotion(); + expect(result).toBe(false); + }); + }); +}); diff --git a/lib/test/plugins/surge.test.ts b/lib/test/plugins/surge.test.ts new file mode 100644 index 0000000..83107bd --- /dev/null +++ b/lib/test/plugins/surge.test.ts @@ -0,0 +1,395 @@ +import { signal } from "$core/signal"; +import { registerTransition } from "$core/transitions"; +import { executeSurgeEnter, executeSurgeLeave, hasSurge, surgePlugin } from "$plugins/surge"; +import type { TransitionPreset } from "$types/volt"; +import type { PluginContext } from "$types/volt"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +describe("Surge Plugin", () => { + let container: HTMLDivElement; + let element: HTMLElement; + let mockContext: PluginContext; + let cleanups: Array<() => void>; + + beforeEach(() => { + container = document.createElement("div"); + element = document.createElement("div"); + element.textContent = "Test Content"; + container.append(element); + document.body.append(container); + + cleanups = []; + + mockContext = { + element, + scope: {}, + addCleanup: (fn) => { + cleanups.push(fn); + }, + findSignal: vi.fn(), + evaluate: vi.fn(), + lifecycle: { onMount: vi.fn(), onUnmount: vi.fn(), beforeBinding: vi.fn(), afterBinding: vi.fn() }, + }; + + globalThis.matchMedia = vi.fn().mockReturnValue({ matches: false }); + }); + + afterEach(() => { + for (const cleanup of cleanups) { + cleanup(); + } + cleanups = []; + container.remove(); + vi.restoreAllMocks(); + }); + + describe("Configuration Storage", () => { + it("should store config when no signal path provided", () => { + surgePlugin(mockContext, "fade"); + expect(hasSurge(element as HTMLElement)).toBe(true); + }); + + it("should store enter-specific config", () => { + surgePlugin(mockContext, "enter:slide-down"); + const stored = (element as HTMLElement & { _voltSurgeEnter?: unknown })._voltSurgeEnter; + expect(stored).toBeDefined(); + }); + + it("should store leave-specific config", () => { + surgePlugin(mockContext, "leave:fade.300"); + const stored = (element as HTMLElement & { _voltSurgeLeave?: unknown })._voltSurgeLeave; + expect(stored).toBeDefined(); + }); + }); + + describe("Signal Watching (Explicit Mode)", () => { + it("should watch signal and show/hide element", async () => { + vi.useFakeTimers(); + + const showSignal = signal(false); + mockContext.findSignal = vi.fn().mockReturnValue(showSignal); + mockContext.scope = { show: showSignal }; + + surgePlugin(mockContext, "show:fade"); + + expect(element.style.display).toBe("none"); + + showSignal.set(true); + await vi.advanceTimersByTimeAsync(400); + expect(element.style.display).not.toBe("none"); + + showSignal.set(false); + await vi.advanceTimersByTimeAsync(400); + expect(element.style.display).toBe("none"); + + vi.useRealTimers(); + }); + + it("should apply transitions when showing element", async () => { + const showSignal = signal(false); + mockContext.findSignal = vi.fn().mockReturnValue(showSignal); + + surgePlugin(mockContext, "show:fade"); + + showSignal.set(true); + + await new Promise((resolve) => { + setTimeout(resolve, 50); + }); + + expect(element.style.display).not.toBe("none"); + }); + + it("should cleanup subscription on unmount", () => { + const showSignal = signal(true); + mockContext.findSignal = vi.fn().mockReturnValue(showSignal); + + surgePlugin(mockContext, "show:fade"); + + expect(cleanups.length).toBeGreaterThan(0); + + for (const cleanup of cleanups) { + cleanup(); + } + + const initialDisplay = element.style.display; + showSignal.set(false); + + expect(element.style.display).toBe(initialDisplay); + }); + + it("should error when signal not found", () => { + const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + mockContext.findSignal = vi.fn().mockReturnValue(void 0); + + surgePlugin(mockContext, "nonexistent:fade"); + expect(consoleSpy).toHaveBeenCalledWith("[Volt] Signal \"nonexistent\" not found for surge binding"); + + consoleSpy.mockRestore(); + }); + + it("should not transition if already in target state", async () => { + const showSignal = signal(true); + mockContext.findSignal = vi.fn().mockReturnValue(showSignal); + + surgePlugin(mockContext, "show:fade"); + expect(element.style.display).not.toBe("none"); + + const initialStyles = element.style.cssText; + showSignal.set(true); + + await new Promise((resolve) => { + setTimeout(resolve, 50); + }); + + expect(element.style.cssText).toBe(initialStyles); + }); + }); + + describe("Custom Presets", () => { + it("should use custom registered preset", async () => { + const customPreset: TransitionPreset = { + enter: { + from: { opacity: 0, transform: "scale(0.5)" }, + to: { opacity: 1, transform: "scale(1)" }, + duration: 200, + easing: "ease-out", + }, + leave: { + from: { opacity: 1, transform: "scale(1)" }, + to: { opacity: 0, transform: "scale(0.5)" }, + duration: 200, + easing: "ease-in", + }, + }; + + registerTransition("custom-scale", customPreset); + + const showSignal = signal(false); + mockContext.findSignal = vi.fn().mockReturnValue(showSignal); + + surgePlugin(mockContext, "show:custom-scale"); + + showSignal.set(true); + + await new Promise((resolve) => { + setTimeout(resolve, 50); + }); + + expect(element.style.display).not.toBe("none"); + }); + }); + + describe("Duration and Delay Overrides", () => { + it("should parse duration override", () => { + const showSignal = signal(false); + mockContext.findSignal = vi.fn().mockReturnValue(showSignal); + + surgePlugin(mockContext, "show:fade.500"); + + expect(mockContext.findSignal).toHaveBeenCalledWith("show"); + }); + + it("should parse duration and delay overrides", () => { + const showSignal = signal(false); + mockContext.findSignal = vi.fn().mockReturnValue(showSignal); + + surgePlugin(mockContext, "show:slide-down.600.100"); + + expect(mockContext.findSignal).toHaveBeenCalledWith("show"); + }); + }); + + describe("Error Handling", () => { + it("should error on invalid surge value", () => { + const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + surgePlugin(mockContext, "nonexistent-preset"); + expect(consoleSpy).toHaveBeenCalledWith("[Volt] Unknown transition preset: \"nonexistent-preset\""); + consoleSpy.mockRestore(); + }); + + it("should error on invalid enter value", () => { + const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + surgePlugin(mockContext, "enter:nonexistent"); + expect(consoleSpy).toHaveBeenCalled(); + consoleSpy.mockRestore(); + }); + + it("should error on invalid leave value", () => { + const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + surgePlugin(mockContext, "leave:nonexistent"); + expect(consoleSpy).toHaveBeenCalled(); + consoleSpy.mockRestore(); + }); + }); + + describe("Helper Functions", () => { + describe("hasSurge", () => { + it("should return true when surge config exists", () => { + surgePlugin(mockContext, "fade"); + expect(hasSurge(element as HTMLElement)).toBe(true); + }); + + it("should return true when custom enter exists", () => { + surgePlugin(mockContext, "enter:slide-down"); + expect(hasSurge(element as HTMLElement)).toBe(true); + }); + + it("should return true when custom leave exists", () => { + surgePlugin(mockContext, "leave:fade"); + expect(hasSurge(element as HTMLElement)).toBe(true); + }); + + it("should return false when no surge config exists", () => { + expect(hasSurge(element as HTMLElement)).toBe(false); + }); + }); + + describe("executeSurgeEnter", () => { + it("should execute enter transition", async () => { + surgePlugin(mockContext, "fade"); + await executeSurgeEnter(element as HTMLElement); + expect(element).toBeDefined(); + }); + + it("should use custom enter if available", async () => { + surgePlugin(mockContext, "enter:slide-down"); + surgePlugin(mockContext, "leave:fade"); + + await executeSurgeEnter(element as HTMLElement); + + expect(element).toBeDefined(); + }); + + it("should do nothing if no enter config", async () => { + await executeSurgeEnter(element as HTMLElement); + expect(element).toBeDefined(); + }); + }); + + describe("executeSurgeLeave", () => { + it("should execute leave transition", async () => { + surgePlugin(mockContext, "fade"); + await executeSurgeLeave(element as HTMLElement); + expect(element).toBeDefined(); + }); + + it("should use custom leave if available", async () => { + surgePlugin(mockContext, "enter:fade"); + surgePlugin(mockContext, "leave:slide-up"); + + await executeSurgeLeave(element as HTMLElement); + + expect(element).toBeDefined(); + }); + + it("should do nothing if no leave config", async () => { + await executeSurgeLeave(element as HTMLElement); + expect(element).toBeDefined(); + }); + }); + }); + + describe("Accessibility", () => { + it("should skip animations when prefers-reduced-motion is enabled", async () => { + globalThis.matchMedia = vi.fn().mockReturnValue({ matches: true }); + + const showSignal = signal(false); + mockContext.findSignal = vi.fn().mockReturnValue(showSignal); + + surgePlugin(mockContext, "show:fade"); + + showSignal.set(true); + + await new Promise((resolve) => { + setTimeout(resolve, 50); + }); + + expect(element.style.display).not.toBe("none"); + }); + }); + + describe("Transition Lifecycle", () => { + it("should not start overlapping transitions", async () => { + const showSignal = signal(false); + mockContext.findSignal = vi.fn().mockReturnValue(showSignal); + + surgePlugin(mockContext, "show:fade"); + + showSignal.set(true); + showSignal.set(false); + showSignal.set(true); + + await new Promise((resolve) => { + setTimeout(resolve, 100); + }); + + expect(element).toBeDefined(); + }); + + it("should cleanup transition styles after completion", async () => { + const showSignal = signal(false); + mockContext.findSignal = vi.fn().mockReturnValue(showSignal); + + registerTransition("test-fast", { + enter: { from: { opacity: 0 }, to: { opacity: 1 }, duration: 10 }, + leave: { from: { opacity: 1 }, to: { opacity: 0 }, duration: 10 }, + }); + + surgePlugin(mockContext, "show:test-fast"); + + showSignal.set(true); + + await new Promise((resolve) => { + setTimeout(resolve, 100); + }); + + expect(element.style.transition).toBe(""); + }); + }); + + describe("View Transitions API", () => { + it("should use View Transitions API when available", async () => { + const mockStartViewTransition = vi.fn((callback) => { + callback(); + }); + + // @ts-expect-error - Adding View Transitions API mock + document.startViewTransition = mockStartViewTransition; + + const showSignal = signal(false); + mockContext.findSignal = vi.fn().mockReturnValue(showSignal); + + surgePlugin(mockContext, "show:fade"); + + showSignal.set(true); + + await new Promise((resolve) => { + setTimeout(resolve, 50); + }); + + expect(mockStartViewTransition).toHaveBeenCalled(); + + // @ts-expect-error - Cleanup mock + delete document.startViewTransition; + }); + + it("should fallback to CSS when View Transitions API not available", async () => { + // @ts-expect-error - Ensure View Transitions API is not available + delete document.startViewTransition; + + const showSignal = signal(false); + mockContext.findSignal = vi.fn().mockReturnValue(showSignal); + + surgePlugin(mockContext, "show:fade"); + + showSignal.set(true); + + await new Promise((resolve) => { + setTimeout(resolve, 50); + }); + + expect(element.style.display).not.toBe("none"); + }); + }); +}); -- 2.51.2