diff --git a/.babelrc b/.babelrc deleted file mode 100644 index 01ddf8f..0000000 --- a/.babelrc +++ /dev/null @@ -1,4 +0,0 @@ -{ - "plugins": ["transform-react-jsx"], - "presets": ["stage-2"] -} diff --git a/.gitignore b/.gitignore index 40b878d..f830dd8 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,5 @@ -node_modules/ \ No newline at end of file +node_modules/ +.idea/ +package-lock.json +.DS_Store +dist/ diff --git a/README.md b/README.md index 77df975..dbd4c80 100644 --- a/README.md +++ b/README.md @@ -1,21 +1,26 @@ # React Native Animated Ellipsis -A simple, customizable animated dots component ideal for loading screens in React Native apps (forked from adorableio/react-native-animated-ellipsis - not maintained). + +A simple, customizable animated dots component ideal for loading screens in +React Native apps (forked from Thanhal-P-A/rn-animated-ellipsis - not maintained). ![Kinda like iOS](https://raw.githubusercontent.com/wiki/adorableio/react-native-animated-ellipsis/images/example_ios_ish.gif) +## Supported Versions +- **React:** >=18.0.0 +- **React Native:** >=0.70.0 ## Installation using npm ```shell -npm install rn-animated-ellipsis +npm install @reagankm/rn-animated-ellipsis ``` or using yarn ```shell -yarn add rn-animated-ellipsis +yarn add @reagankm/rn-animated-ellipsis ``` ## Importing ```js -import AnimatedEllipsis from 'rn-animated-ellipsis'; +import AnimatedEllipsis from '@reagankm/rn-animated-ellipsis'; ``` ## Usage @@ -90,3 +95,11 @@ Customize the number of dots, animation speed, and style using these props: }} /> ``` + +This is a fork of [rn-animated-ellipsis](https://github.com/Thanhal-P-A/rn-animated-ellipsis) by +[Thanhal-P-A](https://github.com/Thanhal-P-A), which is a fork of +[react-native-animated-ellipsis](https://github.com/adorableio/react-native-animated-ellipsis) +by [adorableio](https://github.com/adorableio). + +This fork includes TypeScript types and additional updates. + diff --git a/babel.config.js b/babel.config.js new file mode 100644 index 0000000..0c81577 --- /dev/null +++ b/babel.config.js @@ -0,0 +1,6 @@ +module.exports = { + presets: ["module:metro-react-native-babel-preset"], + plugins: [ + ["@babel/plugin-transform-private-methods", { loose: true }], + ] +}; diff --git a/index.js b/index.js deleted file mode 100644 index 9d3907d..0000000 --- a/index.js +++ /dev/null @@ -1,92 +0,0 @@ -import React, { Component } from 'react'; -import { Animated, View, StyleSheet } from 'react-native'; -import { TextPropTypes } from 'deprecated-react-native-prop-types'; -import PropTypes from 'prop-types'; - - -export default class AnimatedEllipsis extends Component { - static propTypes = { - numberOfDots: PropTypes.number, - animationDelay: PropTypes.number, - minOpacity: PropTypes.number, - style: TextPropTypes.style, - useNativeDriver: PropTypes.bool - }; - - static defaultProps = { - numberOfDots: 3, - animationDelay: 300, - minOpacity: 0, - style: { - color: '#aaa', - fontSize: 32, - }, - useNativeDriver: true - }; - - constructor(props) { - super(props); - - this._animation_state = { - dot_opacities: this.initializeDots(), - target_opacity: 1, - should_animate: true, - }; - } - - initializeDots() { - let opacities = []; - - for (let i = 0; i < this.props.numberOfDots; i++) { - let dot = new Animated.Value(this.props.minOpacity); - opacities.push(dot); - } - - return opacities; - } - - componentDidMount() { - this.animate_dots.bind(this)(0); - } - - componentWillUnmount() { - this._animation_state.should_animate = false; - } - - animate_dots(which_dot) { - if (!this._animation_state.should_animate) return; - - // swap fade direction when we hit end of list - if (which_dot >= this._animation_state.dot_opacities.length) { - which_dot = 0; - let min = this.props.minOpacity; - this._animation_state.target_opacity = - this._animation_state.target_opacity == min ? 1 : min; - } - - let next_dot = which_dot + 1; - - Animated.timing(this._animation_state.dot_opacities[which_dot], { - toValue: this._animation_state.target_opacity, - duration: this.props.animationDelay, - useNativeDriver: this.props.useNativeDriver, - }).start(this.animate_dots.bind(this, next_dot)); - } - - render() { - let dots = this._animation_state.dot_opacities.map((o, i) => - - {' '} - . - - ); - - return {dots} - } -} - -const styles = StyleSheet.create({ - container: { - flexDirection: 'row' - } -}); \ No newline at end of file diff --git a/jest.config.js b/jest.config.js new file mode 100644 index 0000000..fec72b2 --- /dev/null +++ b/jest.config.js @@ -0,0 +1,22 @@ +// module.exports = { +// preset: 'react-native', +// setupFilesAfterEnv: ['/jest.setup.js'], +// transformIgnorePatterns: [ +// 'node_modules/(?!(react-native|@react-native|@react-native-community|@testing-library)/)', +// ], +// transform: { +// '^.+\\.(js|jsx|ts|tsx)$': 'babel-jest', +// }, +// moduleFileExtensions: ['js', 'jsx', 'ts', 'tsx'], +// }; + +module.exports = { + preset: 'react-native', + setupFilesAfterEnv: ['/jest.setup.js'], + transformIgnorePatterns: [ + 'node_modules/(?!(react-native|@react-native|@react-native-community|@testing-library)/)', + ], + transform: { + '^.+\\.(js|jsx|ts|tsx)$': ['babel-jest', { configFile: './babel.config.js' }] + } +}; diff --git a/jest.setup.js b/jest.setup.js new file mode 100644 index 0000000..827c65d --- /dev/null +++ b/jest.setup.js @@ -0,0 +1,9 @@ +import '@testing-library/jest-native/extend-expect'; + +jest.mock('react-native/Libraries/Animated/NativeAnimatedHelper'); + +jest.useFakeTimers(); +afterEach(() => { + jest.clearAllMocks(); + jest.clearAllTimers(); +}); diff --git a/package.json b/package.json index 7ae95d8..8dc3db5 100644 --- a/package.json +++ b/package.json @@ -1,27 +1,99 @@ { - "name": "rn-animated-ellipsis", - "version": "2.1.3", - "description": "A simple, customizable animated dots component ideal for loading screens in React Native apps.", - "main": "index.js", + "name": "@reagankm/rn-animated-ellipsis", + "version": "3.0.0", + "description": "An updated version of rn-animated-ellipsis, a simple, customizable animated dots component ideal for loading screens in React Native apps.", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", "author": { - "name": "Thanhal P A", - "email": "thanhalpa@gmail.com" + "name": "Reagan Middlebrook", + "email": "reagankm@gmail.com" + }, + "publishConfig": { + "registry": "https://registry.npmjs.org/" }, "repository": { "type": "git", - "url": "git+https://github.com/thanhal-p-a/rn-animated-ellipsis.git" + "url": "git+https://github.com/reagankm/rn-animated-ellipsis.git" + }, + "bugs": { + "url": "https://github.com/reagankm/rn-animated-ellipsis/issues" }, - "homepage": "https://github.com/thanhal-p-a/rn-animated-ellipsis", + "homepage": "https://github.com/reagankm/rn-animated-ellipsis", "license": "MIT", "licenseFilename": "LICENSE", "readmeFilename": "README.md", - "dependencies": { - "prop-types": "^15.5.10", - "babel-preset-stage-2": "^6.24.1", - "deprecated-react-native-prop-types": "^2.3.0" - }, "devDependencies": { - "babel-cli": "^6.24.1", - "babel-plugin-transform-react-jsx": "^6.24.1" - } + "@babel/core": "^7.26.0", + "@babel/plugin-transform-private-methods": "^7.25.9", + "@babel/runtime": "^7.26.0", + "@react-native/eslint-config": "^0.73.1", + "@testing-library/jest-native": "^5.4.3", + "@testing-library/react-native": "^12.9.0", + "@types/jest": "^29.5.14", + "@types/react": "^18.0.0", + "@types/react-native": "^0.70.0", + "eslint": "^8.51.0", + "eslint-config-prettier": "^9.0.0", + "eslint-plugin-prettier": "^5.0.1", + "jest": "^29.7.0", + "metro-react-native-babel-preset": "^0.77.0", + "prettier": "^3.0.3", + "react-test-renderer": "^18.3.1", + "tsup": "^8.3.5", + "typescript": "^4.9.5" + }, + "peerDependencies": { + "react": "*", + "react-native": "*" + }, + "scripts": { + "build": "tsup src/index.ts --format cjs,esm --dts", + "test": "jest", + "typecheck": "tsc --noEmit", + "lint": "eslint \"**/*.{js,ts,tsx}\"", + "postbuild": "rm -f dist/*.mts" + }, + "eslintConfig": { + "root": true, + "extends": [ + "@react-native", + "prettier" + ], + "rules": { + "react/react-in-jsx-scope": "off", + "prettier/prettier": [ + "error", + { + "quoteProps": "consistent", + "singleQuote": true, + "tabWidth": 4, + "trailingComma": "es5", + "useTabs": false + } + ] + } + }, + "eslintIgnore": [ + "node_modules/" + ], + "prettier": { + "quoteProps": "consistent", + "singleQuote": true, + "tabWidth": 4, + "trailingComma": "es5", + "useTabs": false + }, + "keywords": [ + "react-native", + "animated ellipsis", + "rn-animated-ellipsis", + "react-native-animated-ellipsis", + "loading animation" + ], + "files": [ + "dist", + "README.md", + "LICENSE" + ], + "readme": "README.md" } diff --git a/src/AnimatedEllipsis.tsx b/src/AnimatedEllipsis.tsx new file mode 100644 index 0000000..da814d7 --- /dev/null +++ b/src/AnimatedEllipsis.tsx @@ -0,0 +1,111 @@ +import React, { type PropsWithChildren, useEffect } from 'react'; +import { + Animated, + type StyleProp, + StyleSheet, + type TextStyle, + View, +} from 'react-native'; + +type Props = PropsWithChildren<{ + numberOfDots?: number; + animationDelay?: number; + minOpacity?: number; + style?: StyleProp; + useNativeDriver?: boolean; +}>; + +const initializeDots = ( + numberOfDots: number, + minOpacity: number +): Animated.Value[] => { + let opacities: Animated.Value[] = []; + + for (let i = 0; i < numberOfDots; i++) { + let dot: Animated.Value = new Animated.Value(minOpacity); + opacities.push(dot); + } + + return opacities; +}; + +const styles = StyleSheet.create({ + container: { + flexDirection: 'row', + }, +}); + +const initialTargetOpacity: number = 1; + +const defaultStyle: TextStyle = { + color: '#aaa', + fontSize: 32, +}; + +const AnimatedEllipsis: React.FC = ({ + numberOfDots = 3, + animationDelay = 300, + minOpacity = 0, + style = defaultStyle, + useNativeDriver = true, + }: Props): React.JSX.Element => { + // Ensure non-negative dots + const validatedNumberOfDots = Math.max(0, numberOfDots); + + const isMountedRef = React.useRef(true); + const targetOpacityRef = React.useRef(initialTargetOpacity); + const dotOpacitiesRef = React.useRef(initializeDots(validatedNumberOfDots, minOpacity)); + + const animateDots = React.useCallback( + (currentDot: number): void => { + if (!isMountedRef.current) return; + + if (currentDot >= dotOpacitiesRef.current.length) { + currentDot = 0; + targetOpacityRef.current = targetOpacityRef.current === minOpacity ? 1 : minOpacity; + } + + const nextDot = currentDot + 1; + + Animated.timing(dotOpacitiesRef.current[currentDot]!, { + toValue: targetOpacityRef.current, + duration: animationDelay, + useNativeDriver: useNativeDriver, + } as Animated.TimingAnimationConfig).start(() => animateDots(nextDot)); + }, + [ + animationDelay, + useNativeDriver, + minOpacity + ] + ); + + useEffect(() => { + isMountedRef.current = true; + + if (validatedNumberOfDots > 0) { + animateDots(0); + } + + return () => { + isMountedRef.current = false; + dotOpacitiesRef.current.forEach(opacity => opacity.stopAnimation()); + }; + }, [animateDots, numberOfDots]); + + return ( + + {dotOpacitiesRef.current.map((opacity, index) => ( + + {' '} + . + + ))} + + ) as React.ReactElement; +}; + +export default AnimatedEllipsis; diff --git a/src/__tests__/AnimatedEllipsis.test.tsx b/src/__tests__/AnimatedEllipsis.test.tsx new file mode 100644 index 0000000..63b43e5 --- /dev/null +++ b/src/__tests__/AnimatedEllipsis.test.tsx @@ -0,0 +1,104 @@ +import { act, render } from '@testing-library/react-native'; +import { Animated } from 'react-native'; +import AnimatedEllipsis from '../AnimatedEllipsis'; + +test('renders the correct number of dots with default props', () => { + const { getAllByText } = render(); + const dots = getAllByText('.'); + expect(dots.length).toBe(3); // Default `numberOfDots` is 3 +}); + +test('renders the correct number of dots with custom numberOfDots', () => { + const { getAllByText } = render(); + const dots = getAllByText('.'); + expect(dots.length).toBe(5); // Custom `numberOfDots` is 5 +}); + +test('applies custom styles to the dots', () => { + const customStyle = { color: 'red', fontSize: 20 }; + const { getAllByText } = render(); + const dots = getAllByText('.'); + + dots.forEach(dot => { + expect(dot.props.style).toEqual( + expect.objectContaining(customStyle) + ); + }); +}); + +test('triggers animations for each dot', () => { + jest.useFakeTimers(); + + const { getAllByText } = render(); + + act(() => { + jest.advanceTimersByTime(300 * 3); // Assuming animationDelay is 300ms + }); + + const dots = getAllByText('.'); + expect(dots.length).toBe(3); + + jest.useRealTimers(); +}); + +test('uses custom animationDelay', () => { + jest.useFakeTimers(); + + const timingSpy = jest.spyOn(Animated, 'timing'); + + render(); + + act(() => { + // Advance timers to simulate animation + jest.advanceTimersByTime(500); + }); + + expect(timingSpy).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ duration: 500 }) + ); + + jest.useRealTimers(); +}); + +test('renders no dots when numberOfDots is 0', () => { + const { queryAllByText } = render(); + const dots = queryAllByText('.'); + expect(dots.length).toBe(0); // No dots should be rendered +}); + +test('renders no dots when numberOfDots is negative', () => { + const { queryAllByText } = render(); + const dots = queryAllByText('.'); + expect(dots.length).toBe(0); // No dots should be rendered +}); + +test('applies minOpacity to dots', () => { + const { getAllByText } = render(); + const dots = getAllByText('.'); + + dots.forEach(dot => { + expect(dot.props.style).toEqual( + expect.objectContaining({ opacity: 0.5 }) + ); + }); +}); + +test('uses useNativeDriver when provided', () => { + jest.useFakeTimers(); + const timingSpy = jest.spyOn(Animated, 'timing'); + + render(); + + act(() => { + jest.advanceTimersByTime(300); + }); + + expect(timingSpy).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ useNativeDriver: true }) + ); + + jest.useRealTimers(); + timingSpy.mockRestore(); // Cleanup the spy +}); diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..ddfdfa5 --- /dev/null +++ b/src/index.ts @@ -0,0 +1 @@ +export { default } from './AnimatedEllipsis'; diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..68561de --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,32 @@ +{ + "include": ["src/**/*"], + "exclude": ["node_modules", "dist", "**/__tests__/**", "**/*.test.ts", "**/*.test.tsx"], + "compilerOptions": { + "outDir": "dist", + "rootDir": "src", + "target": "ESNext", + "jsx": "react-jsx", + "lib": ["ES2017", "DOM"], + "module": "ESNext", + "moduleResolution": "node", + "declaration": true, + "declarationMap": true, + "emitDeclarationOnly": true, + "sourceMap": true, + + "allowUnreachableCode": false, + "allowUnusedLabels": false, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "noFallthroughCasesInSwitch": true, + "noImplicitReturns": true, + "noImplicitUseStrict": false, + "noStrictGenericChecks": false, + "noUncheckedIndexedAccess": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "resolveJsonModule": true, + "skipLibCheck": true, + "strict": true, + } +} diff --git a/tsconfig.test.json b/tsconfig.test.json new file mode 100644 index 0000000..4a49656 --- /dev/null +++ b/tsconfig.test.json @@ -0,0 +1,8 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "moduleResolution": "node", // Overrides "Bundler" for Jest compatibility + "types": ["jest", "@testing-library/jest-native"] + }, + "include": ["__tests__/**/*", "src/**/*"] +}