diff --git a/src/hooks/useAnimatedChildren/useAnimatedChildren.spec.tsx b/src/hooks/useAnimatedChildren/useAnimatedChildren.spec.tsx
index 078560e..e6a8b8e 100644
--- a/src/hooks/useAnimatedChildren/useAnimatedChildren.spec.tsx
+++ b/src/hooks/useAnimatedChildren/useAnimatedChildren.spec.tsx
@@ -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';
@@ -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))}>
@@ -51,9 +49,9 @@ describe('useAnimatedChildren hook', () => {
});
it('handles nested React elements with text', () => {
- const { splittedNode } = splitNodeAndExtractText(
Hello
, split);
+ const { nodes } = splitReactNode(Hello
, 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(
<>
@@ -78,9 +76,9 @@ describe('useAnimatedChildren hook', () => {
});
it('handles React element without children', () => {
- const { splittedNode } = splitNodeAndExtractText(, split);
+ const { nodes } = splitReactNode(, 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(
<>
@@ -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(
<>
@@ -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,
@@ -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', () => {
@@ -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;
@@ -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;
@@ -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();
+ });
+});
diff --git a/src/hooks/useAnimatedChildren/useAnimatedChildren.tsx b/src/hooks/useAnimatedChildren/useAnimatedChildren.tsx
index dda1a35..e310703 100644
--- a/src/hooks/useAnimatedChildren/useAnimatedChildren.tsx
+++ b/src/hooks/useAnimatedChildren/useAnimatedChildren.tsx
@@ -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';
@@ -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;
@@ -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.
@@ -35,7 +35,7 @@ type WrapResult = {
* @returns {ReactNode[]} An array of animated React nodes.
*/
export const useAnimatedChildren = ({
- splittedNode,
+ nodes,
initialDelay,
animationOrder,
resolvedMotion,
@@ -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,
@@ -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);
@@ -111,5 +111,5 @@ const wrapWithAnimatedSpan = (
return node;
});
- return { nodes, nextSequenceIndex: sequenceIndex };
+ return { nodes: animatedNodes, nextSequenceIndex: sequenceIndex };
};
diff --git a/src/hooks/useTextMotionAnimation/useTextMotionAnimation.spec.tsx b/src/hooks/useTextMotionAnimation/useTextMotionAnimation.spec.tsx
index e6ab6fc..e53ada7 100644
--- a/src/hooks/useTextMotionAnimation/useTextMotionAnimation.spec.tsx
+++ b/src/hooks/useTextMotionAnimation/useTextMotionAnimation.spec.tsx
@@ -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';
@@ -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',
@@ -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', () => {
@@ -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: {},
@@ -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));
diff --git a/src/hooks/useTextMotionAnimation/useTextMotionAnimation.ts b/src/hooks/useTextMotionAnimation/useTextMotionAnimation.ts
index 43d5a37..0d82865 100644
--- a/src/hooks/useTextMotionAnimation/useTextMotionAnimation.ts
+++ b/src/hooks/useTextMotionAnimation/useTextMotionAnimation.ts
@@ -1,7 +1,7 @@
import { useMemo } from 'react';
import type { 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';
@@ -30,12 +30,12 @@ export const useTextMotionAnimation = (props: TextMotionProps) => {
const [targetRef, isIntersecting] = useIntersectionObserver({ repeat });
const shouldAnimate = trigger === 'on-load' || isIntersecting;
- const { splittedNode, text } = useMemo(() => splitNodeAndExtractText(children, split), [children, split]);
+ const { nodes, text } = useMemo(() => splitReactNode(children, split), [children, split]);
const resolvedMotion = useResolvedMotion({ motion, preset });
const animatedChildren = useAnimatedChildren({
- splittedNode: shouldAnimate ? splittedNode : [children],
+ nodes: shouldAnimate ? nodes : [children],
initialDelay,
animationOrder,
resolvedMotion,
diff --git a/src/utils/accessibility/accessibility.ts b/src/utils/accessibility/accessibility.ts
index b7ff8c2..21dff01 100644
--- a/src/utils/accessibility/accessibility.ts
+++ b/src/utils/accessibility/accessibility.ts
@@ -2,6 +2,7 @@
* Generates aria-label attribute for accessibility
*
* @param text - The text to be used as aria-label
+ *
* @returns Object with aria-label attribute
*/
export const getAriaLabel = (text: string): { 'aria-label'?: string } => {
diff --git a/src/utils/countNodes/countNodes.spec.tsx b/src/utils/countNodes/countNodes.spec.tsx
index e509162..1ebc6ee 100644
--- a/src/utils/countNodes/countNodes.spec.tsx
+++ b/src/utils/countNodes/countNodes.spec.tsx
@@ -1,138 +1,138 @@
-import { Children, type ReactNode } from 'react';
+import type { ReactNode } from 'react';
import { countNodes } from './countNodes';
describe('countNodes', () => {
- const getNodes = (children: ReactNode) => Children.toArray(children);
-
- it('should handle null nodes', () => {
- const nodes = [null];
- expect(countNodes(nodes)).toBe(0);
- });
-
- it('should count a single text node as 1', () => {
- const nodes = getNodes('Hello');
- expect(countNodes(nodes)).toBe(1);
+ describe('non-renderable nodes', () => {
+ it('returns 0 for null, undefined, and boolean values', () => {
+ expect(countNodes(null)).toBe(0);
+ expect(countNodes(undefined)).toBe(0);
+ expect(countNodes(false)).toBe(0);
+ });
+
+ it('returns 0 for an empty fragment', () => {
+ expect(countNodes(<>>)).toBe(0);
+ });
});
- it('should count multiple text nodes correctly', () => {
- const nodes = getNodes(<>Hello World>);
- expect(countNodes(nodes)).toBe(1);
-
- const nodesWithSeparators = getNodes(
- <>
- {'Hello'} {'World'}
- >
- );
- expect(countNodes(nodesWithSeparators)).toBe(3);
+ describe('text nodes', () => {
+ it('counts a single text node as 1', () => {
+ expect(countNodes('Hello')).toBe(1);
+ expect(countNodes(123)).toBe(1);
+ });
+
+ it('counts multiple text nodes separated by JSX expressions', () => {
+ expect(
+ countNodes(
+ <>
+ {'Hello'} {'World'}
+ >
+ )
+ ).toBe(3);
+ });
+
+ it('counts mixed text and element siblings correctly', () => {
+ expect(
+ countNodes(
+ <>
+ StartMiddleEnd
+ >
+ )
+ ).toBe(3);
+ });
});
- it('should count a single element node as 1', () => {
- const nodes = getNodes(Hello);
- expect(countNodes(nodes)).toBe(1);
+ describe('element nodes', () => {
+ it('counts text inside a single element', () => {
+ expect(countNodes(Hello)).toBe(1);
+ });
+
+ it('counts multiple sibling elements', () => {
+ expect(
+ countNodes(
+ <>
+ Hello
+ World
+ >
+ )
+ ).toBe(2);
+ });
+
+ it('counts nested elements recursively', () => {
+ expect(
+ countNodes(
+
+
Hello
+
+ Deep
+ Nested
+
+
+ )
+ ).toBe(3);
+ });
});
- it('should count multiple element nodes correctly', () => {
- const nodes = getNodes(
- <>
- Hello
- World
- >
- );
- expect(countNodes(nodes)).toBe(2);
+ describe('arrays and composite children', () => {
+ it('counts nodes inside an array of ReactNode', () => {
+ expect(
+ countNodes([
+ 'A',
+ 'B',
+
+ C
+
,
+ 'D',
+ ])
+ ).toBe(4);
+ });
+
+ it('ignores nullish values inside children arrays', () => {
+ expect(
+ countNodes(
+ <>
+ {'Hello'}
+ {null}
+ {undefined}
+ World
+ >
+ )
+ ).toBe(2);
+ });
});
- it('should count nested elements correctly', () => {
- const nodes = getNodes(
-
- Hello
- World
-
- );
-
- expect(countNodes(nodes)).toBe(2);
+ describe('custom components', () => {
+ it('counts text nodes rendered by a functional component', () => {
+ const Wrapper = ({ children }: { children: ReactNode }) => {children}
;
+
+ expect(
+ countNodes(
+
+ Child1
+ Child2
+
+ )
+ ).toBe(2);
+ });
});
- it('should count deeply nested elements correctly', () => {
- const nodes = getNodes(
-
-
Hello
-
- Deep
- Nested
-
-
- );
-
- expect(countNodes(nodes)).toBe(3);
- });
-
- it('should handle mixed text and element nodes', () => {
- const nodes = getNodes(
- <>
- StartMiddleEnd
- >
- );
- expect(countNodes(nodes)).toBe(3);
- });
-
- it('should count nodes within an array of children', () => {
- const nodes = getNodes([
- 'A',
- 'B',
-
- C
-
,
- 'D',
- ]);
-
- expect(countNodes(nodes)).toBe(4);
- });
-
- it('should return 0 for an empty array', () => {
- const nodes = getNodes(<>>);
- expect(countNodes(nodes)).toBe(0);
- });
-
- it('should handle null and undefined nodes gracefully (not count them)', () => {
- const nodes = getNodes(
- <>
- {'Hello'}
- {null}
- {undefined}
- World
- >
- );
- expect(countNodes(nodes)).toBe(2);
- });
-
- it('should count children of a functional component', () => {
- const MyComponent = ({ children }: { children: ReactNode }) => {children}
;
- const nodes = getNodes(
-
- Child1
- Child2
-
- );
-
- expect(countNodes(nodes)).toBe(2);
- });
-
- it('should count nodes for a complex structure', () => {
- const nodes = getNodes(
- <>
- Line 1
-
- Part 1 Part 2
-
-
- >
- );
-
- expect(countNodes(nodes)).toBe(5);
+ describe('complex real-world structures', () => {
+ it('counts text nodes in a complex nested tree', () => {
+ expect(
+ countNodes(
+ <>
+ Line 1
+
+ Part 1 Part 2
+
+
+ >
+ )
+ ).toBe(5);
+ });
});
});
diff --git a/src/utils/countNodes/countNodes.ts b/src/utils/countNodes/countNodes.ts
index d879328..2c13564 100644
--- a/src/utils/countNodes/countNodes.ts
+++ b/src/utils/countNodes/countNodes.ts
@@ -1,28 +1,33 @@
import { Children, type ReactNode } from 'react';
-import { isElementWithChildren, isNullishNode, isTextNode } from '../typeGuards';
+import { isElementWithChildren, isNonRenderableNode, isTextNode } from '../typeGuards';
/**
* @description
* `countNodes` is a recursive pure function that counts the number of text nodes in a React node tree.
* It returns the total number of text nodes in the tree, which are the nodes that will be animated.
*
- * @param {ReactNode[]} nodes - The array of React nodes to count.
+ * @param {ReactNode} node - The React node to count.
+ *
* @returns {number} The total number of animated (text) nodes in the tree.
*/
-export const countNodes = (nodes: ReactNode[]): number => {
- let count = 0;
+export const countNodes = (node: ReactNode): number => {
+ if (isNonRenderableNode(node)) {
+ return 0;
+ }
+
+ if (isTextNode(node)) {
+ return 1;
+ }
- Children.forEach(nodes, node => {
- if (isNullishNode(node)) {
- return;
- }
+ if (isElementWithChildren(node)) {
+ return countNodes(node.props.children);
+ }
+
+ let count = 0;
- if (isTextNode(node)) {
- count += 1;
- } else if (isElementWithChildren(node)) {
- count += countNodes(Children.toArray(node.props.children));
- }
+ Children.forEach(node, child => {
+ count += countNodes(child);
});
return count;
diff --git a/src/utils/generateAnimation/generateAnimation.ts b/src/utils/generateAnimation/generateAnimation.ts
index dfbe2a1..320277b 100644
--- a/src/utils/generateAnimation/generateAnimation.ts
+++ b/src/utils/generateAnimation/generateAnimation.ts
@@ -24,7 +24,7 @@ export const generateAnimation = (
initialDelay: number
): { style: StyleWithCustomProperties } => {
const animations: string[] = [];
- let style: StyleWithCustomProperties = {};
+ const style: StyleWithCustomProperties = {};
Object.entries(motionConfig).forEach(([name, config]) => {
if (config === undefined || config === null) return;
@@ -32,19 +32,28 @@ export const generateAnimation = (
if (name === ANIMATION_DEFAULTS.CUSTOM_ANIMATION_KEY) {
const { animationString } = processCustomAnimation(config as CustomAnimation, sequenceIndex, initialDelay);
animations.push(animationString);
- } else if ('variant' in config) {
+ return;
+ }
+
+ if (typeof config === 'object' && config !== null && 'variant' in config) {
const { animationString, customProps } = processStandardAnimation(
name,
config as StandardAnimation,
sequenceIndex,
initialDelay
);
+
animations.push(animationString);
- style = { ...style, ...customProps };
+ Object.assign(style, customProps);
}
});
- return { style: { animation: animations.join(', '), ...style } };
+ return {
+ style: {
+ animation: animations.join(', '),
+ ...style,
+ },
+ };
};
const processStandardAnimation = (
diff --git a/src/utils/sequenceHelpers/sequenceHelpers.ts b/src/utils/sequenceHelpers/sequenceHelpers.ts
index 986d7d8..0274e7f 100644
--- a/src/utils/sequenceHelpers/sequenceHelpers.ts
+++ b/src/utils/sequenceHelpers/sequenceHelpers.ts
@@ -8,6 +8,7 @@ import type { AnimationOrder } from '../../types';
* @param {number} currentIndex - The current index of the node.
* @param {number} totalNodes - The total number of nodes in the sequence.
* @param {AnimationOrder} animationOrder - The animation order of the sequence.
+ *
* @returns {number} The sequence index of the node.
*/
export const calculateSequenceIndex = (
@@ -25,6 +26,7 @@ export const calculateSequenceIndex = (
*
* @param {number} sequenceIndex - The sequence index of the node.
* @param {number} totalNodes - The total number of nodes in the sequence.
+ *
* @returns {boolean} `true` if the node is the last node, otherwise `false`.
*/
export const isLastNode = (sequenceIndex: number, totalNodes: number): boolean => {
diff --git a/src/utils/splitNodeAndExtractText/index.ts b/src/utils/splitNodeAndExtractText/index.ts
deleted file mode 100644
index 5d5c2b1..0000000
--- a/src/utils/splitNodeAndExtractText/index.ts
+++ /dev/null
@@ -1 +0,0 @@
-export { splitNodeAndExtractText } from './splitNodeAndExtractText';
diff --git a/src/utils/splitNodeAndExtractText/splitNodeAndExtractText.spec.tsx b/src/utils/splitNodeAndExtractText/splitNodeAndExtractText.spec.tsx
deleted file mode 100644
index e81935f..0000000
--- a/src/utils/splitNodeAndExtractText/splitNodeAndExtractText.spec.tsx
+++ /dev/null
@@ -1,85 +0,0 @@
-import { splitNodeAndExtractText } from './splitNodeAndExtractText';
-
-describe('splitNodeAndExtractText utility', () => {
- it('splits a string into individual characters when split type is "character"', () => {
- const { splittedNode, text } = splitNodeAndExtractText('Hi', 'character');
-
- expect(splittedNode).toHaveLength(2);
- expect(splittedNode[0]).toBe('H');
- expect(splittedNode[1]).toBe('i');
- expect(text).toBe('Hi');
- });
-
- it('splits a number into individual characters when split type is "character"', () => {
- const { splittedNode, text } = splitNodeAndExtractText(123, 'character');
-
- expect(splittedNode).toHaveLength(3);
- expect(splittedNode[0]).toBe('1');
- expect(splittedNode[1]).toBe('2');
- expect(splittedNode[2]).toBe('3');
- expect(text).toBe('123');
- });
-
- it('returns empty array and empty text when input is an empty string', () => {
- const { splittedNode, text } = splitNodeAndExtractText('', 'character');
-
- expect(splittedNode).toHaveLength(0);
- expect(text).toBe('');
- });
-
- it('recursively splits children of a valid React element', () => {
- const { splittedNode, text } = splitNodeAndExtractText(Hello, 'character');
-
- expect(splittedNode).toHaveLength(1);
- expect(text).toBe('Hello');
- });
-
- it('returns element itself with empty text when React element has no children', () => {
- const { splittedNode, text } = splitNodeAndExtractText(, 'character');
-
- expect(splittedNode).toHaveLength(1);
- expect(text).toBe('');
- });
-
- it('splits an array of nodes into combined substrings and concatenated text', () => {
- const { splittedNode, text } = splitNodeAndExtractText(['Hello', ' ', 'World'], 'character');
-
- expect(splittedNode).toHaveLength(11);
- expect(text).toBe('Hello World');
- });
-
- it('returns empty array and empty text when input is null or boolean', () => {
- const { splittedNode, text } = splitNodeAndExtractText(null, 'character');
- const boolResult = splitNodeAndExtractText(true, 'character');
-
- expect(splittedNode).toEqual([]);
- expect(text).toBe('');
-
- expect(boolResult.splittedNode).toEqual([]);
- expect(boolResult.text).toBe('');
- });
-
- it('returns node as-is with empty text when input is an unsupported type', () => {
- const symbolNode = Symbol('test');
- const fnNode = () => 'test';
-
- const symbolResult = splitNodeAndExtractText(symbolNode as any, 'character');
- const fnResult = splitNodeAndExtractText(fnNode as any, 'character');
-
- expect(symbolResult.splittedNode).toEqual([symbolNode]);
- expect(symbolResult.text).toBe('');
-
- expect(fnResult.splittedNode).toEqual([fnNode]);
- expect(fnResult.text).toBe('');
- });
-
- it('splits a string into words and spaces when split type is "word"', () => {
- const { splittedNode, text } = splitNodeAndExtractText('Hello World', 'word');
-
- expect(splittedNode).toHaveLength(3);
- expect(splittedNode[0]).toBe('Hello');
- expect(splittedNode[1]).toBe(' ');
- expect(splittedNode[2]).toBe('World');
- expect(text).toBe('Hello World');
- });
-});
diff --git a/src/utils/splitNodeAndExtractText/splitNodeAndExtractText.ts b/src/utils/splitNodeAndExtractText/splitNodeAndExtractText.ts
deleted file mode 100644
index 90f93f6..0000000
--- a/src/utils/splitNodeAndExtractText/splitNodeAndExtractText.ts
+++ /dev/null
@@ -1,55 +0,0 @@
-import { cloneElement, type ReactNode } from 'react';
-
-import type { Split } from '../../types';
-import { splitText } from '../splitText';
-import { isElementWithChildren, isNullishNode, isTextNode } from '../typeGuards/typeGuards';
-
-/**
- * @description
- * `splitNodeAndExtractText` is a recursive pure function that traverses a React node and its children,
- * splitting nodes into substrings based on the specified split type.
- * It returns an object containing the array of substrings and the extracted text.
- *
- * @param {ReactNode} node - The React node to split.
- * @param {Split} split - The split type for text animations (`character` or `word`).
- *
- * @returns {{ splittedNode: ReactNode[]; text: string }} An object containing the array of substrings and the extracted text.
- */
-export const splitNodeAndExtractText = (node: ReactNode, split: Split): { splittedNode: ReactNode[]; text: string } => {
- if (isNullishNode(node)) {
- return { splittedNode: [], text: '' };
- }
-
- if (isTextNode(node)) {
- const text = String(node);
- return {
- splittedNode: splitText(text, split),
- text,
- };
- }
-
- if (Array.isArray(node)) {
- return node.reduce<{ splittedNode: ReactNode[]; text: string }>(
- (accumulator, child) => {
- const { splittedNode, text } = splitNodeAndExtractText(child, split);
-
- return {
- splittedNode: [...accumulator.splittedNode, ...splittedNode],
- text: accumulator.text + text,
- };
- },
- { splittedNode: [], text: '' }
- );
- }
-
- if (isElementWithChildren(node)) {
- const { splittedNode, text } = splitNodeAndExtractText(node.props.children, split);
-
- return {
- splittedNode: [cloneElement(node, { children: splittedNode })],
- text,
- };
- }
-
- return { splittedNode: [node], text: '' };
-};
diff --git a/src/utils/splitReactNode/index.ts b/src/utils/splitReactNode/index.ts
new file mode 100644
index 0000000..e2ca70d
--- /dev/null
+++ b/src/utils/splitReactNode/index.ts
@@ -0,0 +1 @@
+export { splitReactNode } from './splitReactNode';
diff --git a/src/utils/splitReactNode/splitReactNode.spec.tsx b/src/utils/splitReactNode/splitReactNode.spec.tsx
new file mode 100644
index 0000000..95e6f10
--- /dev/null
+++ b/src/utils/splitReactNode/splitReactNode.spec.tsx
@@ -0,0 +1,85 @@
+import { splitReactNode } from './splitReactNode';
+
+describe('splitReactNode utility', () => {
+ it('splits a string into individual characters when split type is "character"', () => {
+ const { nodes, text } = splitReactNode('Hi', 'character');
+
+ expect(nodes).toHaveLength(2);
+ expect(nodes[0]).toBe('H');
+ expect(nodes[1]).toBe('i');
+ expect(text).toBe('Hi');
+ });
+
+ it('splits a number into individual characters when split type is "character"', () => {
+ const { nodes, text } = splitReactNode(123, 'character');
+
+ expect(nodes).toHaveLength(3);
+ expect(nodes[0]).toBe('1');
+ expect(nodes[1]).toBe('2');
+ expect(nodes[2]).toBe('3');
+ expect(text).toBe('123');
+ });
+
+ it('returns empty array and empty text when input is an empty string', () => {
+ const { nodes, text } = splitReactNode('', 'character');
+
+ expect(nodes).toHaveLength(0);
+ expect(text).toBe('');
+ });
+
+ it('recursively splits children of a valid React element', () => {
+ const { nodes, text } = splitReactNode(Hello, 'character');
+
+ expect(nodes).toHaveLength(1);
+ expect(text).toBe('Hello');
+ });
+
+ it('returns element itself with empty text when React element has no children', () => {
+ const { nodes, text } = splitReactNode(, 'character');
+
+ expect(nodes).toHaveLength(1);
+ expect(text).toBe('');
+ });
+
+ it('splits an array of nodes into combined substrings and concatenated text', () => {
+ const { nodes, text } = splitReactNode(['Hello', ' ', 'World'], 'character');
+
+ expect(nodes).toHaveLength(11);
+ expect(text).toBe('Hello World');
+ });
+
+ it('returns empty array and empty text when input is null or boolean', () => {
+ const { nodes, text } = splitReactNode(null, 'character');
+ const boolResult = splitReactNode(true, 'character');
+
+ expect(nodes).toEqual([]);
+ expect(text).toBe('');
+
+ expect(boolResult.nodes).toEqual([]);
+ expect(boolResult.text).toBe('');
+ });
+
+ it('returns node as-is with empty text when input is an unsupported type', () => {
+ const symbolNode = Symbol('test');
+ const fnNode = () => 'test';
+
+ const symbolResult = splitReactNode(symbolNode as any, 'character');
+ const fnResult = splitReactNode(fnNode as any, 'character');
+
+ expect(symbolResult.nodes).toEqual([symbolNode]);
+ expect(symbolResult.text).toBe('');
+
+ expect(fnResult.nodes).toEqual([fnNode]);
+ expect(fnResult.text).toBe('');
+ });
+
+ it('splits a string into words and spaces when split type is "word"', () => {
+ const { nodes, text } = splitReactNode('Hello World', 'word');
+
+ expect(nodes).toHaveLength(3);
+ expect(nodes[0]).toBe('Hello');
+ expect(nodes[1]).toBe(' ');
+ expect(nodes[2]).toBe('World');
+ expect(text).toBe('Hello World');
+ });
+});
diff --git a/src/utils/splitReactNode/splitReactNode.ts b/src/utils/splitReactNode/splitReactNode.ts
new file mode 100644
index 0000000..0a84518
--- /dev/null
+++ b/src/utils/splitReactNode/splitReactNode.ts
@@ -0,0 +1,62 @@
+import { cloneElement, type ReactNode } from 'react';
+
+import type { Split } from '../../types';
+import { splitText } from '../splitText';
+import { isElementWithChildren, isNonRenderableNode, isTextNode } from '../typeGuards/typeGuards';
+
+type SplitResult = {
+ nodes: ReactNode[];
+ text: string;
+};
+
+/**
+ * @description
+ * `splitReactNode` is a recursive pure function that traverses a React node and its children,
+ * splitting nodes into substrings based on the specified split type.
+ * It returns an object containing the array of substrings and the extracted text.
+ *
+ * @param {ReactNode} node - The React node to split.
+ * @param {Split} split - The split type for text animations (`character` or `word`).
+ *
+ * @returns {SplitResult} An object containing the array of substrings and the extracted text.
+ */
+export const splitReactNode = (node: ReactNode, split: Split): SplitResult => {
+ if (isNonRenderableNode(node)) {
+ return { nodes: [], text: '' };
+ }
+
+ if (isTextNode(node)) {
+ const text = String(node);
+ return {
+ nodes: splitText(text, split),
+ text,
+ };
+ }
+
+ if (Array.isArray(node)) {
+ return node.map(child => splitReactNode(child, split)).reduce(mergeResults, { nodes: [], text: '' });
+ }
+
+ if (isElementWithChildren(node)) {
+ const result = splitReactNode(node.props.children, split);
+
+ return {
+ nodes: [
+ cloneElement(node, {
+ children: result.nodes,
+ }),
+ ],
+ text: result.text,
+ };
+ }
+
+ return {
+ nodes: [node],
+ text: '',
+ };
+};
+
+const mergeResults = (a: SplitResult, b: SplitResult): SplitResult => ({
+ nodes: [...a.nodes, ...b.nodes],
+ text: a.text + b.text,
+});
diff --git a/src/utils/splitText/splitText.spec.ts b/src/utils/splitText/splitText.spec.ts
index c7f3803..bca0815 100644
--- a/src/utils/splitText/splitText.spec.ts
+++ b/src/utils/splitText/splitText.spec.ts
@@ -16,12 +16,11 @@ describe('splitText utility', () => {
});
describe('when using an invalid split type', () => {
- it('should default to splitting by character', () => {
+ it('should return the invalid split type value (exhaustive check fallback)', () => {
const input = 'Hi';
- const expected = ['H', 'i'];
const invalidSplit = 'invalid' as any;
- expect(splitText(input, invalidSplit)).toEqual(expected);
+ expect(splitText(input, invalidSplit)).toEqual('invalid');
});
});
});
diff --git a/src/utils/splitText/splitText.ts b/src/utils/splitText/splitText.ts
index 54e9c8b..6d4fe6d 100644
--- a/src/utils/splitText/splitText.ts
+++ b/src/utils/splitText/splitText.ts
@@ -11,12 +11,18 @@ import type { Split } from '../../types';
*/
export const splitText = (text: string, split: Split): string[] => {
switch (split) {
+ case 'character':
+ return [...text];
+
case 'word':
return text.split(/(\s+)/).filter(Boolean);
+
// case 'line':
// return text.split(/(\n)/).filter(Boolean);
- case 'character':
- default:
- return text.split('');
+
+ default: {
+ const exhaustiveCheck: never = split;
+ return exhaustiveCheck;
+ }
}
};
diff --git a/src/utils/typeGuards/typeGuards.spec.ts b/src/utils/typeGuards/typeGuards.spec.ts
index ecfa9de..7d6c8fa 100644
--- a/src/utils/typeGuards/typeGuards.spec.ts
+++ b/src/utils/typeGuards/typeGuards.spec.ts
@@ -1,6 +1,6 @@
import { createElement } from 'react';
-import { isElementWithChildren, isNullishNode, isTextNode } from './typeGuards';
+import { isElementWithChildren, isNonRenderableNode, isTextNode } from './typeGuards';
describe('typeGuards', () => {
describe('isTextNode', () => {
@@ -35,35 +35,35 @@ describe('typeGuards', () => {
});
});
- describe('isNullishNode', () => {
+ describe('isNonRenderableNode', () => {
it('should return true for null', () => {
- expect(isNullishNode(null)).toBe(true);
+ expect(isNonRenderableNode(null)).toBe(true);
});
it('should return true for undefined', () => {
- expect(isNullishNode(undefined)).toBe(true);
+ expect(isNonRenderableNode(undefined)).toBe(true);
});
it('should return true for boolean', () => {
- expect(isNullishNode(true)).toBe(true);
- expect(isNullishNode(false)).toBe(true);
+ expect(isNonRenderableNode(true)).toBe(true);
+ expect(isNonRenderableNode(false)).toBe(true);
});
it('should return false for string', () => {
- expect(isNullishNode('hello')).toBe(false);
+ expect(isNonRenderableNode('hello')).toBe(false);
});
it('should return false for number', () => {
- expect(isNullishNode(123)).toBe(false);
+ expect(isNonRenderableNode(123)).toBe(false);
});
it('should return false for React elements', () => {
const element = createElement('div', {}, 'hello');
- expect(isNullishNode(element)).toBe(false);
+ expect(isNonRenderableNode(element)).toBe(false);
});
it('should return false for arrays', () => {
- expect(isNullishNode(['hello', 'world'])).toBe(false);
+ expect(isNonRenderableNode(['hello', 'world'])).toBe(false);
});
});
@@ -78,9 +78,9 @@ describe('typeGuards', () => {
expect(isElementWithChildren(element)).toBe(true);
});
- it('should return false for React elements without children prop', () => {
+ it('should return true for React elements without children prop', () => {
const element = createElement('div');
- expect(isElementWithChildren(element)).toBe(false);
+ expect(isElementWithChildren(element)).toBe(true);
});
it('should return false for string', () => {
diff --git a/src/utils/typeGuards/typeGuards.ts b/src/utils/typeGuards/typeGuards.ts
index 6633693..9d80e3a 100644
--- a/src/utils/typeGuards/typeGuards.ts
+++ b/src/utils/typeGuards/typeGuards.ts
@@ -6,6 +6,7 @@ import { isValidElement, type ReactElement, type ReactNode } from 'react';
* It returns `true` if the node is a string or number, otherwise `false`.
*
* @param {ReactNode} node - The React node to check.
+ *
* @returns {boolean} `true` if the node is a string or number, otherwise `false`.
*/
export const isTextNode = (node: ReactNode): node is string | number => {
@@ -14,13 +15,14 @@ export const isTextNode = (node: ReactNode): node is string | number => {
/**
* @description
- * Type guard function that checks if a React node is nullish (null, undefined, or boolean).
+ * Type guard function that checks if a React node is non-renderable (null, undefined, or boolean).
* It returns `true` if the node is null, undefined, or boolean, otherwise `false`.
*
* @param {ReactNode} node - The React node to check.
+ *
* @returns {boolean} `true` if the node is null, undefined, or boolean, otherwise `false`.
*/
-export const isNullishNode = (node: ReactNode): node is null | undefined | boolean => {
+export const isNonRenderableNode = (node: ReactNode): node is null | undefined | boolean => {
return node == null || typeof node === 'boolean';
};
@@ -30,13 +32,9 @@ export const isNullishNode = (node: ReactNode): node is null | undefined | boole
* It returns `true` if the node is a valid React element with children, otherwise `false`.
*
* @param {ReactNode} node - The React node to check.
+ *
* @returns {boolean} `true` if the node is a valid React element with children, otherwise `false`.
*/
export const isElementWithChildren = (node: ReactNode): node is ReactElement<{ children?: ReactNode }> => {
- if (!isValidElement(node)) {
- return false;
- }
-
- const props = node.props as Record | null | undefined;
- return props !== null && props !== undefined && 'children' in props;
+ return isValidElement<{ children?: ReactNode }>(node);
};