├── .babelrc ├── .travis.yml ├── example ├── demo.gif ├── index.html ├── index-promises.html └── src │ ├── app.promise.js │ └── app.js ├── .gitignore ├── Makefile ├── LICENSE ├── package.json ├── react-progress-button.css ├── src └── index.js └── README.md /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["es2015", "stage-0", "react"] 3 | } 4 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - "4" 4 | - "5" 5 | -------------------------------------------------------------------------------- /example/demo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mathieudutour/react-progress-button/HEAD/example/demo.gif -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | pids 2 | logs 3 | npm-debug.log 4 | node_modules 5 | example/lib 6 | example/app.bundle.js 7 | example/app.promise.bundle.js 8 | /lib 9 | coverage 10 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | BIN=node_modules/.bin 2 | 3 | build: 4 | $(BIN)/babel src --out-dir lib && \ 5 | $(BIN)/babel example/src --out-dir example/lib && \ 6 | $(BIN)/webpack example/lib/app.js example/app.bundle.js -p && \ 7 | $(BIN)/webpack example/lib/app.promise.js example/app.promise.bundle.js -p 8 | 9 | clean: 10 | rm -rf lib && rm -rf example/lib && rm -f example/app.bundle.js && rm -f example/app.promise.bundle.js 11 | 12 | lint: 13 | $(BIN)/standard 14 | 15 | test: lint 16 | echo "TODO write test" 17 | 18 | PHONY: build clean lint 19 | -------------------------------------------------------------------------------- /example/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | react-progress-button example 5 | 6 | 31 | 32 | 33 |
34 | 35 | 36 | 37 | -------------------------------------------------------------------------------- /example/index-promises.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | react-progress-button example 5 | 6 | 31 | 32 | 33 |
34 | 35 | 36 | 37 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Mathieu Dutour 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /example/src/app.promise.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import ReactDOM from 'react-dom' 3 | import ProgressButton from '../../lib/index' 4 | import createReactClass from 'create-react-class' 5 | 6 | const App = createReactClass({ 7 | render () { 8 | return ( 9 |
10 |
11 | 12 | Go! 13 | 14 |
15 |
16 | 17 | Go! 18 | 19 |
20 |
21 | 22 | Don't Go! 23 | 24 |
25 |
26 | ) 27 | }, 28 | 29 | handleClick1 () { 30 | return new Promise(resolve => { 31 | // make asynchronous call 32 | setTimeout(resolve, 3000) 33 | }) 34 | }, 35 | 36 | handleClick2 () { 37 | return new Promise((resolve, reject) => { 38 | // make asynchronous call 39 | setTimeout(reject, 3000) 40 | }) 41 | } 42 | }) 43 | 44 | ReactDOM.render(, document.getElementById('app')) 45 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-progress-button", 3 | "version": "5.1.0", 4 | "description": "Simple react.js component for a inline progress indicator", 5 | "main": "lib/index.js", 6 | "jsnext:main": "src/index.js", 7 | "files": [ 8 | "lib", 9 | "src", 10 | "react-progress-button.css" 11 | ], 12 | "dependencies": { 13 | "prop-types": "^15.6.0", 14 | "create-react-class": "^15.6.2" 15 | }, 16 | "devDependencies": { 17 | "babel-cli": "^6.26.0", 18 | "babel-core": "^6.26.0", 19 | "babel-preset-es2015": "^6.24.1", 20 | "babel-preset-react": "^6.24.1", 21 | "babel-preset-stage-0": "^6.24.1", 22 | "react": "^16.0.0", 23 | "react-dom": "^16.0.0", 24 | "standard": "^8.6.0", 25 | "webpack": "^3.6.0" 26 | }, 27 | "scripts": { 28 | "prepublish": "make clean build", 29 | "test": "make test", 30 | "build": "make build" 31 | }, 32 | "repository": { 33 | "type": "git", 34 | "url": "https://github.com/mathieudutour/react-progress-button" 35 | }, 36 | "keywords": [ 37 | "react", 38 | "progress", 39 | "button", 40 | "component", 41 | "javascript", 42 | "react-component" 43 | ], 44 | "author": "Mathieu Dutour", 45 | "license": "MIT", 46 | "bugs": { 47 | "url": "https://github.com/mathieudutour/react-progress-button/issues" 48 | }, 49 | "homepage": "https://github.com/mathieudutour/react-progress-button" 50 | } 51 | -------------------------------------------------------------------------------- /example/src/app.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import ReactDOM from 'react-dom' 3 | import ProgressButton, {STATE} from '../../lib/index' 4 | import createReactClass from 'create-react-class' 5 | 6 | const App = createReactClass({ 7 | getInitialState () { 8 | return { 9 | button1State: STATE.NOTHING, 10 | button2State: STATE.NOTHING, 11 | button3State: STATE.DISABLED 12 | } 13 | }, 14 | 15 | render () { 16 | return ( 17 |
18 |
19 | 20 | Go! 21 | 22 |
23 |
24 | 25 | Go! 26 | 27 |
28 |
29 | 30 | Don't Go! 31 | 32 |
33 |
34 | ) 35 | }, 36 | 37 | handleClick1 () { 38 | this.setState({button1State: STATE.LOADING}) 39 | // make asynchronous call 40 | setTimeout(() => { 41 | this.setState({button1State: STATE.SUCCESS}) 42 | }, 3000) 43 | }, 44 | 45 | handleClick2 () { 46 | this.setState({button2State: STATE.LOADING}) 47 | // make asynchronous call 48 | setTimeout(() => { 49 | this.setState({button2State: STATE.ERROR}) 50 | }, 3000) 51 | } 52 | }) 53 | 54 | ReactDOM.render(, document.getElementById('app')) 55 | -------------------------------------------------------------------------------- /react-progress-button.css: -------------------------------------------------------------------------------- 1 | .pb-container { 2 | display: inline-block; 3 | text-align: center; 4 | width: 100%; 5 | } 6 | .pb-container .pb-button { 7 | background: transparent; 8 | border: 2px solid currentColor; 9 | border-radius: 27px; 10 | color: currentColor; 11 | cursor: pointer; 12 | padding: 0.7em 1em; 13 | text-decoration: none; 14 | text-align: center; 15 | height: 54px; 16 | width: 100%; 17 | -webkit-tap-highlight-color: transparent; 18 | outline: none; 19 | transition: background-color 0.3s, width 0.3s, border-width 0.3s, border-color 0.3s, border-radius 0.3s; 20 | } 21 | .pb-container .pb-button span { 22 | display: inherit; 23 | transition: opacity 0.3s 0.1s; 24 | font-size: 2em; 25 | font-weight: 100; 26 | } 27 | .pb-container .pb-button svg { 28 | height: 54px; 29 | width: 54px; 30 | position: absolute; 31 | transform: translate(-50%, -50%); 32 | pointer-events: none; 33 | } 34 | .pb-container .pb-button svg path { 35 | opacity: 0; 36 | fill: none; 37 | } 38 | .pb-container .pb-button svg.pb-progress-circle { 39 | animation: spin 0.9s infinite cubic-bezier(0.085, 0.260, 0.935, 0.710); 40 | } 41 | .pb-container .pb-button svg.pb-progress-circle path { 42 | stroke: currentColor; 43 | stroke-width: 5; 44 | } 45 | .pb-container .pb-button svg.pb-checkmark path, 46 | .pb-container .pb-button svg.pb-cross path { 47 | stroke: #fff; 48 | stroke-linecap: round; 49 | stroke-width: 4; 50 | } 51 | .pb-container.disabled .pb-button { 52 | cursor: not-allowed; 53 | } 54 | .pb-container.loading .pb-button { 55 | width: 54px; 56 | border-width: 6.5px; 57 | border-color: #ddd; 58 | cursor: wait; 59 | background-color: transparent; 60 | padding: 0; 61 | } 62 | .pb-container.loading .pb-button span { 63 | transition: all 0.15s; 64 | opacity: 0; 65 | display: none; 66 | } 67 | .pb-container.loading .pb-button .pb-progress-circle > path { 68 | transition: opacity 0.15s 0.3s; 69 | opacity: 1; 70 | } 71 | .pb-container.success .pb-button { 72 | border-color: #A0D468; 73 | background-color: #A0D468; 74 | } 75 | .pb-container.success .pb-button span { 76 | transition: all 0.15s; 77 | opacity: 0; 78 | display: none; 79 | } 80 | .pb-container.success .pb-button .pb-checkmark > path { 81 | opacity: 1; 82 | } 83 | .pb-container.error .pb-button { 84 | border-color: #ED5565; 85 | background-color: #ED5565; 86 | } 87 | .pb-container.error .pb-button span { 88 | transition: all 0.15s; 89 | opacity: 0; 90 | display: none; 91 | } 92 | .pb-container.error .pb-button .pb-cross > path { 93 | opacity: 1; 94 | } 95 | @keyframes spin { 96 | from { 97 | transform: translate(-50%, -50%) rotate(0deg); 98 | transform-origin: center center; 99 | } 100 | to { 101 | transform: translate(-50%, -50%) rotate(360deg); 102 | transform-origin: center center; 103 | } 104 | } 105 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import PropTypes from 'prop-types' 3 | import createReactClass from 'create-react-class' 4 | 5 | export const STATE = { 6 | LOADING: 'loading', 7 | DISABLED: 'disabled', 8 | SUCCESS: 'success', 9 | ERROR: 'error', 10 | NOTHING: '' 11 | } 12 | 13 | const ProgressButton = createReactClass({ 14 | propTypes: { 15 | classNamespace: PropTypes.string, 16 | controlled: PropTypes.bool, 17 | durationError: PropTypes.number, 18 | durationSuccess: PropTypes.number, 19 | form: PropTypes.string, 20 | onClick: PropTypes.func, 21 | onError: PropTypes.func, 22 | onSuccess: PropTypes.func, 23 | state: PropTypes.oneOf(Object.keys(STATE).map(k => STATE[k])), 24 | type: PropTypes.string, 25 | shouldAllowClickOnLoading: PropTypes.bool 26 | }, 27 | 28 | getDefaultProps () { 29 | return { 30 | classNamespace: 'pb-', 31 | controlled: false, 32 | durationError: 1200, 33 | durationSuccess: 500, 34 | onClick () {}, 35 | onError () {}, 36 | onSuccess () {}, 37 | shouldAllowClickOnLoading: false 38 | } 39 | }, 40 | 41 | getInitialState () { 42 | return { 43 | currentState: this.props.state || STATE.NOTHING 44 | } 45 | }, 46 | 47 | componentWillReceiveProps (nextProps) { 48 | if (nextProps.state === this.props.state) { return } 49 | switch (nextProps.state) { 50 | case STATE.SUCCESS: 51 | this.success() 52 | return 53 | case STATE.ERROR: 54 | this.error() 55 | return 56 | case STATE.LOADING: 57 | this.loading() 58 | return 59 | case STATE.DISABLED: 60 | this.disable() 61 | return 62 | case STATE.NOTHING: 63 | this.notLoading() 64 | return 65 | default: 66 | return 67 | } 68 | }, 69 | 70 | componentWillUnmount () { 71 | clearTimeout(this._timeout) 72 | }, 73 | 74 | render () { 75 | const { 76 | className, 77 | classNamespace, 78 | children, 79 | type, 80 | form, 81 | durationError, // eslint-disable-line no-unused-vars 82 | durationSuccess, // eslint-disable-line no-unused-vars 83 | onClick, // eslint-disable-line no-unused-vars 84 | onError, // eslint-disable-line no-unused-vars 85 | onSuccess, // eslint-disable-line no-unused-vars 86 | state, // eslint-disable-line no-unused-vars 87 | shouldAllowClickOnLoading, // eslint-disable-line no-unused-vars 88 | controlled, // eslint-disable-line no-unused-vars 89 | ...containerProps 90 | } = this.props 91 | 92 | containerProps.className = classNamespace + 'container ' + this.state.currentState + ' ' + className 93 | containerProps.onClick = this.handleClick 94 | return ( 95 |
96 | 112 |
113 | ) 114 | }, 115 | 116 | handleClick (e) { 117 | const shouldAllowClick = (this.props.shouldAllowClickOnLoading || 118 | this.state.currentState !== STATE.LOADING) && 119 | this.state.currentState !== STATE.DISABLED 120 | if (this.props.controlled && shouldAllowClick) { 121 | this.props.onClick(e) 122 | return true 123 | } 124 | 125 | if (shouldAllowClick) { 126 | this.loading() 127 | const ret = this.props.onClick(e) 128 | this.handlePromise(ret) 129 | } else { 130 | e.preventDefault() 131 | } 132 | }, 133 | 134 | handlePromise (promise) { 135 | if (promise && promise.then && promise.catch) { 136 | promise 137 | .then(() => { 138 | this.success() 139 | }) 140 | .catch((err) => { 141 | this.error(null, err) 142 | }) 143 | } 144 | }, 145 | 146 | loading () { 147 | this.setState({currentState: STATE.LOADING}) 148 | }, 149 | 150 | notLoading () { 151 | this.setState({currentState: STATE.NOTHING}) 152 | }, 153 | 154 | enable () { 155 | this.setState({currentState: STATE.NOTHING}) 156 | }, 157 | 158 | disable () { 159 | this.setState({currentState: STATE.DISABLED}) 160 | }, 161 | 162 | success (callback, dontRemove) { 163 | this.setState({currentState: STATE.SUCCESS}) 164 | this._timeout = setTimeout(() => { 165 | if (!dontRemove) { this.setState({currentState: STATE.NOTHING}) } 166 | callback = callback || this.props.onSuccess 167 | if (typeof callback === 'function') { callback() } 168 | }, this.props.durationSuccess) 169 | }, 170 | 171 | error (callback, err) { 172 | this.setState({currentState: STATE.ERROR}) 173 | this._timeout = setTimeout(() => { 174 | this.setState({currentState: STATE.NOTHING}) 175 | callback = callback || this.props.onError 176 | if (typeof callback === 'function') { callback(err) } 177 | }, this.props.durationError) 178 | } 179 | }) 180 | 181 | export default ProgressButton 182 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # react-progress-button 2 | 3 | [![build status](https://img.shields.io/travis/mathieudutour/react-progress-button/master.svg?style=flat-square)](https://travis-ci.org/mathieudutour/react-progress-button) 4 | [![npm version](https://img.shields.io/npm/v/react-progress-button.svg?style=flat-square)](https://www.npmjs.com/package/react-progress-button) 5 | [![Dependency Status](https://david-dm.org/mathieudutour/react-progress-button.svg)](https://david-dm.org/mathieudutour/react-progress-button) 6 | [![devDependency Status](https://david-dm.org/mathieudutour/react-progress-button/dev-status.svg)](https://david-dm.org/mathieudutour/react-progress-button#info=devDependencies) 7 | 8 | > Simple [React](http://facebook.github.io/react/index.html) component for a circular Progress Button. 9 | 10 | ### [Demo](https://mathieudutour.github.io/react-progress-button) 11 | 12 | [![Demo](https://cdn.rawgit.com/mathieudutour/react-progress-button/master/example/demo.gif "Demo")](https://github.com/mathieudutour/react-progress-button/blob/master/example/index.html) 13 | 14 | ## Install 15 | 16 | ```bash 17 | npm install react-progress-button --save 18 | ``` 19 | 20 | ## Example 21 | 22 | ### Controlled usage: 23 | 24 | ```javascript 25 | import ProgressButton from 'react-progress-button' 26 | 27 | const App = React.createClass({ 28 | getInitialState () { 29 | return { 30 | buttonState: '' 31 | } 32 | }, 33 | 34 | render () { 35 | return ( 36 |
37 | 38 | Go! 39 | 40 |
41 | ) 42 | }, 43 | 44 | handleClick () { 45 | this.setState({buttonState: 'loading'}) 46 | // make asynchronous call 47 | setTimeout(() => { 48 | this.setState({buttonState: 'success'}) 49 | }, 3000) 50 | } 51 | }) 52 | ``` 53 | 54 | [Source](https://github.com/mathieudutour/react-progress-button/blob/master/example/index.html) 55 | 56 | ### Using Promises: 57 | 58 | If the function passed in via the `onClick` prop return a Promise or if a promise 59 | is passed as an argument of the `loading` method, 60 | the component will automatically transition to its success or error 61 | states based on the outcome of the Promise without the need for 62 | external manipulation of state using a ref. 63 | 64 | ```javascript 65 | import ProgressButton from 'react-progress-button' 66 | 67 | const App = React.createClass({ 68 | render () { 69 | return ( 70 |
71 | 72 | Go! 73 | 74 |
75 | ) 76 | }, 77 | 78 | handleClick() { 79 | return new Promise(function(resolve, reject) { 80 | setTimeout(resolve, 3000) 81 | }) 82 | } 83 | }); 84 | ``` 85 | 86 | [Source](https://github.com/mathieudutour/react-progress-button/blob/master/example/index-promises.html) 87 | 88 | ## API 89 | 90 | ### Props 91 | 92 | All props are optional. All props other than that will be passed to the top element. 93 | 94 | ##### controlled 95 | 96 | `true` if you control the button state (by providing `props.state` and `props.onClick`).`false` to let the button manage its state with Promises. 97 | 98 | ##### classNamespace 99 | 100 | Namespace for CSS classes, default is `pb-` i.e CSS classes are `pb-button`. 101 | 102 | ##### durationError 103 | 104 | Duration (ms) before going back to normal state when an error occurs, 105 | default is 1200 106 | 107 | ##### durationSuccess 108 | 109 | Duration (ms) before going back to normal state when an success occurs, 110 | default is 500 111 | 112 | ##### onClick 113 | 114 | Function to call when the button is clicked; if it returns a Promise 115 | then the component will transition to the success/error state based on 116 | the outcome of the Promise 117 | 118 | ##### onError 119 | 120 | Function to call when going back to the normal state after an error 121 | 122 | ##### onSuccess 123 | 124 | Function to call when going back to the normal state after a success 125 | 126 | ##### state 127 | 128 | State of the button if you do not want to use the functions. Can be `''`, `loading`, `success`, `error` or `disabled`. 129 | 130 | ##### type 131 | 132 | Type of the button (can be 'submit' for example). 133 | 134 | ##### form 135 | 136 | Id of the form to submit (useful if the button is not directly inside the form). 137 | 138 | ##### shouldAllowClickOnLoading 139 | 140 | Whether click event should bubble when in loading state 141 | 142 | ### Methods 143 | 144 | ##### loading() 145 | 146 | Put the button in the loading state. 147 | 148 | ##### disable() 149 | 150 | Put the button in the disabled state. 151 | 152 | ##### notLoading(), enable() 153 | 154 | Put the button in the normal state. 155 | 156 | ##### success([callback, dontGoBackToNormal]) 157 | 158 | Put the button in the success state. Call the callback or the onSuccess prop when going back to the normal state. 159 | 160 | ##### error([callback]) 161 | 162 | Put the button in the error state. Call the callback or the onError prop when going back to the normal state. 163 | 164 | ## Styles 165 | 166 | Look at [react-progress-button.css](https://github.com/mathieudutour/react-progress-button/blob/master/react-progress-button.css) for an idea on how to style this component. 167 | 168 | If you are using webpack, you'll need to have ```css-loader``` installed and include 169 | ``` 170 | { 171 | test: /\.css$/, 172 | loader: "style!css" 173 | } 174 | ``` 175 | 176 | in your webpack config. In your jsx file you can then import the CSS with ```import "../node_modules/react-progress-button/react-progress-button.css";``` although the path depends on how deeply nested your jsx is. If you wish to theme it yourself, copy the CSS to a convenient location and point the import path at the copy, which is part of your repo, unlike the original in ```node_modules```. 177 | 178 | ## License 179 | 180 | MIT 181 | --------------------------------------------------------------------------------