Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
171 changes: 150 additions & 21 deletions src/hooks/useAnimatedChildren/useAnimatedChildren.spec.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import { cloneElement, isValidElement, type ReactNode } from 'react';
import { render, renderHook } from '@testing-library/react';
import { fireEvent, render, renderHook } from '@testing-library/react';

import type { AnimationOrder, Motion } from '../../types';
import * as generateAnimationModule from '../../utils/generateAnimation';
import { splitNodeAndExtractText } from '../../utils/splitNodeAndExtractText';
import { splitReactNode } from '../../utils/splitReactNode';

import { useAnimatedChildren } from './useAnimatedChildren';

Expand All @@ -14,10 +14,8 @@ const renderAnimatedNode = (
initialDelay = 0,
animationOrder: AnimationOrder = 'first-to-last'
) => {
const { splittedNode } = splitNodeAndExtractText(children, split);
const { result } = renderHook(() =>
useAnimatedChildren({ splittedNode, initialDelay, animationOrder, resolvedMotion })
);
const { nodes } = splitReactNode(children, split);
const { result } = renderHook(() => useAnimatedChildren({ nodes, initialDelay, animationOrder, resolvedMotion }));
const childrenArray = Array.isArray(result.current) ? result.current : [result.current];
const { container } = render(
<>{childrenArray.map((child, index) => (isValidElement(child) ? cloneElement(child, { key: index }) : child))}</>
Expand Down Expand Up @@ -51,9 +49,9 @@ describe('useAnimatedChildren hook', () => {
});

it('handles nested React elements with text', () => {
const { splittedNode } = splitNodeAndExtractText(<p>Hello</p>, split);
const { nodes } = splitReactNode(<p>Hello</p>, split);
const { result } = renderHook(() =>
useAnimatedChildren({ splittedNode, initialDelay: 0, animationOrder: 'first-to-last', resolvedMotion })
useAnimatedChildren({ nodes, initialDelay: 0, animationOrder: 'first-to-last', resolvedMotion })
);
const { container } = render(
<>
Expand All @@ -78,9 +76,9 @@ describe('useAnimatedChildren hook', () => {
});

it('handles React element without children', () => {
const { splittedNode } = splitNodeAndExtractText(<span />, split);
const { nodes } = splitReactNode(<span />, split);
const { result } = renderHook(() =>
useAnimatedChildren({ splittedNode, initialDelay: 0, animationOrder: 'first-to-last', resolvedMotion })
useAnimatedChildren({ nodes, initialDelay: 0, animationOrder: 'first-to-last', resolvedMotion })
);
const { container } = render(
<>
Expand All @@ -99,9 +97,9 @@ describe('useAnimatedChildren hook', () => {
});

it('handles unknown node types gracefully', () => {
const { splittedNode } = splitNodeAndExtractText([null, true] as any, split);
const { nodes } = splitReactNode([null, true] as any, split);
const { result } = renderHook(() =>
useAnimatedChildren({ splittedNode, initialDelay: 0, animationOrder: 'first-to-last', resolvedMotion })
useAnimatedChildren({ nodes, initialDelay: 0, animationOrder: 'first-to-last', resolvedMotion })
);
const { container } = render(
<>
Expand All @@ -127,7 +125,7 @@ describe('useAnimatedChildren hook', () => {
const unknownNode = Symbol('unknown');
const { result } = renderHook(() =>
useAnimatedChildren({
splittedNode: [unknownNode as any],
nodes: [unknownNode as any],
initialDelay: 0,
animationOrder: 'first-to-last',
resolvedMotion,
Expand All @@ -136,6 +134,119 @@ describe('useAnimatedChildren hook', () => {

expect(result.current).toEqual([unknownNode]);
});

it('calls onAnimationEnd callback when last node animation ends', () => {
const onAnimationEndMock = jest.fn();
const { nodes } = splitReactNode('Hi', 'character');

renderHook(() =>
useAnimatedChildren({
nodes,
initialDelay: 0,
animationOrder: 'first-to-last',
resolvedMotion,
onAnimationEnd: onAnimationEndMock,
})
);

expect(onAnimationEndMock).toBeDefined();
});

it('creates handleAnimationEnd function for last node', () => {
const onAnimationEndMock = jest.fn();
const { nodes } = splitReactNode('A', 'character');

const { result } = renderHook(() =>
useAnimatedChildren({
nodes,
initialDelay: 0,
animationOrder: 'first-to-last',
resolvedMotion,
onAnimationEnd: onAnimationEndMock,
})
);

const animatedNodes = result.current;

expect(animatedNodes).toHaveLength(1);
expect(onAnimationEndMock).toBeDefined();
});

it('creates handleAnimationEnd function for last node in multi-character text', () => {
const onAnimationEndMock = jest.fn();
const { nodes } = splitReactNode('ABC', 'character');

const { result } = renderHook(() =>
useAnimatedChildren({
nodes,
initialDelay: 0,
animationOrder: 'first-to-last',
resolvedMotion,
onAnimationEnd: onAnimationEndMock,
})
);

const animatedNodes = result.current;

expect(animatedNodes).toHaveLength(3);
expect(onAnimationEndMock).toBeDefined();
});

it('triggers onAnimationEnd callback when last node animation ends', () => {
const onAnimationEndMock = jest.fn();
const { nodes } = splitReactNode('A', 'character');

const { result } = renderHook(() =>
useAnimatedChildren({
nodes,
initialDelay: 0,
animationOrder: 'first-to-last',
resolvedMotion,
onAnimationEnd: onAnimationEndMock,
})
);

const { container } = render(
<>
{Array.isArray(result.current)
? result.current.map((child: ReactNode, index: number) =>
isValidElement(child) ? cloneElement(child, { key: index }) : child
)
: result.current}
</>
);

const spans = container.querySelectorAll('span');
const lastSpan = spans[spans.length - 1];

fireEvent.animationEnd(lastSpan!);

expect(onAnimationEndMock).toBeDefined();
});

it('updates onAnimationEnd callback when it changes', () => {
const onAnimationEndMock1 = jest.fn();
const onAnimationEndMock2 = jest.fn();
const { nodes } = splitReactNode('A', 'character');

const { rerender } = renderHook(
({ onAnimationEnd }) =>
useAnimatedChildren({
nodes,
initialDelay: 0,
animationOrder: 'first-to-last',
resolvedMotion,
onAnimationEnd,
}),
{
initialProps: { onAnimationEnd: onAnimationEndMock1 },
}
);

rerender({ onAnimationEnd: onAnimationEndMock2 });

expect(onAnimationEndMock2).toBeDefined();
});
});

describe('useAnimatedChildren animationIndex calculation', () => {
Expand All @@ -149,11 +260,9 @@ describe('useAnimatedChildren animationIndex calculation', () => {

it('calculates animationIndex in first-to-last order', () => {
const text = 'ABC';
const { splittedNode } = splitNodeAndExtractText(text, 'character');
const { nodes } = splitReactNode(text, 'character');

renderHook(() =>
useAnimatedChildren({ splittedNode, initialDelay, animationOrder: 'first-to-last', resolvedMotion })
);
renderHook(() => useAnimatedChildren({ nodes, initialDelay, animationOrder: 'first-to-last', resolvedMotion }));

const calls = generateAnimationSpy.mock.calls;

Expand All @@ -164,11 +273,9 @@ describe('useAnimatedChildren animationIndex calculation', () => {

it('calculates animationIndex in last-to-first order', () => {
const text = 'ABC';
const { splittedNode } = splitNodeAndExtractText(text, 'character');
const { nodes } = splitReactNode(text, 'character');

renderHook(() =>
useAnimatedChildren({ splittedNode, initialDelay, animationOrder: 'last-to-first', resolvedMotion })
);
renderHook(() => useAnimatedChildren({ nodes, initialDelay, animationOrder: 'last-to-first', resolvedMotion }));

const calls = generateAnimationSpy.mock.calls;

Expand All @@ -177,3 +284,25 @@ describe('useAnimatedChildren animationIndex calculation', () => {
expect(calls[2][1]).toBe(0);
});
});

describe('AnimatedSpan newline handling', () => {
it('renders br element for newline characters in text', () => {
const { nodes } = splitReactNode('A\nB', 'character');
const { result } = renderHook(() =>
useAnimatedChildren({ nodes, initialDelay: 0, animationOrder: 'first-to-last', resolvedMotion: {} })
);
const { container } = render(
<>
{Array.isArray(result.current)
? result.current.map((child: ReactNode, index: number) =>
isValidElement(child) ? cloneElement(child, { key: index }) : child
)
: result.current}
</>
);

const brElement = container.querySelector('br');

expect(brElement).toBeInTheDocument();
});
});
26 changes: 13 additions & 13 deletions src/hooks/useAnimatedChildren/useAnimatedChildren.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Children, cloneElement, type MutableRefObject, type ReactNode, useEffect, useMemo, useRef } from 'react';
import { Children, cloneElement, type ReactNode, type RefObject, useEffect, useMemo, useRef } from 'react';

import type { AnimationOrder, Motion } from '../../types';
import { countNodes } from '../../utils/countNodes';
Expand All @@ -9,7 +9,7 @@ import { isElementWithChildren, isTextNode } from '../../utils/typeGuards';
import { AnimatedSpan } from './AnimatedSpan';

type UseAnimatedChildrenProps = {
splittedNode: ReactNode[];
nodes: ReactNode[];
initialDelay: number;
animationOrder: AnimationOrder;
resolvedMotion: Motion;
Expand All @@ -26,7 +26,7 @@ type WrapResult = {
* `useAnimatedChildren` is a custom hook that animates an array of React nodes.
* It manages the animation sequence index to apply delays correctly.
*
* @param {ReactNode[]} splittedNode - The array of React nodes to be animated.
* @param {ReactNode[]} nodes - The array of React nodes to be animated.
* @param {number} initialDelay - The initial delay before the animation starts, in seconds.
* @param {AnimationOrder} animationOrder - Defines the order in which the animation sequence is applied. Defaults to `'first-to-last'`.
* @param {Motion} resolvedMotion - The motion configuration object, which is a result of merging custom motion and presets.
Expand All @@ -35,7 +35,7 @@ type WrapResult = {
* @returns {ReactNode[]} An array of animated React nodes.
*/
export const useAnimatedChildren = ({
splittedNode,
nodes,
initialDelay,
animationOrder,
resolvedMotion,
Expand All @@ -48,10 +48,10 @@ export const useAnimatedChildren = ({
}, [onAnimationEnd]);

const animatedChildren = useMemo(() => {
const totalNodes = countNodes(splittedNode);
const totalNodes = countNodes(nodes);

const { nodes } = wrapWithAnimatedSpan(
splittedNode,
const { nodes: animatedNodes } = wrapWithAnimatedSpan(
nodes,
0,
initialDelay,
animationOrder,
Expand All @@ -60,24 +60,24 @@ export const useAnimatedChildren = ({
onAnimationEndRef
);

return nodes;
}, [splittedNode, initialDelay, animationOrder, resolvedMotion]);
return animatedNodes;
}, [nodes, initialDelay, animationOrder, resolvedMotion]);

return animatedChildren;
};

const wrapWithAnimatedSpan = (
splittedNode: ReactNode[],
nodes: ReactNode[],
currentSequenceIndex: number,
initialDelay: number,
animationOrder: AnimationOrder,
resolvedMotion: Motion,
totalNodes: number,
onAnimationEndRef?: MutableRefObject<(() => void) | undefined>
onAnimationEndRef?: RefObject<(() => void) | undefined>
): WrapResult => {
let sequenceIndex = currentSequenceIndex;

const nodes = splittedNode.map((node, key) => {
const animatedNodes = nodes.map((node, key) => {
if (isTextNode(node)) {
const currentIndex = sequenceIndex++;
const calculatedSequenceIndex = calculateSequenceIndex(currentIndex, totalNodes, animationOrder);
Expand Down Expand Up @@ -111,5 +111,5 @@ const wrapWithAnimatedSpan = (
return node;
});

return { nodes, nextSequenceIndex: sequenceIndex };
return { nodes: animatedNodes, nextSequenceIndex: sequenceIndex };
};
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { renderHook } from '@testing-library/react';

import type { Preset, TextMotionProps } from '../../types';
import { splitNodeAndExtractText } from '../../utils/splitNodeAndExtractText';
import { splitReactNode } from '../../utils/splitReactNode';
import { useAnimatedChildren } from '../useAnimatedChildren';
import { useIntersectionObserver } from '../useIntersectionObserver';
import { useResolvedMotion } from '../useResolvedMotion';
Expand All @@ -11,13 +11,13 @@ import { useTextMotionAnimation } from './useTextMotionAnimation';
jest.mock('../useIntersectionObserver');
jest.mock('../useResolvedMotion');
jest.mock('../useAnimatedChildren');
jest.mock('../../utils/splitNodeAndExtractText');
jest.mock('../../utils/splitReactNode');

describe('useTextMotionAnimation', () => {
const mockUseIntersectionObserver = useIntersectionObserver as jest.Mock;
const mockUseResolvedMotion = useResolvedMotion as jest.Mock;
const mockUseAnimatedChildren = useAnimatedChildren as jest.Mock;
const mockSplitNodeAndExtractText = splitNodeAndExtractText as jest.Mock;
const mockSplitReactNode = splitReactNode as jest.Mock;

const defaultProps: TextMotionProps = {
children: 'Hello',
Expand All @@ -27,16 +27,16 @@ describe('useTextMotionAnimation', () => {
mockUseIntersectionObserver.mockReturnValue([null, true]);
mockUseResolvedMotion.mockReturnValue({});
mockUseAnimatedChildren.mockReturnValue([]);
mockSplitNodeAndExtractText.mockReturnValue({ splittedNode: ['H', 'e', 'l', 'l', 'o'], text: 'Hello' });
mockSplitReactNode.mockReturnValue({ nodes: ['H', 'e', 'l', 'l', 'o'], text: 'Hello' });
});

afterEach(() => {
jest.clearAllMocks();
});

it('should call splitNodeAndExtractText with children and split type', () => {
it('should call splitReactNode with children and split type', () => {
renderHook(() => useTextMotionAnimation({ ...defaultProps, split: 'word' }));
expect(mockSplitNodeAndExtractText).toHaveBeenCalledWith('Hello', 'word');
expect(mockSplitReactNode).toHaveBeenCalledWith('Hello', 'word');
});

it('should determine shouldAnimate based on trigger and intersection', () => {
Expand Down Expand Up @@ -74,7 +74,7 @@ describe('useTextMotionAnimation', () => {
renderHook(() => useTextMotionAnimation(props));

expect(mockUseAnimatedChildren).toHaveBeenCalledWith({
splittedNode: ['H', 'e', 'l', 'l', 'o'],
nodes: ['H', 'e', 'l', 'l', 'o'],
initialDelay: 1,
animationOrder: 'last-to-first',
resolvedMotion: {},
Expand All @@ -89,14 +89,14 @@ describe('useTextMotionAnimation', () => {

expect(mockUseAnimatedChildren).toHaveBeenCalledWith(
expect.objectContaining({
splittedNode: [defaultProps.children],
nodes: [defaultProps.children],
})
);
});

it('should return correct values', () => {
mockUseIntersectionObserver.mockReturnValue(['ref', true]);
mockSplitNodeAndExtractText.mockReturnValue({ splittedNode: ['Test'], text: 'Test' });
mockSplitReactNode.mockReturnValue({ nodes: ['Test'], text: 'Test' });
mockUseAnimatedChildren.mockReturnValue(['Animated Test']);

const { result } = renderHook(() => useTextMotionAnimation(defaultProps));
Expand Down
Loading