├── .gitignore ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── babel.config.js ├── package.json ├── prettier.config.js ├── public └── index.html ├── src ├── App.test.tsx ├── App.tsx ├── index.tsx ├── lib-ts │ └── index.tsx ├── react-app-env.d.ts └── style.css ├── tsconfig.json └── yarn.lock /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | node_modules/ 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # production 12 | /build 13 | 14 | # misc 15 | .DS_Store 16 | .env.local 17 | .env.development.local 18 | .env.test.local 19 | .env.production.local 20 | 21 | npm-debug.log* 22 | yarn-debug.log* 23 | yarn-error.log* 24 | /src/dist 25 | /src/lib 26 | .babelrc 27 | debug.log -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | Hello, contributor! 2 | 3 | Thanks for your interest in react-step-builder. 4 | 5 | In order to make any type of contribution to the library, please follow steps below: 6 | 7 | 1. Fork the repository and clone it in your local machine. 8 | 9 | 2. To add a feature the only file you want to interact is `lib-ts/index.tsx`. Make required changes in the file. 10 | 11 | 3. Add your test cases in `App.test.tsx` file. 12 | 13 | 4. Run `npm run test` or `yarn test` to make sure your tests are passing. 14 | 15 | 5. If everything looks good, create a Pull Request along with the library and test updates as well as updated CRA app that you did your manual testing in. 16 | 17 | 6. When your PR is accepted, library maintainer will download your code, build it, version it, and publish on the NPM registry. 18 | 19 | 7. Thank you for your time. 20 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Samet Mutevelli 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # React Step Builder ![npm (tag)](https://img.shields.io/npm/v/react-step-builder/latest?label=latest) [![Total NPM Download](https://img.shields.io/npm/dt/react-step-builder.svg)](https://www.npmjs.com/package/react-step-builder) 2 | 3 | React Step Builder is a headless, unopinionated, multi-step interface builder. 4 | 5 | > Version 3 introduces some breaking changes. If you are upgrading from earlier versions, please read the documentation carefully. 6 | 7 | > Global state management methods are removed from the library. React Step Builder will only focus on building step-by-step interfaces starting from version 3. You may use a state management tool of your choice. If this is bad news for you, I am sorry 🙇‍♂️ 8 | 9 |
10 | 11 | # Installation 12 | 13 | Using [npm](https://www.npmjs.com/): 14 | 15 | ``` 16 | npm install react-step-builder 17 | ``` 18 | 19 |
20 | 21 | # Usage 22 | 23 | Example: 24 | 25 | ```jsx 26 | import { Steps, StepsProvider, useSteps } from "react-step-builder"; 27 | 28 | const App = () => { 29 | return ( 30 | 31 | 32 | 33 | ); 34 | }; 35 | 36 | const MySteps = () => { 37 | const { next, prev } = useSteps(); 38 | 39 | return ( 40 | 41 |
42 |

Step 1

43 |
44 |
45 |

Step 2

46 |
47 |
48 |

Step 3

49 |
50 |
51 | ); 52 | }; 53 | 54 | export default App; 55 | ``` 56 | 57 | # Documentation 58 | 59 | ### **``** 60 | 61 | A component whose each direct sibling is treated as a step. **Do not add anything else inside `Steps` component** as they will be treated as a separate step. 62 | 63 | ❌ Incorrect: 64 | 65 | ```jsx 66 | 67 | 68 | 69 | 70 | 71 | ``` 72 | 73 | ✅ Correct: 74 | 75 | ```jsx 76 | 77 | 78 | 79 | 80 | 81 | 82 | ``` 83 | 84 | This reason for this method is due to React's _composition over inheritance_ principle. It also allows you to manage your state easily in the parent component. 85 | 86 | | Property | Type | Description | 87 | | -------------- | ------------ | ---------------------------------------------------------- | 88 | | `onStepChange` | `() => void` | Runs on every step change. Does not run on initial render. | 89 | 90 |
91 |
92 |
93 | 94 | ### **`useSteps`** 95 | 96 | A special hook that accesses the state of `` component and exposes methods to move between steps. 97 | 98 | `const stepsState = useSteps();` 99 | 100 | These are the properties inside `stepsState` object. 101 | 102 | | Property | Type | Description | 103 | | ---------- | ------------------------ | --------------------------------------------------- | 104 | | `total` | `number` | Total number of steps | 105 | | `current` | `number` | Current step number | 106 | | `progress` | `number` | Progress of the current step, value between 0 and 1 | 107 | | `next` | `() => void` | Function to move to the next step | 108 | | `prev` | `() => void` | Function to move to the previous step | 109 | | `jump` | `(step: number) => void` | Function to jump to the given step | 110 | | `isFirst` | `boolean` | If the step is the first | 111 | | `isLast` | `boolean` | If the step is the last | 112 | | `hasPrev` | `boolean` | If the step has any previous step | 113 | | `hasNext` | `boolean` | If the step has any next step | 114 | 115 |
116 |
117 |
118 | 119 | ### `` 120 | 121 | The component that renders `` should be wrapped with `StepsProvider` component. `useSteps` can only be called in a component that is rendered in the DOM tree under `StepsProvider`. 122 | 123 | | Property | Type | Description | 124 | | -------------- | ------------ | --------------------------------------- | 125 | | `startsFrom` | `number` | The default step number to be rendered. | 126 | 127 | > Step numbers start from 1 and goes up to the count of direct siblings given to the `Steps` component. If the number is out of range, first step is rendered by default. 128 | 129 |
130 |
131 |
132 | Example project: https://codesandbox.io/s/react-step-builder-v3-5625v?file=/src/App.tsx 133 | -------------------------------------------------------------------------------- /babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: [["react-app", { absoluteRuntime: false }]], 3 | }; 4 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-step-builder", 3 | "description": "Headless, unopinionated, multi-step interface builder.", 4 | "author": "Samet Mutevelli (https://sametmutevelli.com)", 5 | "version": "3.0.3", 6 | "private": false, 7 | "main": "src/dist/index", 8 | "types": "src/dist/index.d.ts", 9 | "module": "src/dist/index.js", 10 | "files": [ 11 | "src/dist", 12 | "README.md" 13 | ], 14 | "repository": { 15 | "type": "git", 16 | "url": "https://github.com/sametweb/react-step-builder" 17 | }, 18 | "devDependencies": { 19 | "@babel/cli": "^7.16.0", 20 | "@babel/core": "^7.16.5", 21 | "@babel/runtime": "^7.16.5", 22 | "@testing-library/jest-dom": "^5.16.1", 23 | "@testing-library/react": "^12.1.2", 24 | "@testing-library/user-event": "^13.2.1", 25 | "@types/jest": "^27.0.1", 26 | "@types/node": "^17.0.5", 27 | "@types/react": "^17.0.38", 28 | "@types/react-dom": "^17.0.11", 29 | "react": "^17.0.2", 30 | "react-dom": "^17.0.2", 31 | "react-scripts": "5.0.0", 32 | "typescript": "^4.5.4" 33 | }, 34 | "collectCoverage": true, 35 | "collectCoverageFrom": [ 36 | "src/**/*.{js,jsx,ts,tsx}", 37 | "!src/dist/*.js", 38 | "!src/dist/*.jsx", 39 | "!src/dist/*.ts", 40 | "!src/dist/*.tsx" 41 | ], 42 | "scripts": { 43 | "start": "react-scripts start", 44 | "build": "SET NODE_ENV=production&tsc&babel src/lib --out-dir src/dist --copy-files --ignore __tests__,spec.js,test.js,__snapshots__", 45 | "test": "react-scripts test" 46 | }, 47 | "keywords": [ 48 | "step-builder", 49 | "react-step-builder", 50 | "create steps in react", 51 | "step by step form", 52 | "multi step form", 53 | "react multi step registration" 54 | ], 55 | "eslintConfig": { 56 | "extends": "react-app" 57 | }, 58 | "browserslist": { 59 | "production": [ 60 | ">0.2%", 61 | "not dead", 62 | "not op_mini all" 63 | ], 64 | "development": [ 65 | "last 1 chrome version", 66 | "last 1 firefox version", 67 | "last 1 safari version" 68 | ] 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /prettier.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | trailingComma: "all", 3 | tabWidth: 2, 4 | bracketSpacing: true, 5 | useTabs: true, 6 | singleQuote: false, 7 | semi: true, 8 | requirePragma: false, 9 | proseWrap: "preserve", 10 | arrowParens: "always", 11 | htmlWhitespaceSensitivity: "css", 12 | }; 13 | -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | React Step Builder 4 | 5 | 6 |
7 | 8 | 9 | -------------------------------------------------------------------------------- /src/App.test.tsx: -------------------------------------------------------------------------------- 1 | import "@testing-library/jest-dom"; 2 | import React from "react"; 3 | import { render, screen } from "@testing-library/react"; 4 | import { StepsProvider } from "./dist"; 5 | import App, { StepsComponent } from "./App"; 6 | import { act } from "react-dom/test-utils"; 7 | 8 | const calculateProgress = (current, total) => 9 | Number(((current - 1) / (total - 1)).toFixed(2)); 10 | 11 | const onStepChange = jest.fn(); 12 | 13 | describe("Testing", () => { 14 | beforeEach(() => { 15 | render( 16 | 17 | 18 | , 19 | ); 20 | }); 21 | 22 | it("Step1 renders on initial load", () => { 23 | const step1 = screen.getByTestId("step1"); 24 | expect(step1).toBeVisible(); 25 | }); 26 | 27 | it("Clicking next button moves to the next step", () => { 28 | const nextButton = screen.getByTestId("next"); 29 | nextButton.click(); 30 | const step2 = screen.getByTestId("step2"); 31 | expect(step2).toBeVisible(); 32 | }); 33 | 34 | it("Clicking prev button moves to the previous step", () => { 35 | const nextButton = screen.getByTestId("next"); 36 | nextButton.click(); 37 | const prevButton = screen.getByTestId("prev"); 38 | prevButton.click(); 39 | const step1 = screen.getByTestId("step1"); 40 | expect(step1).toBeVisible(); 41 | }); 42 | 43 | it("Clicking jump 3 button jumps to the 3rd step", () => { 44 | const jumpButton = screen.getByTestId("jump"); 45 | jumpButton.click(); 46 | const step3 = screen.getByTestId("step3"); 47 | expect(step3).toBeVisible(); 48 | }); 49 | 50 | it("Total count is correct", () => { 51 | const total = screen.getByTestId("total"); 52 | expect(total).toHaveTextContent("Total: 4"); 53 | }); 54 | 55 | it("Current count updates correct", () => { 56 | const current = screen.getByTestId("current"); 57 | const jumpButton = screen.getByTestId("jump"); 58 | jumpButton.click(); 59 | expect(current).toHaveTextContent("Current: 3"); 60 | }); 61 | 62 | it("Progress is correct", () => { 63 | const progress = screen.getByTestId("progress"); 64 | expect(progress).toHaveTextContent("Progress: 0%"); 65 | }); 66 | 67 | it("Progress updates correctly", () => { 68 | const nextButton = screen.getByTestId("next"); 69 | nextButton.click(); 70 | const progressElement = screen.getByTestId("progress"); 71 | const progress = calculateProgress(2, 4) * 100; 72 | expect(progressElement).toHaveTextContent(`Progress: ${progress}%`); 73 | }); 74 | }); 75 | 76 | describe("StepsProvider props", () => { 77 | it("Steps start from provided number", () => { 78 | render( 79 | 80 | 81 | , 82 | ); 83 | const step2 = screen.getByTestId("step2"); 84 | expect(step2).toBeVisible(); 85 | }); 86 | 87 | it("If startsFrom is out of range, first step is rendered by default", () => { 88 | render( 89 | 90 | 91 | , 92 | ); 93 | const step1 = screen.getByTestId("step1"); 94 | expect(step1).toBeVisible(); 95 | }); 96 | }); 97 | 98 | describe("Steps props", () => { 99 | beforeEach(() => { 100 | render( 101 | 102 | 103 | , 104 | ); 105 | }); 106 | it("onStepChange runs on every step change", () => { 107 | const nextButton = screen.getByTestId("next"); 108 | act(() => { 109 | nextButton.click(); 110 | }); 111 | expect(onStepChange).toHaveBeenCalledTimes(1); 112 | }); 113 | }); 114 | -------------------------------------------------------------------------------- /src/App.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Steps, useSteps } from "./dist"; 3 | 4 | const App = () => { 5 | const onStepChange = () => { 6 | console.log("Step Changed"); 7 | }; 8 | 9 | return ( 10 |
11 |

React Step Builder v2.0.7

12 | 13 |
14 | ); 15 | }; 16 | 17 | interface StepsComponentProps { 18 | onStepChange: () => void; 19 | startsFrom: number; 20 | } 21 | 22 | export const StepsComponent: React.FC = (props) => { 23 | const { prev, next, progress, jump, total, current } = useSteps(); 24 | 25 | return ( 26 | <> 27 | 28 |
29 |

Step 1

30 |

This is Step 1.

31 |
32 |
33 |

Step 2

34 |

This is Step 2.

35 |
36 |
37 |

Step 3

38 |

This is Step 3.

39 |
40 |
41 |

Step 4

42 |

This is Step 4.

43 |
44 |
45 | 48 | 51 | 54 |
Total: {total}
55 |
Current: {current}
56 |
Progress: {progress * 100}%
57 | 58 | ); 59 | }; 60 | 61 | export default App; 62 | -------------------------------------------------------------------------------- /src/index.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import ReactDOM from "react-dom"; 3 | import App from "./App"; 4 | import { StepsProvider } from "./dist"; 5 | import "./style.css"; 6 | ReactDOM.render( 7 | 8 | 9 | , 10 | document.querySelector("#root"), 11 | ); 12 | -------------------------------------------------------------------------------- /src/lib-ts/index.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | 3 | interface IStepsContext { 4 | current: number; 5 | setCurrent: React.Dispatch>; 6 | size: number; 7 | setSize: React.Dispatch>; 8 | isLast: boolean; 9 | isFirst: boolean; 10 | hasPrev: boolean; 11 | hasNext: boolean; 12 | progress: number; 13 | next: () => void; 14 | prev: () => void; 15 | jump: (step: number) => void; 16 | } 17 | 18 | const StepsContext = React.createContext({ 19 | current: 1, 20 | setCurrent: () => {}, 21 | size: 0, 22 | setSize: () => {}, 23 | isLast: false, 24 | isFirst: false, 25 | hasPrev: false, 26 | hasNext: false, 27 | progress: 0, 28 | next: () => {}, 29 | prev: () => {}, 30 | jump: () => {}, 31 | }); 32 | 33 | export const StepsProvider: React.ComponentType = ({ children }) => { 34 | const [current, setCurrent] = React.useState(1); 35 | const [size, setSize] = React.useState(0); 36 | 37 | const next = () => { 38 | const nextStep = current + 1; 39 | nextStep <= size && setCurrent(nextStep); 40 | }; 41 | 42 | const prev = () => { 43 | const prevStep = current - 1; 44 | prevStep >= 1 && setCurrent(prevStep); 45 | }; 46 | 47 | const jump = (step: number) => { 48 | step >= 1 && step <= size && setCurrent(step); 49 | }; 50 | 51 | const isLast = current === size; 52 | const isFirst = current === 1; 53 | const hasPrev = current > 1; 54 | const hasNext = current < size; 55 | const progress = Number(((current - 1) / (size - 1)).toFixed(2)); 56 | 57 | const contextValue = { 58 | current, 59 | setCurrent, 60 | size, 61 | setSize, 62 | isLast, 63 | isFirst, 64 | hasPrev, 65 | progress, 66 | next, 67 | prev, 68 | jump, 69 | hasNext, 70 | }; 71 | 72 | return ( 73 | 74 | {children} 75 | 76 | ); 77 | }; 78 | 79 | export interface StepsProps { 80 | onStepChange?: () => void; 81 | startsFrom?: number; 82 | } 83 | 84 | export const Steps: React.ComponentType = (props) => { 85 | const stepsContext = React.useContext(StepsContext); 86 | const { current, setCurrent, setSize } = stepsContext; 87 | const [isInitialRender, setIsInitialRender] = React.useState(true); 88 | 89 | React.useEffect(() => { 90 | setIsInitialRender(false); 91 | const { startsFrom = 1 } = props; 92 | const size = React.Children.count(props.children); 93 | if (startsFrom > size) { 94 | setCurrent(1); 95 | console.warn( 96 | "React Step Builder: startsFrom is greater than the number of steps. First step will be rendered by default.", 97 | ); 98 | } else { 99 | setCurrent(startsFrom); 100 | } 101 | }, []); 102 | 103 | React.useEffect(() => { 104 | const size = React.Children.count(props.children); 105 | setSize(size); 106 | }, [props.children]); 107 | 108 | React.useEffect(() => { 109 | !isInitialRender && props.onStepChange?.(); 110 | }, [current]); 111 | 112 | const steps = React.Children.map(props.children, (child, index) => { 113 | const step = index + 1; 114 | const stepsChild = React.cloneElement(child as React.ReactElement); 115 | return current === step && stepsChild; 116 | }); 117 | 118 | return <>{steps}; 119 | }; 120 | 121 | export const useSteps = () => { 122 | const stepsContext = React.useContext(StepsContext); 123 | 124 | const { 125 | prev, 126 | next, 127 | jump, 128 | isFirst, 129 | isLast, 130 | hasPrev, 131 | hasNext, 132 | progress, 133 | size: total, 134 | current, 135 | } = stepsContext; 136 | return { 137 | prev, 138 | next, 139 | jump, 140 | isFirst, 141 | isLast, 142 | hasPrev, 143 | hasNext, 144 | progress, 145 | total, 146 | current, 147 | }; 148 | }; 149 | -------------------------------------------------------------------------------- /src/react-app-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /src/style.css: -------------------------------------------------------------------------------- 1 | html { 2 | font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, 3 | Ubuntu, Cantarell, "Open Sans", "Helvetica Neue", sans-serif; 4 | } 5 | 6 | .steps_wrapper { 7 | width: 400px; 8 | margin: 50px auto; 9 | } 10 | 11 | .steps_wrapper > div { 12 | display: flex; 13 | flex-direction: column; 14 | padding: 0 0 50px; 15 | } 16 | 17 | .steps_wrapper h1 { 18 | text-align: center; 19 | } 20 | 21 | .step { 22 | font-size: 1.1rem; 23 | } 24 | 25 | .step input { 26 | margin: 5px 0 15px 0; 27 | padding: 7px; 28 | border: 1px solid #e1e1e2; 29 | border-radius: 8px; 30 | outline: none; 31 | } 32 | 33 | .navigation { 34 | display: flex; 35 | justify-content: space-between; 36 | padding: 20px 0 0 0; 37 | } 38 | 39 | .navigation button { 40 | padding: 12px 15px; 41 | border: 1px solid #e1e1e2; 42 | background: none; 43 | border-radius: 50px; 44 | cursor: pointer; 45 | outline: none; 46 | } 47 | 48 | .navigation button:hover { 49 | border: 1px solid lightseagreen; 50 | } 51 | 52 | .navigation button:disabled:hover { 53 | border: 1px solid #e1e1e2; 54 | } 55 | 56 | .navigation button.active-button { 57 | background: lightgreen; 58 | } 59 | 60 | .navigation button.visited-button { 61 | background: lightgrey; 62 | } 63 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "module": "esnext", 5 | "jsx": "react", 6 | "declaration": true, 7 | "declarationMap": true, 8 | "sourceMap": true, 9 | "outDir": "src/lib", 10 | "strict": true, 11 | "esModuleInterop": true, 12 | "skipLibCheck": true, 13 | "forceConsistentCasingInFileNames": true, 14 | "lib": ["dom", "dom.iterable", "esnext"], 15 | "allowJs": true, 16 | "allowSyntheticDefaultImports": true, 17 | "moduleResolution": "node", 18 | "resolveJsonModule": true, 19 | "isolatedModules": true, 20 | "noEmit": false 21 | }, 22 | "include": ["src/lib-ts/**/*.ts", "src/lib-ts/**/*.tsx"] 23 | } 24 | --------------------------------------------------------------------------------