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
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
81 changes: 30 additions & 51 deletions src/components/TextMotion/TextMotion.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<ReturnType<typeof useTextMotionAnimation.useTextMotionAnimation>>;
hookReturn?: Partial<ReturnType<typeof useController.useController>>;
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 : '',
Expand All @@ -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(<TextMotion trigger="scroll">{TEXT}</TextMotion>);

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', () => {
Expand All @@ -57,19 +54,17 @@ describe('TextMotion component', () => {
</TextMotion>
);

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) => (
<span key={i} aria-hidden="true">
{ch}
</span>
));

render(<MockTextMotion hookReturn={{ shouldAnimate: true, text: TEXT, animatedChildren }}>{TEXT}</MockTextMotion>);
render(<MockTextMotion hookReturn={{ canAnimate: true, text: TEXT, animatedChildren }}>{TEXT}</MockTextMotion>);

const container = screen.getByLabelText(TEXT);
const spans = container.querySelectorAll<HTMLSpanElement>('span[aria-hidden="true"]');
Expand All @@ -78,31 +73,15 @@ describe('TextMotion component', () => {
expect(container).toHaveClass('text-motion');
});

it('renders plain text when shouldAnimate is false', () => {
render(<MockTextMotion hookReturn={{ shouldAnimate: false, text: TEXT }}>{TEXT}</MockTextMotion>);
it('renders plain text when canAnimate is false', () => {
render(<MockTextMotion hookReturn={{ canAnimate: false, text: TEXT }}>{TEXT}</MockTextMotion>);

const container = screen.getByText(TEXT);
const spans = container.querySelectorAll<HTMLSpanElement>('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) => (
<span key={i} aria-hidden="true">
{ch}
</span>
));

render(<MockTextMotion hookReturn={{ shouldAnimate: true, text: TEXT, animatedChildren }}>{TEXT}</MockTextMotion>);

const container = screen.getByLabelText(TEXT);
const spans = container.querySelectorAll<HTMLSpanElement>('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', () => {
Expand All @@ -116,7 +95,7 @@ describe('TextMotion component', () => {
});

it('uses DEFAULT_ARIA_LABEL when text is empty while animating', () => {
render(<MockTextMotion hookReturn={{ shouldAnimate: true, text: '', animatedChildren: [] }}>{''}</MockTextMotion>);
render(<MockTextMotion hookReturn={{ canAnimate: true, text: '', animatedChildren: [] }}>{''}</MockTextMotion>);

const container = screen.getByLabelText(DEFAULT_ARIA_LABEL);
const spans = container.querySelectorAll<HTMLSpanElement>('span[aria-hidden="true"]');
Expand All @@ -125,50 +104,50 @@ describe('TextMotion component', () => {
});

it('explicitly verifies aria-label when animating with empty text', () => {
render(<MockTextMotion hookReturn={{ shouldAnimate: true, text: '', animatedChildren: [] }}>{''}</MockTextMotion>);
render(<MockTextMotion hookReturn={{ canAnimate: true, text: '', animatedChildren: [] }}>{''}</MockTextMotion>);
const animatedContainer = screen.getByLabelText(DEFAULT_ARIA_LABEL);

expect(animatedContainer).toBeInTheDocument();
expect(animatedContainer).toHaveClass('text-motion');
});

it('uses DEFAULT_ARIA_LABEL when not animating and text is falsy', () => {
render(<MockTextMotion hookReturn={{ shouldAnimate: false, text: '' }}>{null}</MockTextMotion>);
render(<MockTextMotion hookReturn={{ canAnimate: false, text: '' }}>{null}</MockTextMotion>);

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(
<MockTextMotion hookReturn={{ shouldAnimate: true, text: TEXT }} onAnimationStart={onAnimationStart}>
<MockTextMotion hookReturn={{ canAnimate: true, text: TEXT }} onAnimationStart={onAnimationStart}>
{TEXT}
</MockTextMotion>
);

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(
<MockTextMotion hookReturn={{ shouldAnimate: false, text: TEXT }} onAnimationStart={onAnimationStart}>
<MockTextMotion hookReturn={{ canAnimate: false, text: TEXT }} onAnimationStart={onAnimationStart}>
{TEXT}
</MockTextMotion>
);

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(
<MockTextMotion hookReturn={{ shouldAnimate: true, text: TEXT }} onAnimationStart={onAnimationStart}>
<MockTextMotion hookReturn={{ canAnimate: true, text: TEXT }} onAnimationStart={onAnimationStart}>
{TEXT}
</MockTextMotion>
);
Expand All @@ -185,7 +164,7 @@ describe('TextMotion with different split options (component-level via hook mock
</span>
));

render(<MockTextMotion hookReturn={{ shouldAnimate: true, text: 'Hi', animatedChildren }}>Hi</MockTextMotion>);
render(<MockTextMotion hookReturn={{ canAnimate: true, text: 'Hi', animatedChildren }}>Hi</MockTextMotion>);

const container = screen.getByLabelText('Hi');
const spans = container.querySelectorAll<HTMLSpanElement>('span[aria-hidden="true"]');
Expand All @@ -203,7 +182,7 @@ describe('TextMotion with different split options (component-level via hook mock
));

render(
<MockTextMotion hookReturn={{ shouldAnimate: true, text: 'Hello World', animatedChildren }}>
<MockTextMotion hookReturn={{ canAnimate: true, text: 'Hello World', animatedChildren }}>
Hello World
</MockTextMotion>
);
Expand Down Expand Up @@ -247,7 +226,7 @@ describe('TextMotion with different split options (component-level via hook mock
));

render(
<MockTextMotion hookReturn={{ shouldAnimate: true, text: 'Hello World!', animatedChildren }}>
<MockTextMotion hookReturn={{ canAnimate: true, text: 'Hello World!', animatedChildren }}>
Hello <strong>World</strong>!
</MockTextMotion>
);
Expand Down
45 changes: 22 additions & 23 deletions src/components/TextMotion/TextMotion.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -69,40 +69,39 @@ import { getAriaLabel } from '../../utils/accessibility';
* }
*/
export const TextMotion: FC<TextMotionProps> = 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 (
<Tag ref={targetRef} className="text-motion-inanimate" {...ariaProps}>
<Component ref={targetRef} className="text-motion-inactive" {...ariaProps}>
{children}
</Tag>
</Component>
);
}

return (() => {
const ariaProps = getAriaLabel(text || DEFAULT_ARIA_LABEL);

return (
<Tag ref={targetRef} className="text-motion" {...ariaProps}>
{animatedChildren}
</Tag>
);
})();
return (
<Component ref={targetRef} className="text-motion" {...ariaProps}>
{animatedChildren}
</Component>
);
});

TextMotion.displayName = 'TextMotion';
Original file line number Diff line number Diff line change
Expand Up @@ -31,11 +31,11 @@ describe('AnimatedSpan component', () => {
expect(span).toHaveStyle({ animation: 'fade-in 1s ease-out 0.5s both' });
});

it('renders <br> when text is newline character', () => {
const { container } = renderSpan('\n');
// it('renders <br> 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('');
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,9 +21,9 @@ type Props = {
export const AnimatedSpan: FC<Props> = ({ text, style, onAnimationEnd }) => {
const handleAnimationEnd = useAnimationEndCallback(onAnimationEnd);

if (text === '\n') {
return <br />;
}
// if (text === '\n') {
// return <br />;
// }

return (
<span style={style} aria-hidden="true" onAnimationEnd={handleAnimationEnd}>
Expand Down
2 changes: 2 additions & 0 deletions src/hooks/useAnimateChildren/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export { AnimatedSpan } from './AnimatedSpan';
export { useAnimateChildren } from './useAnimateChildren';
Loading