├── .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 |
3 |
4 | > A multi-step form component library built with React and styled with Emotion
5 |
6 | [](https://www.npmjs.com/package/react-emotion-multi-step-form)
7 |
8 | [](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 |
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 | changeActiveIndex(index)}
351 | activated={index < activeIndex}
352 | />
353 | ))
354 | : null // render null on initial form render
355 | }
356 |
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 | You need to enable JavaScript to run this app.
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 |
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 |
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 |
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 |
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 |
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 |
95 |
101 | */}
102 |
103 | {isSubmitPage ? ( ) : null}
104 |
105 |
113 |
114 |
115 |
116 |
117 |
118 |
119 |
120 |
128 |
137 |
138 | {error.message}
139 |
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 |
39 |
45 |
46 |
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 |
40 | {value}
41 |
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 ;
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 | 
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 |
35 | {caption}
36 |
37 | )
38 |
39 | const Captions = ({ callToActionText }) => {
40 | const { inputs, activeIndex, isSubmitPage } = useInputs();
41 |
42 | return (
43 |
44 | {(inputs.length > 0)
45 | ?
46 | {inputs.map((input, index) => (
47 |
52 | ))}
53 |
54 |
55 | : null
56 | }
57 |
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 {text} ;
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 | ? {part}
84 | : (part.length > 0 || (index > 0 && index < parts.length - 1)) // exclude leading/trailing empty element
85 | && {part}
86 | ));
87 | return {textWithHighlight} ;
88 | }
89 |
90 | const CustomCheckbox = ({ name, value, checked, onKeyDown, onChange, highlightedText, focusState }) => (
91 |
92 |
100 |
101 |
102 |
103 |
104 |
105 |
106 |
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 |
366 | {
381 | return (
382 |
383 | {state.selected.map((option, idx) => (
384 |
385 | {option}
386 |
387 | ))}
388 | e.keyCode !== TAB && (e.keyCode === RETURN || shouldSelect)}
390 | >
391 | {
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 |
401 |
402 | );
403 | }}
404 | />
405 |
406 |
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 |
415 | {heading}
416 |
417 | {filteredGroup.map((option, index) => {
418 | optionsIndexCounter++;
419 | return (
420 |
421 |
430 |
431 | );
432 | })}
433 |
434 |
435 | );
436 | }
437 | })}
438 |
439 |
440 |
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 |
269 | {tabs
270 | ?
278 | : null
279 | }
280 |
287 |
301 |
302 |
303 | {(inputs.length > 0)
304 | ? inputs.map((input, index) => (
305 |
306 | ))
307 | : null
308 | }
309 |
310 |
311 |
312 | {children}
313 |
314 |
315 |
322 |
323 |
324 |
325 |
326 |
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 |
25 | {(IconComponent) ? : null}
26 |
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 |
37 |
49 |
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 |
57 | {children}
58 |
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 |
50 | {inputValue || label}
51 |
52 | )
53 | }
54 |
55 | const Labels = () => {
56 | const {
57 | inputs,
58 | activeIndex,
59 | changeActiveIndex,
60 | inputValues
61 | } = useInputs();
62 |
63 | return (
64 |
65 | {(inputs.length > 0)
66 | ? inputs.map((input, index) => (
67 | changeActiveIndex(index)}
73 | activated={index < activeIndex}
74 | />
75 | ))
76 | : null
77 | }
78 |
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 |
61 |
68 |
73 | {label || value}
74 |
75 |
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 |
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 |
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 |
160 | {label}
161 |
162 | )
163 | }
164 |
165 | const BackTab = ({ active, changeActiveIndex }) => {
166 | const handleClick = event => {
167 | if (active) {
168 | changeActiveIndex();
169 | }
170 | }
171 |
172 | return (
173 |
177 |
178 |
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 |
202 |
203 | {(inputs.length > 0)
204 | ? inputs.map((input, index) => (
205 | changeActiveIndex(index)}
212 | activated={index < activeIndex}
213 | />
214 | ))
215 | : null
216 | }
217 |
218 |
219 | 0}
221 | changeActiveIndex={() => changeActiveIndex(activeIndex - 1)}
222 | />
223 |
224 |
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 |
49 |
50 |
51 |
52 |
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 {children} ;
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;
--------------------------------------------------------------------------------