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/**/*"]
+}