diff --git a/README.md b/README.md index 243e53b..f3146a3 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ From **character-level typing effects** to **complex UI block animations**, it p - **Lightweight & Performant** – minimal footprint, no heavy deps - **Robust & Tested** – 100% test coverage with Jest + React Testing Library -- **Flexible API** – animate by character, word, or line, supporting both plain text and rich React nodes. +- **Flexible API** – animate by character or word, supporting both plain text and rich React nodes. - **Presets & Motion** – use built-in effects or define your property values - **Developer-Friendly** – JSDoc, examples, TypeScript support @@ -44,7 +44,7 @@ Instantly animates `"Hello World!"` with fade + slide. ### `TextMotion` -Animate **plain text strings** or **any React children** (mixed tags, custom components, blocks) with per-character, word, or line animations. +Animate **plain text strings** or **any React children** (mixed tags, custom components, blocks) with per-character or word animations. ```tsx // Animating a plain string @@ -93,19 +93,19 @@ Animate **plain text strings** or **any React children** (mixed tags, custom com ### TextMotion Props -| Prop | Type | Default | Required | Description | -| ------------------ | ------------------------------------ | --------------- | ----------------------- | ---------------------------------------------------------------------- | -| `children` | `ReactNode` | `-` | Yes | Content to animate. Can be a string, a number, or any React element. | -| `as` | `string` | `"span"` | No | HTML tag wrapper | -| `split` | `"character" \| "word" \| "line"` | `"character"` | No | Text split granularity. `line` is only applicable for string children. | -| `trigger` | `"on-load" \| "scroll"` | `"scroll"` | No | When animation starts | -| `repeat` | `boolean` | `true` | No | Repeat entire animation | -| `initialDelay` | `number` | `0` | No | Initial delay before animation starts (in `s`) | -| `animationOrder` | `"first-to-last" \| "last-to-first"` | `first-to-last` | No | Order of the animation sequence | -| `motion` | `Motion` | `-` | Yes (if `preset` unset) | Custom animation config | -| `preset` | `Preset[]` | `-` | Yes (if `motion` unset) | Predefined animation presets | -| `onAnimationStart` | `() => void` | `-` | No | Callback function that is called when the animation starts | -| `onAnimationEnd` | `() => void` | `-` | No | Callback function that is called when the animation ends | +| Prop | Type | Default | Required | Description | +| ------------------ | ------------------------------------ | --------------- | ----------------------- | -------------------------------------------------------------------- | +| `children` | `ReactNode` | `-` | Yes | Content to animate. Can be a string, a number, or any React element. | +| `as` | `string` | `"span"` | No | HTML tag wrapper | +| `split` | `"character" \| "word"` | `"character"` | No | Text split granularity. | +| `trigger` | `"on-load" \| "scroll"` | `"scroll"` | No | When animation starts | +| `repeat` | `boolean` | `true` | No | Repeat entire animation | +| `initialDelay` | `number` | `0` | No | Initial delay before animation starts (in `s`) | +| `animationOrder` | `"first-to-last" \| "last-to-first"` | `first-to-last` | No | Order of the animation sequence | +| `motion` | `Motion` | `-` | Yes (if `preset` unset) | Custom animation config | +| `preset` | `Preset[]` | `-` | Yes (if `motion` unset) | Predefined animation presets | +| `onAnimationStart` | `() => void` | `-` | No | Callback function that is called when the animation starts | +| `onAnimationEnd` | `() => void` | `-` | No | Callback function that is called when the animation ends | ## Presets diff --git a/package.json b/package.json index 0afa190..418a999 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "react-textmotion", - "version": "0.0.9", + "version": "0.0.10", "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 7812f2d..3fe63d8 100644 --- a/src/components/TextMotion/TextMotion.spec.tsx +++ b/src/components/TextMotion/TextMotion.spec.tsx @@ -262,20 +262,20 @@ describe('TextMotion with different split options (component-level via hook mock expect(animatedSpans[3].textContent).toBe('!'); }); - it('should warn when using split="line" with non-string children', () => { - const consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {}); + // it('should warn when using split="line" with non-string children', () => { + // const consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {}); - render( - - Hello World - - ); + // render( + // + // Hello World + // + // ); - expect(consoleWarnSpy).toHaveBeenCalledWith( - 'TextMotion validation warnings:', - expect.arrayContaining(['split="line" is only applicable when children is a string.']) - ); + // expect(consoleWarnSpy).toHaveBeenCalledWith( + // 'TextMotion validation warnings:', + // expect.arrayContaining(['split="line" is only applicable when children is a string.']) + // ); - consoleWarnSpy.mockRestore(); - }); + // consoleWarnSpy.mockRestore(); + // }); }); diff --git a/src/components/TextMotion/TextMotion.tsx b/src/components/TextMotion/TextMotion.tsx index 18feae4..eb6d2ea 100644 --- a/src/components/TextMotion/TextMotion.tsx +++ b/src/components/TextMotion/TextMotion.tsx @@ -1,21 +1,22 @@ import '../../styles/animations.scss'; import '../../styles/motion.scss'; -import { type FC, memo, useEffect } from 'react'; +import { type FC, memo, useEffect, useRef } from 'react'; import { DEFAULT_ARIA_LABEL } from '../../constants'; import { useTextMotionAnimation } from '../../hooks/useTextMotionAnimation'; import { useValidation } from '../../hooks/useValidation'; import type { TextMotionProps } from '../../types'; +import { getAriaLabel } from '../../utils/accessibility'; /** * @description * `TextMotion` is a component that animates its children by applying motion presets or custom motion configurations. - * It can animate text nodes by splitting them into characters, words or lines, and can also animate other React elements. + * It can animate text nodes by splitting them into characters or words, and can also animate other React elements. * * @param {ReactNode} children - The content to animate. Can be a string, a number, or any React element. * @param {ElementType} [as='span'] - The HTML tag to render. Defaults to `span`. - * @param {Split} [split='character'] - Defines how the text is split for animation (`character`, `word`, or `line`). `line` is only applicable for string children. Defaults to `'character'`. + * @param {Split} [split='character'] - Defines how the text is split for animation (`character` or `word`). Defaults to `'character'`. * @param {Trigger} [trigger='scroll'] - Defines when the animation should start. 'on-load' starts the animation immediately. 'scroll' starts the animation only when the component enters the viewport. Defaults to `'scroll'`. * @param {boolean} [repeat=true] - Determines if the animation should repeat every time it enters the viewport. Only applicable when `trigger` is `'scroll'`. Defaults to `true`. * @param {number} [initialDelay=0] - The initial delay before the animation starts, in seconds. Defaults to `0`. @@ -69,28 +70,39 @@ import type { TextMotionProps } from '../../types'; */ export const TextMotion: FC = memo(props => { const { as: Tag = 'span', children, onAnimationStart } = props; - useValidation({ componentName: 'TextMotion', props }); const { shouldAnimate, targetRef, animatedChildren, text } = useTextMotionAnimation(props); + const onAnimationStartRef = useRef(onAnimationStart); + + useEffect(() => { + onAnimationStartRef.current = onAnimationStart; + }, [onAnimationStart]); + useEffect(() => { if (shouldAnimate) { - onAnimationStart?.(); + onAnimationStartRef.current?.(); } - }, [shouldAnimate, onAnimationStart]); + }, [shouldAnimate]); if (!shouldAnimate) { + const ariaProps = getAriaLabel(text || DEFAULT_ARIA_LABEL); + return ( - + {children} ); } - return ( - - {animatedChildren} - - ); + return (() => { + const ariaProps = getAriaLabel(text || DEFAULT_ARIA_LABEL); + + return ( + + {animatedChildren} + + ); + })(); }); diff --git a/src/hooks/useAnimatedChildren/useAnimatedChildren.tsx b/src/hooks/useAnimatedChildren/useAnimatedChildren.tsx index ddcc825..dda1a35 100644 --- a/src/hooks/useAnimatedChildren/useAnimatedChildren.tsx +++ b/src/hooks/useAnimatedChildren/useAnimatedChildren.tsx @@ -1,4 +1,4 @@ -import { Children, cloneElement, type ReactNode, useMemo } from 'react'; +import { Children, cloneElement, type MutableRefObject, type ReactNode, useEffect, useMemo, useRef } from 'react'; import type { AnimationOrder, Motion } from '../../types'; import { countNodes } from '../../utils/countNodes'; @@ -41,6 +41,12 @@ export const useAnimatedChildren = ({ resolvedMotion, onAnimationEnd, }: UseAnimatedChildrenProps): ReactNode[] => { + const onAnimationEndRef = useRef(onAnimationEnd); + + useEffect(() => { + onAnimationEndRef.current = onAnimationEnd; + }, [onAnimationEnd]); + const animatedChildren = useMemo(() => { const totalNodes = countNodes(splittedNode); @@ -51,11 +57,11 @@ export const useAnimatedChildren = ({ animationOrder, resolvedMotion, totalNodes, - onAnimationEnd + onAnimationEndRef ); return nodes; - }, [splittedNode, initialDelay, animationOrder, resolvedMotion, onAnimationEnd]); + }, [splittedNode, initialDelay, animationOrder, resolvedMotion]); return animatedChildren; }; @@ -67,7 +73,7 @@ const wrapWithAnimatedSpan = ( animationOrder: AnimationOrder, resolvedMotion: Motion, totalNodes: number, - onAnimationEnd?: () => void + onAnimationEndRef?: MutableRefObject<(() => void) | undefined> ): WrapResult => { let sequenceIndex = currentSequenceIndex; @@ -76,7 +82,7 @@ const wrapWithAnimatedSpan = ( const currentIndex = sequenceIndex++; const calculatedSequenceIndex = calculateSequenceIndex(currentIndex, totalNodes, animationOrder); const isLast = isLastNode(calculatedSequenceIndex, totalNodes); - const handleAnimationEnd = isLast ? onAnimationEnd : undefined; + const handleAnimationEnd = isLast ? () => onAnimationEndRef?.current?.() : undefined; const { style } = generateAnimation(resolvedMotion, calculatedSequenceIndex, initialDelay); return ; @@ -91,7 +97,7 @@ const wrapWithAnimatedSpan = ( animationOrder, resolvedMotion, totalNodes, - onAnimationEnd + onAnimationEndRef ); sequenceIndex = nextSequenceIndex; diff --git a/src/hooks/useValidation/useValidation.spec.tsx b/src/hooks/useValidation/useValidation.spec.tsx index c5bed43..f87a556 100644 --- a/src/hooks/useValidation/useValidation.spec.tsx +++ b/src/hooks/useValidation/useValidation.spec.tsx @@ -22,7 +22,7 @@ describe('useValidation hook', () => { expect(consoleErrorSpy).toHaveBeenCalledWith( 'TextMotion validation errors:', - expect.arrayContaining(['split prop must be one of: character, word, line']) + expect.arrayContaining(['split prop must be one of: character, word']) ); consoleErrorSpy.mockRestore(); diff --git a/src/hooks/useValidation/validation.spec.tsx b/src/hooks/useValidation/validation.spec.tsx index f0f85ab..ccf8ed0 100644 --- a/src/hooks/useValidation/validation.spec.tsx +++ b/src/hooks/useValidation/validation.spec.tsx @@ -27,18 +27,18 @@ describe('validation utility', () => { expect(warnings).toContain('children prop is empty'); }); - it('should return a warning for "line" split with non-string children', () => { - const props: TextMotionProps = { - children: [
], - split: 'line', - }; - const { warnings } = validateTextMotionProps(props); + // it('should return a warning for "line" split with non-string children', () => { + // const props: TextMotionProps = { + // children: [
], + // split: 'line', + // }; + // const { warnings } = validateTextMotionProps(props); - expect(warnings).toContain('split="line" is only applicable when children is a string.'); - }); + // expect(warnings).toContain('split="line" is only applicable when children is a string.'); + // }); it('should accept valid split values', () => { - (['character', 'word', 'line'] as const).forEach(split => { + (['character', 'word'] as const).forEach(split => { const props: TextMotionProps = { children: 'hello', split }; const { errors } = validateTextMotionProps(props); diff --git a/src/hooks/useValidation/validation.ts b/src/hooks/useValidation/validation.ts index 24719ae..284bb2c 100644 --- a/src/hooks/useValidation/validation.ts +++ b/src/hooks/useValidation/validation.ts @@ -22,9 +22,9 @@ export const validateTextMotionProps = (props: TextMotionProps): ValidationResul warnings.push('children prop is empty'); } - if (props.split === 'line' && typeof props.children !== 'string') { - warnings.push('split="line" is only applicable when children is a string.'); - } + // if (props.split === 'line' && typeof props.children !== 'string') { + // warnings.push('split="line" is only applicable when children is a string.'); + // } const common = validateCommonProps(props); @@ -45,8 +45,8 @@ const validateCommonProps = (props: Partial): ValidationResult const errors: string[] = []; const warnings: string[] = []; - if (props.split !== undefined && !['character', 'word', 'line'].includes(props.split)) { - errors.push('split prop must be one of: character, word, line'); + if (props.split !== undefined && !['character', 'word'].includes(props.split)) { + errors.push('split prop must be one of: character, word'); } if (props.trigger !== undefined && !['on-load', 'scroll'].includes(props.trigger)) { diff --git a/src/types/common.ts b/src/types/common.ts index 95df3b4..84197e6 100644 --- a/src/types/common.ts +++ b/src/types/common.ts @@ -14,9 +14,8 @@ import type { * Defines how text is split for animation. * - `character`: Splits the text into individual characters. * - `word`: Splits the text into words. - * - `line`: Splits the text into lines. */ -export type Split = 'character' | 'word' | 'line'; +export type Split = 'character' | 'word'; /** * @description diff --git a/src/utils/accessibility/accessibility.spec.ts b/src/utils/accessibility/accessibility.spec.ts new file mode 100644 index 0000000..e3e8c91 --- /dev/null +++ b/src/utils/accessibility/accessibility.spec.ts @@ -0,0 +1,11 @@ +import { getAriaLabel } from './accessibility'; + +describe('getAriaLabel', () => { + it('should return aria-label when text is provided', () => { + expect(getAriaLabel('Hello')).toEqual({ 'aria-label': 'Hello' }); + }); + + it('should return empty object when text is empty', () => { + expect(getAriaLabel('')).toEqual({}); + }); +}); diff --git a/src/utils/accessibility/accessibility.ts b/src/utils/accessibility/accessibility.ts new file mode 100644 index 0000000..b7ff8c2 --- /dev/null +++ b/src/utils/accessibility/accessibility.ts @@ -0,0 +1,9 @@ +/** + * 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 } => { + return text.trim() ? { 'aria-label': text } : {}; +}; diff --git a/src/utils/accessibility/index.ts b/src/utils/accessibility/index.ts new file mode 100644 index 0000000..7ac25be --- /dev/null +++ b/src/utils/accessibility/index.ts @@ -0,0 +1 @@ +export * from './accessibility'; diff --git a/src/utils/splitText/splitText.spec.ts b/src/utils/splitText/splitText.spec.ts index 66f1d22..c7f3803 100644 --- a/src/utils/splitText/splitText.spec.ts +++ b/src/utils/splitText/splitText.spec.ts @@ -7,7 +7,7 @@ describe('splitText utility', () => { const testCases: [Split, string, string[]][] = [ ['character', 'Hello', ['H', 'e', 'l', 'l', 'o']], ['word', 'Hello World', ['Hello', ' ', 'World']], - ['line', 'Hello\nWorld', ['Hello', '\n', 'World']], + // ['line', 'Hello\nWorld', ['Hello', '\n', 'World']], ]; it.each(testCases)('should split the text by %s', (split, input, expected) => { diff --git a/src/utils/splitText/splitText.ts b/src/utils/splitText/splitText.ts index 46d7b84..54e9c8b 100644 --- a/src/utils/splitText/splitText.ts +++ b/src/utils/splitText/splitText.ts @@ -5,7 +5,7 @@ import type { Split } from '../../types'; * `splitText` is a utility function that splits a string into an array of substrings based on the specified split type. * * @param {string} text - The text to be split. - * @param {Split} split - The split type (`character`, `word`, or `line`). + * @param {Split} split - The split type (`character` or `word`). * * @returns {string[]} An array of substrings. */ @@ -13,8 +13,8 @@ export const splitText = (text: string, split: Split): string[] => { switch (split) { case 'word': return text.split(/(\s+)/).filter(Boolean); - case 'line': - return text.split(/(\n)/).filter(Boolean); + // case 'line': + // return text.split(/(\n)/).filter(Boolean); case 'character': default: return text.split('');