├── .babelrc ├── .eslintrc ├── .gitignore ├── LICENSE.md ├── README.md ├── example ├── README.md ├── package-lock.json ├── package-original.json ├── package.json ├── public │ ├── favicon.ico │ ├── index.html │ ├── logo192.png │ ├── logo512.png │ ├── manifest.json │ └── robots.txt └── src │ ├── App.css │ ├── App.test.js │ ├── appVideo.css │ ├── assets │ └── svg │ │ ├── arrow-down2.svg │ │ ├── arrow-left2.svg │ │ ├── arrow-right2.svg │ │ ├── code.svg │ │ ├── devices.svg │ │ ├── github.svg │ │ ├── info.svg │ │ ├── keyboard.svg │ │ ├── link.svg │ │ ├── note.svg │ │ ├── price-tags.svg │ │ └── tree.svg │ ├── code-snippets │ ├── appVideoCode.js │ ├── captionsComponentCode.js │ ├── coreComponentsCode.js │ └── inputComponentsCode.js │ ├── components │ ├── App.js │ ├── AppSandbox.js │ ├── AppVideo.js │ ├── CodeSnippet.js │ ├── StyledComponents.js │ └── SubscriptionForm.js │ ├── data.js │ ├── example-app-demo-final.gif │ ├── index.css │ ├── index.js │ ├── logo.svg │ ├── serviceWorker.js │ ├── setupTests.js │ └── wip │ ├── Drawer.js │ ├── Form.js │ ├── InfoCheckbox.js │ ├── Title.js │ └── withLog.js ├── manual.md ├── package-lock.json ├── package.json ├── rollup.config.js ├── src ├── .eslintrc ├── components │ ├── Captions.js │ ├── Checkbox.js │ ├── ComboboxMulti.js │ ├── FormBody.js │ ├── Icon.js │ ├── Input.js │ ├── InputWrapper.js │ ├── Labels.js │ ├── RadioControl.js │ ├── StyledComponents.js │ ├── Tabs.js │ └── withFormContextAndTheme.js ├── core │ ├── FormContext.js │ ├── useAddInput.js │ ├── useInputState.js │ ├── useInputs.js │ └── useScaleAnimation.js ├── index.js ├── logic │ ├── getValidationAttributes.js │ └── validateInput.js ├── propTypes.js ├── styles.css ├── test.js └── utils │ ├── createKeyFrameAnimation.js │ ├── debounce.js │ ├── helpers.js │ └── throttle.js └── yarn.lock /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | ["env", { 4 | "modules": false 5 | }], 6 | "stage-0", 7 | "react" 8 | ] 9 | } 10 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "parser": "babel-eslint", 3 | "extends": [ 4 | "standard", 5 | "standard-react" 6 | ], 7 | "env": { 8 | "es6": true 9 | }, 10 | "plugins": [ 11 | "react" 12 | ], 13 | "parserOptions": { 14 | "sourceType": "module" 15 | }, 16 | "rules": { 17 | // don't force es6 functions to include space before paren 18 | "space-before-function-paren": 0, 19 | 20 | // allow specifying true explicitly for boolean props 21 | "react/jsx-boolean-value": 0 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/ignore-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | node_modules 5 | 6 | # builds 7 | build 8 | dist 9 | 10 | # misc 11 | .DS_Store 12 | .env 13 | .env.local 14 | .env.development.local 15 | .env.test.local 16 | .env.production.local 17 | 18 | npm-debug.log* 19 | yarn-debug.log* 20 | yarn-error.log* 21 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Zheng Lai 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. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 |

React Emotion Multi-step Form

3 | 4 | > A multi-step form component library built with React and styled with Emotion 5 | 6 | [![NPM](https://img.shields.io/npm/v/react-emotion-multi-step-form.svg)](https://www.npmjs.com/package/react-emotion-multi-step-form) 7 | 8 | [![Example App Demo](example/src/example-app-demo-final.gif)](https://z2lai.github.io/react-emotion-multi-step-form/) 9 |
10 | 11 | ## Introduction 12 | A declarative component library where input components are displayed in a multi-step form format with smooth page transitions. It's built with React hooks and React Context API so that form state and input prop values can be reused for UI customization. 13 | 14 | ### Important Links 15 | * [Demo the example app](http://z2lai.github.io/react-emotion-multi-step-form#example-app) 16 | * [Learn how to get started](http://z2lai.github.io/react-emotion-multi-step-form#getting-started) 17 | * [Demo the library](https://codesandbox.io/s/react-emotion-multi-step-form-subscription-form-h6mpc) 18 | 19 | ## Features 20 | * Declarative configuration - captions, page height, input icon and input validation 21 | * Smooth/Optimized page transition animations 22 | * Accessible keyboard-only navigation 23 | * Responsive design 24 | * Custom hook to access form state and input prop values for UI customization 25 | * Three Input Components: 26 | 1. HTML Input with HTML5 form validation 27 | 2. Single-select Input - Radio Input with declarative configuration of radio options 28 | 3. Multi-select Input - Multi-select Combobox with Autocomplete and Typeahead 29 | 30 | ## Getting Started 31 | 32 | ### Peer Dependencies 33 | The following packages are required to be installed as dependencies for using this library: 34 | * react: ^16.8.0 35 | * react-dom": ^16.8.0 36 | * react-scripts: ^3.4.0 37 | * @emotion/core: ^10.0.27 38 | * @emotion/styled: ^10.0.27 39 | * emotion-theming: ^10.0.27 40 | 41 | ### Installation 42 | ```bash 43 | npm install --save react-emotion-multi-step-form 44 | ``` 45 | 46 | ### Basic Usage 47 | ```jsx 48 | import React from "react"; 49 | import { 50 | useInputs, 51 | FormBody, 52 | Input, 53 | RadioControl, 54 | RadioOption, 55 | withFormContextAndTheme, 56 | } from "react-emotion-multi-step-form"; 57 | 58 | // Import SVG icons as React components using SVGR (built-in with create-react-app) 59 | import { ReactComponent as LinkIcon } from "./assets/link.svg"; 60 | import { ReactComponent as TreeIcon } from "./assets/tree.svg"; 61 | import { ReactComponent as PriceTagsIcon } from "./assets/price-tags.svg"; 62 | 63 | function App() { 64 | const { error } = useInputs(); // grab the active input's error message 65 | const handleSubmit = (data) => { 66 | console.log(data); 67 | }; 68 | 69 | return ( 70 |
71 | 72 | 77 | 82 | 87 | 88 | 89 | 90 | 91 | 92 |
{error.message}
93 |
94 | ); 95 | } 96 | 97 | // Wrap component with React Context.Provider and Emotion ThemeProvider 98 | export default withFormContextAndTheme(App); 99 | ``` 100 | 101 | ## Examples 102 | Demo the library with the following CodeSandbox examples: 103 | * [Basic Usage Example](https://codesandbox.io/s/react-emotion-multi-step-form-basic-example-mhibp) 104 | * ["Subscription Form" Example](https://codesandbox.io/s/react-emotion-multi-step-form-subscription-form-h6mpc) 105 | 106 | ## API Reference 107 | The components and custom hook described below are publicly exposed in the top-level module. 108 | 109 | **Components** 110 | - [``](https://github.com/z2lai/react-emotion-multi-step-form#formbody) 111 | - [Input Components](https://github.com/z2lai/react-emotion-multi-step-form#input-components) 112 | 1. [``](https://github.com/z2lai/react-emotion-multi-step-form#Input) 113 | 2. [`` and ``](https://github.com/z2lai/react-emotion-multi-step-form#radiocontrol-and-radiooption) 114 | 3. [``](https://github.com/z2lai/react-emotion-multi-step-form#comboboxmulti) 115 | - [``](https://github.com/z2lai/react-emotion-multi-step-form#captions) 116 | 117 | **HOCs & Hooks** 118 | - [`withFormContextAndTheme` HOC](https://github.com/z2lai/react-emotion-multi-step-form#withformcontextandtheme-hoc) 119 | - [`useInputs` hook](https://github.com/z2lai/react-emotion-multi-step-form#useinputs-hook) 120 | 121 | ### `` 122 | The main component provided by the module which includes the body of the form, icon container, input container, navigation buttons, Tabs component and the Submit button. The icon container contains the icon of the currently active (displayed) input and the input container contains the active input (only one input can be active at a time). On click of the Next button, the active input is validated and the next input is made active if validation passes. The Submit button appears after the last input has been validated. 123 | 124 | **Props** 125 | Name | Type | Default | Description 126 | -----|------|---------|------------ 127 | initialFocus | boolean | true | Specifies if the form (first input) should be focused on initial render 128 | onSubmit | function | | Invoked when the Submit button on the final page is clicked on. Receives an object where the keys are the input names and the values are the input values. 129 | submitText | string | 'Submit' | Text displayed on the Submit button 130 | submitWidth | number | 110 | Width in pixels of the Submit button 131 | 132 | **Children** 133 | 134 | FormBody currently only accepts input components from this module as children. These input components will be contained within the input container and be displayed one at a time depending on which input is active. 135 | ```jsx 136 | 137 | 138 | 139 | 140 | ``` 141 | 142 | ### Input Components 143 | This module provides the following custom input components to be used as form inputs within `FormBody`. 144 | 1. [``](https://github.com/z2lai/react-emotion-multi-step-form#Input) 145 | 2. [`` and ``](https://github.com/z2lai/react-emotion-multi-step-form#radiocontrol-and-radiooption) 146 | 3. [``](https://github.com/z2lai/react-emotion-multi-step-form#comboboxmulti) 147 | 148 | These input components all share the following props in common which allow them to be registered in `FormContext` and displayed appropriately: 149 | 150 | #### **Base Props** 151 | Name | Type | Default | Description 152 | -----|------|---------|------------ 153 | name `required` | string | | HTML name attribute for inputs - must be **unique** within form. 154 | onChange | function | | Invoked when controlled input value changes - receives the input value. **Note**: Input value state is managed internally and can also be retrieved with the `useInputs` hook. 155 | label | string | | Label text to be displayed as a "tab" above the input - if not specified, `name` is displayed instead. 156 | caption | string | | Caption to be displayed in the `` custom component when this input is active. 157 | icon | elementType | | An SVG file imported as a React component. Refer to [Basic Usage](https://github.com/z2lai/react-emotion-multi-step-form#basic-usage) for an example or see the section below on [importing SVG icons as React components](https://github.com/z2lai/react-emotion-multi-step-form#importing-svg-icons-as-react-components). 158 | height | number | 60 | Specifies the height, in pixels, of the form body when this input is active. Includes top and bottom padding of 10px and excludes the tabs. 159 | validationRules | object | | Specifies rules that the input is validated against on navigation to the next input (i.e. clicking the Next button). On the first rule validation failure, navigation is cancelled and the form goes into an error state until the input is validated again and passes. The default/custom error message can be retrieved from the `useInputs` hook. See below for all available validation rules. 160 | 161 | #### Importing SVG Icons As React Components 162 | Refer to the section in the Create React App documentation on [adding SVGs](https://create-react-app.dev/docs/adding-images-fonts-and-files/#adding-svgs). 163 | 164 | #### Validation Rules 165 | Input validation rules are passed as an object prop into each input component. The object contains the following key-value pairs where the key is the rule name and the value describes the validation criteria and/or the custom error message. The following table lists all of the available validation rules that `validationRules` can contain for all input components: 166 | Key | Value Type | Default | Description 167 | ----------|------------|---------|------------ 168 | required | boolean \| string | | Specifies whether or not the input is required. Instead of `true`, a custom error message can be provided (as a string) to replace the default HTML5 validation message. 169 | validate | function | | Custom validation function that accepts two arguments, input value and input name, and should return `true` if the input value is valid or a string containing the error message if the input value is invalid. 170 | 171 | **Note:** Additional HTML5 validation rules can be defined for the [``](https://github.com/z2lai/react-emotion-multi-step-form#Input) component. 172 | 173 | **Example** 174 | ```jsx 175 | value.length >= 3 || 'Please select at least 3 topics.' 181 | }} 182 | options={options} 183 | /> 184 | ``` 185 | 186 | #### `` 187 | The component to be used for standard HTML inputs. It accepts the [base props](https://github.com/z2lai/react-emotion-multi-step-form#base-props) and the following props: 188 | 189 | **Props** 190 | Name | Type | Default | Description 191 | -----|------|---------|------------ 192 | type | string | 'text' | HTML type attribute - see full list of [HTML input types](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input). 193 | title | string | | HTML title attribute. 194 | placeholder | string | | HTML placeholder attribute. 195 | 196 | **HTML5 Validation Rules** 197 | 198 | In addition to the validation rules listed [above](https://github.com/z2lai/react-emotion-multi-step-form#validation-rules), HTML5 validation rules can be specified for the `` component. The default error message for each HTML5 validation rule is the corresponding HTML5 validation message. Instead of a value, an object containing the value and a custom error message can be provided to replace the default HTML5 validation message. 199 | 200 | The following table lists all of the available HTML5 validation rules that `validationRules` can contain for the `` component: 201 | Key | Value Type | Default | Description 202 | ----------|------------|---------|------------ 203 | minLength | number \| { value: number, message: string } | | Specifies the minimum number of characters for the appropriate input type. 204 | maxLength | number \| { value: number, message: string } | | Specifies the maximum number of characters for the appropriate input type. 205 | min | number \| { value: number, message: string } | | Specifies the minimum value for the appropriate input type. 206 | max | number \| { value: number, message: string } | | Specifies the maximum value for the appropriate input type. 207 | pattern | RegExp \| { value: RegExp, message: string } | | Specifies a JavaScript regular expression to be matched for the appropriate input type. 208 | 209 | **Note:** Different HTML5 validation rules are supported by different input types according to this [table](https://developer.mozilla.org/en-US/docs/Web/Guide/HTML/HTML5/Constraint_validation#Validation-related_attributes).
210 | **Note:** HTML5 validation is automatically performed on inputs based on the [intrinsic constraints](https://developer.mozilla.org/en-US/docs/Web/Guide/HTML/HTML5/Constraint_validation#Semantic_input_types) set by the `type` attribute (e.g. `type="email"` or `type="URL"`). 211 | 212 | **Example** 213 | ```jsx 214 | 226 | ``` 227 | 228 | #### `` and `` 229 | The component to be used for radio inputs (single-select). `` accepts the [base props](https://github.com/z2lai/react-emotion-multi-step-form#base-props) and accepts multiple `` input components as children. 230 | 231 | `` accepts the following props: 232 | 233 | **Props (``)** 234 | Name | Type | Default | Description 235 | -----|------|---------|------------ 236 | value `required` | string \| number | | Value of the radio option. 237 | label | string | | Label text of radio option - if not specified, `value` is displayed instead. 238 | 239 | **Example** 240 | ```jsx 241 | 247 | 248 | 249 | 250 | 251 | ``` 252 | 253 | #### `` 254 | The component to be used for checkbox inputs (multi-select). It includes many features such as autocomplete/autofilter, typeahead, and tokens. The selected options will be stored as an array of strings. `` accepts the [base props](https://github.com/z2lai/react-emotion-multi-step-form#base-props) and the following props: 255 | 256 | **Props** 257 | Name | Type | Default | Description 258 | -----|------|---------|------------ 259 | options | [array, array] | | An array of two arrays containing equal number of elements. The second array contains groups of checkbox options (represented by arrays of strings) and the first array contains the headings for each of these groups. See examples below. 260 | 261 | **Examples** 262 | 263 | If the checkbox options can be logically separated into multiple groups, then the array passed into the options prop should be in the following format: 264 | ```jsx 265 | const options = [ 266 | ['fruits', 'vegetables', 'meats'], 267 | [ 268 | [ 269 | 'papaya', 270 | 'kiwi', 271 | 'watermelon', 272 | 'dragon fruit', 273 | ], 274 | [ 275 | 'brocolli', 276 | 'spinach', 277 | ], 278 | [ 279 | 'chicken', 280 | 'pork', 281 | 'beef', 282 | ], 283 | ] 284 | ] 285 | ``` 286 | 287 | Otherwise, the array passed into the options prop should be in the following format: 288 | ```jsx 289 | const options = [ 290 | ['colours'], 291 | [ 292 | ['red', 'orange', 'yellow', 'green', 'blue', 'indigo', 'violet'] 293 | ] 294 | ] 295 | ``` 296 | 297 | ### `` 298 | This component uses the [`useInputs` hook](https://github.com/z2lai/react-emotion-multi-step-form#useinputs-hook) to display the caption of the currently active input. It accepts the following props: 299 | 300 | **Props** 301 | Name | Type | Default | Description 302 | -----|------|---------|------------ 303 | callToActionText `required` | string | | Call-to-action text to be displayed on the final page with the Submit button. 304 | 305 | ### withFormContextAndTheme HOC 306 | This higher-order component (HOC) provides the wrapped component with access to the theme and `FormContext` which stores the form state. This HOC must be called with the parent component as follows: 307 | ```jsx 308 | const AppWithContextAndTheme = withFormContextAndTheme(App); 309 | export default AppWithContextAndTheme; 310 | ``` 311 | 312 | Or simply: 313 | ```jsx 314 | export default withFormContextAndTheme(App); 315 | ``` 316 | 317 | ### useInputs Hook 318 | This custom hook returns the following form state values from `FormContext`: 319 | 320 | **Returned Values** 321 | Name | Type | Initial Value | Description 322 | -----|------|---------------|------------ 323 | inputs | Array\ | `[]` | An array of objects where each object contains the prop values of each input. Each input object contains prop-value pairs for the following props: `label`, `caption`, `icon` and `height`.
**Note**: On initial form render, `inputs` is always empty as all input components still need to be rendered once for their ref callbacks to "register" them in `inputs` (which triggers an immediate re-render). 324 | activeIndex | number | `0` | An index from 0 to n where n is the number of inputs in the form. The index specifies which input is currently active: `0` refers to the first input and n refers to the Submit button which comes after the last input. 325 | changeActiveIndex | function | | Accepts a number that should specify what to change `activeIndex` to (which input to make active). Input validation is performed on the currently active input only if the number is greater than activeIndex (going forward in the form). 326 | activeInput | object | | The input object from `inputs` for the currently active input. `activeInput` is `null` when `activeIndex` = n. 327 | error | { state: boolean, message: string } | `{ state: false, message: '' }` | Error object containing the error state of the form and the error message to display.
**Note**: `error.message` must be added to the form as it's not displayed by default. 328 | isSubmitPage | boolean | false | Specifies if the form is on the last "page" with the Submit button. 329 | inputValues | object | `{}` | An object containing all form values where each key is the input name and each value is the input value. `inputValues` gets updated every time `changeActiveIndex` is called (e.g. on click of the Next button). 330 | 331 | **Example** 332 | 333 | These values and functions can be used, as follows, to create a custom component for navigating backwards to previous inputs in the form: 334 | ```jsx 335 | // All custom components not defined here are just styled components (Emotion) that only contain styling 336 | 337 | // Labels component 338 | const Labels = () => { 339 | const { inputs, activeIndex, changeActiveIndex, inputValues } = useInputs(); 340 | 341 | return ( 342 | 343 | {(inputs.length > 0) ? 344 | inputs.map((input, index) => ( 345 | 357 | ) 358 | } 359 | 360 | // Label component 361 | const Label = ({ 362 | label, 363 | inputValue, 364 | active, 365 | changeActiveIndex, 366 | activated 367 | }) => { 368 | const handleClick = event => { 369 | if (!activated) return; 370 | changeActiveIndex(); 371 | } 372 | 373 | return ( 374 | 379 | {inputValue || label} 380 | 381 | ) 382 | } 383 | ``` 384 | 385 | ## Feature Roadmap 386 | * Web accessibility (WCAG 2.1 conformance) 387 | * Test coverage 388 | * Customizable theme/more props to customize styling 389 | * More input components: 390 | 1. Range input (Slider) 391 | 2. Toggle/Switch input 392 | 3. Multi-select input - tag cloud format 393 | * Ability to have multiple inputs on one page with declarative configuration 394 | * Typescript support 395 | 396 | ## Browser Support 397 | Recent versions of the following browsers are supported: 398 | - Chrome 399 | - Firefox 400 | 401 | ## Changelog 402 | 403 | ## Credits 404 | - [React Bootstrap Typeahead](https://github.com/ericgio/react-bootstrap-typeahead) component by Eric Giovanola 405 | - [react-rewards](https://github.com/thedevelobear/react-rewards) (confetti) component by Develobear (not included in library) 406 | 407 | ## License 408 | [MIT](https://github.com/z2lai/react-emotion-multi-step-form/blob/master/LICENSE.md) © [Zheng Lai](https://github.com/z2lai) -------------------------------------------------------------------------------- /example/README.md: -------------------------------------------------------------------------------- 1 | This project was bootstrapped with [Create React App](https://github.com/facebook/create-react-app). 2 | 3 | ## Available Scripts 4 | 5 | In the project directory, you can run: 6 | 7 | ### `npm start` 8 | 9 | Runs the app in the development mode.
10 | Open [http://localhost:3000](http://localhost:3000) to view it in the browser. 11 | 12 | The page will reload if you make edits.
13 | You will also see any lint errors in the console. 14 | 15 | ### `npm test` 16 | 17 | Launches the test runner in the interactive watch mode.
18 | See the section about [running tests](https://facebook.github.io/create-react-app/docs/running-tests) for more information. 19 | 20 | ### `npm run build` 21 | 22 | Builds the app for production to the `build` folder.
23 | It correctly bundles React in production mode and optimizes the build for the best performance. 24 | 25 | The build is minified and the filenames include the hashes.
26 | Your app is ready to be deployed! 27 | 28 | See the section about [deployment](https://facebook.github.io/create-react-app/docs/deployment) for more information. 29 | 30 | ### `npm run eject` 31 | 32 | **Note: this is a one-way operation. Once you `eject`, you can’t go back!** 33 | 34 | If you aren’t satisfied with the build tool and configuration choices, you can `eject` at any time. This command will remove the single build dependency from your project. 35 | 36 | Instead, it will copy all the configuration files and the transitive dependencies (Webpack, Babel, ESLint, etc) right into your project so you have full control over them. All of the commands except `eject` will still work, but they will point to the copied scripts so you can tweak them. At this point you’re on your own. 37 | 38 | You don’t have to ever use `eject`. The curated feature set is suitable for small and middle deployments, and you shouldn’t feel obligated to use this feature. However we understand that this tool wouldn’t be useful if you couldn’t customize it when you are ready for it. 39 | 40 | ## Learn More 41 | 42 | You can learn more in the [Create React App documentation](https://facebook.github.io/create-react-app/docs/getting-started). 43 | 44 | To learn React, check out the [React documentation](https://reactjs.org/). 45 | 46 | ### Code Splitting 47 | 48 | This section has moved here: https://facebook.github.io/create-react-app/docs/code-splitting 49 | 50 | ### Analyzing the Bundle Size 51 | 52 | This section has moved here: https://facebook.github.io/create-react-app/docs/analyzing-the-bundle-size 53 | 54 | ### Making a Progressive Web App 55 | 56 | This section has moved here: https://facebook.github.io/create-react-app/docs/making-a-progressive-web-app 57 | 58 | ### Advanced Configuration 59 | 60 | This section has moved here: https://facebook.github.io/create-react-app/docs/advanced-configuration 61 | 62 | ### Deployment 63 | 64 | This section has moved here: https://facebook.github.io/create-react-app/docs/deployment 65 | 66 | ### `npm run build` fails to minify 67 | 68 | This section has moved here: https://facebook.github.io/create-react-app/docs/troubleshooting#npm-run-build-fails-to-minify 69 | -------------------------------------------------------------------------------- /example/package-original.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-emotion-multi-step-form-example", 3 | "homepage": "", 4 | "version": "0.1.0", 5 | "private": true, 6 | "license": "MIT", 7 | "dependencies": { 8 | "@emotion/core": "^10.0.27", 9 | "@emotion/styled": "^10.0.27", 10 | "@testing-library/jest-dom": "^4.2.4", 11 | "@testing-library/react": "^9.4.0", 12 | "@testing-library/user-event": "^7.2.1", 13 | "bootstrap": "^4.5.0", 14 | "emotion-theming": "^10.0.27", 15 | "react": "^16.12.0", 16 | "react-dom": "^16.12.0", 17 | "react-scripts": "3.3.0", 18 | "react-bootstrap-typeahead": "^5.0.0-rc.1", 19 | "react-emotion-multi-step-form": "link:.." 20 | }, 21 | "scripts": { 22 | "start": "react-scripts start", 23 | "build": "react-scripts build", 24 | "test": "react-scripts test", 25 | "eject": "react-scripts eject" 26 | }, 27 | "proxy": "http://localhost:8000", 28 | "eslintConfig": { 29 | "extends": [ 30 | "react-app", 31 | "plugin:react-hooks/recommended" 32 | ] 33 | }, 34 | "browserslist": { 35 | "production": [ 36 | ">0.2%", 37 | "not dead", 38 | "not op_mini all" 39 | ], 40 | "development": [ 41 | "last 1 chrome version", 42 | "last 1 firefox version", 43 | "last 1 safari version" 44 | ] 45 | } 46 | } -------------------------------------------------------------------------------- /example/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-emotion-multi-step-form-example", 3 | "version": "0.1.0", 4 | "homepage": "http://z2lai.github.io/react-emotion-multi-step-form", 5 | "private": true, 6 | "license": "MIT", 7 | "dependencies": { 8 | "@emotion/core": "^10.0.27", 9 | "@emotion/styled": "^10.0.27", 10 | "@testing-library/jest-dom": "^4.2.4", 11 | "@testing-library/react": "^9.4.0", 12 | "@testing-library/user-event": "^7.2.1", 13 | "emotion-theming": "^10.0.27", 14 | "prop-types": "^15.6.2", 15 | "react": "16.12.0", 16 | "react-dom": "16.12.0", 17 | "react-emotion-multi-step-form": "^0.10.0", 18 | "react-rewards": "^1.1.1", 19 | "react-scripts": "^3.4.1", 20 | "react-syntax-highlighter": "^15.2.1" 21 | }, 22 | "scripts": { 23 | "start": "react-scripts start", 24 | "build": "react-scripts build", 25 | "test": "react-scripts test --env=jsdom", 26 | "eject": "react-scripts eject" 27 | }, 28 | "eslintConfig": { 29 | "extends": [ 30 | "react-app", 31 | "plugin:react-hooks/recommended" 32 | ] 33 | }, 34 | "browserslist": { 35 | "production": [ 36 | ">0.2%", 37 | "not dead", 38 | "not op_mini all" 39 | ], 40 | "development": [ 41 | "last 1 chrome version", 42 | "last 1 firefox version", 43 | "last 1 safari version" 44 | ] 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /example/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/z2lai/react-emotion-multi-step-form/f09b0b7dc7f2d5f507fd2cdf21b03c98cf72cf0d/example/public/favicon.ico -------------------------------------------------------------------------------- /example/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 12 | 13 | 17 | 18 | 27 | React Emotion Mult-step Form 28 | 29 | 30 | 31 |
32 | 42 | 43 | 44 | -------------------------------------------------------------------------------- /example/public/logo192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/z2lai/react-emotion-multi-step-form/f09b0b7dc7f2d5f507fd2cdf21b03c98cf72cf0d/example/public/logo192.png -------------------------------------------------------------------------------- /example/public/logo512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/z2lai/react-emotion-multi-step-form/f09b0b7dc7f2d5f507fd2cdf21b03c98cf72cf0d/example/public/logo512.png -------------------------------------------------------------------------------- /example/public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "React App", 3 | "name": "Create React App Sample", 4 | "icons": [ 5 | { 6 | "src": "favicon.ico", 7 | "sizes": "64x64 32x32 24x24 16x16", 8 | "type": "image/x-icon" 9 | }, 10 | { 11 | "src": "logo192.png", 12 | "type": "image/png", 13 | "sizes": "192x192" 14 | }, 15 | { 16 | "src": "logo512.png", 17 | "type": "image/png", 18 | "sizes": "512x512" 19 | } 20 | ], 21 | "start_url": ".", 22 | "display": "standalone", 23 | "theme_color": "#000000", 24 | "background_color": "#ffffff" 25 | } 26 | -------------------------------------------------------------------------------- /example/public/robots.txt: -------------------------------------------------------------------------------- 1 | # https://www.robotstxt.org/robotstxt.html 2 | User-agent: * 3 | -------------------------------------------------------------------------------- /example/src/App.css: -------------------------------------------------------------------------------- 1 | * { 2 | box-sizing: border-box; 3 | } 4 | 5 | :root { 6 | --text-color: #6D169C; 7 | --black: #404040; 8 | --section-width: 980px; 9 | } 10 | 11 | html { 12 | scroll-behavior: smooth; 13 | } 14 | 15 | .app { 16 | margin: 0; 17 | overflow: hidden; 18 | text-align: center; 19 | color: var(--black); 20 | } 21 | 22 | div { 23 | -webkit-tap-highlight-color: transparent; 24 | } 25 | 26 | .hero-banner { 27 | position: relative; 28 | margin-bottom: 1rem; 29 | background-color: #DFDBE5; 30 | background-image: url("data:image/svg+xml,%3Csvg width='80' height='80' viewBox='0 0 80 80' xmlns='http://www.w3.org/2000/svg'%3E%3Cg fill='none' fill-rule='evenodd'%3E%3Cg fill='%239C92AC' fill-opacity='0.13'%3E%3Cpath d='M50 50c0-5.523 4.477-10 10-10s10 4.477 10 10-4.477 10-10 10c0 5.523-4.477 10-10 10s-10-4.477-10-10 4.477-10 10-10zM10 10c0-5.523 4.477-10 10-10s10 4.477 10 10-4.477 10-10 10c0 5.523-4.477 10-10 10S0 25.523 0 20s4.477-10 10-10zm10 8c4.418 0 8-3.582 8-8s-3.582-8-8-8-8 3.582-8 8 3.582 8 8 8zm40 40c4.418 0 8-3.582 8-8s-3.582-8-8-8-8 3.582-8 8 3.582 8 8 8z' /%3E%3C/g%3E%3C/g%3E%3C/svg%3E"); 31 | } 32 | 33 | .hero-banner__icon-link { 34 | margin-right: 10px; 35 | position: absolute; 36 | right: 0; 37 | } 38 | 39 | .hero-banner__title { 40 | font-size: 2.5rem; 41 | margin: 0 0 5px 0; 42 | padding-top: 2rem; 43 | color: var(--text-color); 44 | text-shadow: -2px 2px 3px rgba(0, 0, 0, 0.3); 45 | } 46 | 47 | .hero-banner__subtitle { 48 | margin: 5px 0 0 0; 49 | font-size: 1rem; 50 | font-weight: 500; 51 | } 52 | 53 | .link { 54 | font-weight: 600; 55 | color: var(--text-color); 56 | } 57 | 58 | .link--large { 59 | margin: 10px 10px 0 10px; 60 | font-size: 1.25rem; 61 | } 62 | 63 | .hero-banner__video { 64 | position: relative; 65 | margin: 25px 0 50px 0; 66 | padding: 53.7% 0 0 0; 67 | background: #9fdfb4; 68 | } 69 | 70 | .hero-banner__video iframe { 71 | position: absolute; 72 | top: 0; 73 | left: 0; 74 | width: 100%; 75 | height: 100%; 76 | border-radius: 5px; 77 | box-shadow: -2px 2px 3px rgba(0, 0, 0, 0.3); 78 | } 79 | 80 | .section { 81 | padding: 1rem 0 1rem 0; 82 | } 83 | 84 | .section__container { 85 | position: relative; 86 | max-width: var(--section-width); 87 | margin: auto; 88 | padding: 0 20px; 89 | } 90 | 91 | .section__title { 92 | position: relative; 93 | margin: 0 auto 2.5rem auto; 94 | max-width: 500px; 95 | font-size: 2.25rem; 96 | overflow: hidden; 97 | text-align: center; 98 | font-weight: normal; 99 | } 100 | 101 | .section__title::before { 102 | content: ""; 103 | background-color:hsl(279, 25%, 25%); 104 | display: inline-block; 105 | position: relative; 106 | right: 0.5em; 107 | margin-left: -50%; 108 | height: 1px; 109 | width: 50%; 110 | vertical-align: middle; 111 | } 112 | 113 | .section__title::after { 114 | content: ""; 115 | background-color:hsl(279, 25%, 25%); 116 | display: inline-block; 117 | position: relative; 118 | left: 0.5em; 119 | margin-right: -50%; 120 | height: 1px; 121 | width: 50%; 122 | vertical-align: middle; 123 | } 124 | 125 | .section__heading { 126 | display: inline-block; 127 | margin: 2rem auto 1rem auto; 128 | padding-bottom: 5px; 129 | font-size: 1.5rem; 130 | font-weight: normal; 131 | border-bottom: 2px solid var(--black); 132 | } 133 | 134 | .text-container { 135 | margin: 0 auto 1.5rem auto; 136 | max-width: 680px; 137 | line-height: 1.5; 138 | font-size: 1.125rem; 139 | } 140 | 141 | .list { 142 | margin-block-start: 10px; 143 | padding-inline-start: 60px; 144 | text-align: left; 145 | } 146 | 147 | .code { 148 | white-space: pre-wrap; 149 | } 150 | 151 | .flex-row { 152 | position: relative; 153 | display: flex; 154 | flex-flow: row wrap; 155 | justify-content: center; 156 | } 157 | 158 | .flex-row__flex-item { 159 | margin: 0 20px 20px 20px; 160 | flex: 0 1 220px; 161 | } 162 | 163 | .feature-item__svg { 164 | stroke: var(--black); 165 | fill: none; 166 | } 167 | 168 | .feature-item__title { 169 | margin: 0.625rem 0 1.375rem 0; 170 | } 171 | 172 | .feature-item__text { 173 | font-size: 1.125rem; 174 | } 175 | 176 | .example-app { 177 | box-sizing: border-box; 178 | margin: 0 auto 1rem auto; 179 | max-width: var(--section-width); 180 | height: 450px; 181 | border-radius: 5px; 182 | padding: 20px 10px; 183 | text-align: center; 184 | background: hsl(139, 50%, 75%); 185 | } 186 | 187 | .footer { 188 | margin: 30px 0; 189 | text-align: center; 190 | } 191 | 192 | .footer__list { 193 | list-style: none; 194 | padding: 0; 195 | } 196 | 197 | /* Medium Layout (Tablet/Laptops) */ 198 | @media (min-width: 768px) { 199 | .hero-banner__title { 200 | font-size: 3rem; 201 | } 202 | 203 | .hero-banner__subtitle { 204 | font-size: 1.125rem; 205 | } 206 | 207 | .flex-row--space-around { 208 | justify-content: space-around; 209 | } 210 | 211 | .flex-row__flex-item { 212 | margin: 5px 0; 213 | } 214 | 215 | .footer__links li { 216 | display: inline-block; 217 | margin: 0 10px 0 10px; 218 | } 219 | } 220 | 221 | /* Large Layout (Desktop) */ 222 | @media (min-width: 1060px) { 223 | .section__container { 224 | padding: 0; 225 | } 226 | } -------------------------------------------------------------------------------- /example/src/App.test.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { render } from '@testing-library/react'; 3 | import App from './App'; 4 | 5 | test('renders learn react link', () => { 6 | const { getByText } = render(); 7 | const linkElement = getByText(/learn react/i); 8 | expect(linkElement).toBeInTheDocument(); 9 | }); 10 | -------------------------------------------------------------------------------- /example/src/appVideo.css: -------------------------------------------------------------------------------- 1 | body { 2 | padding-top: 50px; 3 | background: hsl(139, 50%, 75%); 4 | text-align: center; 5 | } 6 | 7 | h1 { 8 | font-size: 1.875rem; 9 | margin: 5px auto; 10 | } 11 | 12 | .error-message { 13 | margin: 0 auto 5px auto; 14 | height: 20px; 15 | line-height: 20px; 16 | font-size: 1.125rem; 17 | color: hsl(16, 100%, 40%); 18 | } -------------------------------------------------------------------------------- /example/src/assets/svg/arrow-down2.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | arrow-down2 4 | 5 | 6 | -------------------------------------------------------------------------------- /example/src/assets/svg/arrow-left2.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | arrow-left2 4 | 5 | 6 | -------------------------------------------------------------------------------- /example/src/assets/svg/arrow-right2.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | arrow-right2 4 | 5 | 6 | -------------------------------------------------------------------------------- /example/src/assets/svg/code.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /example/src/assets/svg/devices.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /example/src/assets/svg/github.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | github 4 | 5 | 6 | -------------------------------------------------------------------------------- /example/src/assets/svg/info.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | info 4 | 5 | 6 | -------------------------------------------------------------------------------- /example/src/assets/svg/keyboard.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /example/src/assets/svg/link.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | link 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /example/src/assets/svg/note.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /example/src/assets/svg/price-tags.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | price-tags 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /example/src/assets/svg/tree.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | tree 4 | 5 | 6 | -------------------------------------------------------------------------------- /example/src/code-snippets/appVideoCode.js: -------------------------------------------------------------------------------- 1 | export default ` 2 | // import statements and confetti component are excluded 3 | const App = () => { 4 | const { error } = useInputs(); 5 | 6 | const handleSubmit = data => { 7 | console.log(data); 8 | }; 9 | 10 | return ( 11 |
12 |

13 | Newsletter Subscription 14 |

15 | 16 | 17 | 25 | 31 | 32 | 33 | 34 | 35 | 42 | 43 |
{error.message}
44 |
45 | ); 46 | } 47 | ` -------------------------------------------------------------------------------- /example/src/code-snippets/captionsComponentCode.js: -------------------------------------------------------------------------------- 1 | export default ` 2 | /* All custom components not defined here are just styled components (Emotion) that 3 | only contain styling */ 4 | const Captions = ({ callToActionText }) => { 5 | const { inputs, activeIndex, isSubmitPage } = useInputs(); 6 | 7 | return ( 8 | 9 | {(inputs.length > 0) 10 | ? 11 | {inputs.map((input, index) => ( 12 | 17 | ))} 18 | 19 | 20 | : null 21 | } 22 | 23 | ) 24 | } 25 | 26 | const Caption = ({ caption, isActive }) => ( 27 | 28 | {caption} 29 | 30 | ) 31 | ` -------------------------------------------------------------------------------- /example/src/code-snippets/coreComponentsCode.js: -------------------------------------------------------------------------------- 1 | export default ` 2 | import React from "react"; 3 | import { FormBody, withFormContextAndTheme } from "react-emotion-multi-step-form"; 4 | 5 | const App = () => { 6 | const handleSubmit = data => { 7 | console.log(data); 8 | }; 9 | 10 | return ( 11 | 12 | {/* input components go here */} 13 | 14 | ); 15 | } 16 | 17 | export default withFormContextAndTheme(App); 18 | ` -------------------------------------------------------------------------------- /example/src/code-snippets/inputComponentsCode.js: -------------------------------------------------------------------------------- 1 | export default ` 2 | // ... 3 | import { FormBody, Input, withFormContextAndTheme } from "react-emotion-multi-step-form"; 4 | import { ReactComponent as LinkIcon } from "../assets/svg/link.svg"; 5 | // ... 6 | 7 | 8 | 15 | 16 | ` -------------------------------------------------------------------------------- /example/src/components/App.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | import '../app.css'; 4 | import SubscriptionForm from "./SubscriptionForm"; 5 | import { ReactComponent as GithubIcon } from "../assets/svg/github.svg"; 6 | import { ReactComponent as CodeIcon } from "../assets/svg/code.svg"; 7 | import { ReactComponent as NoteIcon } from "../assets/svg/note.svg"; 8 | import { ReactComponent as KeyboardIcon } from "../assets/svg/keyboard.svg"; 9 | import { ReactComponent as DevicesIcon } from "../assets/svg/devices.svg"; 10 | import CodeSnippet from './CodeSnippet'; 11 | 12 | import appVideoCode from '../code-snippets/appVideoCode'; 13 | import coreComponentsCode from '../code-snippets/coreComponentsCode'; 14 | import inputComponentsCode from '../code-snippets/inputComponentsCode'; 15 | import captionsComponentCode from '../code-snippets/captionsComponentCode'; 16 | 17 | const App = props => ( 18 |
19 |
20 |
21 | 22 | 23 | 24 |

React Emotion Multi-step Form

25 |

26 | Interactive multi-step form library with concise declarative code 27 |

28 | 32 |
33 | 34 |
35 |
36 |
37 |
38 |
39 |

Features

40 |
41 |
42 | 43 |

Declarative Code

44 |

45 | Describe what your form should look like with clear and concise code 46 |

47 |
48 |
49 | 50 |

Smooth Transitions

51 |

52 | Optimized animations for a smooth interactive user experience 53 |

54 |
55 |
56 | 57 |

Keyboard Navigation

58 |

59 | Allow users to quickly navigate through the entire form using only their keyboard 60 |

61 |
62 |
63 | 64 |

Responsive Design

65 |

66 | Build forms that look good on any device 67 |

68 |
69 |
70 |
71 |
72 |
73 |
74 |

Example App Demo

75 |
76 | 77 |
78 | Get Started 79 | CodeSandbox 80 |
81 |
82 |
83 |
84 |

Example App Code

85 |
86 | 87 | {appVideoCode} 88 | 89 |
90 |
91 |
92 |

Getting Started

93 |

This library is for apps built with Create React App (CRA) and styled with Emotion (see Peer Dependencies). Install the library with the following command: 94 |

95 |
 96 |           npm install --save react-emotion-multi-step-form
 97 |         
98 |
99 |
100 |

Core Components

101 |

102 | FormBody is the main component that includes the body of the multi-step form, the navigation buttons, the label tabs and the Submit button. The app needs to be wrapped with the higher-order component (HOC), withFormContextAndTheme, in order for all components to access form state in Context via a custom hook (see Custom Hook section). 103 |

104 |
105 | 106 | {coreComponentsCode} 107 | 108 |
109 |

Input Components

110 |

111 | The library provides custom input components which are passed to FormBody as children and displayed on separate "pages" of the multi-step form. All input values are automatically made available for both form validation and submission. Upon clicking the "Next Page" button, the active input's value is validated and stored in form state before the next input is displayed. 112 |

113 |

114 | The following props are the base props for all input components in this library: 115 |

116 |
    117 |
  • name - unique identifier for input to be properly registered in Context
  • 118 |
  • label - text to be displayed in a "tab" to label input
  • 119 |
  • onChange - callback invoked when controlled input value changes
  • 120 |
  • caption - text to prompt user for input (displayed by Captions component)
  • 121 |
  • icon - SVG icon imported as a component to be displayed beside input
  • 122 |
  • height - height (in pixels) of the form body when input is active
  • 123 |
  • validationRules - an object containing input validation rules that align with the HTML5 form validation standard (also accepts a custom validation function)
  • 124 |
125 |
126 | 127 | {inputComponentsCode} 128 | 129 |
130 |

Custom Hook

131 |

132 | The custom hook, useInputs, can be used to retrieve form state values such as the current error state and error message. useInputs can also be used to retrieve certain input prop values. 133 |

134 |

135 | For example, the Captions component is built with useInputs to display the caption of the active input: 136 |

137 |
138 | 139 | {captionsComponentCode} 140 | 141 |
142 |
143 | Back to Top 144 |
145 | 162 |
163 | ) 164 | 165 | export default App; -------------------------------------------------------------------------------- /example/src/components/AppSandbox.js: -------------------------------------------------------------------------------- 1 | import "../appVideo.css"; 2 | 3 | import React from "react"; 4 | import { 5 | useInputs, 6 | Captions, 7 | FormBody, 8 | ComboboxMulti, 9 | RadioControl, 10 | RadioOption, 11 | Input, 12 | withFormContextAndTheme, 13 | } from "react-emotion-multi-step-form"; 14 | 15 | import { ReactComponent as LinkIcon } from "../assets/svg/link.svg"; 16 | import { ReactComponent as TreeIcon } from "../assets/svg/tree.svg"; 17 | import { ReactComponent as PriceTagsIcon } from "../assets/svg/price-tags.svg"; 18 | import options from "../data"; 19 | 20 | const App = () => { 21 | const { error } = useInputs(); 22 | 23 | const handleSubmit = data => { 24 | console.log(data); 25 | }; 26 | 27 | return ( 28 |
29 |

30 | Newsletter Subscription 31 |

32 | 33 | 34 | value.length >= 3 || 'Please select at least 3 topics.' 40 | }} 41 | height={240} 42 | options={options} 43 | /> 44 | 50 | 51 | 52 | 53 | 54 | 68 | 69 |
{error.message}
70 |
71 | ); 72 | } 73 | 74 | export default withFormContextAndTheme(App); -------------------------------------------------------------------------------- /example/src/components/AppVideo.js: -------------------------------------------------------------------------------- 1 | import "../appVideo.css"; 2 | 3 | import React from "react"; 4 | import { 5 | FormBody, 6 | withFormContextAndTheme, 7 | Captions, 8 | Input, 9 | RadioControl, 10 | RadioOption, 11 | ComboboxMulti, 12 | useInputs, 13 | } from "react-emotion-multi-step-form"; 14 | 15 | import { ReactComponent as LinkIcon } from "../assets/svg/link.svg"; 16 | import { ReactComponent as TreeIcon } from "../assets/svg/tree.svg"; 17 | import { ReactComponent as PriceTagsIcon } from "../assets/svg/price-tags.svg"; 18 | import options from "../data"; 19 | 20 | const App = () => { 21 | const { error } = useInputs(); 22 | 23 | const handleSubmit = data => { 24 | console.log(data); 25 | }; 26 | 27 | return ( 28 |
29 |

30 | Newsletter Subscription 31 |

32 | 33 | 34 | 42 | 48 | 49 | 50 | 51 | 52 | 59 | 60 |
{error.message}
61 |
62 | ); 63 | } 64 | 65 | export default withFormContextAndTheme(App); -------------------------------------------------------------------------------- /example/src/components/CodeSnippet.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter'; 3 | import { dracula } from 'react-syntax-highlighter/dist/esm/styles/prism'; 4 | 5 | const preTagStyle = { 6 | margin: '0 auto 1rem auto', 7 | maxWidth: '980px', 8 | padding: '20px 30px', 9 | overflow: 'hidden', 10 | } 11 | 12 | const codeTagProps = { 13 | style: { 14 | display: 'block', 15 | overflow: 'auto', 16 | } 17 | } 18 | 19 | const CodeSnippet = ({ children, language }) => ( 20 | 29 | {children.trim()} 30 | 31 | ) 32 | 33 | export default CodeSnippet; 34 | -------------------------------------------------------------------------------- /example/src/components/StyledComponents.js: -------------------------------------------------------------------------------- 1 | /** @jsx jsx */ 2 | import { jsx } from "@emotion/core"; 3 | import styled from "@emotion/styled"; 4 | /* Note: From Emotion documentation: https://emotion.sh/docs/styled#composing-dynamic-styles 5 | const dynamicStyles = props => 6 | css` 7 | color: ${props.checked ? 'black' : 'grey'}; 8 | background: ${props.checked ? 'linear-gradient(45deg, #FFC107 0%, #fff200 100%)' : '#f5f5f5'}; 9 | ` 10 | */ 11 | 12 | export const ShapeDivider = () => ( 13 |
14 | 15 | 16 | 17 |
18 | ) 19 | 20 | export const Heading = styled.h1` 21 | position: relative; 22 | font-size: 1.875rem; 23 | margin: 5px auto; 24 | ` 25 | 26 | export const TitleContainer = styled.div` 27 | font-size: 1.5rem; 28 | line-height: 1; 29 | display: flex; 30 | flex-flow: column nowrap; 31 | align-items: center; 32 | ` 33 | export const ErrorMessage = styled.div` 34 | margin: 0 auto 5px auto; 35 | height: 20px; 36 | line-height: 20px; 37 | font-size: 1.125rem; 38 | color: hsl(16, 100%, 40%); 39 | ` 40 | 41 | export const IconContainer = styled.div` 42 | width: 40px; 43 | overflow: hidden; 44 | ` 45 | 46 | export const IconsWrapper = styled.div` 47 | position: relative; 48 | display: flex; 49 | flex-flow: row nowrap; 50 | transition: transform 300ms ease-out; 51 | transform: ${props => `translateX(${props.index * -40}px)`}; 52 | ` 53 | 54 | export const InputContainer = styled.div` 55 | position: relative; 56 | height: ${props => props.pageContainerheight ? props.pageContainerheight - 20 : '40'}px; 57 | max-width: 400px; 58 | flex: 1; 59 | display: flex; 60 | flex-flow: column nowrap; 61 | ` 62 | 63 | export const StyledInput = styled.input` 64 | width: 100%; 65 | border: 1px solid #ced4da; 66 | border-radius: 0.25rem; 67 | padding: 0.375rem 0.75rem; 68 | outline: none; 69 | color: ${props => props.theme.colors.extraDark.indigo}; 70 | text-align: left; 71 | transition: border-color 0.15s ease-in-out; 72 | &:focus { 73 | border-color: ${props => props.theme.colors.light.indigo}; 74 | box-shadow: 0 0 0 0.2rem rgba(166, 0, 255, .25); 75 | } 76 | `; 77 | 78 | export const SubmitLabel = styled.div` 79 | font-size: 1.125rem; 80 | font-weight: 500; 81 | &::before { 82 | content: ${props => `'${props.text}'`}; 83 | position: absolute; 84 | top: -3px; 85 | left: -30px; 86 | transition: opacity 400ms ease-in-out, transform 400ms ease-out; 87 | ${props => props.isSubmitPage ? ` 88 | opacity: 1; 89 | visibility: visible; 90 | ` : ` 91 | opacity: 0; 92 | visibility: hidden; 93 | transform: translateX(-60px); 94 | `} 95 | } 96 | ` 97 | 98 | export const NextButton = styled.button` 99 | position: relative; 100 | height: 40px; 101 | width: 40px; 102 | border: 0; 103 | border-radius: 3px; 104 | padding: 0; 105 | background: none; 106 | cursor: pointer; 107 | transition: transform 100ms ease-in-out; 108 | @media (hover: hover) { 109 | &:hover { 110 | background: hsl(0, 0%, 95%); 111 | transition: transform 100ms ease-in-out, background 200ms ease; 112 | } 113 | } 114 | &:active, &.active { 115 | transform: translateX(2px); 116 | background-color: hsl(0, 0%, 100%); 117 | transition: none; 118 | } 119 | &:focus { 120 | outline: none; 121 | border: 2px solid ${props => props.theme.colors.light.indigo}; 122 | } 123 | &:disabled { 124 | right: -350px; 125 | pointer-events: none; 126 | transform: translate(-350px, -10px); 127 | transition: transform 350ms ease-in-out; 128 | } 129 | ` 130 | 131 | export const DownButtonIcon = styled.div` 132 | position: absolute; 133 | top: 50%; 134 | left: 50%; 135 | transform: translate(-50%, -50%); 136 | width: 2px; 137 | height: 17px; 138 | background: hsl(0, 0%, 20%); 139 | &::before { 140 | content: ''; 141 | position: absolute; 142 | left: -3px; 143 | bottom: 1px; 144 | width: 6px; 145 | height: 6px; 146 | transform: rotate(45deg); 147 | border-right: 2px solid; 148 | border-bottom: 2px solid; 149 | border-color: hsl(0, 0%, 20%); 150 | } 151 | ` 152 | 153 | export const NextButtonIcon = styled.div` 154 | position: absolute; 155 | top: 50%; 156 | left: 50%; 157 | transform: translate(-50%, -50%); 158 | width: 17px; 159 | height: 2px; 160 | border-radius: 1px; 161 | background: hsl(0, 0%, 20%); 162 | &::before { 163 | content: ''; 164 | position: absolute; 165 | left: 6px; 166 | bottom: -4px; 167 | width: 8px; 168 | height: 8px; 169 | border-radius: 2px; 170 | transform: rotate(-45deg); 171 | border-right: 2px solid; 172 | border-bottom: 2px solid; 173 | border-color: hsl(0, 0%, 20%); 174 | } 175 | ` 176 | 177 | export const BackButtonIcon = styled.div` 178 | position: absolute; 179 | top: 50%; 180 | left: 50%; 181 | transform: translate(-50%, -50%); 182 | width: 17px; 183 | height: 2px; 184 | border-radius: 1px; 185 | background: hsl(0, 0%, 20%); 186 | &::before { 187 | content: ''; 188 | position: absolute; 189 | left: 1px; 190 | bottom: -4px; 191 | width: 8px; 192 | height: 8px; 193 | border-radius: 2px; 194 | transform: rotate(45deg); 195 | border-left: 2px solid; 196 | border-bottom: 2px solid; 197 | border-color: hsl(0, 0%, 20%); 198 | } 199 | ` -------------------------------------------------------------------------------- /example/src/components/SubscriptionForm.js: -------------------------------------------------------------------------------- 1 | import React, { useRef } from "react"; 2 | import { 3 | useInputs, 4 | withFormContextAndTheme, 5 | FormBody, 6 | Captions, 7 | Input, 8 | RadioControl, 9 | RadioOption, 10 | ComboboxMulti 11 | } from "react-emotion-multi-step-form"; 12 | import Reward from 'react-rewards'; 13 | 14 | import { Heading, ErrorMessage } from "./StyledComponents"; 15 | import { ReactComponent as LinkIcon } from "../assets/svg/link.svg"; 16 | import { ReactComponent as TreeIcon } from "../assets/svg/tree.svg"; 17 | import { ReactComponent as PriceTagsIcon } from "../assets/svg/price-tags.svg"; 18 | import options from "../data"; 19 | 20 | const Form = ({ className }) => { 21 | const { error, isSubmitPage } = useInputs(); 22 | const rewardRef = useRef(); 23 | 24 | const handleSubmit = data => { 25 | console.log(data); 26 | rewardRef.current.rewardMe(); 27 | }; 28 | 29 | return ( 30 |
31 | 32 | Newsletter Subscription 33 | 34 | 35 | {isSubmitPage ? () : null} 36 | 37 | 45 | 51 | 52 | 53 | 54 | 55 | 62 | 63 | {error.message} 64 |
65 | ); 66 | } 67 | 68 | export default withFormContextAndTheme(Form); -------------------------------------------------------------------------------- /example/src/data.js: -------------------------------------------------------------------------------- 1 | export default [ 2 | ['Arts & Entertainment', 'Industry', 'Innovation & Tech', 'Life',], 3 | [ 4 | [ 5 | 'Art', 6 | 'Beauty', 7 | 'Culture', 8 | 'Fiction', 9 | 'Film', 10 | 'Food', 11 | 'Gaming', 12 | 'Humor', 13 | 'Music', 14 | 'Nonfiction', 15 | 'Sports', 16 | ], 17 | [ 18 | 'Business', 19 | 'Design', 20 | 'Freelancing', 21 | 'Leadership', 22 | 'Marketing', 23 | 'Productivity', 24 | 'Remote Work', 25 | 'Startups', 26 | 'Venture Capital', 27 | ], 28 | [ 29 | 'Accessibility', 30 | 'Android Dev', 31 | 'Artificial Intelligence', 32 | 'Blockchain', 33 | 'Data Science', 34 | 'Gadgets', 35 | 'iOS Dev', 36 | 'Javascript', 37 | 'Machine Learning', 38 | 'Math', 39 | 'Science', 40 | 'Space', 41 | 'Technology', 42 | 'UX', 43 | 'Visual Design', 44 | 'Web Dev', 45 | ], 46 | [ 47 | 'Addiction', 48 | 'Creativity', 49 | 'Disability', 50 | 'Family', 51 | 'Fitness', 52 | 'Health', 53 | 'Lifestyle', 54 | 'Mental Health', 55 | 'Money', 56 | 'Outdoors', 57 | 'Parenting', 58 | 'Pets', 59 | 'Psychology', 60 | 'Relationships', 61 | 'Self', 62 | 'Sexuality', 63 | 'Spirituality', 64 | 'Travel', 65 | ], 66 | ] 67 | ] -------------------------------------------------------------------------------- /example/src/example-app-demo-final.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/z2lai/react-emotion-multi-step-form/f09b0b7dc7f2d5f507fd2cdf21b03c98cf72cf0d/example/src/example-app-demo-final.gif -------------------------------------------------------------------------------- /example/src/index.css: -------------------------------------------------------------------------------- 1 | body { 2 | overflow-x: hidden; 3 | margin: 0; 4 | font-family: 'Segoe UI', 'Roboto', 'Oxygen', 5 | 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', 6 | sans-serif; 7 | -webkit-font-smoothing: antialiased; 8 | -moz-osx-font-smoothing: grayscale; 9 | } 10 | 11 | code { 12 | font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New', 13 | monospace; 14 | } -------------------------------------------------------------------------------- /example/src/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | 4 | import './index.css'; 5 | import App from './components/App'; 6 | import * as serviceWorker from './serviceWorker'; 7 | 8 | ReactDOM.render(, document.getElementById('root')); 9 | 10 | // If you want your app to work offline and load faster, you can change 11 | // unregister() to register() below. Note this comes with some pitfalls. 12 | // Learn more about service workers: https://bit.ly/CRA-PWA 13 | serviceWorker.unregister(); 14 | -------------------------------------------------------------------------------- /example/src/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /example/src/serviceWorker.js: -------------------------------------------------------------------------------- 1 | // This optional code is used to register a service worker. 2 | // register() is not called by default. 3 | 4 | // This lets the app load faster on subsequent visits in production, and gives 5 | // it offline capabilities. However, it also means that developers (and users) 6 | // will only see deployed updates on subsequent visits to a page, after all the 7 | // existing tabs open on the page have been closed, since previously cached 8 | // resources are updated in the background. 9 | 10 | // To learn more about the benefits of this model and instructions on how to 11 | // opt-in, read https://bit.ly/CRA-PWA 12 | 13 | const isLocalhost = Boolean( 14 | window.location.hostname === 'localhost' || 15 | // [::1] is the IPv6 localhost address. 16 | window.location.hostname === '[::1]' || 17 | // 127.0.0.0/8 are considered localhost for IPv4. 18 | window.location.hostname.match( 19 | /^127(?:\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}$/ 20 | ) 21 | ); 22 | 23 | export function register(config) { 24 | if (process.env.NODE_ENV === 'production' && 'serviceWorker' in navigator) { 25 | // The URL constructor is available in all browsers that support SW. 26 | const publicUrl = new URL(process.env.PUBLIC_URL, window.location.href); 27 | if (publicUrl.origin !== window.location.origin) { 28 | // Our service worker won't work if PUBLIC_URL is on a different origin 29 | // from what our page is served on. This might happen if a CDN is used to 30 | // serve assets; see https://github.com/facebook/create-react-app/issues/2374 31 | return; 32 | } 33 | 34 | window.addEventListener('load', () => { 35 | const swUrl = `${process.env.PUBLIC_URL}/service-worker.js`; 36 | 37 | if (isLocalhost) { 38 | // This is running on localhost. Let's check if a service worker still exists or not. 39 | checkValidServiceWorker(swUrl, config); 40 | 41 | // Add some additional logging to localhost, pointing developers to the 42 | // service worker/PWA documentation. 43 | navigator.serviceWorker.ready.then(() => { 44 | console.log( 45 | 'This web app is being served cache-first by a service ' + 46 | 'worker. To learn more, visit https://bit.ly/CRA-PWA' 47 | ); 48 | }); 49 | } else { 50 | // Is not localhost. Just register service worker 51 | registerValidSW(swUrl, config); 52 | } 53 | }); 54 | } 55 | } 56 | 57 | function registerValidSW(swUrl, config) { 58 | navigator.serviceWorker 59 | .register(swUrl) 60 | .then(registration => { 61 | registration.onupdatefound = () => { 62 | const installingWorker = registration.installing; 63 | if (installingWorker == null) { 64 | return; 65 | } 66 | installingWorker.onstatechange = () => { 67 | if (installingWorker.state === 'installed') { 68 | if (navigator.serviceWorker.controller) { 69 | // At this point, the updated precached content has been fetched, 70 | // but the previous service worker will still serve the older 71 | // content until all client tabs are closed. 72 | console.log( 73 | 'New content is available and will be used when all ' + 74 | 'tabs for this page are closed. See https://bit.ly/CRA-PWA.' 75 | ); 76 | 77 | // Execute callback 78 | if (config && config.onUpdate) { 79 | config.onUpdate(registration); 80 | } 81 | } else { 82 | // At this point, everything has been precached. 83 | // It's the perfect time to display a 84 | // "Content is cached for offline use." message. 85 | console.log('Content is cached for offline use.'); 86 | 87 | // Execute callback 88 | if (config && config.onSuccess) { 89 | config.onSuccess(registration); 90 | } 91 | } 92 | } 93 | }; 94 | }; 95 | }) 96 | .catch(error => { 97 | console.error('Error during service worker registration:', error); 98 | }); 99 | } 100 | 101 | function checkValidServiceWorker(swUrl, config) { 102 | // Check if the service worker can be found. If it can't reload the page. 103 | fetch(swUrl, { 104 | headers: { 'Service-Worker': 'script' } 105 | }) 106 | .then(response => { 107 | // Ensure service worker exists, and that we really are getting a JS file. 108 | const contentType = response.headers.get('content-type'); 109 | if ( 110 | response.status === 404 || 111 | (contentType != null && contentType.indexOf('javascript') === -1) 112 | ) { 113 | // No service worker found. Probably a different app. Reload the page. 114 | navigator.serviceWorker.ready.then(registration => { 115 | registration.unregister().then(() => { 116 | window.location.reload(); 117 | }); 118 | }); 119 | } else { 120 | // Service worker found. Proceed as normal. 121 | registerValidSW(swUrl, config); 122 | } 123 | }) 124 | .catch(() => { 125 | console.log( 126 | 'No internet connection found. App is running in offline mode.' 127 | ); 128 | }); 129 | } 130 | 131 | export function unregister() { 132 | if ('serviceWorker' in navigator) { 133 | navigator.serviceWorker.ready.then(registration => { 134 | registration.unregister(); 135 | }); 136 | } 137 | } 138 | -------------------------------------------------------------------------------- /example/src/setupTests.js: -------------------------------------------------------------------------------- 1 | // jest-dom adds custom jest matchers for asserting on DOM nodes. 2 | // allows you to do things like: 3 | // expect(element).toHaveTextContent(/react/i) 4 | // learn more: https://github.com/testing-library/jest-dom 5 | import '@testing-library/jest-dom/extend-expect'; 6 | -------------------------------------------------------------------------------- /example/src/wip/Drawer.js: -------------------------------------------------------------------------------- 1 | /** @jsx jsx */ 2 | import { jsx } from "@emotion/core"; 3 | import styled from "@emotion/styled"; 4 | 5 | const StyledDrawer = styled.div` 6 | box-sizing: border-box; 7 | position: absolute; 8 | top: 0; 9 | right: 0; 10 | display: inline-block; 11 | width: 33%; 12 | height: 100vh; 13 | padding: 20px; 14 | background: hsl(279, 25%, 25%); 15 | color: hsl(0, 100%, 99%); 16 | transform: translate3d(100%, 0, 0); 17 | will-change: transform; 18 | visibility: hidden; 19 | ${props => props.isDrawerOut ? ` 20 | transform: translate3d(0, 0, 0); 21 | visibility: visible; 22 | transition: transform 400ms cubic-bezier(0.190, 1.000, 0.220, 1.000); 23 | ` : ` 24 | `} 25 | p, li { 26 | line-height: 1.6rem; 27 | } 28 | h2 { 29 | margin-bottom: 10px; 30 | } 31 | `; 32 | 33 | const Drawer = ({ isDrawerOut }) => { 34 | 35 | return ( 36 | 37 |

Introduction

38 |

Multi-step Form Benefits

39 |

40 | Multi-step forms are a great way to break long forms into multiple pieces. By allowing users to submit information in smaller chunks, you can create a positive user experience and increase conversions. Each chunk appears less intimidating and users can be encouraged to complete the form with a progress indicator. 41 |

42 |

43 | One key benefit of multi-step forms is the ability to capture sensitive information by starting with a low-friction question that engages users. In this Newsletter Subscription example, the first question is "What are your interests?" Not only is it a low friction question, it also puts the user in the frame of mind where they’re thinking about the benefit of your product/service. 44 |

45 |

46 | Other benefits include the ability to use custom formatted inputs that's optimized for mobile, and the ability to use conditional logic to personalize or pre-filter the questions at later steps. 47 |

48 |

Library Features

49 |

50 | The main goal of this form library is to allow you to configure your own multi-step form using concise and declarative code. Other features include: 51 |

52 |
    53 |
  • Smooth animations for an interactive feel
  • 54 |
  • Easy keyboard-only navigation
  • 55 |
  • Responsive design
  • 56 |
  • Custom Hooks to access form state and customize navigation and progress indicator
  • 57 |
58 |
59 | ) 60 | } 61 | 62 | export default Drawer; -------------------------------------------------------------------------------- /example/src/wip/Form.js: -------------------------------------------------------------------------------- 1 | import React, { useState, useRef } from "react"; 2 | import { 3 | useInputs, 4 | withFormContextAndTheme, 5 | FormBody, 6 | Labels, 7 | TextInput, 8 | RadioControl, 9 | RadioOption, 10 | ComboboxMulti 11 | } from "react-emotion-multi-step-form"; 12 | import Reward from 'react-rewards'; 13 | 14 | import { ReactComponent as LinkIcon } from "../fonts/icomoon/svg/link.svg"; 15 | import { ReactComponent as TreeIcon } from "../fonts/icomoon/svg/tree.svg"; 16 | import { ReactComponent as PriceTagsIcon } from "../fonts/icomoon/svg/price-tags.svg"; 17 | 18 | import { Heading, TitleContainer, ErrorMessage } from "../components/StyledComponents"; 19 | // import Title from "./Title"; 20 | 21 | // If Form is re-rendered a lot, improve performance by memoizing child components that are large like so: 22 | // const MemoizedCheckboxMultiControl = React.memo(CheckboxMultiControl); 23 | 24 | const Form = props => { 25 | console.log('Form rendered!'); 26 | const { error, isSubmitPage } = useInputs(); 27 | const [tagOptions, setTagOptions] = useState([ // fetch data in useEffect hook to update this state after initial render 28 | ['suggestions', 'parent categories', 'syntax', 'fundamentals'], 29 | [ 30 | [ 31 | 'object', 32 | 'scope', 33 | 'execution context', 34 | 'closures', 35 | 'nodejs', 36 | 'es6', 37 | 'express', 38 | ], 39 | [ 40 | 'asynchronous', 41 | 'execution context', 42 | 'syntax', 43 | 'context', 44 | 'fundamentals', 45 | 'object', 46 | 'object oriented programming', 47 | 'ES6', 48 | 'web browser', 49 | 'developer tools', 50 | 'best practice', 51 | ], 52 | [ 53 | 'operators', 54 | 'control flow', 55 | 'data types', 56 | 'express', 57 | 'nodejs', 58 | ], 59 | [ 60 | 'scope', 61 | 'error handling', 62 | 'asynchronous', 63 | 'closures', 64 | ], 65 | ] 66 | ]); 67 | const rewardRef = useRef(); 68 | 69 | const handleUrlChange = url => console.log(`handleUrlChange called with: ${url}`); 70 | const handleTypeChange = type => console.log(`handleType called with: ${type}`); 71 | const handleTagsChange = tags => console.log(`handleTags called with: ${tags}`); 72 | const handleSubmit = payload => { 73 | console.log('Form submitted with the form fields:'); 74 | console.log(payload); 75 | console.log(rewardRef); 76 | rewardRef.current.rewardMe(); 77 | }; 78 | 79 | return ( 80 |
81 | Submit An Article To the Communal Curator 82 | {/* 83 | 89 | <Title 90 | value={inputValues['type'] || 'Select Resource Type'} 91 | page={1} 92 | active={activeIndex === 1} 93 | changeActivePage={changeActiveIndex} 94 | /> 95 | <Title 96 | value={((inputValues['tags'] && inputValues['tags'].length) && inputValues['tags'].join(', ')) || 'Select Article Tags'} 97 | page={2} 98 | active={activeIndex === 2} 99 | changeActivePage={changeActiveIndex} 100 | /> 101 | </TitleContainer> */} 102 | <Labels /> 103 | {isSubmitPage ? (<Reward ref={rewardRef} type="confetti"></Reward>) : null} 104 | <FormBody onSubmit={handleSubmit}> 105 | <RadioControl 106 | name="type" 107 | label="Type" 108 | icon={TreeIcon} 109 | height={100} 110 | validationRules={{ required: 'Please select a Type!' }} 111 | onChange={handleTypeChange} 112 | > 113 | <RadioOption value="guide" /> 114 | <RadioOption value="tutorial" /> 115 | <RadioOption value="reference" /> 116 | <RadioOption value="video" /> 117 | <RadioOption value="library" /> 118 | <RadioOption value="tool" /> 119 | </RadioControl> 120 | <TextInput 121 | name="url" 122 | placeholder="url" 123 | label="Url" 124 | icon={LinkIcon} 125 | validationRules={{ required: 'Please fill in the URL!' }} 126 | onChange={handleUrlChange} 127 | /> 128 | <ComboboxMulti 129 | name="tags" 130 | label="Tags" 131 | icon={PriceTagsIcon} 132 | height={240} 133 | validationRules={{ required: 'Please select a Tag!' }} 134 | options={tagOptions} 135 | onChange={handleTagsChange} 136 | /> 137 | </FormBody> 138 | <ErrorMessage>{error.message}</ErrorMessage> 139 | </div> 140 | ); 141 | } 142 | 143 | export default withFormContextAndTheme(Form); -------------------------------------------------------------------------------- /example/src/wip/InfoCheckbox.js: -------------------------------------------------------------------------------- 1 | /** @jsx jsx */ 2 | import { jsx } from "@emotion/core"; 3 | import styled from "@emotion/styled"; 4 | 5 | import { ReactComponent as InfoIcon } from "../assets/info.svg"; 6 | 7 | const InfoLabel = styled.label` 8 | margin-left: 10px; 9 | opacity: .5; 10 | cursor: pointer; 11 | ${props => props.checked ? ` 12 | opacity: 1; 13 | ` : ` 14 | &:hover { 15 | opacity: .75; 16 | } 17 | ` 18 | } 19 | ` 20 | 21 | // Hide checkbox visually but remain accessible to screen readers. 22 | // Source: https://polished.js.org/docs/#hidevisually 23 | const HiddenCheckbox = styled.input` 24 | position: absolute; 25 | margin: -1px; 26 | border: 0; 27 | padding: 0; 28 | overflow: hidden; 29 | white-space: nowrap; 30 | clip-path: inset(50%); 31 | ` 32 | 33 | const StyledInfoIcon = styled(InfoIcon)` 34 | fill: ${props => props.theme.colors.dark.indigo}; 35 | ` 36 | 37 | const InfoCheckbox = ({ checked, onChange }) => ( 38 | <InfoLabel checked={checked}> 39 | <HiddenCheckbox 40 | type="checkbox" 41 | id="infoCheckbox" 42 | checked={checked} 43 | onChange={onChange} 44 | /> 45 | <StyledInfoIcon /> 46 | </InfoLabel> 47 | ) 48 | 49 | export default InfoCheckbox; -------------------------------------------------------------------------------- /example/src/wip/Title.js: -------------------------------------------------------------------------------- 1 | /** @jsx jsx */ 2 | import { useState, useEffect } from "react"; 3 | import { jsx } from "@emotion/core"; 4 | import styled from "@emotion/styled"; 5 | 6 | //? Change this to a link element for accessibility? 7 | const StyledTitle = styled.span` 8 | margin: 5px 0; 9 | text-transform: capitalize; 10 | transition: all 600ms; 11 | ${props => props.active ? ` 12 | color: hsl(279, 75%, 35%); 13 | ` : props.activated ? ` 14 | color: hsl(279, 9%, 25%); 15 | cursor: pointer; 16 | ` : ` 17 | color: hsl(0, 100%, 99%); 18 | opacity: 0.5; 19 | `} 20 | `; 21 | 22 | const Title = ({ active, value, page, changeActivePage }) => { 23 | const [activated, setActivated] = useState(false); 24 | 25 | useEffect(() => { 26 | if (active && !activated) { 27 | setActivated(true); 28 | } 29 | }, [active, activated]) 30 | 31 | const handleClick = event => { 32 | if (activated && !active) { 33 | console.log('click!'); 34 | changeActivePage(page); 35 | } 36 | } 37 | 38 | return ( 39 | <StyledTitle active={active} activated={activated} onClick={handleClick}> 40 | {value} 41 | </StyledTitle> 42 | ) 43 | } 44 | 45 | export default Title; -------------------------------------------------------------------------------- /example/src/wip/withLog.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | // Console logs component name if component is rendered by reconciliation starting in its parent 4 | const withLog = Component => props => { 5 | console.log(`${Component.name}`); 6 | return <Component {...props} />; 7 | } 8 | 9 | export default withLog; -------------------------------------------------------------------------------- /manual.md: -------------------------------------------------------------------------------- 1 | ## Walkthrough 2 | 3 | This guide is for people who don't want to use the accompanying CLI [create-react-library](https://github.com/transitive-bullshit/create-react-library). 4 | 5 | Check out the accompanying [blog post](https://hackernoon.com/publishing-baller-react-modules-2b039d84bce7) which gives more in-depth explanations on how to create an example component using this boilerplate. 6 | 7 | On this page, we'll give a quick rundown of the essential steps. 8 | 9 | #### Getting Started 10 | 11 | The first step is to clone this repo and rename / replace all boilerplate names to match your custom module. In this example, we'll be creating a module named `react-poop-emoji`. 12 | 13 | ```bash 14 | # clone and rename base boilerplate repo 15 | git clone https://github.com/transitive-bullshit/react-modern-library-boilerplate.git 16 | mv react-modern-library-boilerplate react-poop-emoji 17 | cd react-poop-emoji 18 | rm -rf .git 19 | ``` 20 | 21 | ```bash 22 | # replace boilerplate placeholders with your module-specific values 23 | # NOTE: feel free to use your favorite find & replace method instead of sed 24 | mv readme.template.md readme.md 25 | sed -i 's/react-modern-library-boilerplate/react-poop-emoji/g' *.{json,md} src/*.js example/*.json example/src/*.js example/public/*.{html,json} 26 | sed -i 's/transitive-bullshit/your-github-username/g' package.json example/package.json 27 | ``` 28 | 29 | #### Local Development 30 | 31 | Now you're ready to run a local version of rollup that will watch your `src/` component and automatically recompile it into `dist/` whenever you make changes. 32 | 33 | ```bash 34 | # run example to start developing your new component against 35 | npm link # the link commands are important for local development 36 | npm install # disregard any warnings about missing peer dependencies 37 | npm start # runs rollup with watch flag 38 | ``` 39 | 40 | We'll also be running our `example/` create-react-app that's linked to the local version of your `react-poop-emoji` module. 41 | 42 | ```bash 43 | # (in another tab) 44 | cd example 45 | npm link react-poop-emoji 46 | npm install 47 | npm start # runs create-react-app dev server 48 | ``` 49 | 50 | Now, anytime you make a change to your component in `src/` or to the example app's `example/src`, `create-react-app` will live-reload your local dev server so you can iterate on your component in real-time. 51 | 52 | ![](https://media.giphy.com/media/12NUbkX6p4xOO4/giphy.gif) 53 | 54 | #### NPM Stuffs 55 | 56 | The only difference when publishing your component to **npm** is to make sure you add any npm modules you want as peer dependencies are properly marked as `peerDependencies` in `package.json`. The rollup config will automatically recognize them as peer dependencies and not try to bundle them in your module. 57 | 58 | Then publish as per usual. 59 | 60 | ```bash 61 | # note this will build `commonjs` and `es`versions of your module to dist/ 62 | npm publish 63 | ``` 64 | 65 | #### Github Pages 66 | 67 | Deploying the example to github pages is simple. We create a production build of our example `create-react-app` that showcases your library and then run `gh-pages` to deploy the resulting bundle. This can be done as follows: 68 | 69 | ```bash 70 | npm run deploy 71 | ``` 72 | 73 | Note that it's important for your `example/package.json` to have the correct `homepage` property set, as `create-react-app` uses this value as a prefix for resolving static asset URLs. 74 | 75 | ## Examples 76 | 77 | Here is an example react module created from this guide: [react-background-slideshow](https://github.com/transitive-bullshit/react-background-slideshow), a sexy tiled background slideshow for React. It comes with an example create-react-app hosted on github pages and should give you a good idea of the type of module you’ll be able to create starting from this boilerplate. 78 | 79 | ### Multiple Named Exports 80 | 81 | Here is a [branch](https://github.com/transitive-bullshit/react-modern-library-boilerplate/tree/feature/multiple-exports) which demonstrates how to create a module with multiple named exports. The module in this branch exports two components, `Foo` and `Bar`, and shows how to use them from the example app. 82 | 83 | ### Material-UI 84 | 85 | Here is a [branch](https://github.com/transitive-bullshit/react-modern-library-boilerplate/tree/feature/material-ui) which demonstrates how to create a module that makes use of a relatively complicated peer dependency, [material-ui](https://github.com/mui-org/material-ui). It shows the power of [rollup-plugin-peer-deps-external](https://www.npmjs.com/package/rollup-plugin-peer-deps-external) which makes it a breeze to create reusable modules that include complicated material-ui subcomponents without having them bundled as a part of your module. 86 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-emotion-multi-step-form", 3 | "version": "0.10.0", 4 | "description": "React multi-step form component library styled with Emotion", 5 | "author": "Zheng Lai", 6 | "license": "MIT", 7 | "repository": { 8 | "type": "git", 9 | "url": "https://github.com/z2lai/react-emotion-multi-step-form.git" 10 | }, 11 | "main": "dist/index.js", 12 | "module": "dist/index.es.js", 13 | "engines": { 14 | "node": ">=8", 15 | "npm": ">=5" 16 | }, 17 | "scripts": { 18 | "test": "cross-env CI=1 react-scripts test --env=jsdom", 19 | "build": "rollup -c", 20 | "start": "rollup -c -w", 21 | "prepublish": "npm run build", 22 | "predeploy": "cd example && npm install && npm run build", 23 | "deploy": "gh-pages -d example/build" 24 | }, 25 | "dependencies": { 26 | "prop-types": "^15.7.2", 27 | "react-bootstrap-typeahead": "^5.0.0-rc.1" 28 | }, 29 | "peerDependencies": { 30 | "@emotion/core": "^10.0.27", 31 | "@emotion/styled": "^10.0.27", 32 | "emotion-theming": "^10.0.27", 33 | "react": "^16.8.0", 34 | "react-dom": "^16.8.0", 35 | "react-scripts": "^3.4.0" 36 | }, 37 | "devDependencies": { 38 | "babel-core": "^6.26.3", 39 | "babel-plugin-external-helpers": "^6.22.0", 40 | "babel-preset-env": "^1.7.0", 41 | "babel-preset-react": "^6.24.1", 42 | "babel-preset-stage-0": "^6.24.1", 43 | "cross-env": "^5.1.4", 44 | "eslint-config-standard": "^11.0.0", 45 | "eslint-config-standard-react": "^6.0.0", 46 | "eslint-plugin-node": "^7.0.1", 47 | "eslint-plugin-promise": "^4.0.0", 48 | "eslint-plugin-standard": "^3.1.0", 49 | "gh-pages": "^1.2.0", 50 | "react": "16.12.0", 51 | "react-dom": "16.12.0", 52 | "react-scripts": "^3.4.1", 53 | "rollup": "^0.64.1", 54 | "rollup-plugin-babel": "^3.0.7", 55 | "rollup-plugin-commonjs": "^9.1.3", 56 | "rollup-plugin-node-resolve": "^3.3.0", 57 | "rollup-plugin-peer-deps-external": "^2.2.0", 58 | "rollup-plugin-postcss": "^1.6.2", 59 | "rollup-plugin-url": "^1.4.0" 60 | }, 61 | "files": [ 62 | "dist" 63 | ] 64 | } 65 | -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | import babel from 'rollup-plugin-babel' 2 | import commonjs from 'rollup-plugin-commonjs' 3 | import external from 'rollup-plugin-peer-deps-external' 4 | import postcss from 'rollup-plugin-postcss' 5 | import resolve from 'rollup-plugin-node-resolve' 6 | import url from 'rollup-plugin-url' 7 | 8 | import pkg from './package.json' 9 | 10 | export default { 11 | input: 'src/index.js', 12 | output: [ 13 | { 14 | file: pkg.main, 15 | format: 'cjs', 16 | sourcemap: true 17 | }, 18 | { 19 | file: pkg.module, 20 | format: 'es', 21 | sourcemap: true 22 | } 23 | ], 24 | external: [ 25 | 'react', 26 | 'react-dom', 27 | '@emotion/core', 28 | '@emotion/styled', 29 | 'emotion-theming' 30 | ], 31 | plugins: [ 32 | external(), 33 | postcss({ 34 | modules: false 35 | }), 36 | url(), 37 | babel({ 38 | exclude: 'node_modules/**', 39 | plugins: [ 'external-helpers' ] 40 | }), 41 | resolve(), 42 | commonjs() 43 | ] 44 | } 45 | -------------------------------------------------------------------------------- /src/.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "jest": true 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /src/components/Captions.js: -------------------------------------------------------------------------------- 1 | /** @jsx jsx */ 2 | import { Fragment } from "react"; 3 | import { jsx } from "@emotion/core"; 4 | import styled from "@emotion/styled"; 5 | import PropTypes from 'prop-types'; 6 | 7 | import useInputs from "../core/useInputs"; 8 | 9 | const StyledCaption = styled.span` 10 | ${props => props.isActive ? ` 11 | visibility: visible; 12 | opacity: 1; 13 | transition: opacity 600ms ease-out; 14 | ` : ` 15 | position: absolute; 16 | visibility: hidden; 17 | opacity: 0; 18 | `} 19 | `; 20 | 21 | export const CaptionsContainer = styled.div` 22 | display: flex; 23 | justify-content: center; 24 | align-items: center; 25 | margin-bottom: 20px; 26 | height: 3rem; 27 | font-size: 1.25rem; 28 | @media (min-width: 481px) { 29 | height: 2rem; 30 | } 31 | ` 32 | 33 | const Caption = ({ caption, isActive }) => ( 34 | <StyledCaption isActive={isActive}> 35 | {caption} 36 | </StyledCaption> 37 | ) 38 | 39 | const Captions = ({ callToActionText }) => { 40 | const { inputs, activeIndex, isSubmitPage } = useInputs(); 41 | 42 | return ( 43 | <CaptionsContainer> 44 | {(inputs.length > 0) 45 | ? <Fragment> 46 | {inputs.map((input, index) => ( 47 | <Caption 48 | key={`${index}${input.name}`} 49 | caption={input.caption} 50 | isActive={index === activeIndex} 51 | /> 52 | ))} 53 | <Caption key="CTA" caption={callToActionText} isActive={isSubmitPage} /> 54 | </Fragment> 55 | : null 56 | } 57 | </CaptionsContainer> 58 | ) 59 | } 60 | 61 | Captions.propTypes = { callToActionText: PropTypes.string.isRequired }; 62 | 63 | export default Captions; -------------------------------------------------------------------------------- /src/components/Checkbox.js: -------------------------------------------------------------------------------- 1 | /** @jsx jsx */ 2 | import { Fragment } from 'react' 3 | import { jsx } from "@emotion/core"; 4 | import styled from "@emotion/styled"; 5 | 6 | const StyledLabel = styled.label` 7 | position: relative; 8 | margin: 0; 9 | display: inline-flex; 10 | align-items: flex-start; 11 | line-height: 1.25rem; 12 | font-size: 1.125rem; 13 | white-space: nowrap; 14 | cursor: pointer; 15 | ${props => ` 16 | font-weight: ${props.focusState ? '600' : '400'}; 17 | color: ${props.checked ? props.theme.colors.dark.indigo : 'inherit'}; 18 | :hover { 19 | color: ${props.theme.colors.dark.indigo}; 20 | } 21 | input + div { 22 | box-shadow: ${props.focusState ? `0 0 0 2px ${props.theme.colors.base.indigo}` : `none`}; 23 | } 24 | input:focus + div { 25 | box-shadow: 0 0 0 2px ${props.theme.colors.base.indigo}; 26 | } 27 | mark { 28 | display: inline; 29 | padding: 0; 30 | font-weight: bold; 31 | background-color: inherit; 32 | color: ${props.theme.colors.dark.indigo}; 33 | } 34 | `} 35 | ` 36 | 37 | // Hide checkbox visually but remain accessible to screen readers. 38 | // Source: https://polished.js.org/docs/#hidevisually 39 | const HiddenCheckbox = styled.input` 40 | position: absolute; 41 | margin: -1px; 42 | border: 0; 43 | padding: 0; 44 | overflow: hidden; 45 | white-space: nowrap; 46 | clip-path: inset(50%); 47 | ` 48 | 49 | const StyledCheckbox = styled.div` 50 | flex: none; 51 | width: 16px; 52 | height: 16px; 53 | margin-top: 6px; 54 | margin-right: 10px; 55 | border-radius: 3px; 56 | ${props => ` 57 | border: ${props.checked ? 'none' : `2px solid ${props.theme.colors.extraDark.indigo}`}; 58 | background: ${props.checked ? props.theme.colors.dark.indigo : 'none'}; 59 | svg { 60 | visibility: ${props.checked ? 'visible' : 'hidden'}; 61 | } 62 | `} 63 | transition: all 150ms; 64 | ` 65 | 66 | const Icon = styled.svg` 67 | display: block; 68 | margin-top: -1; 69 | fill: none; 70 | stroke: white; 71 | stroke-width: 2px; 72 | ` 73 | 74 | const TextWithHighlight = ({ text = '', highlight = '' }) => { 75 | if (!highlight.trim()) { 76 | return <span>{text}</span>; 77 | } 78 | // Split on highlight text and include text, ignore case 79 | const regex = new RegExp(`(${highlight})`, 'gi'); 80 | const parts = text.split(regex); // if match is found at start/end, an empty element is inserted at start/end 81 | const textWithHighlight = parts.map((part, index) => ( 82 | regex.test(part) 83 | ? <mark key={index}>{part}</mark> 84 | : (part.length > 0 || (index > 0 && index < parts.length - 1)) // exclude leading/trailing empty element 85 | && <Fragment key={index}>{part}</Fragment> 86 | )); 87 | return <span>{textWithHighlight}</span>; 88 | } 89 | 90 | const CustomCheckbox = ({ name, value, checked, onKeyDown, onChange, highlightedText, focusState }) => ( 91 | <StyledLabel checked={checked} focusState={focusState}> 92 | <HiddenCheckbox 93 | type="checkbox" 94 | name={name} 95 | value={value} 96 | checked={checked} 97 | onKeyDown={onKeyDown} 98 | onChange={onChange} 99 | /> 100 | <StyledCheckbox checked={checked}> 101 | <Icon viewBox="0 0 24 24"> 102 | <polyline points="20 6 9 17 4 12" /> 103 | </Icon> 104 | </StyledCheckbox> 105 | <TextWithHighlight text={value} highlight={highlightedText} /> 106 | </StyledLabel> 107 | ); 108 | 109 | export default CustomCheckbox; -------------------------------------------------------------------------------- /src/components/ComboboxMulti.js: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect, useRef, useCallback } from "react"; 2 | import styled from "@emotion/styled"; 3 | import inputPropTypes from '../propTypes' 4 | 5 | import useAddInput from "../core/useAddInput"; 6 | import useInputState from "../core/useInputState"; 7 | 8 | import "react-bootstrap-typeahead/css/Typeahead.css"; 9 | import { Typeahead, Hint, Input, Token } from "react-bootstrap-typeahead"; 10 | import InputWrapper from "./InputWrapper"; 11 | import Checkbox from "./Checkbox"; 12 | 13 | import debounce from "../utils/debounce"; 14 | import throttle from "../utils/throttle"; 15 | 16 | const StyledTypeahead = styled(Typeahead)` 17 | width: 100%; 18 | // Bootstrap class 19 | .sr-only { 20 | position: absolute; 21 | margin: -1px; 22 | width: 1px; 23 | height: 1px; 24 | border: 0; 25 | padding: 0; 26 | overflow: hidden; 27 | clip: rect(0, 0, 0, 0); 28 | white-space: nowrap; 29 | } 30 | button.close { 31 | border: 0; 32 | padding: 0 7px; 33 | background-color: transparent; 34 | cursor: pointer; 35 | opacity: .5; 36 | :hover, :focus { 37 | opacity: .75; 38 | } 39 | } 40 | #typeahead { 41 | visibility: hidden; 42 | } 43 | input.rbt-input-main { 44 | width: 100%; 45 | border: 0px; 46 | padding: 0px; 47 | outline: none; 48 | box-shadow: none; 49 | background-color: transparent; 50 | cursor: inherit; 51 | z-index: 1; 52 | } 53 | ${props => ` 54 | .rbt-input-wrapper { 55 | width: 100%; 56 | border: 1px solid #ced4da; 57 | border-radius: 0.25rem; 58 | padding: 6px 34px 6px 12px; 59 | overflow: hidden; 60 | display: flex; 61 | align-items: flex-start; 62 | flex-flow: row wrap; 63 | font-weight: 400; 64 | color: ${props.theme.colors.extraDark.indigo}; 65 | background-color: #fff; 66 | background-clip: padding-box; 67 | transition: border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out; 68 | } 69 | .rbt-input-wrapper.focus { 70 | border-color: ${props.theme.colors.light.indigo}; 71 | box-shadow: 0 0 0 0.2rem rgba(166, 0, 255, .25); 72 | } 73 | .rbt-token { 74 | margin: 2px 3px 1px 0px; 75 | padding-top: 2px; 76 | padding-bottom: 3px; 77 | background-color: ${props.theme.colors.extraLight.indigo}; 78 | color: ${props.theme.colors.dark.indigo}; 79 | button.close { 80 | opacity: 1; 81 | } 82 | } 83 | .rbt-token-active { 84 | background-color: ${props.theme.colors.dark.indigo}; 85 | color: #FFF; 86 | } 87 | `} 88 | `; 89 | 90 | const CheckboxSectionContainer = styled.div` 91 | margin-top: 10px; 92 | flex: 1 1 auto; 93 | width: 100%; 94 | height: 60px; 95 | overflow: auto; 96 | `; 97 | 98 | const CheckboxSectionWrapper = styled.div` 99 | height: auto; 100 | width: 100%; 101 | text-align: left; 102 | ${(props) => ` 103 | color: ${props.theme.colors.extraDark.indigo}; 104 | `} 105 | `; 106 | 107 | const GroupContainer = styled.div` 108 | display: flex; 109 | flex-flow: row wrap; 110 | padding: 0 10px; 111 | `; 112 | 113 | const GroupHeading = styled.div` 114 | margin: 0 0 5px 5px; 115 | font-size: 1.25rem; 116 | font-weight: bold; 117 | text-transform: capitalize; 118 | `; 119 | 120 | const CheckboxWrapper = styled.div` 121 | flex: 1 1 50%; 122 | margin: 3px 0; 123 | padding: 0 2px; 124 | `; 125 | 126 | const ComboboxMulti = ({ 127 | name, 128 | onChange, 129 | label, 130 | caption, 131 | icon, 132 | height, 133 | validationRules, 134 | options, 135 | }) => { 136 | const { refCallback } = useAddInput({ label, caption, icon, height, validationRules }); 137 | const { value: selected, setValue: setSelected } = useInputState(name, []); 138 | const [filter, setFilter] = useState(""); 139 | const [filteredOptions, setFilteredOptions] = useState(options); 140 | const [focusedOptionIndex, setFocusedOptionIndex] = useState(-1); 141 | 142 | const [filteredGroupHeadings, filteredGroups] = filteredOptions; 143 | const [groupHeadings, groups] = options; 144 | const optionsArray = groups.flat(); 145 | 146 | const typeaheadRef = useRef(); 147 | const inputWrapperRef = useRef(); 148 | const inputNodeRef = useRef(); 149 | 150 | const BACKSPACE = 8; 151 | const TAB = 9; 152 | const RETURN = 13; 153 | const ESC = 27; 154 | const UP = 38; 155 | const DOWN = 40; 156 | const DEBOUNCETIME = 100; 157 | const THROTTLEPERIOD = 10; 158 | 159 | const handleInputChange = (inputValue) => { 160 | setFocusedOptionIndex(-1); 161 | updateFilter(inputValue); 162 | }; 163 | 164 | const debouncedHandleInputChange = debounce(handleInputChange, DEBOUNCETIME); 165 | 166 | const updateFilter = (value, excluded = selected) => { 167 | const filter = value.toLowerCase(); 168 | setFilter(filter); 169 | if (filter === "") { 170 | setFilteredOptions(options); 171 | return; 172 | } 173 | const _groupHeadings = []; 174 | const _groups = []; 175 | groups.forEach((group, index) => { 176 | const filteredGroup = group.filter(option => { 177 | // (option) => option.toLowerCase().includes(filter) && !excluded.includes(option) // includes() not supported on Chrome for Android 178 | const optionText = option.toLowerCase() 179 | return (excluded.indexOf(option) === -1) && (optionText.indexOf(filter) !== -1) 180 | }); 181 | if (filteredGroup.length > 0) { 182 | _groupHeadings.push(groupHeadings[index]); 183 | _groups.push(filteredGroup); 184 | } 185 | }); 186 | setFilteredOptions([_groupHeadings, _groups]); 187 | }; 188 | 189 | const handleSelectionChange = selected => { 190 | if (onChange) onChange(selected); 191 | setSelected(selected); 192 | } 193 | 194 | const handleInputSelection = (newSelected) => { 195 | if (newSelected.length > selected.length) { 196 | debouncedHandleInputChange(""); 197 | } 198 | handleSelectionChange(newSelected); 199 | }; 200 | 201 | const removeToken = (token, selected) => { 202 | const newSelected = [...selected]; 203 | newSelected.splice(newSelected.indexOf(token), 1); 204 | handleSelectionChange(newSelected); 205 | if (inputNodeRef.current.value.length > 0) { 206 | updateFilter(inputNodeRef.current.value, newSelected); 207 | } 208 | typeaheadRef.current.focus(); 209 | }; 210 | 211 | /** 212 | * In order to throttle removeToken, the returned throttled function (and 213 | * its closure) has to be memoized so that it can be called by each 214 | * handleTokenRemove event handler that gets defined on each render 215 | */ 216 | const memoizedThrottledRemoveToken = useCallback(throttle(removeToken, THROTTLEPERIOD), []); 217 | 218 | /** 219 | * In handleTokenRemove event handler, the throttled and memoized 220 | * removeToken function is called with the following arguments: 221 | * 1. "token" from the event listener 222 | * 2. "selected" from state - this value gets refreshed as 223 | * handleTokenRemove gets defined on each render 224 | */ 225 | const handleTokenRemove = (token) => { 226 | memoizedThrottledRemoveToken(token, selected); 227 | }; 228 | 229 | const handleCheckboxKeyDown = (event) => { 230 | if (event.key === "Enter") { 231 | event.currentTarget.click(); // might need to replace with event.dispatchEvent for IE due to no activeElement API 232 | } 233 | }; 234 | 235 | const handleCheckboxChange = (event) => { 236 | const selection = event.target.value; 237 | const checked = event.target.checked; 238 | const newSelected = [...selected]; 239 | if (checked) { 240 | newSelected.push(selection); 241 | typeaheadRef.current.clear(); 242 | debouncedHandleInputChange(""); 243 | } else { 244 | newSelected.splice(newSelected.indexOf(selection), 1); 245 | } 246 | handleSelectionChange(newSelected); 247 | typeaheadRef.current.focus(); 248 | }; 249 | 250 | const handleFocus = () => { 251 | inputWrapperRef.current.classList.add("focus"); 252 | }; 253 | 254 | const handleBlur = () => { 255 | // disable hidden menu 256 | typeaheadRef.current.hideMenu(); 257 | inputWrapperRef.current.classList.remove("focus"); 258 | }; 259 | 260 | const handleMenuToggle = () => setFocusedOptionIndex(-1); 261 | 262 | const handleInputWrapperKeyDown = event => { 263 | // stop Enter keydown event from triggering FormBody keydown handler if not initiated from text input 264 | if (event.key === 'Enter' && event.target.type !== 'text') event.stopPropagation(); 265 | } 266 | 267 | /** 268 | * this handler replaces Typeahead's internal onKeyDown handler (which gets 269 | * passed to the input element) once on initial render so there should be 270 | * no references to state in here as they will be stale 271 | */ 272 | let _handleKeyDown; 273 | const handleKeyDown = useCallback(event => { 274 | const inputNode = event.currentTarget; 275 | switch (event.keyCode) { 276 | case RETURN: 277 | // stop Enter key from triggering FormBody keydown handler 278 | if (inputNode.value.length > 0) event.stopPropagation(); 279 | break; 280 | case ESC: 281 | break; 282 | case BACKSPACE: 283 | if (inputNode.value.length === 0 && typeaheadRef.current.props.selected.length) { 284 | // prevent browser from going back. 285 | event.preventDefault(); 286 | 287 | // if the input is selected and there is no text, focus the last token when the user hits backspace. 288 | if (inputWrapperRef.current) { 289 | const { children } = inputWrapperRef.current; 290 | const lastToken = children[children.length - 2]; 291 | lastToken && lastToken.focus(); 292 | } 293 | } 294 | break; 295 | case UP: 296 | case DOWN: 297 | // prevent UP and DOWN from navigating options (which is Typeahead's internal behaviour) 298 | return; 299 | case TAB: 300 | if (typeaheadRef.current.isMenuShown) { 301 | // convert Tab and Shift+Tab into Up and Down respectively to navigate internal menu 302 | event.keyCode = event.shiftKey ? UP : DOWN; 303 | 304 | // set focusedOptionIndex to match Typeahead's internal menu's activeIndex 305 | let newIndex = typeaheadRef.current.state.activeIndex; 306 | let items = typeaheadRef.current.items 307 | newIndex += event.shiftKey ? -1 : 1; 308 | if (newIndex === items.length) { 309 | newIndex = -1; 310 | } else if (newIndex === -2) { 311 | newIndex = items.length - 1; 312 | } 313 | setFocusedOptionIndex(newIndex); 314 | } 315 | break; 316 | default: 317 | break; 318 | } 319 | 320 | /** 321 | * call internal handler to handle internal menu (hidden) navigation and selection; 322 | * function definition reference kept in closure which is created when the effect is 323 | * first called to assign handleKeyDown to typeaheadRef.current (persisted by React) 324 | */ 325 | _handleKeyDown(event); 326 | }, [setFocusedOptionIndex]); 327 | 328 | /** 329 | * override Typeahead internal key handler - should only be called once on 330 | * intial render as handleKeyDown should never change across renders, 331 | * otherwise the reference to the original internal method, stored in 332 | * _handleKeyDown and closed over by handleKeyDown in the initial render, 333 | * will be overwritten. 334 | */ 335 | useEffect(() => { 336 | // save Typeahead internal method (https://github.com/ericgio/react-bootstrap-typeahead/blob/1cf74a4e3f65d4d80e992d1f926bfaf9f5a349bc/src/core/Typeahead.js) in _handleKeyDown 337 | _handleKeyDown = typeaheadRef.current._handleKeyDown; 338 | 339 | // replace internal method with handleKeyDown which closes over _handleKeyDown 340 | typeaheadRef.current._handleKeyDown = handleKeyDown; 341 | }, [handleKeyDown]); 342 | 343 | const handleClear = () => { 344 | handleSelectionChange([]); 345 | typeaheadRef.current.clear(); 346 | typeaheadRef.current.focus(); 347 | debouncedHandleInputChange(""); 348 | }; 349 | 350 | useEffect(() => { 351 | typeaheadRef.current._handleClear = handleClear; 352 | }, [handleClear]); 353 | 354 | useEffect(() => { 355 | inputNodeRef.current = typeaheadRef.current.getInput(); 356 | }, []); 357 | 358 | useEffect(() => { 359 | // If 0 filtered options, disable hidden menu to allow TAB to return to default behaviour 360 | if (filteredGroups.length === 0) typeaheadRef.current.hideMenu(); 361 | }, [filteredGroups]); 362 | 363 | let optionsIndexCounter = -1; 364 | return ( 365 | <InputWrapper name={name} column onKeyDown={handleInputWrapperKeyDown}> 366 | <StyledTypeahead 367 | id="typeahead" 368 | multiple 369 | maxHeight="300px" 370 | clearButton 371 | options={optionsArray} 372 | onInputChange={debouncedHandleInputChange} // only handles adding/removing characters - excludes input clear from pressing 'Enter' 373 | minLength={1} // to activate hint and hidden menu 374 | selected={selected} 375 | onChange={handleInputSelection} 376 | onBlur={handleBlur} 377 | onFocus={handleFocus} 378 | onMenuToggle={handleMenuToggle} 379 | ref={typeaheadRef} 380 | renderInput={({ inputRef, referenceElementRef, inputClassName, ...inputProps }, state) => { 381 | return ( 382 | <div className="rbt-input-wrapper" ref={inputWrapperRef}> 383 | {state.selected.map((option, idx) => ( 384 | <Token key={idx} option={option} onRemove={handleTokenRemove}> 385 | {option} 386 | </Token> 387 | ))} 388 | <Hint 389 | shouldSelect={(shouldSelect, e) => e.keyCode !== TAB && (e.keyCode === RETURN || shouldSelect)} 390 | > 391 | <Input 392 | {...inputProps} 393 | name={name} //? this text input shares the same name as the checkbox inputs, does this break anything? 394 | ref={(element) => { 395 | inputRef(element); // Typeahead internal ref 396 | // referenceElementRef(element); // to position the dropdown menu, may be a container element, hence the need for separate refs. 397 | refCallback(element); // useAddInput custom hook ref 398 | }} 399 | /> 400 | </Hint> 401 | </div> 402 | ); 403 | }} 404 | /> 405 | <CheckboxSectionContainer> 406 | <CheckboxSectionWrapper> 407 | {filteredGroupHeadings.map((heading, index) => { 408 | //? need to refactor this component to prevent unnecessary re-rendering on every state change? 409 | const filteredGroup = filteredGroups[index]; 410 | if (filteredGroup.length === 0) { 411 | return null; 412 | } else { 413 | return ( 414 | <div key={heading}> 415 | <GroupHeading>{heading}</GroupHeading> 416 | <GroupContainer> 417 | {filteredGroup.map((option, index) => { 418 | optionsIndexCounter++; 419 | return ( 420 | <CheckboxWrapper key={option}> 421 | <Checkbox 422 | name={name} 423 | value={option} 424 | checked={selected.indexOf(option) !== -1} 425 | onKeyDown={handleCheckboxKeyDown} 426 | onChange={handleCheckboxChange} 427 | highlightedText={filter} 428 | focusState={optionsIndexCounter === focusedOptionIndex} 429 | /> 430 | </CheckboxWrapper> 431 | ); 432 | })} 433 | </GroupContainer> 434 | </div> 435 | ); 436 | } 437 | })} 438 | </CheckboxSectionWrapper> 439 | </CheckboxSectionContainer> 440 | </InputWrapper> 441 | ); 442 | }; 443 | 444 | ComboboxMulti.propTypes = { 445 | ...inputPropTypes, 446 | options: function (props, propName, componentName) { 447 | const propValue = props[propName]; 448 | if (!Array.isArray(propValue) 449 | || propValue.length != 2 450 | || !propValue.every(e => Array.isArray(e)) 451 | || propValue[0].length != propValue[1].length 452 | ) { 453 | return new Error( 454 | `Invalid prop \`${propName}\` supplied to \`${componentName}\`, 455 | expected an array of two arrays containing equal number of elements` 456 | ); 457 | } 458 | } 459 | } 460 | 461 | export default ComboboxMulti; -------------------------------------------------------------------------------- /src/components/FormBody.js: -------------------------------------------------------------------------------- 1 | import React, { useRef, useEffect } from "react"; 2 | import { css, keyframes } from '@emotion/core'; 3 | import styled from "@emotion/styled"; 4 | import PropTypes from 'prop-types'; 5 | 6 | import useInputs from "../core/useInputs"; 7 | import useScaleAnimation from "../core/useScaleAnimation"; 8 | 9 | import Tabs from "./Tabs"; 10 | import { IconContainer, IconsWrapper, InputContainer, SubmitLabel, NextButton, NextButtonIcon } from "./StyledComponents"; 11 | import Icon from "./Icon"; 12 | 13 | import { isEmpty } from '../utils/helpers'; 14 | 15 | const headShake = keyframes` 16 | 0% { 17 | transform: translateX(0) 18 | } 19 | 12.5% { 20 | transform: translateX(6px) rotateY(9deg) 21 | } 22 | 37.5% { 23 | transform: translateX(-5px) rotateY(-7deg) 24 | } 25 | 62.5% { 26 | transform: translateX(3px) rotateY(5deg) 27 | } 28 | 87.5% { 29 | transform: translateX(-2px) rotateY(-3deg) 30 | } 31 | 100% { 32 | transform: translateX(0) 33 | } 34 | ` 35 | 36 | const bounceRight = keyframes` 37 | 0%, 38 | 100% { 39 | transform: translate(-8px, -1px); 40 | } 41 | 50% { 42 | transform: translate(-2px, -1px); 43 | } 44 | ` 45 | 46 | /** 47 | * When interpolating keyframes into plain strings you have to wrap it in a 48 | * css call, like this: css`animation: ${keyframes({ })}` 49 | * https://github.com/emotion-js/emotion/issues/1066#issuecomment-546703172 50 | * How to use animation name as a partial (with other properties defined with prop values): 51 | * https://styled-components.com/docs/api#keyframes 52 | * How to use animation name inside conditional based on props: 53 | * https://github.com/styled-components/styled-components/issues/397#issuecomment-275588876 54 | */ 55 | const FormBodyWrapper = styled.div` 56 | margin-bottom: ${props => props.heightIncrease ? 5 + props.heightIncrease : 5}px; 57 | filter: blur(0); 58 | ${props => props.isError ? css` 59 | animation: ${headShake} .5s ease-in-out infinite; 60 | ` : ` 61 | animation: none; 62 | `} 63 | &.active { 64 | transform: translateY(2px); 65 | } 66 | &.active > div:last-child { 67 | box-shadow: 0 2px 2px hsl(120, 60%, 40%); 68 | } 69 | * { 70 | box-sizing: border-box; 71 | } 72 | ` 73 | 74 | const PageContainer = styled.div` 75 | margin: 0px auto; 76 | max-width: 500px; 77 | height: 60px; 78 | ${props => ` 79 | border-bottom-left-radius: ${5 / props.widthScale}px ${5 / props.heightScale}px; 80 | border-bottom-right-radius: ${5 / props.widthScale}px ${5 / props.heightScale}px; 81 | ${props.isSubmitPage ? ` 82 | border-top-left-radius: ${5 / props.widthScale}px ${5 / props.heightScale}px; 83 | border-top-right-radius: ${5 / props.widthScale}px ${5 / props.heightScale}px; 84 | ` : ` 85 | `} 86 | `} 87 | overflow: hidden; 88 | background-color: hsl(0, 0%, 100%); 89 | ${props => props.isError ? css` 90 | box-shadow: 0 ${5 / props.heightScale}px ${5 / props.heightScale}px hsla(16, 100%, 40%, .8); 91 | ` : ` 92 | box-shadow: 0 ${5 / props.heightScale}px ${5 / props.heightScale}px hsla(120, 60%, 40%, .8); 93 | `} 94 | ${props => css` 95 | transform-origin: center top; 96 | animation-name: ${keyframes(props.scaleAnimation)}; 97 | animation-duration: 400ms; 98 | animation-timing-function: linear; 99 | animation-fill-mode: forwards; 100 | `} 101 | ` 102 | 103 | const PageWrapper = styled.div` 104 | padding: 10px 0; 105 | display: flex; 106 | flex-flow: row nowrap; 107 | justify-content: space-evenly; 108 | align-items: flex-start; 109 | ${props => css` 110 | transform-origin: left top; 111 | animation-name: ${keyframes(props.inverseScaleAnimation)}; 112 | animation-duration: 400ms; 113 | animation-timing-function: linear; 114 | animation-fill-mode: forwards; 115 | `} 116 | &:focus { 117 | outline: none; 118 | } 119 | ${props => props.isSubmitPage ? css` 120 | width: ${props.submitWidth}px; 121 | height: 40px; 122 | border-radius: 5px; 123 | padding: 10px 3px; 124 | z-index: 1; 125 | cursor: pointer; 126 | &:focus { 127 | border: 2px solid ${props.theme.colors.light.indigo}; 128 | padding: 8px 1px; 129 | } 130 | div { 131 | pointer-events: none; 132 | } 133 | @media (prefers-reduced-motion: no-preference) { 134 | &:focus > button > div, &:hover > button > div { 135 | animation: ${bounceRight} .8s ease-in-out infinite; 136 | } 137 | } 138 | ` : ` 139 | `} 140 | ` 141 | 142 | const FormBody = ({ 143 | tabs = true, 144 | submitText = 'Submit', 145 | submitWidth = 110, 146 | initialFocus = true, 147 | onSubmit, 148 | children 149 | }) => { 150 | const { inputs, activeIndex, changeActiveIndex, activeInput, error, isSubmitPage, inputValues } = useInputs(); 151 | 152 | const formBodyWrapperRef = useRef(); 153 | const pageWrapperRef = useRef(); 154 | const buttonRef = useRef(); 155 | const basePageWidthRef = useRef(); 156 | 157 | const BASE_PAGE_HEIGHT = 60; 158 | const SUBMIT_PAGE_HEIGHT = 40; 159 | 160 | const activeInputHeight = (activeInput && activeInput.height) ? activeInput.height : null; 161 | const pageHeight = activeInputHeight 162 | ? activeInputHeight 163 | : isSubmitPage 164 | ? SUBMIT_PAGE_HEIGHT 165 | : BASE_PAGE_HEIGHT; 166 | const pageRelativeHeight = pageHeight / BASE_PAGE_HEIGHT; 167 | 168 | const pageRelativeWidth = isSubmitPage ? submitWidth / basePageWidthRef.current : 1; 169 | 170 | const { scaleAnimation, inverseScaleAnimation } = useScaleAnimation(pageRelativeWidth, pageRelativeHeight); 171 | 172 | const handleAnimationIteration = event => { 173 | // Manually change DOM node instead of setting state to avoid re-render 174 | formBodyWrapperRef.current.style.animationPlayState = "paused" 175 | } 176 | 177 | const handleSubmitClick = event => { 178 | if (isSubmitPage && event.button === 0) { 179 | onSubmit(inputValues); 180 | } 181 | } 182 | 183 | const handleMouseDownAndUp = event => { 184 | if (isSubmitPage) { 185 | if (event.type === 'mousedown' || event.type === 'touchstart') { 186 | formBodyWrapperRef.current.classList.add('active'); 187 | } else { 188 | formBodyWrapperRef.current.classList.remove('active'); 189 | } 190 | } 191 | } 192 | 193 | const simulateMouseEvent = (element, eventName) => { 194 | element.dispatchEvent(new MouseEvent(eventName, { 195 | view: window, 196 | bubbles: true, 197 | cancelable: true, 198 | button: 0 199 | })); 200 | }; 201 | 202 | const activateAndClick = (event, target) => { 203 | if (event.repeat) return; 204 | const node = target || event.currentTarget; 205 | node.classList.add('active'); 206 | 207 | const handleKeyUp = event => { 208 | node.classList.remove('active'); 209 | simulateMouseEvent(node, 'click') 210 | pageWrapperRef.current.removeEventListener('keyup', handleKeyUp, false); 211 | } 212 | pageWrapperRef.current.addEventListener('keyup', handleKeyUp, false); 213 | } 214 | 215 | const handleKeyDown = event => { 216 | if (event.key === 'Enter') { 217 | if (!isSubmitPage) { 218 | activateAndClick(event, buttonRef.current); 219 | } else { 220 | activateAndClick(event, formBodyWrapperRef.current); // just to add 'active' class 221 | activateAndClick(event, pageWrapperRef.current); 222 | } 223 | } 224 | } 225 | 226 | const handleNextButtonClick = event => { 227 | changeActiveIndex(activeIndex + 1); 228 | }; 229 | 230 | const handleNextButtonKeyDown = event => { 231 | // replace default behaviour with clickButtonOnKeyDown to streamline behaviour between Enter and Space keys 232 | if (event.key === 'Enter' || event.key === ' ') { 233 | event.preventDefault(); 234 | 235 | // stop Enter or Space keys from triggering FormBody keydown handler 236 | event.stopPropagation(); 237 | activateAndClick(event); 238 | } 239 | } 240 | 241 | useEffect(() => { 242 | const boundingClientRect = pageWrapperRef.current.getBoundingClientRect(); 243 | basePageWidthRef.current = boundingClientRect.width; 244 | }, [pageWrapperRef.current]); 245 | 246 | useEffect(() => { 247 | if (error.state) formBodyWrapperRef.current.style.animationPlayState = "running"; 248 | }, [error]); 249 | 250 | useEffect(() => { 251 | if (inputs.length === 0) return; 252 | 253 | // don't focus on initial render of form if initialFocus is false 254 | if (!initialFocus && activeIndex === 0 && isEmpty(activeInput.value)) return; 255 | if (!isSubmitPage) { 256 | setTimeout(() => activeInput.node.focus(), 450); 257 | } else { 258 | setTimeout(() => pageWrapperRef.current.focus(), 450); 259 | } 260 | }, [inputs.length, initialFocus, activeIndex, activeInput, isSubmitPage]) 261 | 262 | return ( 263 | <FormBodyWrapper 264 | ref={formBodyWrapperRef} 265 | heightIncrease={activeInputHeight ? activeInputHeight - BASE_PAGE_HEIGHT : null} 266 | isError={error.state} 267 | onAnimationIteration={handleAnimationIteration} 268 | > 269 | {tabs 270 | ? <Tabs 271 | basePageWidth={basePageWidthRef.current} 272 | inputs={inputs} 273 | activeIndex={activeIndex} 274 | changeActiveIndex={changeActiveIndex} 275 | activeInput={activeInput} 276 | isSubmitPage={isSubmitPage} 277 | /> 278 | : null 279 | } 280 | <PageContainer 281 | isError={error.state} 282 | widthScale={pageRelativeWidth} 283 | heightScale={pageRelativeHeight} 284 | scaleAnimation={scaleAnimation} 285 | isSubmitPage={isSubmitPage} 286 | > 287 | <PageWrapper 288 | ref={pageWrapperRef} 289 | role={isSubmitPage ? "button" : null} 290 | tabIndex={isSubmitPage ? "0" : "-1"} 291 | inverseScaleAnimation={inverseScaleAnimation} 292 | isSubmitPage={isSubmitPage} 293 | submitWidth={submitWidth} 294 | onClick={handleSubmitClick} 295 | onKeyDown={handleKeyDown} 296 | onMouseDown={handleMouseDownAndUp} 297 | onMouseUp={handleMouseDownAndUp} 298 | onTouchStart={handleMouseDownAndUp} 299 | onTouchEnd={handleMouseDownAndUp} 300 | > 301 | <IconContainer> 302 | <IconsWrapper index={Math.min(activeIndex, inputs.length - 1)}> 303 | {(inputs.length > 0) 304 | ? inputs.map((input, index) => ( 305 | <Icon key={`${index}${input.name}`} IconComponent={input.icon} isSubmitPage={isSubmitPage} /> 306 | )) 307 | : null 308 | } 309 | </IconsWrapper> 310 | </IconContainer> 311 | <InputContainer pageContainerheight={activeInputHeight}> 312 | {children} 313 | <SubmitLabel text={submitText} isSubmitPage={isSubmitPage} /> 314 | </InputContainer> 315 | <NextButton 316 | ref={buttonRef} 317 | type="button" 318 | disabled={isSubmitPage} 319 | onClick={handleNextButtonClick} 320 | onKeyDown={handleNextButtonKeyDown} 321 | > 322 | <NextButtonIcon /> 323 | </NextButton> 324 | </PageWrapper> 325 | </PageContainer> 326 | </FormBodyWrapper> 327 | ) 328 | }; 329 | 330 | FormBody.propTypes = { 331 | initialFocus: PropTypes.bool, 332 | onSubmit: PropTypes.func, 333 | submitText: PropTypes.string, 334 | submitWidth: PropTypes.number, 335 | } 336 | 337 | export default FormBody; -------------------------------------------------------------------------------- /src/components/Icon.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import styled from "@emotion/styled"; 3 | 4 | const IconWrapper = styled.div` 5 | flex: none; 6 | height: 40px; 7 | width: 40px; 8 | display: flex; 9 | align-items: center; 10 | justify-content: center; 11 | ${props => ` 12 | ${props.isSubmitPage ? ` 13 | opacity: 0; 14 | visibility: hidden; 15 | ` : ` 16 | opacity: 1; 17 | visibility: visible; 18 | transition: opacity 300ms; 19 | `} 20 | `} 21 | `; 22 | 23 | const Icon = ({ IconComponent = null, isSubmitPage }) => ( 24 | <IconWrapper isSubmitPage={isSubmitPage}> 25 | {(IconComponent) ? <IconComponent /> : null} 26 | </IconWrapper> 27 | ) 28 | 29 | export default Icon; -------------------------------------------------------------------------------- /src/components/Input.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import PropTypes from 'prop-types'; 3 | import inputPropTypes from '../propTypes' 4 | 5 | import useAddInput from "../core/useAddInput"; 6 | import useInputState from "../core/useInputState"; 7 | 8 | import InputWrapper from "./InputWrapper"; 9 | import { StyledInput } from "./StyledComponents"; 10 | 11 | import getValidationAttributes from "../logic/getValidationAttributes"; 12 | 13 | const Input = ({ 14 | name, 15 | type, 16 | title, 17 | placeholder, 18 | onChange, 19 | label, 20 | caption, 21 | icon, 22 | height, 23 | validationRules, 24 | }) => { 25 | const validationAttributes = getValidationAttributes(validationRules); 26 | const { refCallback } = useAddInput({ label, caption, icon, height, validationRules, html5Validation: true }); 27 | const { value, setValue } = useInputState(name, ''); 28 | 29 | const handleChange = event => { 30 | const value = event.target.value; 31 | if (onChange) onChange(value); 32 | setValue(value); 33 | } 34 | 35 | return ( 36 | <InputWrapper name={name}> 37 | <StyledInput 38 | id={name} 39 | name={name} 40 | type={type} 41 | title={title} 42 | placeholder={placeholder} 43 | autocomplete="new-password" 44 | value={value} 45 | onChange={handleChange} 46 | ref={refCallback} 47 | {...validationAttributes} 48 | /> 49 | </InputWrapper> 50 | ); 51 | }; 52 | 53 | Input.propTypes = { 54 | ...inputPropTypes, 55 | placeholder: PropTypes.string, 56 | } 57 | 58 | export default Input; -------------------------------------------------------------------------------- /src/components/InputWrapper.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import styled from "@emotion/styled"; 3 | 4 | import useInputs from "../core/useInputs"; 5 | 6 | const StyledInputWrapper = styled.div` 7 | height: 100%; 8 | line-height: 1.625rem; 9 | font-size: 1.125rem; 10 | display: flex; 11 | ${props => props.column ? ` 12 | flex-flow: column nowrap; 13 | justify-content: flex-start; 14 | ` : ` 15 | flex-flow: row wrap; 16 | justify-content: space-evenly; 17 | `} 18 | align-items: center; 19 | outline: none; 20 | ${props => props.isActive ? ` 21 | visibility: visible; 22 | opacity: 1; 23 | transition: opacity 400ms ease-out; 24 | ` : ` 25 | position: absolute; 26 | visibility: hidden; 27 | opacity: 0; 28 | `} 29 | input, label, button, select, optgroup, textarea { 30 | font-family: inherit; 31 | font-size: inherit; 32 | line-height: inherit; 33 | } 34 | `; 35 | 36 | const InputWrapper = ({ name, inputRef, column, onKeyDown, children }) => { 37 | const { activeInput } = useInputs(); 38 | 39 | let isActive = false; 40 | if (activeInput) { 41 | const activeInputNode = activeInput.node; 42 | const activeInputName = activeInputNode.name || activeInputNode.dataset.name; 43 | isActive = name === activeInputName; 44 | } else { 45 | isActive = false; 46 | }; 47 | 48 | return ( 49 | <StyledInputWrapper 50 | ref={inputRef} 51 | data-name={name} 52 | tabIndex={-1} 53 | column={column} 54 | isActive={isActive} 55 | onKeyDown={onKeyDown} 56 | > 57 | {children} 58 | </StyledInputWrapper> 59 | ) 60 | } 61 | 62 | export default InputWrapper; -------------------------------------------------------------------------------- /src/components/Labels.js: -------------------------------------------------------------------------------- 1 | /** @jsx jsx */ 2 | import { jsx } from "@emotion/core"; 3 | import styled from "@emotion/styled"; 4 | 5 | import useInputs from "../core/useInputs"; 6 | 7 | //? Change this to a link element for accessibility? 8 | const StyledLabel = styled.label` 9 | margin: 5px 0; 10 | text-transform: capitalize; 11 | transition: all 600ms; 12 | ${props => props.active ? ` 13 | color: hsl(279, 75%, 35%); 14 | ` : props.activated ? ` 15 | color: hsl(279, 9%, 25%); 16 | cursor: pointer; 17 | ` : ` 18 | color: hsl(0, 100%, 99%); 19 | opacity: 0.5; 20 | `} 21 | `; 22 | 23 | export const LabelsContainer = styled.div` 24 | margin-bottom: 10px; 25 | font-size: 1.5rem; 26 | line-height: 1; 27 | display: flex; 28 | flex-flow: column nowrap; 29 | align-items: center; 30 | ` 31 | 32 | const Label = ({ 33 | label, 34 | inputValue, 35 | active, 36 | changeActiveIndex, 37 | activated 38 | }) => { 39 | const handleClick = event => { 40 | if (!activated) return; 41 | changeActiveIndex(); 42 | } 43 | 44 | return ( 45 | <StyledLabel 46 | active={active} 47 | activated={activated} 48 | onClick={handleClick} 49 | > 50 | {inputValue || label} 51 | </StyledLabel> 52 | ) 53 | } 54 | 55 | const Labels = () => { 56 | const { 57 | inputs, 58 | activeIndex, 59 | changeActiveIndex, 60 | inputValues 61 | } = useInputs(); 62 | 63 | return ( 64 | <LabelsContainer> 65 | {(inputs.length > 0) 66 | ? inputs.map((input, index) => ( 67 | <Label 68 | key={`${index}${input.name}`} 69 | label={input.label} 70 | inputValue={inputValues[input.name]} 71 | active={index === activeIndex} 72 | changeActiveIndex={() => changeActiveIndex(index)} 73 | activated={index < activeIndex} 74 | /> 75 | )) 76 | : null 77 | } 78 | </LabelsContainer> 79 | ) 80 | } 81 | 82 | export default Labels; -------------------------------------------------------------------------------- /src/components/RadioControl.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import styled from "@emotion/styled"; 3 | import PropTypes from 'prop-types'; 4 | import inputPropTypes from '../propTypes' 5 | 6 | import useAddInput from "../core/useAddInput"; 7 | import useInputState from "../core/useInputState"; 8 | 9 | import InputWrapper from "./InputWrapper"; 10 | 11 | const StyledLabel = styled.label` 12 | display: inline-block; 13 | margin: 0 2px; 14 | padding: 0 10px; 15 | border-radius: 25px; 16 | text-align: center; 17 | text-transform: capitalize; 18 | cursor: pointer; 19 | transition: border 0.1s; 20 | ${props => ` 21 | border: 1px solid white; 22 | color: ${props.theme.colors.extraDark[props.color]}; 23 | ${props.isChecked ? ` 24 | color: ${props.theme.colors.white}; 25 | background: ${props.theme.colors.dark[props.color]}; 26 | border: 1px solid ${props.theme.colors.light[props.color]}; 27 | ` : ` 28 | background: ${props.theme.colors.white}; 29 | &:hover { 30 | border: 1px solid ${props.theme.colors.base[props.color]}; 31 | } 32 | `} 33 | `} 34 | `; 35 | 36 | const HiddenRadio = styled.input` 37 | position: absolute; 38 | margin: -1px; 39 | border: 0; 40 | padding: 0; 41 | overflow: hidden; 42 | white-space: nowrap; 43 | clip-path: inset(50%); 44 | `; 45 | 46 | const RadioWrapper = styled.div` 47 | line-height: 2rem; 48 | input:focus + label { 49 | box-shadow: ${props => `0 0 0 2px ${props.theme.colors.light[props.color]}`}; 50 | } 51 | ` 52 | 53 | export const RadioOption = ({ 54 | name, 55 | value, 56 | label, 57 | isChecked, 58 | handleChange, 59 | }) => ( 60 | <RadioWrapper color="indigo"> 61 | <HiddenRadio 62 | type="radio" 63 | name={name} 64 | id={value} 65 | value={value} 66 | onChange={handleChange} 67 | /> 68 | <StyledLabel 69 | htmlFor={value} 70 | isChecked={isChecked} 71 | color="indigo" 72 | > 73 | {label || value} 74 | </StyledLabel> 75 | </RadioWrapper> 76 | ); 77 | 78 | RadioOption.propTypes = { 79 | value: PropTypes.oneOfType([ 80 | PropTypes.string, 81 | PropTypes.number 82 | ]).isRequired, 83 | label: PropTypes.string, 84 | }; 85 | 86 | export const RadioControl = ({ 87 | name, 88 | onChange, 89 | height, 90 | label, 91 | caption, 92 | icon, 93 | validationRules, 94 | children, 95 | }) => { 96 | const { refCallback } = useAddInput({ label, caption, icon, height, validationRules }); 97 | const { value, setValue } = useInputState(name, ''); 98 | 99 | const handleChange = event => { 100 | const value = event.target.value; 101 | if (onChange) onChange(value); 102 | setValue(value); 103 | } 104 | 105 | return ( 106 | <InputWrapper name={name} inputRef={refCallback}> 107 | {React.Children.map(children, child => { 108 | if (child.type === RadioOption) { 109 | return React.cloneElement(child, { 110 | name: name, 111 | isChecked: child.props.value === value, 112 | handleChange: handleChange, 113 | }); 114 | } 115 | return child; 116 | })} 117 | </InputWrapper> 118 | ) 119 | } 120 | 121 | RadioControl.propTypes = { 122 | ...inputPropTypes, 123 | children: PropTypes.oneOfType([ 124 | PropTypes.arrayOf(PropTypes.node), 125 | PropTypes.node 126 | ]).isRequired 127 | }; -------------------------------------------------------------------------------- /src/components/StyledComponents.js: -------------------------------------------------------------------------------- 1 | import styled from "@emotion/styled"; 2 | /* Note: From Emotion documentation: https://emotion.sh/docs/styled#composing-dynamic-styles 3 | const dynamicStyles = props => 4 | css` 5 | color: ${props.checked ? 'black' : 'grey'}; 6 | background: ${props.checked ? 'linear-gradient(45deg, #FFC107 0%, #fff200 100%)' : '#f5f5f5'}; 7 | ` 8 | */ 9 | 10 | export const IconContainer = styled.div` 11 | width: 40px; 12 | overflow: hidden; 13 | ` 14 | 15 | export const IconsWrapper = styled.div` 16 | position: relative; 17 | display: flex; 18 | flex-flow: row nowrap; 19 | transition: transform 300ms ease-out; 20 | transform: ${props => `translateX(${props.index * -40}px)`}; 21 | ` 22 | 23 | export const InputContainer = styled.div` 24 | position: relative; 25 | height: ${props => props.pageContainerheight ? props.pageContainerheight - 20 : '40'}px; 26 | max-width: 400px; 27 | flex: 1; 28 | display: flex; 29 | flex-flow: column nowrap; 30 | ` 31 | 32 | export const StyledInput = styled.input` 33 | width: 100%; 34 | border: 1px solid #ced4da; 35 | border-radius: 0.25rem; 36 | padding: 0.375rem 0.75rem; 37 | outline: none; 38 | color: ${props => props.theme.colors.extraDark.indigo}; 39 | text-align: left; 40 | transition: border-color 0.15s ease-in-out; 41 | &:focus { 42 | border-color: ${props => props.theme.colors.light.indigo}; 43 | box-shadow: 0 0 0 0.2rem rgba(166, 0, 255, .25); 44 | } 45 | `; 46 | 47 | export const SubmitLabel = styled.div` 48 | font-size: 1.125rem; 49 | font-weight: 500; 50 | &::before { 51 | content: ${props => `'${props.text}'`}; 52 | position: absolute; 53 | top: -3px; 54 | left: -30px; 55 | transition: opacity 400ms ease-in-out, transform 400ms ease-out; 56 | ${props => props.isSubmitPage ? ` 57 | opacity: 1; 58 | visibility: visible; 59 | ` : ` 60 | opacity: 0; 61 | visibility: hidden; 62 | transform: translateX(-60px); 63 | `} 64 | } 65 | ` 66 | 67 | export const NextButton = styled.button` 68 | position: relative; 69 | height: 40px; 70 | width: 40px; 71 | border: 0; 72 | border-radius: 3px; 73 | padding: 0; 74 | background: none; 75 | cursor: pointer; 76 | transition: transform 100ms ease-in-out; 77 | @media (hover: hover) { 78 | &:hover { 79 | background: hsl(0, 0%, 95%); 80 | transition: transform 100ms ease-in-out, background 200ms ease; 81 | } 82 | } 83 | &:active, &.active { 84 | transform: translateX(2px); 85 | background-color: hsl(0, 0%, 100%); 86 | transition: none; 87 | } 88 | &:focus { 89 | outline: none; 90 | border: 2px solid ${props => props.theme.colors.light.indigo}; 91 | } 92 | &:disabled { 93 | right: -350px; 94 | pointer-events: none; 95 | transform: translate(-350px, -10px); 96 | transition: transform 350ms ease-in-out; 97 | } 98 | ` 99 | 100 | export const DownButtonIcon = styled.div` 101 | position: absolute; 102 | top: 50%; 103 | left: 50%; 104 | transform: translate(-50%, -50%); 105 | width: 2px; 106 | height: 17px; 107 | background: hsl(0, 0%, 20%); 108 | &::before { 109 | content: ''; 110 | position: absolute; 111 | left: -3px; 112 | bottom: 1px; 113 | width: 6px; 114 | height: 6px; 115 | transform: rotate(45deg); 116 | border-right: 2px solid; 117 | border-bottom: 2px solid; 118 | border-color: hsl(0, 0%, 20%); 119 | } 120 | ` 121 | 122 | export const NextButtonIcon = styled.div` 123 | position: absolute; 124 | top: 50%; 125 | left: 50%; 126 | transform: translate(-50%, -50%); 127 | width: 17px; 128 | height: 2px; 129 | border-radius: 1px; 130 | background: hsl(0, 0%, 20%); 131 | &::before { 132 | content: ''; 133 | position: absolute; 134 | left: 6px; 135 | bottom: -4px; 136 | width: 8px; 137 | height: 8px; 138 | border-radius: 2px; 139 | transform: rotate(-45deg); 140 | border-right: 2px solid; 141 | border-bottom: 2px solid; 142 | border-color: hsl(0, 0%, 20%); 143 | } 144 | ` 145 | 146 | export const BackButtonIcon = styled.div` 147 | position: absolute; 148 | top: 50%; 149 | left: 50%; 150 | transform: translate(-50%, -50%); 151 | width: 17px; 152 | height: 2px; 153 | border-radius: 1px; 154 | background: hsl(0, 0%, 20%); 155 | &::before { 156 | content: ''; 157 | position: absolute; 158 | left: 1px; 159 | bottom: -4px; 160 | width: 8px; 161 | height: 8px; 162 | border-radius: 2px; 163 | transform: rotate(45deg); 164 | border-left: 2px solid; 165 | border-bottom: 2px solid; 166 | border-color: hsl(0, 0%, 20%); 167 | } 168 | ` -------------------------------------------------------------------------------- /src/components/Tabs.js: -------------------------------------------------------------------------------- 1 | /** @jsx jsx */ 2 | import { useRef } from "react"; 3 | import { jsx, css, keyframes } from "@emotion/core"; 4 | import styled from "@emotion/styled"; 5 | 6 | import { BackButtonIcon } from "./StyledComponents"; 7 | 8 | import useScaleAnimation from "../core/useScaleAnimation"; 9 | 10 | const TabsContainer = styled.div` 11 | margin: 0 auto; 12 | display: flex; 13 | justify-content: flex-end; 14 | max-width: ${props => props.basePageWidth}px; 15 | line-height: 30px; 16 | ${props => css` 17 | transform-origin: center top; 18 | animation-name: ${keyframes(props.scaleAnimation)}; 19 | animation-duration: 400ms; 20 | animation-timing-function: linear; 21 | animation-fill-mode: forwards; 22 | `} 23 | ` 24 | 25 | const LabelTabsWrapper = styled.div` 26 | margin-bottom: -1px; 27 | flex: 0 1 450px; 28 | display: inline-flex; 29 | padding: 0 10px; 30 | text-align: center; 31 | ${props => props.isSubmitPage ? ` 32 | z-index: -1; 33 | opacity: 0; 34 | visibility: hidden; 35 | transition: opacity 200ms ease-in-out 100ms, visibility 0ms linear 400ms; 36 | ` : ` 37 | opacity: 1; 38 | visibility: visible; 39 | `} 40 | ` 41 | 42 | const StyledLabelTab = styled.li` 43 | position: relative; 44 | flex: 1 1 0; 45 | border-top-right-radius: 17px 25px; 46 | border-top-left-radius: 17px 25px; 47 | z-index: ${props => props.zIndex}; 48 | box-shadow: 0 10px 10px rgba(0, 0, 0, .5); 49 | background: hsl(0, 0%, 87%); 50 | list-style: none; 51 | font-weight: 500; 52 | text-transform: capitalize; 53 | label { 54 | pointer-events: none; 55 | } 56 | &::before, &::after { 57 | content: ''; 58 | position: absolute; 59 | top: 0px; 60 | width: 20px; 61 | height: 20px; 62 | border: 10px solid hsl(0, 0%, 87%); 63 | border-radius: 100%; 64 | background: transparent; 65 | } 66 | &::before { 67 | left: -30px; 68 | clip-path: inset(50% 0 0 50%); 69 | } 70 | &::after { 71 | right: -30px; 72 | clip-path: inset(50% 50% 0 0); 73 | } 74 | ${props => props.active ? ` 75 | z-index: 10; 76 | background: hsl(0, 0%, 100%); 77 | &::before, &::after { 78 | border-color: hsl(0, 0%, 100%); 79 | } 80 | ` : props.activated ? ` 81 | cursor: pointer; 82 | &:hover { 83 | z-index: 10; 84 | background: hsl(0, 0%, 100%); 85 | } 86 | &:hover::before, &:hover::after { 87 | border-color: hsl(0, 0%, 100%); 88 | } 89 | ` : ` 90 | color: hsla(0, 0%, 25%, 0.5); 91 | `} 92 | ` 93 | 94 | const IconTabWrapper = styled.div` 95 | flex: ${props => props.isSubmitPage ? 'none' : '1 1 auto'}; 96 | min-width: ${props => props.isSubmitPage ? '50px' : '40px'}; 97 | line-height: 0; 98 | ${props => css` 99 | transform-origin: right top; 100 | animation-name: ${keyframes(props.inverseScaleAnimation)}; 101 | animation-duration: 400ms; 102 | animation-timing-function: linear; 103 | animation-fill-mode: forwards; 104 | `} 105 | ` 106 | 107 | const StyledIconTab = styled.button` 108 | position: relative; 109 | height: 100%; 110 | width: 100%; 111 | margin: 0; 112 | border: 0; 113 | padding: 0; 114 | border-top-left-radius: 30px 30px; 115 | border-top-right-radius: 30px 30px; 116 | background: hsl(0, 0%, 100%); 117 | cursor: pointer; 118 | transition: transform 300ms; 119 | ${props => !props.active ? ` 120 | transform: translateY(30px); 121 | visibility: hidden; 122 | transition: transform 300ms, visibility 0ms ease 300ms; 123 | ` : ` 124 | visibility: visible; 125 | `} 126 | &:focus { 127 | outline: none; 128 | border: 2px solid ${props => props.theme.colors.light.indigo}; 129 | } 130 | div { 131 | opacity: .5; 132 | } 133 | &:hover div, &:focus div { 134 | opacity: .75; 135 | } 136 | ` 137 | 138 | const LabelTab = ({ 139 | htmlFor, 140 | label, 141 | zIndex, 142 | active, 143 | changeActiveIndex, 144 | activated 145 | }) => { 146 | 147 | const handleClick = event => { 148 | if (activated) { 149 | changeActiveIndex(); 150 | } 151 | } 152 | 153 | return ( 154 | <StyledLabelTab 155 | zIndex={zIndex} 156 | active={active} 157 | activated={activated} 158 | onClick={handleClick} 159 | > 160 | <label htmlFor={htmlFor}>{label}</label> 161 | </StyledLabelTab> 162 | ) 163 | } 164 | 165 | const BackTab = ({ active, changeActiveIndex }) => { 166 | const handleClick = event => { 167 | if (active) { 168 | changeActiveIndex(); 169 | } 170 | } 171 | 172 | return ( 173 | <StyledIconTab 174 | active={active} 175 | onClick={handleClick} 176 | > 177 | <BackButtonIcon /> 178 | </StyledIconTab> 179 | ) 180 | } 181 | 182 | const Tabs = ({ 183 | basePageWidth, 184 | inputs, 185 | activeIndex, 186 | changeActiveIndex, 187 | isSubmitPage 188 | }) => { 189 | const tabContainerRef = useRef(); 190 | 191 | const SUBMIT_TABS_WIDTH = 50; 192 | const pageRelativeWidth = isSubmitPage ? SUBMIT_TABS_WIDTH / basePageWidth : 1; 193 | const { scaleAnimation, inverseScaleAnimation } = useScaleAnimation(pageRelativeWidth, 1); 194 | 195 | return ( 196 | <TabsContainer 197 | ref={tabContainerRef} 198 | basePageWidth={basePageWidth} 199 | isSubmitPage={isSubmitPage} 200 | scaleAnimation={scaleAnimation} 201 | > 202 | <LabelTabsWrapper isSubmitPage={isSubmitPage}> 203 | {(inputs.length > 0) 204 | ? inputs.map((input, index) => ( 205 | <LabelTab 206 | key={`${index}${input.name}`} 207 | htmlFor={input.name} 208 | label={input.label || input.name} 209 | zIndex={inputs.length - index} 210 | active={index === activeIndex} 211 | changeActiveIndex={() => changeActiveIndex(index)} 212 | activated={index < activeIndex} 213 | /> 214 | )) 215 | : null 216 | } 217 | </LabelTabsWrapper> 218 | <IconTabWrapper isSubmitPage={isSubmitPage} inverseScaleAnimation={inverseScaleAnimation}> 219 | <BackTab 220 | active={activeIndex > 0} 221 | changeActiveIndex={() => changeActiveIndex(activeIndex - 1)} 222 | /> 223 | </IconTabWrapper> 224 | </TabsContainer> 225 | ) 226 | } 227 | 228 | export default Tabs; -------------------------------------------------------------------------------- /src/components/withFormContextAndTheme.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { ThemeProvider } from 'emotion-theming' 3 | 4 | import { FormProvider } from '../core/FormContext'; 5 | 6 | const theme = { 7 | colors: { 8 | base: { 9 | green: 'hsl(120, 75%, 40%)', 10 | blue: 'hsl(240, 75%, 50%)', 11 | red: 'hsl(0, 75%, 50%)', 12 | indigo: 'hsl(279, 75%, 50%)', 13 | turqoise: 'hsl(139, 75%, 50%)', 14 | }, 15 | extraDark: { 16 | indigo: 'hsl(279, 25%, 25%)' 17 | }, 18 | dark: { 19 | green: 'hsl(120, 75%, 35%)', 20 | blue: 'hsl(240, 75%, 35%)', 21 | red: 'hsl(0, 75%, 35%)', 22 | indigo: 'hsl(279, 75%, 35%)', 23 | turqoise: 'hsl(139, 50%, 35%)', 24 | }, 25 | light: { 26 | green: 'hsl(120, 50%, 75%)', 27 | blue: 'hsl(240, 50%, 75%)', 28 | red: 'hsl(0, 50%, 75%)', 29 | indigo: 'hsl(279, 75%, 75%)', 30 | turqoise: 'hsl(139, 50%, 75%)', 31 | }, 32 | extraLight: { 33 | green: 'hsl(120, 50%, 90%)', 34 | blue: 'hsl(240, 50%, 90%)', 35 | red: 'hsl(0, 50%, 90%)', 36 | indigo: 'hsl(279, 75%, 95%)', 37 | turqoise: 'hsl(139, 50%, 90%)', 38 | }, 39 | white: 'hsl(0, 100%, 99%)', 40 | black: 'hsl(0, 0%, 25%)', 41 | grey: 'hsl(0, 0%, 35%)', 42 | lightGrey: 'hsl(0, 0%, 93%)', 43 | } 44 | } 45 | 46 | const withFormContextAndTheme = Component => ( 47 | props => ( 48 | <FormProvider> 49 | <ThemeProvider theme={theme}> 50 | <Component {...props}/> 51 | </ThemeProvider> 52 | </FormProvider> 53 | ) 54 | ) 55 | 56 | export default withFormContextAndTheme; -------------------------------------------------------------------------------- /src/core/FormContext.js: -------------------------------------------------------------------------------- 1 | import React, { createContext, useState, useEffect, useRef, useCallback } from "react"; 2 | 3 | export const FormContext = createContext({}); 4 | 5 | export const FormProvider = ({ children }) => { 6 | const [inputs, setInputs] = useState([]); 7 | const [inputValues, setInputValues] = useState({}); 8 | const [activeIndex, setActiveIndex] = useState(0); 9 | const [error, setError] = useState({ 10 | state: false, 11 | message: '' 12 | }) 13 | 14 | const inputsRef = useRef({}); 15 | 16 | const addInput = input => { 17 | const name = input.node.name || input.node.dataset.name; 18 | if (!inputsRef.current.hasOwnProperty(name)) { 19 | input.name = name; 20 | inputsRef.current[name] = input; 21 | } 22 | } 23 | 24 | const getInput = useCallback(identifier => { 25 | if (inputs.length === 0) return null; 26 | if (typeof identifier === 'string') return inputsRef.current[identifier] || null; 27 | else if (typeof identifier === 'number') return (identifier < inputs.length && inputs[identifier]) || null; 28 | }, [inputs]); 29 | 30 | const activeInput = getInput(activeIndex); 31 | const isSubmitPage = inputs.length > 0 && activeIndex === inputs.length; 32 | 33 | useEffect(() => { 34 | // This effect runs in the commit phase after all Ref callbacks are invoked 35 | // (in the commit phase as well) to add inputs to inputsRef.current 36 | const updateInputs = () => { 37 | const inputsArray = Object.values(inputsRef.current); 38 | setInputs(inputsArray); 39 | } 40 | updateInputs(); 41 | }, [setInputs, inputsRef.current]); 42 | 43 | const formContext = { 44 | inputs, 45 | addInput, 46 | getInput, 47 | inputValues, 48 | setInputValues, 49 | activeIndex, 50 | setActiveIndex, 51 | activeInput, 52 | error, 53 | setError, 54 | isSubmitPage, 55 | } 56 | 57 | return <FormContext.Provider value={formContext}>{children}</FormContext.Provider>; 58 | }; -------------------------------------------------------------------------------- /src/core/useAddInput.js: -------------------------------------------------------------------------------- 1 | import { useContext, useCallback } from "react"; 2 | import { FormContext } from "./FormContext"; 3 | 4 | import { validateInputHtml5, validateInputCustom } from '../logic/validateInput'; 5 | 6 | const useAddInput = ({ 7 | label, 8 | caption, 9 | icon, 10 | height, 11 | validationRules = {}, 12 | html5Validation = false, 13 | }) => { 14 | const { addInput } = useContext(FormContext); 15 | 16 | const validateInput = html5Validation ? validateInputHtml5 : validateInputCustom; 17 | 18 | const registerInput = () => { 19 | const input = { 20 | label, 21 | caption, 22 | icon, 23 | height, 24 | validationRules, 25 | validate: function () { 26 | return validateInput(this); 27 | }, 28 | } 29 | return node => { 30 | if (node) { 31 | input.node = node; 32 | addInput(input); 33 | } 34 | }; 35 | } 36 | 37 | const refCallback = useCallback(registerInput()); 38 | 39 | return { 40 | refCallback, 41 | } 42 | } 43 | 44 | export default useAddInput; -------------------------------------------------------------------------------- /src/core/useInputState.js: -------------------------------------------------------------------------------- 1 | import { useState, useEffect, useContext } from "react"; 2 | 3 | import { FormContext } from "./FormContext"; 4 | 5 | const useInputState = (name, initialValue) => { 6 | const { getInput } = useContext(FormContext); 7 | const [value, setValue] = useState(initialValue); 8 | 9 | useEffect(() => { 10 | const input = getInput(name); 11 | if (input) { 12 | input.value = value; 13 | } 14 | }, [value, getInput, name]) 15 | 16 | return { 17 | value, 18 | setValue, 19 | } 20 | } 21 | 22 | export default useInputState; -------------------------------------------------------------------------------- /src/core/useInputs.js: -------------------------------------------------------------------------------- 1 | import { useContext } from "react"; 2 | 3 | import { FormContext } from './FormContext'; 4 | 5 | const useInputs = () => { 6 | const { 7 | inputs, 8 | activeIndex, 9 | setActiveIndex, 10 | activeInput, 11 | error, 12 | setError, 13 | isSubmitPage, 14 | inputValues, 15 | setInputValues 16 | } = useContext(FormContext); 17 | 18 | const changeActiveIndex = index => { 19 | const isNextIndex = index > activeIndex; 20 | if (isNextIndex) { 21 | const errorMessage = activeInput.validate(); 22 | if (errorMessage) { 23 | activeInput.node.focus(); 24 | return setErrorMessage(errorMessage); 25 | } 26 | } 27 | updateInputValues(); 28 | setErrorMessage(''); 29 | setActiveIndex(index); 30 | } 31 | 32 | const setErrorMessage = message => { 33 | if (message) { 34 | setError({ state: true, message }); 35 | } else { 36 | setError({ state: false, message: '' }); 37 | } 38 | } 39 | 40 | const updateInputValues = () => { 41 | const newInputValues = { ...inputValues }; 42 | inputs.forEach(input => { 43 | const valueShallowCopy = (Array.isArray(input.value) && [...input.value]) || 44 | // ((typeof input.value === 'object') && { ...input.value }) || 45 | input.value.trim(); 46 | newInputValues[input.name] = valueShallowCopy.length > 0 ? valueShallowCopy : null; 47 | }); 48 | setInputValues(newInputValues); 49 | } 50 | 51 | return { 52 | inputs, 53 | activeIndex, 54 | changeActiveIndex, 55 | activeInput, 56 | error, 57 | setErrorMessage, 58 | isSubmitPage, 59 | inputValues, 60 | } 61 | } 62 | 63 | export default useInputs; -------------------------------------------------------------------------------- /src/core/useScaleAnimation.js: -------------------------------------------------------------------------------- 1 | import { useEffect, useRef } from "react"; 2 | import { createScaleKeyframeAnimation } from "../utils/createKeyFrameAnimation"; 3 | 4 | const useScaleAnimation = (relativeWidth, relativeHeight) => { 5 | const oldRelativeWidthRef = useRef(1); 6 | const oldRelativeHeightRef = useRef(1); 7 | 8 | const { scaleAnimation, inverseScaleAnimation } = createScaleKeyframeAnimation( 9 | { x: oldRelativeWidthRef.current, y: oldRelativeHeightRef.current }, 10 | { x: relativeWidth, y: relativeHeight } 11 | ); 12 | 13 | useEffect(() => { 14 | oldRelativeWidthRef.current = relativeWidth; 15 | oldRelativeHeightRef.current = relativeHeight; 16 | }, [relativeWidth, relativeHeight]); 17 | 18 | return { 19 | scaleAnimation, 20 | inverseScaleAnimation, 21 | } 22 | } 23 | 24 | export default useScaleAnimation; -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | export useInputs from './core/useInputs'; 2 | export useAddInput from './core/useAddInput'; 3 | 4 | export withFormContextAndTheme from './components/withFormContextAndTheme'; 5 | export FormBody from './components/FormBody'; 6 | export Captions from './components/Captions'; 7 | export Labels from './components/Labels'; 8 | export Input from './components/Input'; 9 | export { RadioControl, RadioOption } from './components/RadioControl'; 10 | export ComboboxMulti from './components/ComboboxMulti'; -------------------------------------------------------------------------------- /src/logic/getValidationAttributes.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Retrieves all HTML5 validation attributes 3 | * @param {object} validationRules - an object of key value pairs where the 4 | * key could be a HTML5 validation attribute (e.g. maxLength) and the value could be either: 5 | * 1. the criteria value (e.g. 5) OR 6 | * 2. an object containing two properties: value and message 7 | * (e.g. { value: 5, message: "Text contains too many characters!" }) 8 | * Returns an object of HTML5 validation rules (e.g. { required: true, maxLength: 5 }) 9 | */ 10 | const getValidationAttributes = validationRules => { 11 | const validationAttributes = {}; 12 | 13 | for (const [rule, value] of Object.entries(validationRules)) { 14 | switch (rule) { 15 | case 'required': 16 | validationAttributes[rule] = typeof value === 'string' || value; 17 | break; 18 | case 'minLength': 19 | case 'maxLength': 20 | case 'min': 21 | case 'max': 22 | case 'pattern': 23 | validationAttributes[rule] = typeof value === 'object' && value.value || value; 24 | break; 25 | default: 26 | break; 27 | } 28 | } 29 | 30 | return validationAttributes; 31 | } 32 | 33 | export default getValidationAttributes; -------------------------------------------------------------------------------- /src/logic/validateInput.js: -------------------------------------------------------------------------------- 1 | const REQUIREDMESSAGE = "Please fill in this field." 2 | 3 | /** 4 | * Validates the input using HTML5 validation 5 | * @param {object} input - an input object that contains all the input prop values, the input value and a DOM node 6 | * Returns the standard HTML5 validation message or a custom one if available 7 | */ 8 | export const validateInputHtml5 = input => { 9 | const { name, value, node, validationRules } = input; 10 | const { 11 | required, // boolean or error message string 12 | minLength, // e.g. 3 or { value: 3, message: 'error message' } 13 | maxLength, // e.g. 16 or { value: 16, message: 'error message' } 14 | min, // e.g. 1 15 | max, // e.g. 100 16 | pattern, // `regex pattern` 17 | validate, // customValidator 18 | } = validationRules; 19 | 20 | if (!node.validity.valid) { 21 | // node.reportValidity(); // reports the validity status to the user in whatever way the user agent has available 22 | if (node.validity.valueMissing) return (typeof required === 'string') && required || node.validationMessage; 23 | if (node.validity.typeMismatch) return node.validationMessage; 24 | if (node.validity.patternMismatch) return (typeof pattern.message === 'string') && pattern.message || node.validationMessage; 25 | if (node.validity.tooShort) return (typeof minLength.message === 'string') && minLength.message || node.validationMessage; 26 | if (node.validity.tooLong) return (typeof maxLength.message === 'string') && maxLength.message || node.validationMessage; 27 | if (node.validity.min) return (typeof min.message === 'string') && min.message || node.validationMessage; 28 | if (node.validity.max) return (typeof max.message === 'string') && max.message || node.validationMessage; 29 | } 30 | if (validate) { 31 | const result = validate(value, name); 32 | if (typeof result === 'string') return result; 33 | } 34 | return ''; 35 | } 36 | 37 | export const validateInputCustom = input => { 38 | const { name, value, validationRules } = input; 39 | const { required, validate } = validationRules; 40 | 41 | if (required) { 42 | const dataType = (Array.isArray(value) && 'array') || 43 | ((typeof value === 'object') && 'object') || 44 | 'primitive'; 45 | 46 | switch (dataType) { 47 | case 'array': 48 | if (required && value.length === 0) return (typeof required === 'string') ? required : REQUIREDMESSAGE; 49 | break; 50 | case 'object': 51 | if (required && Object.values(value).length === 0) return (typeof required === 'string') ? required : REQUIREDMESSAGE; 52 | break; 53 | case 'primitive': 54 | if (required && !value.toString().trim()) return (typeof required === 'string') ? required : REQUIREDMESSAGE; 55 | default: 56 | break; 57 | } 58 | } 59 | 60 | if (validate) { 61 | const result = validate(value, name); 62 | if (typeof result === 'string') return result; 63 | } 64 | return ''; 65 | } -------------------------------------------------------------------------------- /src/propTypes.js: -------------------------------------------------------------------------------- 1 | import PropTypes from 'prop-types'; 2 | 3 | const inputPropTypes = { 4 | name: PropTypes.string.isRequired, 5 | onChange: PropTypes.func, 6 | label: PropTypes.string, 7 | caption: PropTypes.string, 8 | icon: PropTypes.elementType, 9 | height: PropTypes.number, 10 | validationRules: PropTypes.object, 11 | } 12 | 13 | export default inputPropTypes; -------------------------------------------------------------------------------- /src/styles.css: -------------------------------------------------------------------------------- 1 | * { 2 | box-sizing: border-box; 3 | } 4 | 5 | body { 6 | margin: 0; 7 | font-family: "Segoe UI", "Roboto", "Oxygen", "Ubuntu", "Cantarell", 8 | "Fira Sans", "Droid Sans", "Helvetica Neue", sans-serif; 9 | -webkit-font-smoothing: antialiased; 10 | -moz-osx-font-smoothing: grayscale; 11 | background: #b7ddc3; 12 | } 13 | 14 | .App { 15 | position: absolute; 16 | top: 50%; 17 | transform: translateY(-50%); 18 | width: 100%; 19 | text-align: center; 20 | } 21 | -------------------------------------------------------------------------------- /src/test.js: -------------------------------------------------------------------------------- 1 | import ExampleComponent from '.' 2 | 3 | describe('ExampleComponent', () => { 4 | it('is truthy', () => { 5 | expect(ExampleComponent).toBeTruthy() 6 | }) 7 | }) 8 | -------------------------------------------------------------------------------- /src/utils/createKeyFrameAnimation.js: -------------------------------------------------------------------------------- 1 | const ease = (v, pow = 4) => { 2 | return 1 - Math.pow(1 - v, pow); 3 | } 4 | 5 | export const createScaleKeyframeAnimation = (oldSize, newSize) => { 6 | let { x: oldX, y: oldY } = oldSize; 7 | let { x: newX, y: newY } = newSize; 8 | let scaleAnimation = ''; 9 | let inverseScaleAnimation = ''; 10 | 11 | for (let step = 0; step <= 100; step++) { 12 | // Remap the step value to an eased one 13 | let easedStep = ease(step / 100); 14 | 15 | // Calculate the scale of the element 16 | const xScale = oldX - (oldX - newX) * easedStep; 17 | const yScale = oldY - (oldY - newY) * easedStep; 18 | 19 | scaleAnimation += `${step}% { 20 | transform: scale(${xScale}, ${yScale}); 21 | }`; 22 | 23 | // And now the inverse for its contents 24 | const invXScale = 1 / xScale; 25 | const invYScale = 1 / yScale; 26 | inverseScaleAnimation += `${step}% { 27 | transform: scale(${invXScale}, ${invYScale}); 28 | }`; 29 | 30 | } 31 | 32 | return { scaleAnimation, inverseScaleAnimation }; 33 | } -------------------------------------------------------------------------------- /src/utils/debounce.js: -------------------------------------------------------------------------------- 1 | const debounce = (func, timer) => { 2 | let timeout; 3 | return function debouncedFunc() { 4 | const context = this; 5 | const args = arguments; 6 | clearTimeout(timeout); 7 | timeout = setTimeout(() => func.apply(context, args), timer); 8 | } 9 | } 10 | 11 | export default debounce; -------------------------------------------------------------------------------- /src/utils/helpers.js: -------------------------------------------------------------------------------- 1 | export function isEmpty(data) { 2 | if (typeof (data) == 'number' || typeof (data) == 'boolean') { 3 | return false; 4 | } 5 | if (typeof (data) == 'undefined' || data === null) { 6 | return true; 7 | } 8 | if (typeof (data.length) != 'undefined') { 9 | return data.length == 0; 10 | } 11 | let count = 0; 12 | for (let i in data) { 13 | if (data.hasOwnProperty(i)) { 14 | count++; 15 | } 16 | } 17 | return count == 0; 18 | } -------------------------------------------------------------------------------- /src/utils/throttle.js: -------------------------------------------------------------------------------- 1 | function throttle(func, period) { 2 | let isThrottled = false; 3 | let args; 4 | let context; 5 | 6 | function throttledFunc() { 7 | if (isThrottled) { 8 | args = arguments; 9 | context = this; 10 | return; 11 | } 12 | 13 | isThrottled = true; 14 | func.apply(this, arguments); 15 | 16 | setTimeout(function () { 17 | isThrottled = false; 18 | if (args) { 19 | throttledFunc.apply(context, args); 20 | args = context = null; 21 | } 22 | }, period); 23 | } 24 | 25 | return throttledFunc; 26 | } 27 | 28 | export default throttle; --------------------------------------------------------------------------------