test(web): add 162 tests for animation components and hooks

Components: AnimatedNumber (14), InfiniteScrollSentinel (16),
FadeIn (22), StaggerList (26).

Hooks: useUrlFilters (32), useWidgetFilterOptions (27),
useProjectDragContext (27).

Web test suite: 96 → 103 files, 1076 → 1238 tests.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-10 22:45:44 +02:00
parent d3f721ce58
commit 9bd7172018
7 changed files with 2019 additions and 0 deletions
@@ -0,0 +1,236 @@
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
import { render, screen, act } from "~/test-utils.js";
import { AnimatedNumber } from "./AnimatedNumber.js";
// ---------------------------------------------------------------------------
// rAF / performance mocking
// ---------------------------------------------------------------------------
// jsdom does not implement requestAnimationFrame in a way that advances with
// fake timers, so we replace it with a minimal synchronous stub that lets us
// drive frames manually when needed.
let rafCallbacks: Map<number, FrameRequestCallback> = new Map();
let rafId = 0;
// Tracks the simulated clock so we can advance it past the animation duration.
let fakeNow = 0;
function flushAllFrames() {
// Advance the fake clock well past the default duration (800 ms) so that
// easeOutExpo reaches progress=1 and setDisplay(to) is called.
fakeNow += 2000;
let safety = 0;
while (rafCallbacks.size > 0 && safety++ < 1000) {
const cbs = new Map(rafCallbacks);
rafCallbacks.clear();
for (const cb of cbs.values()) {
cb(fakeNow);
}
}
}
beforeEach(() => {
rafCallbacks = new Map();
rafId = 0;
fakeNow = 0;
// Stub performance.now so the animation component uses our controlled clock.
vi.spyOn(performance, "now").mockImplementation(() => fakeNow);
vi.spyOn(window, "requestAnimationFrame").mockImplementation((cb) => {
const id = ++rafId;
rafCallbacks.set(id, cb);
return id;
});
vi.spyOn(window, "cancelAnimationFrame").mockImplementation((id) => {
rafCallbacks.delete(id);
});
});
afterEach(() => {
vi.restoreAllMocks();
});
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
describe("AnimatedNumber", () => {
// -------------------------------------------------------------------------
// Initial render
// -------------------------------------------------------------------------
describe("initial render", () => {
it("renders the initial value without animation on first mount", () => {
act(() => {
render(<AnimatedNumber value={42} />);
flushAllFrames();
});
// The component formats with de-DE locale; 42 → "42"
expect(screen.getByText("42")).toBeInTheDocument();
});
it("wraps the output in a <span>", () => {
act(() => {
render(<AnimatedNumber value={10} />);
flushAllFrames();
});
const span = screen.getByText("10");
expect(span.tagName.toLowerCase()).toBe("span");
});
it("applies the className prop to the span", () => {
act(() => {
render(<AnimatedNumber value={5} className="text-red-500" />);
flushAllFrames();
});
const span = screen.getByText("5");
expect(span).toHaveClass("text-red-500");
});
});
// -------------------------------------------------------------------------
// Prefix and suffix
// -------------------------------------------------------------------------
describe("prefix and suffix", () => {
it("prepends the prefix string to the displayed value", () => {
act(() => {
render(<AnimatedNumber value={100} prefix="€ " />);
flushAllFrames();
});
expect(screen.getByText(/€/)).toBeInTheDocument();
});
it("appends the suffix string to the displayed value", () => {
act(() => {
render(<AnimatedNumber value={75} suffix="%" />);
flushAllFrames();
});
const span = screen.getByText(/75/);
expect(span.textContent).toContain("%");
});
it("renders both prefix and suffix together", () => {
act(() => {
render(<AnimatedNumber value={50} prefix="~" suffix=" hrs" />);
flushAllFrames();
});
const span = screen.getByText(/50/);
expect(span.textContent).toContain("~");
expect(span.textContent).toContain(" hrs");
});
});
// -------------------------------------------------------------------------
// Decimal formatting
// -------------------------------------------------------------------------
describe("decimal formatting", () => {
it("formats with 0 decimals by default (no decimal separator)", () => {
act(() => {
render(<AnimatedNumber value={1234} />);
flushAllFrames();
});
// de-DE uses . as thousands separator and , as decimal; 1234 → "1.234"
const span = screen.getByText(/1/);
expect(span.textContent).not.toMatch(/,/);
});
it("formats with 2 decimals when decimals={2}", () => {
act(() => {
render(<AnimatedNumber value={3} decimals={2} />);
flushAllFrames();
});
// de-DE: 3.00 → "3,00"
expect(screen.getByText(/3,00/)).toBeInTheDocument();
});
});
// -------------------------------------------------------------------------
// Animation on value change
// -------------------------------------------------------------------------
describe("animation on value change", () => {
it("cancels any pending rAF when the component unmounts mid-animation", () => {
const { unmount } = render(<AnimatedNumber value={0} />);
// Trigger an animation by re-rendering with a new value but do NOT flush frames.
act(() => {
// The useEffect fires; a rAF is queued but not consumed.
// We just verify that unmounting does not throw.
});
expect(() => unmount()).not.toThrow();
});
it("settles to the final value after all rAF frames are flushed", () => {
const { rerender } = render(<AnimatedNumber value={0} />);
// Flush any frames queued by the initial mount effect.
act(() => {
flushAllFrames();
});
// Rerender in its own act() so the useEffect fires and queues a rAF
// before we call flushAllFrames in the next act().
act(() => {
rerender(<AnimatedNumber value={999} />);
});
// Now flush: performance.now() returns fakeNow; advancing it ensures
// elapsed > duration so progress clamps to 1 and setDisplay(to) is called.
act(() => {
flushAllFrames();
});
// After full animation the display should equal the target value.
expect(screen.getByText("999")).toBeInTheDocument();
});
it("skips animation and immediately shows the value when from === to", () => {
// Render with value=7, then rerender with the same value=7.
const { rerender } = render(<AnimatedNumber value={7} />);
act(() => {
flushAllFrames();
});
act(() => {
rerender(<AnimatedNumber value={7} />);
flushAllFrames();
});
expect(screen.getByText("7")).toBeInTheDocument();
});
});
// -------------------------------------------------------------------------
// Edge cases
// -------------------------------------------------------------------------
describe("edge cases", () => {
it("handles value=0 without error", () => {
act(() => {
render(<AnimatedNumber value={0} />);
flushAllFrames();
});
expect(screen.getByText("0")).toBeInTheDocument();
});
it("handles negative values", () => {
act(() => {
render(<AnimatedNumber value={-42} />);
flushAllFrames();
});
expect(screen.getByText(/-42/)).toBeInTheDocument();
});
it("handles large numbers via de-DE thousand separators", () => {
act(() => {
render(<AnimatedNumber value={1000000} />);
flushAllFrames();
});
// de-DE: 1_000_000 → "1.000.000"
expect(screen.getByText("1.000.000")).toBeInTheDocument();
});
});
});
+290
View File
@@ -0,0 +1,290 @@
import { describe, expect, it, vi } from "vitest";
import { render, screen } from "~/test-utils.js";
import { FadeIn } from "./FadeIn.js";
// ---------------------------------------------------------------------------
// framer-motion mock
// ---------------------------------------------------------------------------
// Replace motion.div with a plain <div> that forwards all non-animation props
// plus exposes animation-variant props as data-* attributes for assertions.
vi.mock("framer-motion", () => ({
motion: {
div: ({
children,
className,
initial,
whileInView,
viewport,
variants,
...rest
}: React.HTMLAttributes<HTMLDivElement> & {
children?: React.ReactNode;
initial?: unknown;
whileInView?: unknown;
viewport?: unknown;
variants?: unknown;
}) => (
<div
className={className}
data-initial={JSON.stringify(initial)}
data-while-in-view={JSON.stringify(whileInView)}
data-viewport={JSON.stringify(viewport)}
data-variants={JSON.stringify(variants)}
{...rest}
>
{children}
</div>
),
},
}));
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function getWrapper(container: HTMLElement) {
return container.firstChild as HTMLElement;
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
describe("FadeIn", () => {
// -------------------------------------------------------------------------
// Children rendering
// -------------------------------------------------------------------------
describe("children rendering", () => {
it("renders its children", () => {
render(
<FadeIn>
<p>Hello FadeIn</p>
</FadeIn>,
);
expect(screen.getByText("Hello FadeIn")).toBeInTheDocument();
});
it("renders multiple children", () => {
render(
<FadeIn>
<span>First</span>
<span>Second</span>
</FadeIn>,
);
expect(screen.getByText("First")).toBeInTheDocument();
expect(screen.getByText("Second")).toBeInTheDocument();
});
it("renders text node children", () => {
render(<FadeIn>plain text</FadeIn>);
expect(screen.getByText("plain text")).toBeInTheDocument();
});
});
// -------------------------------------------------------------------------
// className prop
// -------------------------------------------------------------------------
describe("className prop", () => {
it("applies className to the wrapper div", () => {
const { container } = render(<FadeIn className="custom-class">content</FadeIn>);
expect(getWrapper(container)).toHaveClass("custom-class");
});
it("does not apply a className when the prop is omitted", () => {
const { container } = render(<FadeIn>content</FadeIn>);
// className attribute should be absent or empty
const wrapper = getWrapper(container);
expect(wrapper.className).toBeFalsy();
});
});
// -------------------------------------------------------------------------
// Animation props (initial / whileInView)
// -------------------------------------------------------------------------
describe("animation configuration", () => {
it("sets initial to 'hidden'", () => {
const { container } = render(<FadeIn>content</FadeIn>);
const wrapper = getWrapper(container);
expect(wrapper.dataset["initial"]).toBe(JSON.stringify("hidden"));
});
it("sets whileInView to 'visible'", () => {
const { container } = render(<FadeIn>content</FadeIn>);
const wrapper = getWrapper(container);
expect(wrapper.dataset["whileInView"]).toBe(JSON.stringify("visible"));
});
it("passes once=true to the viewport by default", () => {
const { container } = render(<FadeIn>content</FadeIn>);
const viewport = JSON.parse(getWrapper(container).dataset["viewport"]!) as {
once: boolean;
margin: string;
};
expect(viewport.once).toBe(true);
});
it("passes once=false to the viewport when once={false}", () => {
const { container } = render(<FadeIn once={false}>content</FadeIn>);
const viewport = JSON.parse(getWrapper(container).dataset["viewport"]!) as {
once: boolean;
};
expect(viewport.once).toBe(false);
});
it("passes margin='-40px' to the viewport", () => {
const { container } = render(<FadeIn>content</FadeIn>);
const viewport = JSON.parse(getWrapper(container).dataset["viewport"]!) as {
margin: string;
};
expect(viewport.margin).toBe("-40px");
});
});
// -------------------------------------------------------------------------
// Variants — direction offsets
// -------------------------------------------------------------------------
describe("direction offsets in variants", () => {
function getVariants(container: HTMLElement) {
return JSON.parse(getWrapper(container).dataset["variants"]!) as {
hidden: { opacity: number; x: number; y: number };
visible: { opacity: number; x: number; y: number; transition: object };
};
}
it("direction='up' (default) sets positive y offset in hidden state", () => {
const { container } = render(<FadeIn distance={12}>content</FadeIn>);
const { hidden } = getVariants(container);
expect(hidden.y).toBe(12);
expect(hidden.x).toBe(0);
expect(hidden.opacity).toBe(0);
});
it("direction='down' sets negative y offset in hidden state", () => {
const { container } = render(
<FadeIn direction="down" distance={12}>
content
</FadeIn>,
);
const { hidden } = getVariants(container);
expect(hidden.y).toBe(-12);
expect(hidden.x).toBe(0);
});
it("direction='left' sets positive x offset in hidden state", () => {
const { container } = render(
<FadeIn direction="left" distance={20}>
content
</FadeIn>,
);
const { hidden } = getVariants(container);
expect(hidden.x).toBe(20);
expect(hidden.y).toBe(0);
});
it("direction='right' sets negative x offset in hidden state", () => {
const { container } = render(
<FadeIn direction="right" distance={20}>
content
</FadeIn>,
);
const { hidden } = getVariants(container);
expect(hidden.x).toBe(-20);
expect(hidden.y).toBe(0);
});
it("direction='none' sets both x and y offsets to 0 in hidden state", () => {
const { container } = render(
<FadeIn direction="none" distance={20}>
content
</FadeIn>,
);
const { hidden } = getVariants(container);
expect(hidden.x).toBe(0);
expect(hidden.y).toBe(0);
});
it("visible state always has opacity=1, x=0, y=0", () => {
const directions = ["up", "down", "left", "right", "none"] as const;
for (const direction of directions) {
const { container } = render(
<FadeIn direction={direction} distance={16}>
content
</FadeIn>,
);
const { visible } = getVariants(container);
expect(visible.opacity).toBe(1);
expect(visible.x).toBe(0);
expect(visible.y).toBe(0);
}
});
});
// -------------------------------------------------------------------------
// Variants — transition options
// -------------------------------------------------------------------------
describe("transition options in variants", () => {
function getTransition(container: HTMLElement) {
const variants = JSON.parse(getWrapper(container).dataset["variants"]!) as {
visible: { transition: { duration: number; delay: number; ease: number[] } };
};
return variants.visible.transition;
}
it("uses the default duration of 0.3 when not specified", () => {
const { container } = render(<FadeIn>content</FadeIn>);
expect(getTransition(container).duration).toBe(0.3);
});
it("uses the provided duration prop", () => {
const { container } = render(<FadeIn duration={0.6}>content</FadeIn>);
expect(getTransition(container).duration).toBe(0.6);
});
it("uses the default delay of 0 when not specified", () => {
const { container } = render(<FadeIn>content</FadeIn>);
expect(getTransition(container).delay).toBe(0);
});
it("uses the provided delay prop", () => {
const { container } = render(<FadeIn delay={0.2}>content</FadeIn>);
expect(getTransition(container).delay).toBe(0.2);
});
it("uses the correct cubic-bezier ease array", () => {
const { container } = render(<FadeIn>content</FadeIn>);
expect(getTransition(container).ease).toEqual([0.25, 0.1, 0.25, 1]);
});
});
// -------------------------------------------------------------------------
// Distance prop
// -------------------------------------------------------------------------
describe("distance prop", () => {
it("defaults to a distance of 12", () => {
const { container } = render(<FadeIn direction="up">content</FadeIn>);
const variants = JSON.parse(getWrapper(container).dataset["variants"]!) as {
hidden: { y: number };
};
expect(variants.hidden.y).toBe(12);
});
it("uses a custom distance value", () => {
const { container } = render(
<FadeIn direction="up" distance={32}>
content
</FadeIn>,
);
const variants = JSON.parse(getWrapper(container).dataset["variants"]!) as {
hidden: { y: number };
};
expect(variants.hidden.y).toBe(32);
});
});
});
@@ -0,0 +1,243 @@
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
import { render, screen, act } from "~/test-utils.js";
import { InfiniteScrollSentinel } from "./InfiniteScrollSentinel.js";
// ---------------------------------------------------------------------------
// IntersectionObserver mock
// ---------------------------------------------------------------------------
type IntersectionCallback = (entries: IntersectionObserverEntry[]) => void;
let observerCallback: IntersectionCallback | null = null;
let observedElement: Element | null = null;
let disconnectMock: ReturnType<typeof vi.fn>;
let observeMock: ReturnType<typeof vi.fn>;
function simulateIntersection(isIntersecting: boolean) {
observerCallback?.([{ isIntersecting } as IntersectionObserverEntry]);
}
beforeEach(() => {
observerCallback = null;
observedElement = null;
disconnectMock = vi.fn();
observeMock = vi.fn((el: Element) => {
observedElement = el;
});
vi.stubGlobal(
"IntersectionObserver",
vi.fn((cb: IntersectionCallback, _options?: IntersectionObserverInit) => {
observerCallback = cb;
return {
observe: observeMock,
disconnect: disconnectMock,
unobserve: vi.fn(),
takeRecords: vi.fn(() => []),
root: null,
rootMargin: "",
thresholds: [],
} satisfies IntersectionObserver;
}),
);
});
afterEach(() => {
vi.unstubAllGlobals();
});
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
describe("InfiniteScrollSentinel", () => {
// -------------------------------------------------------------------------
// Rendering
// -------------------------------------------------------------------------
describe("rendering", () => {
it("renders a sentinel div in the DOM", () => {
const { container } = render(
<InfiniteScrollSentinel onVisible={vi.fn()} isLoading={false} />,
);
// The outer div is always present.
expect(container.firstChild).toBeTruthy();
});
it("does NOT render the spinner when isLoading is false", () => {
const { container } = render(
<InfiniteScrollSentinel onVisible={vi.fn()} isLoading={false} />,
);
// The spinner is a div with border classes applied; it must not exist.
const spinner = container.querySelector(".animate-spin");
expect(spinner).toBeNull();
});
it("renders the spinner when isLoading is true", () => {
const { container } = render(<InfiniteScrollSentinel onVisible={vi.fn()} isLoading />);
const spinner = container.querySelector(".animate-spin");
expect(spinner).not.toBeNull();
});
it("toggles the spinner when isLoading changes", () => {
const { rerender, container } = render(
<InfiniteScrollSentinel onVisible={vi.fn()} isLoading={false} />,
);
expect(container.querySelector(".animate-spin")).toBeNull();
rerender(<InfiniteScrollSentinel onVisible={vi.fn()} isLoading />);
expect(container.querySelector(".animate-spin")).not.toBeNull();
rerender(<InfiniteScrollSentinel onVisible={vi.fn()} isLoading={false} />);
expect(container.querySelector(".animate-spin")).toBeNull();
});
});
// -------------------------------------------------------------------------
// IntersectionObserver lifecycle
// -------------------------------------------------------------------------
describe("IntersectionObserver lifecycle", () => {
it("creates an IntersectionObserver on mount", () => {
render(<InfiniteScrollSentinel onVisible={vi.fn()} isLoading={false} />);
expect(IntersectionObserver).toHaveBeenCalledOnce();
});
it("observes the sentinel element", () => {
render(<InfiniteScrollSentinel onVisible={vi.fn()} isLoading={false} />);
expect(observeMock).toHaveBeenCalledOnce();
expect(observedElement).toBeInstanceOf(HTMLDivElement);
});
it("creates the observer with threshold: 0.1", () => {
render(<InfiniteScrollSentinel onVisible={vi.fn()} isLoading={false} />);
const ctorCall = vi.mocked(IntersectionObserver).mock.calls[0]!;
expect(ctorCall[1]).toEqual({ threshold: 0.1 });
});
it("disconnects the observer on unmount", () => {
const { unmount } = render(<InfiniteScrollSentinel onVisible={vi.fn()} isLoading={false} />);
unmount();
expect(disconnectMock).toHaveBeenCalledOnce();
});
it("recreates the observer when isLoading changes", () => {
const { rerender } = render(<InfiniteScrollSentinel onVisible={vi.fn()} isLoading={false} />);
expect(IntersectionObserver).toHaveBeenCalledTimes(1);
rerender(<InfiniteScrollSentinel onVisible={vi.fn()} isLoading />);
// Effect cleanup + re-run: observer should be recreated.
expect(IntersectionObserver).toHaveBeenCalledTimes(2);
});
it("disconnects the previous observer before creating a new one", () => {
const { rerender } = render(<InfiniteScrollSentinel onVisible={vi.fn()} isLoading={false} />);
rerender(<InfiniteScrollSentinel onVisible={vi.fn()} isLoading />);
// disconnect is called at least once during the effect cleanup.
expect(disconnectMock).toHaveBeenCalled();
});
});
// -------------------------------------------------------------------------
// onVisible callback
// -------------------------------------------------------------------------
describe("onVisible callback", () => {
it("calls onVisible when the sentinel intersects and isLoading is false", () => {
const onVisible = vi.fn();
render(<InfiniteScrollSentinel onVisible={onVisible} isLoading={false} />);
act(() => {
simulateIntersection(true);
});
expect(onVisible).toHaveBeenCalledOnce();
});
it("does NOT call onVisible when the sentinel intersects but isLoading is true", () => {
const onVisible = vi.fn();
render(<InfiniteScrollSentinel onVisible={onVisible} isLoading />);
act(() => {
simulateIntersection(true);
});
expect(onVisible).not.toHaveBeenCalled();
});
it("does NOT call onVisible when isIntersecting is false", () => {
const onVisible = vi.fn();
render(<InfiniteScrollSentinel onVisible={onVisible} isLoading={false} />);
act(() => {
simulateIntersection(false);
});
expect(onVisible).not.toHaveBeenCalled();
});
it("calls onVisible each time intersection fires with isLoading=false", () => {
const onVisible = vi.fn();
render(<InfiniteScrollSentinel onVisible={onVisible} isLoading={false} />);
act(() => {
simulateIntersection(true);
});
act(() => {
simulateIntersection(false);
});
act(() => {
simulateIntersection(true);
});
expect(onVisible).toHaveBeenCalledTimes(2);
});
it("uses the latest onVisible reference after prop update", () => {
const firstCallback = vi.fn();
const secondCallback = vi.fn();
const { rerender } = render(
<InfiniteScrollSentinel onVisible={firstCallback} isLoading={false} />,
);
// Update the callback prop — this triggers effect re-run because onVisible is
// listed as a dependency.
rerender(<InfiniteScrollSentinel onVisible={secondCallback} isLoading={false} />);
act(() => {
simulateIntersection(true);
});
expect(secondCallback).toHaveBeenCalledOnce();
expect(firstCallback).not.toHaveBeenCalled();
});
});
// -------------------------------------------------------------------------
// Edge cases
// -------------------------------------------------------------------------
describe("edge cases", () => {
it("does not throw when the observer callback receives an empty entries array", () => {
render(<InfiniteScrollSentinel onVisible={vi.fn()} isLoading={false} />);
expect(() => {
act(() => {
observerCallback?.([]);
});
}).not.toThrow();
});
it("does not call onVisible when entry is undefined (empty array)", () => {
const onVisible = vi.fn();
render(<InfiniteScrollSentinel onVisible={onVisible} isLoading={false} />);
act(() => {
observerCallback?.([]);
});
expect(onVisible).not.toHaveBeenCalled();
});
});
});
@@ -0,0 +1,344 @@
import { describe, expect, it, vi } from "vitest";
import { render, screen } from "~/test-utils.js";
import { StaggerList, StaggerItem } from "./StaggerList.js";
// ---------------------------------------------------------------------------
// framer-motion mock
// ---------------------------------------------------------------------------
// All motion.* components become plain HTML equivalents that forward className,
// children and expose animation props as data-* attributes so we can assert
// on variants / initial / animate values without running actual animations.
type MotionProps = React.HTMLAttributes<HTMLElement> & {
children?: React.ReactNode;
initial?: unknown;
animate?: unknown;
variants?: unknown;
};
function makeMotionComponent(Tag: "div" | "ul" | "ol" | "tbody") {
return function MockMotion({
children,
className,
initial,
animate,
variants,
...rest
}: MotionProps) {
const dataProps = {
"data-initial": JSON.stringify(initial),
"data-animate": JSON.stringify(animate),
"data-variants": JSON.stringify(variants),
};
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const Element = Tag as any;
return (
<Element className={className} {...dataProps} {...rest}>
{children}
</Element>
);
};
}
vi.mock("framer-motion", () => ({
motion: {
div: makeMotionComponent("div"),
ul: makeMotionComponent("ul"),
ol: makeMotionComponent("ol"),
tbody: makeMotionComponent("tbody"),
},
}));
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function getWrapper(container: HTMLElement) {
return container.firstChild as HTMLElement;
}
// ---------------------------------------------------------------------------
// StaggerList tests
// ---------------------------------------------------------------------------
describe("StaggerList", () => {
// -------------------------------------------------------------------------
// Children rendering
// -------------------------------------------------------------------------
describe("children rendering", () => {
it("renders its children", () => {
render(
<StaggerList>
<div>Item A</div>
<div>Item B</div>
</StaggerList>,
);
expect(screen.getByText("Item A")).toBeInTheDocument();
expect(screen.getByText("Item B")).toBeInTheDocument();
});
it("renders with no children without crashing", () => {
const { container } = render(<StaggerList>{null}</StaggerList>);
expect(getWrapper(container)).toBeTruthy();
});
});
// -------------------------------------------------------------------------
// className prop
// -------------------------------------------------------------------------
describe("className prop", () => {
it("applies className to the container element", () => {
const { container } = render(<StaggerList className="grid gap-2">content</StaggerList>);
expect(getWrapper(container)).toHaveClass("grid");
expect(getWrapper(container)).toHaveClass("gap-2");
});
it("does not crash when className is omitted", () => {
const { container } = render(<StaggerList>content</StaggerList>);
expect(getWrapper(container)).toBeTruthy();
});
});
// -------------------------------------------------------------------------
// as prop — rendered element tag
// -------------------------------------------------------------------------
describe("as prop", () => {
it("defaults to a <div> element", () => {
const { container } = render(<StaggerList>content</StaggerList>);
expect(getWrapper(container).tagName.toLowerCase()).toBe("div");
});
it("renders a <ul> when as='ul'", () => {
const { container } = render(<StaggerList as="ul">content</StaggerList>);
expect(getWrapper(container).tagName.toLowerCase()).toBe("ul");
});
it("renders an <ol> when as='ol'", () => {
const { container } = render(<StaggerList as="ol">content</StaggerList>);
expect(getWrapper(container).tagName.toLowerCase()).toBe("ol");
});
it("renders a <tbody> when as='tbody'", () => {
// tbody must be inside a table for valid HTML, but jsdom accepts it.
const { container } = render(
<table>
<StaggerList as="tbody">
<tr>
<td>cell</td>
</tr>
</StaggerList>
</table>,
);
expect(container.querySelector("tbody")).not.toBeNull();
});
});
// -------------------------------------------------------------------------
// Animation props
// -------------------------------------------------------------------------
describe("animation configuration", () => {
it("sets initial to 'hidden'", () => {
const { container } = render(<StaggerList>content</StaggerList>);
expect(getWrapper(container).dataset["initial"]).toBe(JSON.stringify("hidden"));
});
it("sets animate to 'visible'", () => {
const { container } = render(<StaggerList>content</StaggerList>);
expect(getWrapper(container).dataset["animate"]).toBe(JSON.stringify("visible"));
});
it("variants hidden state is an empty object", () => {
const { container } = render(<StaggerList>content</StaggerList>);
const variants = JSON.parse(getWrapper(container).dataset["variants"]!) as {
hidden: Record<string, unknown>;
};
expect(variants.hidden).toEqual({});
});
it("variants visible state has a staggerChildren transition", () => {
const { container } = render(<StaggerList>content</StaggerList>);
const variants = JSON.parse(getWrapper(container).dataset["variants"]!) as {
visible: { transition: { staggerChildren: number } };
};
expect(typeof variants.visible.transition.staggerChildren).toBe("number");
});
it("uses the default staggerDelay of 0.03", () => {
const { container } = render(<StaggerList>content</StaggerList>);
const variants = JSON.parse(getWrapper(container).dataset["variants"]!) as {
visible: { transition: { staggerChildren: number } };
};
expect(variants.visible.transition.staggerChildren).toBe(0.03);
});
it("uses a custom staggerDelay when provided", () => {
const { container } = render(<StaggerList staggerDelay={0.1}>content</StaggerList>);
const variants = JSON.parse(getWrapper(container).dataset["variants"]!) as {
visible: { transition: { staggerChildren: number } };
};
expect(variants.visible.transition.staggerChildren).toBe(0.1);
});
it("staggerDelay=0 is passed through correctly", () => {
const { container } = render(<StaggerList staggerDelay={0}>content</StaggerList>);
const variants = JSON.parse(getWrapper(container).dataset["variants"]!) as {
visible: { transition: { staggerChildren: number } };
};
expect(variants.visible.transition.staggerChildren).toBe(0);
});
});
});
// ---------------------------------------------------------------------------
// StaggerItem tests
// ---------------------------------------------------------------------------
describe("StaggerItem", () => {
// -------------------------------------------------------------------------
// Children rendering
// -------------------------------------------------------------------------
describe("children rendering", () => {
it("renders its children", () => {
render(
<StaggerItem>
<span>List item content</span>
</StaggerItem>,
);
expect(screen.getByText("List item content")).toBeInTheDocument();
});
it("renders text node children", () => {
render(<StaggerItem>raw text</StaggerItem>);
expect(screen.getByText("raw text")).toBeInTheDocument();
});
});
// -------------------------------------------------------------------------
// className prop
// -------------------------------------------------------------------------
describe("className prop", () => {
it("applies className to the wrapper div", () => {
const { container } = render(<StaggerItem className="p-4 border">content</StaggerItem>);
expect(getWrapper(container)).toHaveClass("p-4");
expect(getWrapper(container)).toHaveClass("border");
});
it("renders without a className when the prop is omitted", () => {
const { container } = render(<StaggerItem>content</StaggerItem>);
// Must not throw and wrapper must exist.
expect(getWrapper(container)).toBeTruthy();
});
});
// -------------------------------------------------------------------------
// Always renders a <div>
// -------------------------------------------------------------------------
it("always renders a <div> element", () => {
const { container } = render(<StaggerItem>content</StaggerItem>);
expect(getWrapper(container).tagName.toLowerCase()).toBe("div");
});
// -------------------------------------------------------------------------
// Animation props / variants
// -------------------------------------------------------------------------
describe("animation configuration", () => {
function getVariants(container: HTMLElement) {
return JSON.parse(getWrapper(container).dataset["variants"]!) as {
hidden: { opacity: number; y: number };
visible: { opacity: number; y: number; transition: { duration: number; ease: number[] } };
};
}
it("hidden variant has opacity=0", () => {
const { container } = render(<StaggerItem>content</StaggerItem>);
expect(getVariants(container).hidden.opacity).toBe(0);
});
it("hidden variant has y=8 (slides up slightly)", () => {
const { container } = render(<StaggerItem>content</StaggerItem>);
expect(getVariants(container).hidden.y).toBe(8);
});
it("visible variant has opacity=1", () => {
const { container } = render(<StaggerItem>content</StaggerItem>);
expect(getVariants(container).visible.opacity).toBe(1);
});
it("visible variant has y=0", () => {
const { container } = render(<StaggerItem>content</StaggerItem>);
expect(getVariants(container).visible.y).toBe(0);
});
it("visible transition has duration 0.25", () => {
const { container } = render(<StaggerItem>content</StaggerItem>);
expect(getVariants(container).visible.transition.duration).toBe(0.25);
});
it("visible transition has the correct cubic-bezier ease array", () => {
const { container } = render(<StaggerItem>content</StaggerItem>);
expect(getVariants(container).visible.transition.ease).toEqual([0.25, 0.1, 0.25, 1]);
});
it("does not set an explicit initial or animate prop (inherits from parent)", () => {
const { container } = render(<StaggerItem>content</StaggerItem>);
// StaggerItem does not pass initial/animate — motion should inherit them.
// The data attributes will be 'undefined' serialised as the string "undefined"
// if not passed, or absent. Both mean not explicitly set.
const wrapper = getWrapper(container);
const initialAttr = wrapper.dataset["initial"];
const animateAttr = wrapper.dataset["animate"];
expect(initialAttr === undefined || initialAttr === "undefined").toBe(true);
expect(animateAttr === undefined || animateAttr === "undefined").toBe(true);
});
});
// -------------------------------------------------------------------------
// Composition: StaggerList + StaggerItem
// -------------------------------------------------------------------------
describe("StaggerList + StaggerItem composition", () => {
it("renders nested StaggerItems inside a StaggerList", () => {
render(
<StaggerList>
<StaggerItem>Alpha</StaggerItem>
<StaggerItem>Beta</StaggerItem>
<StaggerItem>Gamma</StaggerItem>
</StaggerList>,
);
expect(screen.getByText("Alpha")).toBeInTheDocument();
expect(screen.getByText("Beta")).toBeInTheDocument();
expect(screen.getByText("Gamma")).toBeInTheDocument();
});
it("each StaggerItem renders its own wrapper div", () => {
const { container } = render(
<StaggerList>
<StaggerItem>One</StaggerItem>
<StaggerItem>Two</StaggerItem>
</StaggerList>,
);
// The StaggerList wrapper contains two StaggerItem divs.
const items = container.querySelectorAll("div > div");
expect(items.length).toBeGreaterThanOrEqual(2);
});
it("renders StaggerItems inside a <ul> StaggerList", () => {
render(
<StaggerList as="ul">
<StaggerItem>Item 1</StaggerItem>
<StaggerItem>Item 2</StaggerItem>
</StaggerList>,
);
expect(screen.getByText("Item 1")).toBeInTheDocument();
expect(screen.getByText("Item 2")).toBeInTheDocument();
});
});
});