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
16 changes: 7 additions & 9 deletions src/components/TextMotion/TextMotion.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -91,13 +91,11 @@ export const TextMotion: FC<TextMotionProps> = memo(props => {
const [targetRef, isIntersecting] = useIntersectionObserver({ repeat });
const shouldAnimate = trigger === 'on-load' || isIntersecting;

const { splittedNode, text } = shouldAnimate
? splitNodeAndExtractText(children, split)
: { splittedNode: [children], text: DEFAULT_ARIA_LABEL };
const { splittedNode, text } = splitNodeAndExtractText(children, split);
const resolvedMotion = useResolvedMotion({ motion, preset });

const animatedChildren = useAnimatedChildren({
splittedNode,
splittedNode: shouldAnimate ? splittedNode : [children],
initialDelay,
animationOrder,
resolvedMotion,
Expand All @@ -110,17 +108,17 @@ export const TextMotion: FC<TextMotionProps> = memo(props => {
}
}, [shouldAnimate, onAnimationStart]);

if (shouldAnimate) {
if (!shouldAnimate) {
return (
<Tag ref={targetRef} className="text-motion" aria-label={text || DEFAULT_ARIA_LABEL} {...rest}>
{animatedChildren}
<Tag ref={targetRef} className="text-motion-inanimate" aria-label={text || DEFAULT_ARIA_LABEL} {...rest}>
{children}
</Tag>
);
}

return (
<Tag ref={targetRef} className="text-motion-inanimate" aria-label={text || DEFAULT_ARIA_LABEL} {...rest}>
{children}
<Tag ref={targetRef} className="text-motion" aria-label={text || DEFAULT_ARIA_LABEL} {...rest}>
{animatedChildren}
</Tag>
);
});
56 changes: 32 additions & 24 deletions src/hooks/useAnimatedChildren/useAnimatedChildren.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@ import { AnimatedSpan } from '../../components/AnimatedSpan';
import type { AnimationOrder, Motion } from '../../types';
import { countNodes } from '../../utils/countNodes';
import { generateAnimation } from '../../utils/generateAnimation';
import { isElementWithChildren, isTextNode } from '../../utils/typeGuards/typeGuards';
import { calculateSequenceIndex, isLastNode } from '../../utils/sequenceHelpers';
import { isElementWithChildren, isTextNode } from '../../utils/typeGuards';

type UseAnimatedChildrenProps = {
splittedNode: ReactNode[];
Expand All @@ -14,6 +15,11 @@ type UseAnimatedChildrenProps = {
onAnimationEnd?: () => void;
};

type WrapResult = {
nodes: ReactNode[];
nextSequenceIndex: number;
};

/**
* @description
* `useAnimatedChildren` is a custom hook that animates an array of React nodes.
Expand All @@ -36,65 +42,67 @@ export const useAnimatedChildren = ({
}: UseAnimatedChildrenProps): ReactNode[] => {
const animatedChildren = useMemo(() => {
const totalNodes = countNodes(splittedNode);
const sequenceIndexRef = { current: 0 };

return wrapWithAnimatedSpan(
const { nodes } = wrapWithAnimatedSpan(
splittedNode,
0,
initialDelay,
animationOrder,
resolvedMotion,
totalNodes,
sequenceIndexRef,
onAnimationEnd
);

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

return animatedChildren;
};

const incrementSequenceIndex = (ref: { current: number }): number => {
const current = ref.current;
ref.current += 1;
return current;
};

export const wrapWithAnimatedSpan = (
const wrapWithAnimatedSpan = (
splittedNode: ReactNode[],
currentSequenceIndex: number,
initialDelay: number,
animationOrder: AnimationOrder,
resolvedMotion: Motion,
totalNodes: number,
sequenceIndexRef: { current: number },
onAnimationEnd?: () => void
): ReactNode[] => {
return splittedNode.map(node => {
const currentIndex = incrementSequenceIndex(sequenceIndexRef);
const sequenceIndex = animationOrder === 'first-to-last' ? currentIndex : totalNodes - currentIndex - 1;

const isLast = sequenceIndex === totalNodes - 1;
const handleAnimationEnd = isLast ? onAnimationEnd : undefined;
): WrapResult => {
let sequenceIndex = currentSequenceIndex;

const nodes = splittedNode.map((node, key) => {
if (isTextNode(node)) {
const { style } = generateAnimation(resolvedMotion, sequenceIndex, initialDelay);
const currentIndex = sequenceIndex++;
const calculatedSequenceIndex = calculateSequenceIndex(currentIndex, totalNodes, animationOrder);
const isLast = isLastNode(calculatedSequenceIndex, totalNodes);
const handleAnimationEnd = isLast ? onAnimationEnd : undefined;
const { style } = generateAnimation(resolvedMotion, calculatedSequenceIndex, initialDelay);

return <AnimatedSpan key={currentIndex} text={String(node)} style={style} onAnimationEnd={handleAnimationEnd} />;
return <AnimatedSpan key={key} text={String(node)} style={style} onAnimationEnd={handleAnimationEnd} />;
}

if (isElementWithChildren(node)) {
const childArray = Children.toArray(node.props.children);
const animatedChildren = wrapWithAnimatedSpan(
const { nodes: animatedChildren, nextSequenceIndex } = wrapWithAnimatedSpan(
childArray,
sequenceIndex,
initialDelay,
animationOrder,
resolvedMotion,
totalNodes,
sequenceIndexRef,
onAnimationEnd
);
sequenceIndex = nextSequenceIndex;

return cloneElement(node, { ...node.props, children: animatedChildren, key: currentIndex });
return cloneElement(node, {
...node.props,
children: animatedChildren,
key,
});
}

return node;
});

return { nodes, nextSequenceIndex: sequenceIndex };
};
26 changes: 13 additions & 13 deletions src/utils/countNodes/countNodes.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,19 +12,19 @@ describe('countNodes', () => {

it('should count multiple text nodes correctly', () => {
const nodes = getNodes(<>Hello World</>);
expect(countNodes(nodes)).toBe(2);
expect(countNodes(nodes)).toBe(1);

const nodesWithSeparators = getNodes(
<>
{'Hello'} {'World'}
</>
);
expect(countNodes(nodesWithSeparators)).toBe(4);
expect(countNodes(nodesWithSeparators)).toBe(3);
});

it('should count a single element node as 1', () => {
const nodes = getNodes(<span>Hello</span>);
expect(countNodes(nodes)).toBe(2);
expect(countNodes(nodes)).toBe(1);
});

it('should count multiple element nodes correctly', () => {
Expand All @@ -34,7 +34,7 @@ describe('countNodes', () => {
<span>World</span>
</>
);
expect(countNodes(nodes)).toBe(5);
expect(countNodes(nodes)).toBe(2);
});

it('should count nested elements correctly', () => {
Expand All @@ -45,7 +45,7 @@ describe('countNodes', () => {
</div>
);

expect(countNodes(nodes)).toBe(5);
expect(countNodes(nodes)).toBe(2);
});

it('should count deeply nested elements correctly', () => {
Expand All @@ -59,7 +59,7 @@ describe('countNodes', () => {
</div>
);

expect(countNodes(nodes)).toBe(8);
expect(countNodes(nodes)).toBe(3);
});

it('should handle mixed text and element nodes', () => {
Expand All @@ -68,7 +68,7 @@ describe('countNodes', () => {
Start<span>Middle</span>End
</>
);
expect(countNodes(nodes)).toBe(5);
expect(countNodes(nodes)).toBe(3);
});

it('should count nodes within an array of children', () => {
Expand All @@ -81,12 +81,12 @@ describe('countNodes', () => {
'D',
]);

expect(countNodes(nodes)).toBe(6);
expect(countNodes(nodes)).toBe(4);
});

it('should return 1 for an empty array', () => {
it('should return 0 for an empty array', () => {
const nodes = getNodes(<></>);
expect(countNodes(nodes)).toBe(1);
expect(countNodes(nodes)).toBe(0);
});

it('should handle null and undefined nodes gracefully (not count them)', () => {
Expand All @@ -98,7 +98,7 @@ describe('countNodes', () => {
<span>World</span>
</>
);
expect(countNodes(nodes)).toBe(4);
expect(countNodes(nodes)).toBe(2);
});

it('should count children of a functional component', () => {
Expand All @@ -110,7 +110,7 @@ describe('countNodes', () => {
</MyComponent>
);

expect(countNodes(nodes)).toBe(4);
expect(countNodes(nodes)).toBe(2);
});

it('should count nodes for a complex structure', () => {
Expand All @@ -128,6 +128,6 @@ describe('countNodes', () => {
</>
);

expect(countNodes(nodes)).toBe(11);
expect(countNodes(nodes)).toBe(5);
});
});
14 changes: 7 additions & 7 deletions src/utils/countNodes/countNodes.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,14 @@
import { Children, type ReactNode } from 'react';

import { isElementWithChildren, isNullishNode } from '../typeGuards';
import { isElementWithChildren, isNullishNode, isTextNode } from '../typeGuards';

/**
* @description
* `countNodes` is a recursive pure function that counts the number of nodes in a React node tree.
* It returns the total number of nodes in the tree.
* `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.
* @returns {number} The total number of nodes in the tree.
* @returns {number} The total number of animated (text) nodes in the tree.
*/
export const countNodes = (nodes: ReactNode[]): number => {
let count = 0;
Expand All @@ -18,9 +18,9 @@ export const countNodes = (nodes: ReactNode[]): number => {
return;
}

count += 1;

if (isElementWithChildren(node)) {
if (isTextNode(node)) {
count += 1;
} else if (isElementWithChildren(node)) {
count += countNodes(Children.toArray(node.props.children));
}
});
Expand Down
1 change: 1 addition & 0 deletions src/utils/sequenceHelpers/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './sequenceHelpers';
26 changes: 26 additions & 0 deletions src/utils/sequenceHelpers/sequenceHelpers.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { calculateSequenceIndex, isLastNode } from './sequenceHelpers';

describe('calculateSequenceIndex', () => {
it('should return the current index for "first-to-last" order', () => {
expect(calculateSequenceIndex(0, 10, 'first-to-last')).toBe(0);
expect(calculateSequenceIndex(5, 10, 'first-to-last')).toBe(5);
expect(calculateSequenceIndex(9, 10, 'first-to-last')).toBe(9);
});

it('should return the reversed index for "last-to-first" order', () => {
expect(calculateSequenceIndex(0, 10, 'last-to-first')).toBe(9);
expect(calculateSequenceIndex(5, 10, 'last-to-first')).toBe(4);
expect(calculateSequenceIndex(9, 10, 'last-to-first')).toBe(0);
});
});

describe('isLastNode', () => {
it('should return true if it is the last node', () => {
expect(isLastNode(9, 10)).toBe(true);
});

it('should return false if it is not the last node', () => {
expect(isLastNode(0, 10)).toBe(false);
expect(isLastNode(8, 10)).toBe(false);
});
});
32 changes: 32 additions & 0 deletions src/utils/sequenceHelpers/sequenceHelpers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import type { AnimationOrder } from '../../types';

/**
* @description
* `calculateSequenceIndex` is a pure function that calculates the sequence index of a node in a sequence.
* It returns the sequence index of the node based on the animation order.
*
* @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 = (
currentIndex: number,
totalNodes: number,
animationOrder: AnimationOrder
): number => {
return animationOrder === 'first-to-last' ? currentIndex : totalNodes - currentIndex - 1;
};

/**
* @description
* `isLastNode` is a pure function that checks if a node is the last node in a sequence.
* It returns `true` if the node is the last node, otherwise `false`.
*
* @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 => {
return sequenceIndex === totalNodes - 1;
};
9 changes: 6 additions & 3 deletions src/utils/typeGuards/typeGuards.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,10 @@ import { isValidElement, type ReactElement, type ReactNode } from 'react';
/**
* @description
* Type guard function that checks if a React node is a text node (string or number).
* 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, false otherwise.
* @returns {boolean} `true` if the node is a string or number, otherwise `false`.
*/
export const isTextNode = (node: ReactNode): node is string | number => {
return typeof node === 'string' || typeof node === 'number';
Expand All @@ -14,9 +15,10 @@ 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).
* 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, false otherwise.
* @returns {boolean} `true` if the node is null, undefined, or boolean, otherwise `false`.
*/
export const isNullishNode = (node: ReactNode): node is null | undefined | boolean => {
return node == null || typeof node === 'boolean';
Expand All @@ -25,9 +27,10 @@ export const isNullishNode = (node: ReactNode): node is null | undefined | boole
/**
* @description
* Type guard function that checks if a React node is a valid React element with children.
* 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, false otherwise.
* @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)) {
Expand Down