diff --git a/frontend/__tests__/components/common/anime/Anime.spec.tsx b/frontend/__tests__/components/common/anime/Anime.spec.tsx new file mode 100644 index 000000000000..f95442b884d2 --- /dev/null +++ b/frontend/__tests__/components/common/anime/Anime.spec.tsx @@ -0,0 +1,390 @@ +import { cleanup, render } from "@solidjs/testing-library"; +import { createSignal, Show } from "solid-js"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const { mockAnimate } = vi.hoisted(() => ({ + mockAnimate: vi.fn().mockReturnValue({ + pause: vi.fn(), + then: vi.fn((_cb: unknown) => Promise.resolve()), + }), +})); + +vi.mock("animejs", () => ({ + animate: mockAnimate, +})); + +// Mock applyReducedMotion +vi.mock("../../../../src/ts/utils/misc", () => ({ + applyReducedMotion: vi.fn((duration: number) => duration), +})); + +import { Anime } from "../../../../src/ts/components/common/anime/Anime"; +import { + AnimeGroup, + createStagger, +} from "../../../../src/ts/components/common/anime/AnimeGroup"; +import { AnimePresence } from "../../../../src/ts/components/common/anime/AnimePresence"; + +describe("Anime", () => { + afterEach(() => { + cleanup(); + vi.clearAllMocks(); + }); + + it("renders a div wrapper by default", () => { + const { container } = render(() => ( + + content + + )); + expect(container.querySelector("div")).toBeTruthy(); + expect(container.querySelector("span")).toHaveTextContent("content"); + }); + + it("renders with custom tag via `as` prop", () => { + const { container } = render(() => ( + + hi + + )); + expect(container.querySelector("section")).toBeTruthy(); + expect(container.querySelector("div")).toBeNull(); + }); + + it("applies className and style props", () => { + const { container } = render(() => ( + + + + )); + const el = container.querySelector(".my-class"); + expect(el).toBeTruthy(); + }); + + it("calls animejsAnimate on mount with animation prop", () => { + render(() => ( + +
+ + )); + expect(mockAnimate).toHaveBeenCalledWith( + expect.any(HTMLElement), + expect.objectContaining({ opacity: 1, duration: 300 }), + ); + }); + + it("applies initial state with duration:0 then animates to animate prop", () => { + render(() => ( + +
+ + )); + + // First call: initial state (duration: 0) + expect(mockAnimate).toHaveBeenCalledWith( + expect.any(HTMLElement), + expect.objectContaining({ opacity: 0, duration: 0 }), + ); + // Second call: full animation + expect(mockAnimate).toHaveBeenCalledWith( + expect.any(HTMLElement), + expect.objectContaining({ opacity: 1, duration: 300 }), + ); + }); + + it("re-runs animation when reactive signal changes", () => { + const [opacity, setOpacity] = createSignal(1); + + render(() => ( + +
+ + )); + + const callsBefore = mockAnimate.mock.calls.length; + setOpacity(0); + + expect(mockAnimate.mock.calls.length).toBeGreaterThan(callsBefore); + }); +}); + +describe("AnimePresence", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(() => { + cleanup(); + }); + + it("renders children", () => { + const { container } = render(() => ( + +
hello
+
+ )); + expect(container.querySelector("[data-testid='child']")).toBeTruthy(); + }); + + it("renders children in list mode", () => { + const { container } = render(() => ( + +
one
+
two
+
+ )); + expect(container.querySelector("[data-testid='item-1']")).toBeTruthy(); + expect(container.querySelector("[data-testid='item-2']")).toBeTruthy(); + }); + + it("list mode wraps children in a display:contents div", () => { + const { container } = render(() => ( + +
child
+
+ )); + const wrapper = container.querySelector("div"); + expect(wrapper?.style.display).toBe("contents"); + }); + + it("mounts and unmounts Show child without errors", async () => { + const [show, setShow] = createSignal(true); + + expect(() => { + render(() => ( + + + +
toggled
+
+
+
+ )); + }).not.toThrow(); + + expect(() => setShow(false)).not.toThrow(); + }); + + it("exitBeforeEnter mode does not throw on child switch", () => { + const [view, setView] = createSignal<"a" | "b">("a"); + + expect(() => { + render(() => ( + + + +
View A
+
+
+ + +
View B
+
+
+
+ )); + }).not.toThrow(); + + expect(() => setView("b")).not.toThrow(); + }); +}); + +describe("AnimeGroup", () => { + afterEach(() => { + cleanup(); + vi.clearAllMocks(); + }); + + it("renders a div wrapper by default", () => { + const { container } = render(() => ( + +
a
+
b
+
+ )); + expect(container.querySelector("div")).toBeTruthy(); + }); + + it("renders with custom tag via `as` prop", () => { + const { container } = render(() => ( + +
  • item
  • +
    + )); + expect(container.querySelector("ul")).toBeTruthy(); + }); + + it("animates each child on mount", () => { + render(() => ( + +
    1
    +
    2
    +
    3
    +
    + )); + + // One call per child element + const childCalls = mockAnimate.mock.calls.filter( + ([el]) => el instanceof HTMLElement, + ); + expect(childCalls.length).toBeGreaterThanOrEqual(3); + }); + + it("applies initial state before animating children", () => { + render(() => ( + +
    1
    +
    2
    +
    + )); + + // Initial state calls (duration: 0) should precede animation calls + const zeroDurationCalls = mockAnimate.mock.calls.filter( + ([, params]) => params.duration === 0, + ); + expect(zeroDurationCalls.length).toBeGreaterThanOrEqual(2); + }); + + it("applies stagger delays in forward direction", () => { + render(() => ( + +
    1
    +
    2
    +
    3
    +
    + )); + + // Calls with non-zero delay values reflecting stagger + const delayCalls = mockAnimate.mock.calls + .filter(([, params]) => params.duration === 300) + .map(([, params]) => params.delay as number); + + // forward stagger: delays should be 0, 100, 200 + expect(delayCalls).toContain(0); + expect(delayCalls).toContain(100); + expect(delayCalls).toContain(200); + }); + + it("reverses stagger direction", () => { + render(() => ( + +
    1
    +
    2
    +
    3
    +
    + )); + + const delayCalls = mockAnimate.mock.calls + .filter(([, params]) => params.duration === 300) + .map(([, params]) => params.delay as number); + + // reverse: first child gets highest delay (200), last gets 0 + expect(delayCalls).toContain(0); + expect(delayCalls).toContain(200); + }); + + it("applies center stagger direction", () => { + render(() => ( + +
    1
    +
    2
    +
    3
    +
    + )); + + const delayCalls = mockAnimate.mock.calls + .filter(([, params]) => params.duration === 300) + .map(([, params]) => params.delay as number); + + // center: middle element (index 1) has 0 delay, outer elements have 100 + expect(delayCalls).toContain(0); + expect(delayCalls).toContain(100); + }); + + it("accepts a function stagger", () => { + const staggerFn = vi.fn((_i: number, _t: number) => 75); + + render(() => ( + +
    1
    +
    2
    +
    + )); + + expect(staggerFn).toHaveBeenCalled(); + }); + + it("applies class and style to wrapper", () => { + const { container } = render(() => ( + +
    1
    +
    + )); + expect(container.querySelector(".group-class")).toBeTruthy(); + }); +}); + +describe("createStagger", () => { + it("returns 0 for single element", () => { + const fn = createStagger({ base: 100 }); + expect(fn(0, 1)).toBe(0); + }); + + it("linear stagger from start: first=0, last=base*(total-1)", () => { + const fn = createStagger({ base: 50, ease: "linear", from: "start" }); + expect(fn(0, 3)).toBeCloseTo(0); + expect(fn(2, 3)).toBeCloseTo(100); + }); + + it("linear stagger from end: first=base*(total-1), last=0", () => { + const fn = createStagger({ base: 50, ease: "linear", from: "end" }); + expect(fn(0, 3)).toBeCloseTo(100); + expect(fn(2, 3)).toBeCloseTo(0); + }); + + it("center stagger: middle element has smallest value", () => { + const fn = createStagger({ base: 50, ease: "linear", from: "center" }); + // For 5 items, center is index 2 → distance = 0 + expect(fn(2, 5)).toBeCloseTo(0); + expect(fn(0, 5)).toBeGreaterThan(fn(2, 5)); + }); + + it("easeIn produces smaller values at start", () => { + const linear = createStagger({ base: 100, ease: "linear" }); + const easeIn = createStagger({ base: 100, ease: "easeIn" }); + // At index 1 of 4, easeIn position is less progressed than linear + expect(easeIn(1, 4)).toBeLessThan(linear(1, 4)); + }); + + it("easeOut produces larger values at start compared to easeIn", () => { + const easeOut = createStagger({ base: 100, ease: "easeOut" }); + const easeIn = createStagger({ base: 100, ease: "easeIn" }); + expect(easeOut(1, 4)).toBeGreaterThan(easeIn(1, 4)); + }); + + it("easeInOut is symmetric", () => { + const fn = createStagger({ base: 100, ease: "easeInOut", from: "start" }); + // At 50% position (index 1 of 3), easeInOut should equal linear + // easeInOut at 0.5 = 0.5 → 100 * 0.5 * 2 = 100 + expect(fn(1, 3)).toBeCloseTo(100); + }); +}); diff --git a/frontend/package.json b/frontend/package.json index 9a5ffaa4dfd6..c4e65ca3cdef 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -30,6 +30,8 @@ "@monkeytype/util": "workspace:*", "@sentry/browser": "9.14.0", "@sentry/vite-plugin": "3.3.1", + "@solid-primitives/refs": "1.1.2", + "@solid-primitives/transition-group": "1.1.2", "@solidjs/meta": "0.29.4", "@tanstack/solid-query": "5.90.23", "@tanstack/solid-query-devtools": "5.91.3", diff --git a/frontend/src/ts/components/common/anime/AnimatedShow.tsx b/frontend/src/ts/components/common/anime/AnimatedShow.tsx new file mode 100644 index 000000000000..38d440b7682f --- /dev/null +++ b/frontend/src/ts/components/common/anime/AnimatedShow.tsx @@ -0,0 +1,72 @@ +import { AnimationParams } from "animejs"; +import { JSXElement, ParentProps, Show } from "solid-js"; + +import { Anime } from "./Anime"; +import { AnimePresence } from "./AnimePresence"; + +/** + * A convenient wrapper around AnimePresence + Anime for simple show/hide animations. + * Animations (initial/animate/exit) are hardcoded — use `` + `` directly + * if you need custom animation parameters. + * + * @prop when - Controls visibility + * @prop slide - If true, animates height instead of opacity + * @prop duration - Animation duration in ms (default: 250) + * + * @example + * ```tsx + * + *
    Fades in and out automatically
    + *
    + * ``` + * + * @example + * ```tsx + * + *
    Slides open/closed
    + *
    + * ``` + */ +export function AnimatedShow( + props: ParentProps<{ + when: boolean; + slide?: true; + duration?: number; + }>, +): JSXElement { + const duration = () => props.duration ?? 250; + + return ( + + + } + animate={{ opacity: 1, duration: duration() } as AnimationParams} + exit={{ opacity: 0, duration: duration() } as AnimationParams} + > + {props.children} + + +
    + } + > + + + } + animate={ + { height: "auto", duration: duration() } as AnimationParams + } + exit={{ height: 0, duration: duration() } as AnimationParams} + style={{ overflow: "hidden" }} + > + {props.children} + + + + + ); +} diff --git a/frontend/src/ts/components/common/anime/Anime.tsx b/frontend/src/ts/components/common/anime/Anime.tsx new file mode 100644 index 000000000000..b38469f479cd --- /dev/null +++ b/frontend/src/ts/components/common/anime/Anime.tsx @@ -0,0 +1,361 @@ +import { + animate as animejsAnimate, + AnimationParams, + JSAnimation, +} from "animejs"; +import { + JSXElement, + ParentProps, + createEffect, + onCleanup, + splitProps, + mergeProps, + useContext, +} from "solid-js"; +import { Dynamic } from "solid-js/web"; + +import { applyReducedMotion } from "../../../utils/misc"; +import { AnimePresenceContext } from "./AnimePresence"; + +/** + * Props for the Anime component + * + * @example + * Basic usage: + * ```tsx + * + *
    Fade in content
    + *
    + * ``` + * + * @example + * With initial state and lifecycle animations: + * ```tsx + * + *
    Slide and fade in
    + *
    + * ``` + * + * @example + * With reactive animations: + * ```tsx + * const [visible, setVisible] = createSignal(true); + * + * + *
    Toggle visibility
    + *
    + * ``` + */ +export type AnimeProps = ParentProps<{ + /** + * Initial animation state applied before component mounts. + * Properties set here will be applied immediately without animation. + */ + initial?: Partial; + + /** + * Target animation state. If `initial` is provided, animates from initial to this state. + * If only `animation` is provided without `initial`, this is used directly. + */ + animate?: AnimationParams; + + /** + * Direct animation parameters (alternative to initial/animate pattern). + * Use this for simple animations without initial state. + */ + animation?: AnimationParams; + + /** + * Exit animation state (applied when component unmounts). + * Note: Requires wrapping in to function properly. + */ + exit?: AnimationParams; + + /** + * Apply reduced motion settings automatically. + * When true, animation durations will be set to 0 if user prefers reduced motion. + * @default true + */ + respectReducedMotion?: boolean; + + /** + * Tag name for the wrapper element. + * @default "div" + */ + as?: keyof HTMLElementTagNameMap; + + /** + * CSS class name for the wrapper element. + */ + class?: string; + + /** + * CSS styles for the wrapper element. + */ + style?: string | Record; +}>; + +/** + * A declarative anime.js wrapper component for SolidJS with a Motion One-style API. + * + * This component automatically handles animation lifecycle, cleanup, and reactivity. + * It wraps children in a container element and applies anime.js animations. + * + * ## Features + * + * - **Declarative API**: Define animations with `initial`, `animate`, and `exit` props + * - **Automatic cleanup**: Animations are canceled when component unmounts + * - **Reactive**: Animations update when props change + * - **Reduced motion support**: Respects user's motion preferences by default + * - **Flexible**: Works with any valid anime.js animation parameters + * + * @example + * Simple fade in: + * ```tsx + * + *

    Content

    + *
    + * ``` + * + * @example + * Mount/unmount animations: + * ```tsx + * + *
    Bouncy entrance
    + *
    + * ``` + * + * @example + * Reactive animations with signals: + * ```tsx + * const [isExpanded, setIsExpanded] = createSignal(false); + * + * + *
    Expandable content
    + *
    + * ``` + */ +export function Anime(props: AnimeProps): JSXElement { + const merged = mergeProps( + { + respectReducedMotion: true, + as: "div" as const, + }, + props, + ); + + const [local, others] = splitProps(merged, [ + "children", + "initial", + "animate", + "animation", + "exit", + "respectReducedMotion", + "as", + "class", + "style", + ]); + + let element: HTMLElement | undefined = undefined; + let currentAnimation: JSAnimation | undefined; + let exitAnimation: JSAnimation | undefined; + let exitAnimationResolve: (() => void) | undefined; + let hasInitialized = false; + // Get presence context if available + const presenceContext = useContext(AnimePresenceContext); + + // Cancel exit animation if it's running + const cancelExitAnimation = (): void => { + if (exitAnimation) { + // Pause the animation to stop it + exitAnimation.pause(); + exitAnimation = undefined; + // Resolve the promise to allow cleanup + if (exitAnimationResolve) { + exitAnimationResolve(); + exitAnimationResolve = undefined; + } + } + }; + + // Create exit animation handler + const playExitAnimation = async (): Promise => { + if (!element || !local.exit) return; + + // Cancel any running animation + if (currentAnimation) { + currentAnimation.pause(); + currentAnimation = undefined; + } + + // If height is currently "auto", snap it to a pixel value so anime.js can + // interpolate from a concrete number during the exit animation. + if (element.style.height === "auto") { + element.style.height = `${element.offsetHeight}px`; + } + + // Apply reduced motion if enabled + const exitParams = + local.respectReducedMotion && + local.exit.duration !== undefined && + typeof local.exit.duration === "number" + ? { ...local.exit, duration: applyReducedMotion(local.exit.duration) } + : local.exit; + + // Play exit animation and wait for completion + exitAnimation = animejsAnimate(element, exitParams); + return new Promise((resolve) => { + exitAnimationResolve = resolve; + void exitAnimation?.then(() => { + exitAnimation = undefined; + exitAnimationResolve = undefined; + // Dispatch custom event to signal animation completion + element?.dispatchEvent(new Event("animecomplete")); + resolve(); + }); + }); + }; + + // Register with presence context after element is available + createEffect(() => { + if (presenceContext && local.exit && element) { + presenceContext.register(element, { + exit: local.exit, + playExitAnimation, + cancelExitAnimation, + }); + } + }); + + // Cleanup registration on unmount + // Don't unregister if we have exit animation - let AnimePresence handle it + onCleanup(() => { + if (presenceContext && element && !local.exit) { + presenceContext.unregister(element); + } + }); + + const applyAnimation = (params: AnimationParams): JSAnimation | undefined => { + if (!element) return undefined; + + // Cancel any running animation + if (currentAnimation) { + currentAnimation.pause(); + currentAnimation = undefined; + } + + // Resolve height: "auto" by measuring the element's natural height + let resolvedParams = params; + if (params["height"] === "auto") { + const currentH = element.offsetHeight; + element.style.height = "auto"; + const targetH = element.offsetHeight; + element.style.height = `${currentH}px`; + const originalOnComplete = params.onComplete; + resolvedParams = { + ...params, + height: targetH, + onComplete: (anim: JSAnimation) => { + // Restore auto so the element can resize naturally after animation + if (element) element.style.height = "auto"; + if (typeof originalOnComplete === "function") { + originalOnComplete(anim); + } + }, + }; + } + + // Apply reduced motion if enabled + const animParams = + local.respectReducedMotion && + resolvedParams.duration !== undefined && + typeof resolvedParams.duration === "number" + ? { + ...resolvedParams, + duration: applyReducedMotion(resolvedParams.duration), + } + : resolvedParams; + + currentAnimation = animejsAnimate(element, animParams); + return currentAnimation; + }; + + const applyInitialState = (params: Partial): void => { + if (!element) return; + + // Apply initial styles directly without animation using anime.js + // This ensures consistent property name handling with animate + animejsAnimate(element, { + ...params, + duration: 0, + }); + }; + + // Handle initial state and mounting animation + createEffect(() => { + // If under AnimePresence with exitBeforeEnter, wait for mount signal + if (presenceContext && !presenceContext.mount()) return; + + if (!element || hasInitialized) return; + + // Apply initial state if provided + if (local.initial) { + applyInitialState(local.initial); + } + + // Animate to target state + if (local.animate) { + applyAnimation(local.animate); + } else if (local.animation) { + applyAnimation(local.animation); + } + + hasInitialized = true; + }); + + // Handle reactive animation updates + createEffect(() => { + // Always read reactive params so dependencies are tracked + const animationParams = local.animate ?? local.animation; + + if (!hasInitialized || !animationParams) return; + + applyAnimation(animationParams); + }); + + // Cleanup on unmount — always pause enter animation; AnimePresence handles exit timing + onCleanup(() => { + if (currentAnimation) { + currentAnimation.pause(); + currentAnimation = undefined; + } + }); + + const setElementRef = (el: unknown): void => { + element = el as HTMLElement; + }; + + return ( + + {local.children} + + ); +} diff --git a/frontend/src/ts/components/common/anime/AnimeGroup.tsx b/frontend/src/ts/components/common/anime/AnimeGroup.tsx new file mode 100644 index 000000000000..7e721438576b --- /dev/null +++ b/frontend/src/ts/components/common/anime/AnimeGroup.tsx @@ -0,0 +1,379 @@ +import { + animate as animejsAnimate, + AnimationParams, + JSAnimation, +} from "animejs"; +import { JSXElement, ParentProps, onCleanup, onMount } from "solid-js"; +import { Dynamic } from "solid-js/web"; + +import { applyReducedMotion } from "../../../utils/misc"; + +/** + * Props for the AnimeGroup component + */ +export type AnimeGroupProps = ParentProps<{ + /** + * Animation parameters to apply to all children. + */ + animation: AnimationParams; + + /** + * Stagger delay between each child animation in milliseconds. + * Can be a number for uniform delay, or a function for custom stagger timing. + * @default 50 + */ + stagger?: number | ((index: number, total: number) => number); + + /** + * Direction of stagger effect. + * - "forward": First to last child (default) + * - "reverse": Last to first child + * - "center": From center outward + * @default "forward" + */ + direction?: "forward" | "reverse" | "center"; + + /** + * Initial state applied to all children before animation starts. + */ + initial?: Partial; + + /** + * Apply reduced motion settings automatically. + * When true, animation durations will be set to 0 if user prefers reduced motion. + * @default true + */ + respectReducedMotion?: boolean; + + /** + * Tag name for the wrapper element. + * @default "div" + */ + as?: keyof HTMLElementTagNameMap; + + /** + * CSS class name for the wrapper element. + */ + class?: string; + + /** + * Exit animation applied to children as they are removed from the DOM. + * The component intercepts the removal, plays this animation, then + * finalizes the removal once complete. + */ + exit?: AnimationParams; + + /** + * CSS styles for the wrapper element. + */ + style?: string | Record; +}>; + +/** + * A component that applies staggered animations to multiple children. + * + * AnimeGroup animates all direct children with a configurable delay between each, + * creating a cascading or staggered animation effect. This is useful for lists, + * grids, and any group of elements that should animate in sequence. + * + * ## Features + * + * - **Staggered animations**: Automatically staggers animations across children + * - **Flexible timing**: Control stagger delay with fixed or dynamic values + * - **Multiple directions**: Animate forward, reverse, or from center + * - **Initial state**: Set starting state for all children + * - **Reduced motion support**: Respects user's motion preferences + * + * @example + * Basic staggered list: + * ```tsx + * + *
    Item 1
    + *
    Item 2
    + *
    Item 3
    + *
    + * ``` + * + * @example + * Reverse stagger with initial state: + * ```tsx + * + * + * {(item) =>
    {item}
    } + *
    + *
    + * ``` + * + * @example + * Dynamic stagger timing: + * ```tsx + * { + * // Accelerating stagger + * return 100 * (1 - index / total); + * }} + * direction="center" + * > + * {children} + * + * ``` + * + * @example + * With For loop rendering: + * ```tsx + * const [items, setItems] = createSignal(['A', 'B', 'C', 'D']); + * + * + * + * {(item) =>
    {item}
    } + *
    + *
    + * ``` + */ +export function AnimeGroup(props: AnimeGroupProps): JSXElement { + let containerElement: HTMLElement | undefined; + let animations: JSAnimation[] = []; + const initializedChildren = new WeakSet(); + const exitingChildren = new WeakSet(); + + const applyInitialState = ( + element: HTMLElement, + params: Partial, + ): void => { + animejsAnimate(element, { ...params, duration: 0 }); + }; + + const calculateStaggerDelay = (index: number, total: number): number => { + let baseDelay: number; + const stagger = props.stagger ?? 50; + + if (typeof stagger === "function") { + baseDelay = stagger(index, total); + } else { + baseDelay = stagger; + } + + // Apply direction + const direction = props.direction ?? "forward"; + if (direction === "reverse") { + return baseDelay * (total - 1 - index); + } else if (direction === "center") { + const center = Math.floor(total / 2); + const distanceFromCenter = Math.abs(center - index); + return baseDelay * distanceFromCenter; + } else { + // forward (default) + return baseDelay * index; + } + }; + + const animateChildSet = (children: HTMLElement[]): void => { + const total = children.length; + + children.forEach((child, index) => { + // Apply initial state if provided + if (props.initial) { + applyInitialState(child, props.initial); + } + + const delay = calculateStaggerDelay(index, total); + const originalDelay = props.animation.delay ?? 0; + const totalDelay = + typeof originalDelay === "number" ? originalDelay + delay : delay; + + // Apply animation with stagger delay + const animParams: AnimationParams = { + ...props.animation, + delay: totalDelay, + }; + + // Apply reduced motion if enabled + if ( + (props.respectReducedMotion ?? true) && + animParams.duration !== undefined && + typeof animParams.duration === "number" + ) { + animParams.duration = applyReducedMotion(animParams.duration); + } + + const animation = animejsAnimate(child, animParams); + animations.push(animation); + initializedChildren.add(child); + }); + }; + + const animateChildren = (): void => { + if (!containerElement) return; + + // Clear previous animations + animations.forEach((anim) => anim.pause()); + animations = []; + + const children = Array.from(containerElement.children) as HTMLElement[]; + animateChildSet(children); + }; + + // Animate on mount and when children change + onMount(() => { + const el = containerElement; + if (!el) return; + + animateChildren(); + + const childObserver = new MutationObserver((mutations) => { + const newChildren: HTMLElement[] = []; + + for (const mutation of mutations) { + if (mutation.type !== "childList") continue; + + // Entrance: only animate truly new nodes + for (const node of mutation.addedNodes) { + if ( + node instanceof HTMLElement && + !initializedChildren.has(node) && + !exitingChildren.has(node) + ) { + newChildren.push(node); + } + } + + // Exit: intercept removed nodes, re-insert, animate, then finalize removal + if (props.exit) { + for (const node of mutation.removedNodes) { + if (!(node instanceof HTMLElement)) continue; + if (exitingChildren.has(node)) continue; // already animating out + + exitingChildren.add(node); + + // Re-insert at original position using the recorded next sibling + const refNode = mutation.nextSibling; + if (refNode !== null && el.contains(refNode)) { + el.insertBefore(node, refNode); + } else { + el.appendChild(node); + } + + const exitParams: AnimationParams = { ...props.exit }; + if ( + (props.respectReducedMotion ?? true) && + typeof exitParams.duration === "number" + ) { + exitParams.duration = applyReducedMotion(exitParams.duration); + } + + const anim = animejsAnimate(node, { + ...exitParams, + onComplete: () => { + node.remove(); + }, + }); + animations.push(anim); + } + } + } + + if (newChildren.length > 0) { + animateChildSet(newChildren); + } + }); + + childObserver.observe(el, { childList: true }); + + onCleanup(() => { + childObserver.disconnect(); + }); + }); + + // Cleanup on unmount + onCleanup(() => { + animations.forEach((anim) => anim.pause()); + animations = []; + }); + + return ( + (containerElement = el)} + class={props.class} + style={props.style} + > + {props.children} + + ); +} + +/** + * Utility function to create stagger timing functions. + * + * @example + * ```tsx + * + * {children} + * + * ``` + */ +export function createStagger(options: { + base: number; + ease?: "linear" | "easeIn" | "easeOut" | "easeInOut"; + from?: "start" | "end" | "center"; +}): (index: number, total: number) => number { + const { base, ease = "linear", from = "start" } = options; + + return (index: number, total: number): number => { + if (total <= 1) return 0; + + // Calculate normalized position (0 to 1) + let position: number; + + if (from === "end") { + position = 1 - index / (total - 1); + } else if (from === "center") { + const center = (total - 1) / 2; + position = Math.abs(center - index) / center; + } else { + position = index / (total - 1); + } + + // Apply easing + let easedPosition: number; + + switch (ease) { + case "easeIn": + easedPosition = position * position; + break; + case "easeOut": + easedPosition = 1 - Math.pow(1 - position, 2); + break; + case "easeInOut": + easedPosition = + position < 0.5 + ? 2 * position * position + : 1 - Math.pow(-2 * position + 2, 2) / 2; + break; + default: + easedPosition = position; + } + + return base * easedPosition * (total - 1); + }; +} diff --git a/frontend/src/ts/components/common/anime/AnimeGroupTest.tsx b/frontend/src/ts/components/common/anime/AnimeGroupTest.tsx new file mode 100644 index 000000000000..72cdb713cf6e --- /dev/null +++ b/frontend/src/ts/components/common/anime/AnimeGroupTest.tsx @@ -0,0 +1,61 @@ +import { createSignal, For, JSXElement } from "solid-js"; + +import { Button } from "../Button"; +import { AnimeGroup } from "./AnimeGroup"; + +let nextId = 1; + +export function AnimeGroupTest(): JSXElement { + const [items, setItems] = createSignal<{ id: number; label: string }[]>([ + { id: nextId++, label: "Item 1" }, + { id: nextId++, label: "Item 2" }, + { id: nextId++, label: "Item 3" }, + ]); + + const addItem = (): void => { + const id = nextId++; + setItems((prev) => [...prev, { id, label: `Item ${id}` }]); + }; + + const removeItem = (): void => { + setItems((prev) => prev.slice(0, -1)); + }; + + return ( +
    +
    +
    + + + {(item) => ( +
    + {item.label} +
    + )} +
    +
    +
    + ); +} diff --git a/frontend/src/ts/components/common/anime/AnimePresence.tsx b/frontend/src/ts/components/common/anime/AnimePresence.tsx new file mode 100644 index 000000000000..ec44b3588f1b --- /dev/null +++ b/frontend/src/ts/components/common/anime/AnimePresence.tsx @@ -0,0 +1,312 @@ +import { resolveFirst } from "@solid-primitives/refs"; +import { createSwitchTransition } from "@solid-primitives/transition-group"; +import { AnimationParams } from "animejs"; +import { + JSXElement, + ParentProps, + createSignal, + createContext, + type Context, + type Accessor, + batch, + onCleanup, + onMount, +} from "solid-js"; + +export type AnimePresenceAPI = { + exit?: AnimationParams; + playExitAnimation: () => Promise; + cancelExitAnimation: () => void; +}; + +export type PresenceContextState = { + initial: boolean; + mount: Accessor; + register: (element: HTMLElement, api: AnimePresenceAPI) => void; + unregister: (element: HTMLElement) => void; +}; + +export const AnimePresenceContext: Context = + createContext(); + +/** + * Props for the AnimePresence component + */ +export type AnimePresenceProps = ParentProps<{ + /** + * If `false`, will disable the first animation on all child Anime elements + * the first time AnimePresence is rendered. + * @default true + */ + initial?: boolean; + + /** + * If `true`, AnimePresence will wait for the exiting element to finish + * animating out before animating in the next one. + * Only applies to single-child mode (when used with Show, Switch, etc.). + * @default false + */ + exitBeforeEnter?: boolean; + + /** + * Enable list mode for animating multiple children (e.g., with For loops). + * - `"list"`: uses MutationObserver to handle exit animations for dynamic lists. + * - `"single"`: uses single-child transition logic (for Show, Switch, etc.). + */ + mode?: "list" | "single"; +}>; + +/** + * AnimePresence enables exit animations for components using the `` component. + * + * When a child component is removed from the tree, AnimePresence delays its unmounting + * to allow exit animations (defined via the `exit` prop on ``) to complete. + * + * ## Features + * + * - **Exit animations**: Automatically handles exit animations for removing children + * - **Multiple modes**: Control whether exit and enter animations run in sequence or parallel + * - **Conditional rendering**: Works with ``, ``, and other control flow components + * + * ## Important Notes + * + * - Children should have unique `key` props when rendering lists or conditionally + * - The `exit` prop on `` components only works when wrapped in `` + * - Exit animations are detected based on child removal from the component tree + * + * @example + * Basic usage with conditional rendering: + * ```tsx + * const [show, setShow] = createSignal(true); + * + * + * + * + *
    Content with exit animation
    + *
    + *
    + *
    + * ``` + * + * @example + * Wait for exit before entering: + * ```tsx + * const [currentView, setCurrentView] = createSignal<"a" | "b">("a"); + * + * + * + * + *
    View A
    + *
    + *
    + * + * + *
    View B
    + *
    + *
    + *
    + * ``` + * + * @example + * List animations: + * ```tsx + * const [items, setItems] = createSignal([1, 2, 3]); + * + * + * + * {(item) => ( + * + *
    Item {item}
    + *
    + * )} + *
    + *
    + * ``` + */ +export function AnimePresence(props: AnimePresenceProps): JSXElement { + const [mount, setMount] = createSignal(true); + + // Registry to track elements and their exit animations + const exitRegistry = new WeakMap(); + + // For list mode: track which elements are exiting + const exitingElements = new Set(); + + let containerRef: HTMLDivElement | undefined; + let observer: MutationObserver | undefined; + + const setContainerRef = (el: HTMLDivElement): void => { + containerRef = el; + }; + + const reinsertElement = ( + element: HTMLElement, + target: Node, + nextSibling: Node | null, + ): void => { + if (nextSibling) { + target.insertBefore(element, nextSibling); + } else { + target.appendChild(element); + } + }; + + const handleMutations = (mutations: MutationRecord[]): void => { + for (const mutation of mutations) { + for (const removed of Array.from(mutation.removedNodes)) { + if (removed.nodeType !== Node.ELEMENT_NODE) continue; + const element = removed as HTMLElement; + + // Check if this element has exit animation registered + const api = exitRegistry.get(element); + + if (api?.exit && !exitingElements.has(element)) { + exitingElements.add(element); + reinsertElement(element, mutation.target, mutation.nextSibling); + + void api.playExitAnimation().then(() => { + exitingElements.delete(element); + exitRegistry.delete(element); + element.remove(); + }); + continue; + } + + // Check direct children for registered elements + if (element.children.length === 0) continue; + + for (const child of Array.from(element.children)) { + const childEl = child as HTMLElement; + const childApi = exitRegistry.get(childEl); + + if (childApi?.exit && !exitingElements.has(childEl)) { + exitingElements.add(childEl); + reinsertElement(element, mutation.target, mutation.nextSibling); + + void childApi.playExitAnimation().then(() => { + exitingElements.delete(childEl); + exitRegistry.delete(childEl); + element.remove(); + }); + break; + } + } + } + } + }; + + const state: PresenceContextState = { + initial: props.initial ?? true, + mount, + register: (element: HTMLElement, api: AnimePresenceAPI) => { + exitRegistry.set(element, api); + }, + unregister: (element: HTMLElement) => { + exitRegistry.delete(element); + }, + }; + + // List mode: Watch for DOM changes in the container + // oxlint-disable-next-line solid/reactivity -- mode controls component structure at mount time; treated as stable + if (props.mode === "list") { + onMount(() => { + if (!containerRef) return; + + // Set up observer to watch for child removals + observer = new MutationObserver(handleMutations); + observer.observe(containerRef, { childList: true, subtree: true }); + }); + + onCleanup(() => { + observer?.disconnect(); + }); + + // oxlint-disable-next-line solid/components-return-once -- early return is intentional; mode is structural + return ( + +
    + {props.children} +
    +
    + ); + } + + // Single mode: handle single child switching (original behavior) + // Track currently exiting element and its done callback + let currentlyExiting: { + element: HTMLElement; + api: AnimePresenceAPI; + done: () => void; + } | null = null; + + const render = ( + + { + // @ts-expect-error - createSwitchTransition type incompatibility + createSwitchTransition( + resolveFirst(() => props.children), + { + appear: state.initial, + mode: props.exitBeforeEnter ? "out-in" : "parallel", + onExit(el: Element, done: () => void) { + const htmlEl = el as HTMLElement; + const api = exitRegistry.get(htmlEl); + + batch(() => { + setMount(false); + if (api?.exit) { + // Store reference to currently exiting element and its done callback + currentlyExiting = { element: htmlEl, api, done }; + + // Listen for animation completion event, similar to solid-motionone + htmlEl.addEventListener( + "animecomplete", + () => { + done(); + if (currentlyExiting?.element === htmlEl) { + currentlyExiting = null; + } + }, + { once: true }, + ); + void api.playExitAnimation().then(() => { + exitRegistry.delete(htmlEl); + }); + } else { + done(); + } + }); + }, + onEnter(_: Element, done: () => void) { + batch(() => { + // If exitBeforeEnter is false and there's an element exiting, + // cancel the exit animation and complete the transition immediately + if (!props.exitBeforeEnter && currentlyExiting) { + const { element, api, done: exitDone } = currentlyExiting; + api.cancelExitAnimation(); + exitRegistry.delete(element); + // Complete the exit transition so SolidJS can clean up properly + exitDone(); + currentlyExiting = null; + } + + setMount(true); + }); + done(); + }, + }, + ) as JSXElement + } + + ); + + return render; +} diff --git a/frontend/src/ts/components/common/anime/index.ts b/frontend/src/ts/components/common/anime/index.ts new file mode 100644 index 000000000000..f412b928c242 --- /dev/null +++ b/frontend/src/ts/components/common/anime/index.ts @@ -0,0 +1,12 @@ +export { Anime } from "./Anime"; +export type { AnimeProps } from "./Anime"; + +export { AnimeGroup, createStagger } from "./AnimeGroup"; +export type { AnimeGroupProps } from "./AnimeGroup"; + +export { AnimePresence } from "./AnimePresence"; +export type { AnimePresenceProps } from "./AnimePresence"; + +export { AnimatedShow } from "./AnimatedShow"; + +export type { AnimationParams, JSAnimation } from "animejs"; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4280ffba9265..198be40489a7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -224,7 +224,7 @@ importers: version: 10.0.0 '@vitest/coverage-v8': specifier: 4.0.15 - version: 4.0.15(vitest@4.0.15(@types/node@24.9.1)(happy-dom@20.0.10)(jiti@2.6.1)(jsdom@27.4.0)(lightningcss@1.30.2)(sass@1.70.0)(terser@5.46.0)(tsx@4.16.2)(yaml@2.8.1)) + version: 4.0.15(vitest@4.0.15(@opentelemetry/api@1.8.0)(@types/node@24.9.1)(happy-dom@20.0.10)(jiti@2.6.1)(jsdom@27.4.0)(lightningcss@1.30.2)(sass@1.70.0)(terser@5.46.0)(tsx@4.16.2)(yaml@2.8.1)) concurrently: specifier: 8.2.2 version: 8.2.2 @@ -282,6 +282,12 @@ importers: '@sentry/vite-plugin': specifier: 3.3.1 version: 3.3.1(encoding@0.1.13) + '@solid-primitives/refs': + specifier: 1.1.2 + version: 1.1.2(solid-js@1.9.10) + '@solid-primitives/transition-group': + specifier: 1.1.2 + version: 1.1.2(solid-js@1.9.10) '@solidjs/meta': specifier: 0.29.4 version: 0.29.4(solid-js@1.9.10) @@ -525,7 +531,7 @@ importers: version: 2.11.10(@testing-library/jest-dom@6.9.1)(solid-js@1.9.10)(vite@7.1.12(@types/node@24.9.1)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.70.0)(terser@5.46.0)(tsx@4.16.2)(yaml@2.8.1)) vitest: specifier: 4.0.15 - version: 4.0.15(@opentelemetry/api@1.8.0)(@types/node@24.9.1)(happy-dom@20.0.10)(jiti@2.6.1)(jsdom@27.4.0)(lightningcss@1.30.2)(sass@1.70.0)(terser@5.46.0)(tsx@4.16.2)(yaml@2.8.1) + version: 4.0.15(@types/node@24.9.1)(happy-dom@20.0.10)(jiti@2.6.1)(jsdom@27.4.0)(lightningcss@1.30.2)(sass@1.70.0)(terser@5.46.0)(tsx@4.16.2)(yaml@2.8.1) packages/contracts: dependencies: @@ -3426,6 +3432,21 @@ packages: engines: {node: '>=8.10'} hasBin: true + '@solid-primitives/refs@1.1.2': + resolution: {integrity: sha512-K7tf2thy7L+YJjdqXspXOg5xvNEOH8tgEWsp0+1mQk3obHBRD6hEjYZk7p7FlJphSZImS35je3UfmWuD7MhDfg==} + peerDependencies: + solid-js: ^1.6.12 + + '@solid-primitives/transition-group@1.1.2': + resolution: {integrity: sha512-gnHS0OmcdjeoHN9n7Khu8KNrOlRc8a2weETDt2YT6o1zeW/XtUC6Db3Q9pkMU/9cCKdEmN4b0a/41MKAHRhzWA==} + peerDependencies: + solid-js: ^1.6.12 + + '@solid-primitives/utils@6.3.2': + resolution: {integrity: sha512-hZ/M/qr25QOCcwDPOHtGjxTD8w2mNyVAYvcfgwzBHq2RwNqHNdDNsMZYap20+ruRwW4A3Cdkczyoz0TSxLCAPQ==} + peerDependencies: + solid-js: ^1.6.12 + '@solidjs/meta@0.29.4': resolution: {integrity: sha512-zdIWBGpR9zGx1p1bzIPqF5Gs+Ks/BH8R6fWhmUa/dcK1L2rUC8BAcZJzNRYBQv74kScf1TSOs0EY//Vd/I0V8g==} peerDependencies: @@ -4058,9 +4079,6 @@ packages: ajv@6.12.6: resolution: {integrity: sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==} - ajv@8.12.0: - resolution: {integrity: sha512-sRu1kpcO9yLtYxBKvqfTeh9KzZEwO3STyX1HT+4CaDzC6HpTGYhIhPIzj9XuKU7KYDwnaeh5hcOwjy1QuJzBPA==} - ajv@8.17.1: resolution: {integrity: sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==} @@ -6014,28 +6032,25 @@ packages: glob@10.4.5: resolution: {integrity: sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==} - deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me hasBin: true glob@11.0.3: resolution: {integrity: sha512-2Nim7dha1KVkaiF4q6Dj+ngPPMdfvLJEOpZk/jKiUAkqKebpGAWQXAq9z1xu9HKu5lWfqw/FASuccEjyznjPaA==} engines: {node: 20 || >=22} - deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me hasBin: true glob@7.2.3: resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==} - deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me + deprecated: Glob versions prior to v9 are no longer supported glob@8.1.0: resolution: {integrity: sha512-r8hpEjiQEYlF2QU0df3dS+nxxSIreXQS1qRhMJM0Q5NDdR386C7jb7Hwwod8Fgiuex+k0GFjgft18yvxm5XoCQ==} engines: {node: '>=12'} - deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me + deprecated: Glob versions prior to v9 are no longer supported glob@9.3.5: resolution: {integrity: sha512-e1LleDykUz2Iu+MTYdkSsuWX8lvAjAcs0Xef0lNIu0S2wOAzuTxCJtcd9S3cijlwYF18EsU3rzb8jPVobxDh9Q==} engines: {node: '>=16 || 14 >=14.17'} - deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me global-dirs@0.1.1: resolution: {integrity: sha512-NknMLn7F2J7aflwFOlGdNIuCDpN3VGoSoB+aap3KABFWbHVn1TCgFC+np23J8W2BiZbjfEw3BFBycSMv1AFblg==} @@ -13222,6 +13237,19 @@ snapshots: ignore: 5.3.2 p-map: 4.0.0 + '@solid-primitives/refs@1.1.2(solid-js@1.9.10)': + dependencies: + '@solid-primitives/utils': 6.3.2(solid-js@1.9.10) + solid-js: 1.9.10 + + '@solid-primitives/transition-group@1.1.2(solid-js@1.9.10)': + dependencies: + solid-js: 1.9.10 + + '@solid-primitives/utils@6.3.2(solid-js@1.9.10)': + dependencies: + solid-js: 1.9.10 + '@solidjs/meta@0.29.4(solid-js@1.9.10)': dependencies: solid-js: 1.9.10 @@ -13738,6 +13766,23 @@ snapshots: '@typescript-eslint/types': 8.52.0 eslint-visitor-keys: 4.2.1 + '@vitest/coverage-v8@4.0.15(vitest@4.0.15(@opentelemetry/api@1.8.0)(@types/node@24.9.1)(happy-dom@20.0.10)(jiti@2.6.1)(jsdom@27.4.0)(lightningcss@1.30.2)(sass@1.70.0)(terser@5.46.0)(tsx@4.16.2)(yaml@2.8.1))': + dependencies: + '@bcoe/v8-coverage': 1.0.2 + '@vitest/utils': 4.0.15 + ast-v8-to-istanbul: 0.3.8 + istanbul-lib-coverage: 3.2.2 + istanbul-lib-report: 3.0.1 + istanbul-lib-source-maps: 5.0.6 + istanbul-reports: 3.2.0 + magicast: 0.5.1 + obug: 2.1.1 + std-env: 3.10.0 + tinyrainbow: 3.0.3 + vitest: 4.0.15(@opentelemetry/api@1.8.0)(@types/node@24.9.1)(happy-dom@20.0.10)(jiti@2.6.1)(jsdom@27.4.0)(lightningcss@1.30.2)(sass@1.70.0)(terser@5.46.0)(tsx@4.16.2)(yaml@2.8.1) + transitivePeerDependencies: + - supports-color + '@vitest/coverage-v8@4.0.15(vitest@4.0.15(@types/node@20.5.1)(happy-dom@20.0.10)(jiti@2.6.1)(jsdom@27.4.0)(lightningcss@1.30.2)(sass@1.70.0)(terser@5.46.0)(tsx@4.16.2)(yaml@2.8.1))': dependencies: '@bcoe/v8-coverage': 1.0.2 @@ -13768,7 +13813,7 @@ snapshots: obug: 2.1.1 std-env: 3.10.0 tinyrainbow: 3.0.3 - vitest: 4.0.15(@opentelemetry/api@1.8.0)(@types/node@24.9.1)(happy-dom@20.0.10)(jiti@2.6.1)(jsdom@27.4.0)(lightningcss@1.30.2)(sass@1.70.0)(terser@5.46.0)(tsx@4.16.2)(yaml@2.8.1) + vitest: 4.0.15(@types/node@24.9.1)(happy-dom@20.0.10)(jiti@2.6.1)(jsdom@27.4.0)(lightningcss@1.30.2)(sass@1.70.0)(terser@5.46.0)(tsx@4.16.2)(yaml@2.8.1) transitivePeerDependencies: - supports-color @@ -13909,9 +13954,9 @@ snapshots: clean-stack: 2.2.0 indent-string: 4.0.0 - ajv-formats@2.1.1(ajv@8.12.0): + ajv-formats@2.1.1(ajv@8.17.1): optionalDependencies: - ajv: 8.12.0 + ajv: 8.17.1 ajv-formats@3.0.1(@redocly/ajv@8.17.1): optionalDependencies: @@ -13928,13 +13973,6 @@ snapshots: json-schema-traverse: 0.4.1 uri-js: 4.4.1 - ajv@8.12.0: - dependencies: - fast-deep-equal: 3.1.3 - json-schema-traverse: 1.0.0 - require-from-string: 2.0.2 - uri-js: 4.4.1 - ajv@8.17.1: dependencies: fast-deep-equal: 3.1.3 @@ -15712,8 +15750,8 @@ snapshots: exegesis@4.2.0: dependencies: '@apidevtools/json-schema-ref-parser': 9.1.2 - ajv: 8.12.0 - ajv-formats: 2.1.1(ajv@8.12.0) + ajv: 8.17.1 + ajv-formats: 2.1.1(ajv@8.17.1) body-parser: 1.20.3 content-type: 1.0.5 deep-freeze: 0.0.1 @@ -17344,7 +17382,7 @@ snapshots: light-my-request@4.12.0: dependencies: - ajv: 8.12.0 + ajv: 8.17.1 cookie: 0.5.0 process-warning: 1.0.0 set-cookie-parser: 2.6.0 @@ -20939,6 +20977,45 @@ snapshots: - tsx - yaml + vitest@4.0.15(@types/node@24.9.1)(happy-dom@20.0.10)(jiti@2.6.1)(jsdom@27.4.0)(lightningcss@1.30.2)(sass@1.70.0)(terser@5.46.0)(tsx@4.16.2)(yaml@2.8.1): + dependencies: + '@vitest/expect': 4.0.15 + '@vitest/mocker': 4.0.15(vite@7.1.12(@types/node@24.9.1)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.70.0)(terser@5.46.0)(tsx@4.16.2)(yaml@2.8.1)) + '@vitest/pretty-format': 4.0.15 + '@vitest/runner': 4.0.15 + '@vitest/snapshot': 4.0.15 + '@vitest/spy': 4.0.15 + '@vitest/utils': 4.0.15 + es-module-lexer: 1.7.0 + expect-type: 1.2.2 + magic-string: 0.30.21 + obug: 2.1.1 + pathe: 2.0.3 + picomatch: 4.0.3 + std-env: 3.10.0 + tinybench: 2.9.0 + tinyexec: 1.0.2 + tinyglobby: 0.2.15 + tinyrainbow: 3.0.3 + vite: 7.1.12(@types/node@24.9.1)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.70.0)(terser@5.46.0)(tsx@4.16.2)(yaml@2.8.1) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 24.9.1 + happy-dom: 20.0.10 + jsdom: 27.4.0 + transitivePeerDependencies: + - jiti + - less + - lightningcss + - msw + - sass + - sass-embedded + - stylus + - sugarss + - terser + - tsx + - yaml + vlq@0.2.3: {} w3c-xmlserializer@5.0.0: