diff --git a/src/components/TextMotion/TextMotion.tsx b/src/components/TextMotion/TextMotion.tsx index 89348a0..55e4b8e 100644 --- a/src/components/TextMotion/TextMotion.tsx +++ b/src/components/TextMotion/TextMotion.tsx @@ -91,13 +91,11 @@ export const TextMotion: FC = 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, @@ -110,17 +108,17 @@ export const TextMotion: FC = memo(props => { } }, [shouldAnimate, onAnimationStart]); - if (shouldAnimate) { + if (!shouldAnimate) { return ( - - {animatedChildren} + + {children} ); } return ( - - {children} + + {animatedChildren} ); }); diff --git a/src/hooks/useAnimatedChildren/useAnimatedChildren.tsx b/src/hooks/useAnimatedChildren/useAnimatedChildren.tsx index 77b59ae..f9c3a42 100644 --- a/src/hooks/useAnimatedChildren/useAnimatedChildren.tsx +++ b/src/hooks/useAnimatedChildren/useAnimatedChildren.tsx @@ -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[]; @@ -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. @@ -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 ; + return ; } 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 }; }; diff --git a/src/utils/countNodes/countNodes.spec.tsx b/src/utils/countNodes/countNodes.spec.tsx index f3493ea..9585654 100644 --- a/src/utils/countNodes/countNodes.spec.tsx +++ b/src/utils/countNodes/countNodes.spec.tsx @@ -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(Hello); - expect(countNodes(nodes)).toBe(2); + expect(countNodes(nodes)).toBe(1); }); it('should count multiple element nodes correctly', () => { @@ -34,7 +34,7 @@ describe('countNodes', () => { World ); - expect(countNodes(nodes)).toBe(5); + expect(countNodes(nodes)).toBe(2); }); it('should count nested elements correctly', () => { @@ -45,7 +45,7 @@ describe('countNodes', () => { ); - expect(countNodes(nodes)).toBe(5); + expect(countNodes(nodes)).toBe(2); }); it('should count deeply nested elements correctly', () => { @@ -59,7 +59,7 @@ describe('countNodes', () => { ); - expect(countNodes(nodes)).toBe(8); + expect(countNodes(nodes)).toBe(3); }); it('should handle mixed text and element nodes', () => { @@ -68,7 +68,7 @@ describe('countNodes', () => { StartMiddleEnd ); - expect(countNodes(nodes)).toBe(5); + expect(countNodes(nodes)).toBe(3); }); it('should count nodes within an array of children', () => { @@ -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)', () => { @@ -98,7 +98,7 @@ describe('countNodes', () => { World ); - expect(countNodes(nodes)).toBe(4); + expect(countNodes(nodes)).toBe(2); }); it('should count children of a functional component', () => { @@ -110,7 +110,7 @@ describe('countNodes', () => { ); - expect(countNodes(nodes)).toBe(4); + expect(countNodes(nodes)).toBe(2); }); it('should count nodes for a complex structure', () => { @@ -128,6 +128,6 @@ describe('countNodes', () => { ); - expect(countNodes(nodes)).toBe(11); + expect(countNodes(nodes)).toBe(5); }); }); diff --git a/src/utils/countNodes/countNodes.ts b/src/utils/countNodes/countNodes.ts index 4ed962f..d879328 100644 --- a/src/utils/countNodes/countNodes.ts +++ b/src/utils/countNodes/countNodes.ts @@ -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; @@ -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)); } }); diff --git a/src/utils/sequenceHelpers/index.ts b/src/utils/sequenceHelpers/index.ts new file mode 100644 index 0000000..9a0b62b --- /dev/null +++ b/src/utils/sequenceHelpers/index.ts @@ -0,0 +1 @@ +export * from './sequenceHelpers'; diff --git a/src/utils/sequenceHelpers/sequenceHelpers.spec.ts b/src/utils/sequenceHelpers/sequenceHelpers.spec.ts new file mode 100644 index 0000000..689ee05 --- /dev/null +++ b/src/utils/sequenceHelpers/sequenceHelpers.spec.ts @@ -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); + }); +}); diff --git a/src/utils/sequenceHelpers/sequenceHelpers.ts b/src/utils/sequenceHelpers/sequenceHelpers.ts new file mode 100644 index 0000000..986d7d8 --- /dev/null +++ b/src/utils/sequenceHelpers/sequenceHelpers.ts @@ -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; +}; diff --git a/src/utils/typeGuards/typeGuards.ts b/src/utils/typeGuards/typeGuards.ts index ef99c96..6633693 100644 --- a/src/utils/typeGuards/typeGuards.ts +++ b/src/utils/typeGuards/typeGuards.ts @@ -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'; @@ -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'; @@ -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)) {