diff --git a/package.json b/package.json index 418a999..f38abd3 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "react-textmotion", - "version": "0.0.10", + "version": "0.0.11", "description": "Lightweight yet powerful library that provides variable animation effects for React applications.", "type": "module", "main": "dist/index.cjs.js", diff --git a/src/components/TextMotion/TextMotion.spec.tsx b/src/components/TextMotion/TextMotion.spec.tsx index 3fe63d8..b26d089 100644 --- a/src/components/TextMotion/TextMotion.spec.tsx +++ b/src/components/TextMotion/TextMotion.spec.tsx @@ -2,27 +2,26 @@ import { type FC, type ReactNode } from 'react'; import { render, screen } from '@testing-library/react'; import { DEFAULT_ARIA_LABEL } from '../../constants'; -import * as useTextMotionAnimation from '../../hooks/useTextMotionAnimation'; +import * as useController from '../../hooks/useController'; import { TextMotion } from './TextMotion'; -jest.mock('../../hooks/useTextMotionAnimation', () => ({ - useTextMotionAnimation: jest.fn(() => ({ - shouldAnimate: false, +jest.mock('../../hooks/useController', () => ({ + useController: jest.fn(() => ({ + canAnimate: false, targetRef: { current: null }, animatedChildren: [], text: '', })), })); -// Helper to drive component scenarios by mocking the hook return const MockTextMotion: FC<{ children: ReactNode; - hookReturn?: Partial>; + hookReturn?: Partial>; onAnimationStart?: () => void; }> = ({ children, hookReturn, onAnimationStart }) => { - (useTextMotionAnimation.useTextMotionAnimation as unknown as jest.Mock).mockReturnValueOnce({ - shouldAnimate: false, + (useController.useController as unknown as jest.Mock).mockReturnValueOnce({ + canAnimate: false, targetRef: { current: null }, animatedChildren: [], text: typeof children === 'string' ? children : '', @@ -35,19 +34,17 @@ const MockTextMotion: FC<{ describe('TextMotion component', () => { const TEXT = 'Hello'; const consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {}); - const useTextMotionAnimationSpy = jest.spyOn(useTextMotionAnimation, 'useTextMotionAnimation'); + const useControllerSpy = jest.spyOn(useController, 'useController'); beforeEach(() => { consoleWarnSpy.mockClear(); - useTextMotionAnimationSpy.mockClear(); + useControllerSpy.mockClear(); }); - it('should call useTextMotionAnimation with default trigger and repeat', () => { + it('should call useController with default trigger and repeat', () => { render({TEXT}); - expect(useTextMotionAnimationSpy).toHaveBeenCalledWith( - expect.objectContaining({ children: 'Hello', trigger: 'scroll' }) - ); + expect(useControllerSpy).toHaveBeenCalledWith(expect.objectContaining({ children: 'Hello', trigger: 'scroll' })); }); it('should respect the repeat prop when provided', () => { @@ -57,19 +54,17 @@ describe('TextMotion component', () => { ); - expect(useTextMotionAnimationSpy).toHaveBeenCalledWith( - expect.objectContaining({ trigger: 'scroll', repeat: false }) - ); + expect(useControllerSpy).toHaveBeenCalledWith(expect.objectContaining({ trigger: 'scroll', repeat: false })); }); - it('renders spans when shouldAnimate is true (e.g., trigger="on-load")', () => { + it('renders spans when canAnimate is true', () => { const animatedChildren = Array.from(TEXT).map((ch, i) => ( )); - render({TEXT}); + render({TEXT}); const container = screen.getByLabelText(TEXT); const spans = container.querySelectorAll('span[aria-hidden="true"]'); @@ -78,31 +73,15 @@ describe('TextMotion component', () => { expect(container).toHaveClass('text-motion'); }); - it('renders plain text when shouldAnimate is false', () => { - render({TEXT}); + it('renders plain text when canAnimate is false', () => { + render({TEXT}); const container = screen.getByText(TEXT); const spans = container.querySelectorAll('span[aria-hidden="true"]'); expect(container.textContent).toBe(TEXT); expect(spans.length).toBe(0); - expect(container).toHaveClass('text-motion-inanimate'); - }); - - it('renders spans when shouldAnimate is true', () => { - const animatedChildren = Array.from(TEXT).map((ch, i) => ( - - )); - - render({TEXT}); - - const container = screen.getByLabelText(TEXT); - const spans = container.querySelectorAll('span[aria-hidden="true"]'); - - expect(spans.length).toBe(TEXT.length); - expect(container).toHaveClass('text-motion'); + expect(container).toHaveClass('text-motion-inactive'); }); it('warns when children is empty null/undefined', () => { @@ -116,7 +95,7 @@ describe('TextMotion component', () => { }); it('uses DEFAULT_ARIA_LABEL when text is empty while animating', () => { - render({''}); + render({''}); const container = screen.getByLabelText(DEFAULT_ARIA_LABEL); const spans = container.querySelectorAll('span[aria-hidden="true"]'); @@ -125,7 +104,7 @@ describe('TextMotion component', () => { }); it('explicitly verifies aria-label when animating with empty text', () => { - render({''}); + render({''}); const animatedContainer = screen.getByLabelText(DEFAULT_ARIA_LABEL); expect(animatedContainer).toBeInTheDocument(); @@ -133,18 +112,18 @@ describe('TextMotion component', () => { }); it('uses DEFAULT_ARIA_LABEL when not animating and text is falsy', () => { - render({null}); + render({null}); const container = screen.getByLabelText(DEFAULT_ARIA_LABEL); expect(container).toBeInTheDocument(); - expect(container).toHaveClass('text-motion-inanimate'); + expect(container).toHaveClass('text-motion-inactive'); }); - it('calls onAnimationStart when shouldAnimate is true', () => { + it('calls onAnimationStart when canAnimate is true', () => { const onAnimationStart = jest.fn(); render( - + {TEXT} ); @@ -152,11 +131,11 @@ describe('TextMotion component', () => { expect(onAnimationStart).toHaveBeenCalledTimes(1); }); - it('does not call onAnimationStart when shouldAnimate is false', () => { + it('does not call onAnimationStart when canAnimate is false', () => { const onAnimationStart = jest.fn(); render( - + {TEXT} ); @@ -164,11 +143,11 @@ describe('TextMotion component', () => { expect(onAnimationStart).not.toHaveBeenCalled(); }); - it('calls onAnimationStart when shouldAnimate is true (e.g., intersecting)', () => { + it('calls onAnimationStart when canAnimate is true (e.g., intersecting)', () => { const onAnimationStart = jest.fn(); render( - + {TEXT} ); @@ -185,7 +164,7 @@ describe('TextMotion with different split options (component-level via hook mock )); - render(Hi); + render(Hi); const container = screen.getByLabelText('Hi'); const spans = container.querySelectorAll('span[aria-hidden="true"]'); @@ -203,7 +182,7 @@ describe('TextMotion with different split options (component-level via hook mock )); render( - + Hello World ); @@ -247,7 +226,7 @@ describe('TextMotion with different split options (component-level via hook mock )); render( - + Hello World! ); diff --git a/src/components/TextMotion/TextMotion.tsx b/src/components/TextMotion/TextMotion.tsx index eb6d2ea..d5b23ea 100644 --- a/src/components/TextMotion/TextMotion.tsx +++ b/src/components/TextMotion/TextMotion.tsx @@ -4,7 +4,7 @@ import '../../styles/motion.scss'; import { type FC, memo, useEffect, useRef } from 'react'; import { DEFAULT_ARIA_LABEL } from '../../constants'; -import { useTextMotionAnimation } from '../../hooks/useTextMotionAnimation'; +import { useController } from '../../hooks/useController'; import { useValidation } from '../../hooks/useValidation'; import type { TextMotionProps } from '../../types'; import { getAriaLabel } from '../../utils/accessibility'; @@ -69,40 +69,39 @@ import { getAriaLabel } from '../../utils/accessibility'; * } */ export const TextMotion: FC = memo(props => { - const { as: Tag = 'span', children, onAnimationStart } = props; - useValidation({ componentName: 'TextMotion', props }); + const { as: Component = 'span', children, onAnimationStart } = props; - const { shouldAnimate, targetRef, animatedChildren, text } = useTextMotionAnimation(props); + useValidation(props); - const onAnimationStartRef = useRef(onAnimationStart); + const { canAnimate, targetRef, animatedChildren, text } = useController(props); + + const animationStartCallbackRef = useRef(onAnimationStart); useEffect(() => { - onAnimationStartRef.current = onAnimationStart; + animationStartCallbackRef.current = onAnimationStart; }, [onAnimationStart]); + // Trigger animation start callback when animation becomes active useEffect(() => { - if (shouldAnimate) { - onAnimationStartRef.current?.(); - } - }, [shouldAnimate]); + if (!canAnimate) return; + animationStartCallbackRef.current?.(); + }, [canAnimate]); - if (!shouldAnimate) { - const ariaProps = getAriaLabel(text || DEFAULT_ARIA_LABEL); + const ariaProps = getAriaLabel(text || DEFAULT_ARIA_LABEL); + if (!canAnimate) { return ( - + {children} - + ); } - return (() => { - const ariaProps = getAriaLabel(text || DEFAULT_ARIA_LABEL); - - return ( - - {animatedChildren} - - ); - })(); + return ( + + {animatedChildren} + + ); }); + +TextMotion.displayName = 'TextMotion'; diff --git a/src/hooks/useAnimatedChildren/AnimatedSpan.spec.tsx b/src/hooks/useAnimateChildren/AnimatedSpan.spec.tsx similarity index 93% rename from src/hooks/useAnimatedChildren/AnimatedSpan.spec.tsx rename to src/hooks/useAnimateChildren/AnimatedSpan.spec.tsx index ba65816..f5a5180 100644 --- a/src/hooks/useAnimatedChildren/AnimatedSpan.spec.tsx +++ b/src/hooks/useAnimateChildren/AnimatedSpan.spec.tsx @@ -31,11 +31,11 @@ describe('AnimatedSpan component', () => { expect(span).toHaveStyle({ animation: 'fade-in 1s ease-out 0.5s both' }); }); - it('renders
when text is newline character', () => { - const { container } = renderSpan('\n'); + // it('renders
when text is newline character', () => { + // const { container } = renderSpan('\n'); - expect(container.querySelector('br')).toBeInTheDocument(); - }); + // expect(container.querySelector('br')).toBeInTheDocument(); + // }); it('renders empty span when text is empty string', () => { const { span } = renderSpan(''); diff --git a/src/hooks/useAnimatedChildren/AnimatedSpan.tsx b/src/hooks/useAnimateChildren/AnimatedSpan.tsx similarity index 94% rename from src/hooks/useAnimatedChildren/AnimatedSpan.tsx rename to src/hooks/useAnimateChildren/AnimatedSpan.tsx index 098b1a2..c7f0402 100644 --- a/src/hooks/useAnimatedChildren/AnimatedSpan.tsx +++ b/src/hooks/useAnimateChildren/AnimatedSpan.tsx @@ -21,9 +21,9 @@ type Props = { export const AnimatedSpan: FC = ({ text, style, onAnimationEnd }) => { const handleAnimationEnd = useAnimationEndCallback(onAnimationEnd); - if (text === '\n') { - return
; - } + // if (text === '\n') { + // return
; + // } return (