From 10c26598dcc71ed3b615501aa9d0808ca521b4be Mon Sep 17 00:00:00 2001 From: Donghyun Lee Date: Thu, 15 Jan 2026 17:19:50 +0900 Subject: [PATCH 1/8] refactor: improve 'typeGuards' utility --- src/utils/typeGuards/typeGuards.spec.ts | 24 ++++++++++++------------ src/utils/typeGuards/typeGuards.ts | 11 +++-------- 2 files changed, 15 insertions(+), 20 deletions(-) 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..6a3e749 100644 --- a/src/utils/typeGuards/typeGuards.ts +++ b/src/utils/typeGuards/typeGuards.ts @@ -14,13 +14,13 @@ 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'; }; @@ -33,10 +33,5 @@ export const isNullishNode = (node: ReactNode): node is null | undefined | boole * @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); }; From 83c7ba7f4808fdbfae4828d4833233c91efc5bc3 Mon Sep 17 00:00:00 2001 From: Donghyun Lee Date: Thu, 15 Jan 2026 17:21:09 +0900 Subject: [PATCH 2/8] refactor: improve 'generateAnimation' utility --- .../generateAnimation/generateAnimation.ts | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) 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 = ( From 7dcfc1984650a6aa866cd69e4fc4179d9117d7a7 Mon Sep 17 00:00:00 2001 From: Donghyun Lee Date: Thu, 15 Jan 2026 17:21:51 +0900 Subject: [PATCH 3/8] refactor: improve 'splitText' utility --- src/utils/splitText/splitText.spec.ts | 3 +-- src/utils/splitText/splitText.ts | 12 +++++++++--- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/src/utils/splitText/splitText.spec.ts b/src/utils/splitText/splitText.spec.ts index c7f3803..96acac6 100644 --- a/src/utils/splitText/splitText.spec.ts +++ b/src/utils/splitText/splitText.spec.ts @@ -18,10 +18,9 @@ describe('splitText utility', () => { describe('when using an invalid split type', () => { it('should default to splitting by character', () => { 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; + } } }; From e4a26c36d07e14bf2e136d7c367a2a55c140aee0 Mon Sep 17 00:00:00 2001 From: Donghyun Lee Date: Thu, 15 Jan 2026 18:02:07 +0900 Subject: [PATCH 4/8] refactor: improve 'splitReactNode' utility --- src/utils/splitNodeAndExtractText/index.ts | 1 - .../splitNodeAndExtractText.spec.tsx | 85 ------------------- .../splitNodeAndExtractText.ts | 55 ------------ src/utils/splitReactNode/index.ts | 1 + .../splitReactNode/splitReactNode.spec.tsx | 85 +++++++++++++++++++ src/utils/splitReactNode/splitReactNode.ts | 62 ++++++++++++++ 6 files changed, 148 insertions(+), 141 deletions(-) delete mode 100644 src/utils/splitNodeAndExtractText/index.ts delete mode 100644 src/utils/splitNodeAndExtractText/splitNodeAndExtractText.spec.tsx delete mode 100644 src/utils/splitNodeAndExtractText/splitNodeAndExtractText.ts create mode 100644 src/utils/splitReactNode/index.ts create mode 100644 src/utils/splitReactNode/splitReactNode.spec.tsx create mode 100644 src/utils/splitReactNode/splitReactNode.ts 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..edf5977 --- /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 + * `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 {{ nodes: ReactNode[]; text: string }} 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, +}); From 82a0b15f431c332292f5f2c4f9a59aec16b74dbc Mon Sep 17 00:00:00 2001 From: Donghyun Lee Date: Thu, 15 Jan 2026 19:06:33 +0900 Subject: [PATCH 5/8] refactor: improve 'countNodes' utility --- src/utils/countNodes/countNodes.spec.tsx | 244 +++++++++++------------ src/utils/countNodes/countNodes.ts | 28 +-- 2 files changed, 138 insertions(+), 134 deletions(-) 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 -

-
-
-            Code More Code
-          
-
- - ); - - 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 +

+
+
+                Code More Code
+              
+
+ + ) + ).toBe(5); + }); }); }); diff --git a/src/utils/countNodes/countNodes.ts b/src/utils/countNodes/countNodes.ts index d879328..af0d9f3 100644 --- a/src/utils/countNodes/countNodes.ts +++ b/src/utils/countNodes/countNodes.ts @@ -1,6 +1,6 @@ import { Children, type ReactNode } from 'react'; -import { isElementWithChildren, isNullishNode, isTextNode } from '../typeGuards'; +import { isElementWithChildren, isNonRenderableNode, isTextNode } from '../typeGuards'; /** * @description @@ -10,19 +10,23 @@ import { isElementWithChildren, isNullishNode, isTextNode } from '../typeGuards' * @param {ReactNode[]} nodes - The array of React nodes 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; From 3fe56007e5894ccbe34999db280d823a36707128 Mon Sep 17 00:00:00 2001 From: Donghyun Lee Date: Thu, 15 Jan 2026 19:07:38 +0900 Subject: [PATCH 6/8] refactor: apply improved utility functions --- .../useAnimatedChildren.spec.tsx | 171 +++++++++++++++--- .../useAnimatedChildren.tsx | 26 +-- .../useTextMotionAnimation.spec.tsx | 16 +- .../useTextMotionAnimation.ts | 6 +- 4 files changed, 174 insertions(+), 45 deletions(-) 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..050dc6f 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 mockSplitNodeAndExtractText = splitReactNode as jest.Mock; const defaultProps: TextMotionProps = { children: 'Hello', @@ -27,14 +27,14 @@ describe('useTextMotionAnimation', () => { mockUseIntersectionObserver.mockReturnValue([null, true]); mockUseResolvedMotion.mockReturnValue({}); mockUseAnimatedChildren.mockReturnValue([]); - mockSplitNodeAndExtractText.mockReturnValue({ splittedNode: ['H', 'e', 'l', 'l', 'o'], text: 'Hello' }); + mockSplitNodeAndExtractText.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'); }); @@ -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' }); + mockSplitNodeAndExtractText.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, From 1887db3acfb3e2a2be725183902e9d355bd05ff0 Mon Sep 17 00:00:00 2001 From: Donghyun Lee Date: Thu, 15 Jan 2026 20:08:49 +0900 Subject: [PATCH 7/8] docs: update JSDoc of utility functions --- src/utils/accessibility/accessibility.ts | 1 + src/utils/countNodes/countNodes.ts | 3 ++- src/utils/sequenceHelpers/sequenceHelpers.ts | 2 ++ src/utils/splitReactNode/splitReactNode.ts | 2 +- src/utils/typeGuards/typeGuards.ts | 3 +++ 5 files changed, 9 insertions(+), 2 deletions(-) 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.ts b/src/utils/countNodes/countNodes.ts index af0d9f3..2c13564 100644 --- a/src/utils/countNodes/countNodes.ts +++ b/src/utils/countNodes/countNodes.ts @@ -7,7 +7,8 @@ import { isElementWithChildren, isNonRenderableNode, isTextNode } from '../typeG * `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 = (node: ReactNode): number => { 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/splitReactNode/splitReactNode.ts b/src/utils/splitReactNode/splitReactNode.ts index edf5977..fffe937 100644 --- a/src/utils/splitReactNode/splitReactNode.ts +++ b/src/utils/splitReactNode/splitReactNode.ts @@ -18,7 +18,7 @@ type SplitResult = { * @param {ReactNode} node - The React node to split. * @param {Split} split - The split type for text animations (`character` or `word`). * - * @returns {{ nodes: ReactNode[]; text: string }} An object containing the array of substrings and the extracted text. + * @returns {SplitResult} An object containing the array of substrings and the extracted text. */ export const splitReactNode = (node: ReactNode, split: Split): SplitResult => { if (isNonRenderableNode(node)) { diff --git a/src/utils/typeGuards/typeGuards.ts b/src/utils/typeGuards/typeGuards.ts index 6a3e749..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 => { @@ -18,6 +19,7 @@ export const isTextNode = (node: ReactNode): node is string | number => { * 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 isNonRenderableNode = (node: ReactNode): node is null | undefined | boolean => { @@ -30,6 +32,7 @@ export const isNonRenderableNode = (node: ReactNode): node is null | undefined | * 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 }> => { From aa018738fa82a089920335df1eac218edc914907 Mon Sep 17 00:00:00 2001 From: Donghyun Lee Date: Thu, 15 Jan 2026 20:49:24 +0900 Subject: [PATCH 8/8] refactor: apply changed naming --- .../useTextMotionAnimation.spec.tsx | 8 ++++---- src/utils/splitReactNode/splitReactNode.ts | 2 +- src/utils/splitText/splitText.spec.ts | 2 +- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/hooks/useTextMotionAnimation/useTextMotionAnimation.spec.tsx b/src/hooks/useTextMotionAnimation/useTextMotionAnimation.spec.tsx index 050dc6f..e53ada7 100644 --- a/src/hooks/useTextMotionAnimation/useTextMotionAnimation.spec.tsx +++ b/src/hooks/useTextMotionAnimation/useTextMotionAnimation.spec.tsx @@ -17,7 +17,7 @@ describe('useTextMotionAnimation', () => { const mockUseIntersectionObserver = useIntersectionObserver as jest.Mock; const mockUseResolvedMotion = useResolvedMotion as jest.Mock; const mockUseAnimatedChildren = useAnimatedChildren as jest.Mock; - const mockSplitNodeAndExtractText = splitReactNode as jest.Mock; + const mockSplitReactNode = splitReactNode as jest.Mock; const defaultProps: TextMotionProps = { children: 'Hello', @@ -27,7 +27,7 @@ describe('useTextMotionAnimation', () => { mockUseIntersectionObserver.mockReturnValue([null, true]); mockUseResolvedMotion.mockReturnValue({}); mockUseAnimatedChildren.mockReturnValue([]); - mockSplitNodeAndExtractText.mockReturnValue({ nodes: ['H', 'e', 'l', 'l', 'o'], text: 'Hello' }); + mockSplitReactNode.mockReturnValue({ nodes: ['H', 'e', 'l', 'l', 'o'], text: 'Hello' }); }); afterEach(() => { @@ -36,7 +36,7 @@ describe('useTextMotionAnimation', () => { 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', () => { @@ -96,7 +96,7 @@ describe('useTextMotionAnimation', () => { it('should return correct values', () => { mockUseIntersectionObserver.mockReturnValue(['ref', true]); - mockSplitNodeAndExtractText.mockReturnValue({ nodes: ['Test'], text: 'Test' }); + mockSplitReactNode.mockReturnValue({ nodes: ['Test'], text: 'Test' }); mockUseAnimatedChildren.mockReturnValue(['Animated Test']); const { result } = renderHook(() => useTextMotionAnimation(defaultProps)); diff --git a/src/utils/splitReactNode/splitReactNode.ts b/src/utils/splitReactNode/splitReactNode.ts index fffe937..0a84518 100644 --- a/src/utils/splitReactNode/splitReactNode.ts +++ b/src/utils/splitReactNode/splitReactNode.ts @@ -11,7 +11,7 @@ type SplitResult = { /** * @description - * `splitNodeAndExtractText` is a recursive pure function that traverses a React node and its children, + * `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. * diff --git a/src/utils/splitText/splitText.spec.ts b/src/utils/splitText/splitText.spec.ts index 96acac6..bca0815 100644 --- a/src/utils/splitText/splitText.spec.ts +++ b/src/utils/splitText/splitText.spec.ts @@ -16,7 +16,7 @@ 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 invalidSplit = 'invalid' as any;