├── .gitattributes ├── .gitignore ├── .npmignore ├── .travis.yml ├── .babelrc ├── .editorconfig ├── jest.config.js ├── src ├── __tests__ │ ├── __snapshots__ │ │ └── index.js.snap │ └── index.js └── index.js ├── license ├── package.json └── readme.md /.gitattributes: -------------------------------------------------------------------------------- 1 | * text=auto 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | package-lock.json 3 | src -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - '6' 4 | -------------------------------------------------------------------------------- /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["react", "es2015", "stage-0"] 3 | } 4 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = space 5 | indent_size = 2 6 | end_of_line = lf 7 | charset = utf-8 8 | trim_trailing_whitespace = true 9 | insert_final_newline = true 10 | 11 | [*.md] 12 | trim_trailing_whitespace = false 13 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | roots: ['./src'], 3 | testEnvironment: 'jsdom', 4 | collectCoverageFrom: ['src/**/*.js'], 5 | testPathIgnorePatterns: ['/node_modules/', '/fixtures/'], 6 | coveragePathIgnorePatterns: ['/node_modules/', '/fixtures/'], 7 | coverageThreshold: { 8 | global: { 9 | branches: 100, 10 | functions: 100, 11 | lines: 100, 12 | statements: 100, 13 | }, 14 | }, 15 | snapshotSerializers: ['jest-serializer-html', 'enzyme-to-json/serializer'], 16 | } -------------------------------------------------------------------------------- /src/__tests__/__snapshots__/index.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`getDecrementProps 1`] = ` 4 | Object { 5 | "onClick": [Function], 6 | } 7 | `; 8 | 9 | exports[`getFormProps 1`] = ` 10 | Object { 11 | "onSubmit": [Function], 12 | } 13 | `; 14 | 15 | exports[`getIncrementProps 1`] = ` 16 | Object { 17 | "onClick": [Function], 18 | } 19 | `; 20 | 21 | exports[`getInputProps 1`] = ` 22 | Object { 23 | "focused": undefined, 24 | "onBlur": [Function], 25 | "onFocus": [Function], 26 | "pattern": "[0-9]*", 27 | "ref": [Function], 28 | "type": "text", 29 | "value": 0, 30 | } 31 | `; 32 | 33 | exports[`getInputProps 2`] = ` 34 | Object { 35 | "focused": true, 36 | "onBlur": [Function], 37 | "onFocus": [Function], 38 | "pattern": "[0-9]*", 39 | "ref": [Function], 40 | "type": "text", 41 | } 42 | `; 43 | -------------------------------------------------------------------------------- /license: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) Andrew Joslin (ajoslin.com) 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 13 | all 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 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-stepper-primitive", 3 | "main": "dist/index.js", 4 | "version": "1.1.0", 5 | "description": "A React primitive for building a stepper component", 6 | "scripts": { 7 | "test": "standard --fix 'src/**/*.js' && jest", 8 | "build": "babel ./src -d dist", 9 | "prepublish": "npm -s run build" 10 | }, 11 | "standard": { 12 | "parser": "babel-eslint", 13 | "globals": [ 14 | "jest", 15 | "test", 16 | "expect", 17 | "describe" 18 | ] 19 | }, 20 | "license": "MIT", 21 | "repository": "ajoslin/react-stepper-primitive", 22 | "author": { 23 | "name": "Andrew Joslin", 24 | "email": "andrewtjoslin@gmail.com", 25 | "url": "ajoslin.com" 26 | }, 27 | "keywords": [ 28 | "react", 29 | "stepper-primitive", 30 | "prop getters", 31 | "render" 32 | ], 33 | "devDependencies": { 34 | "babel-cli": "^6.26.0", 35 | "babel-eslint": "^7.2.3", 36 | "babel-preset-es2015": "^6.24.1", 37 | "babel-preset-react": "^6.24.1", 38 | "babel-preset-stage-0": "^6.24.1", 39 | "babel-standard": "^0.2.0", 40 | "enzyme": "^2.9.1", 41 | "enzyme-to-json": "^1.5.1", 42 | "jest": "^21.1.0", 43 | "jest-serializer-html": "^4.0.0", 44 | "react": "^15.6.1", 45 | "react-dom": "^15.6.1", 46 | "react-test-renderer": "^15.6.1", 47 | "standard": "^10.0.3", 48 | "tape": "^4.0.0" 49 | }, 50 | "dependencies": { 51 | "numeric-pattern": "^1.0.0", 52 | "prop-types": "^15.5.10" 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /src/__tests__/index.js: -------------------------------------------------------------------------------- 1 | const React = require('react') 2 | const {mount} = require('enzyme') 3 | const Stepper = require('../') 4 | 5 | function setup ({render = () =>
, ...props} = {}) { 6 | let renderArg 7 | const renderSpy = jest.fn(arg => { 8 | renderArg = arg 9 | return render(arg) 10 | }) 11 | const wrapper = mount() 12 | return {renderSpy, wrapper, ...renderArg} 13 | } 14 | 15 | test('value defaults to 0', () => { 16 | const {value} = setup() 17 | expect(value).toBe(0) 18 | }) 19 | 20 | test('value follows defaultValue', () => { 21 | const {value} = setup({defaultValue: 33}) 22 | expect(value).toBe(33) 23 | }) 24 | 25 | test('getFormProps', () => { 26 | const {getFormProps} = setup({}) 27 | expect(getFormProps()).toMatchSnapshot() 28 | }) 29 | 30 | test('getInputProps', () => { 31 | const {wrapper, getInputProps} = setup({}) 32 | expect(getInputProps().value).toBe(0) 33 | expect(getInputProps()).toMatchSnapshot() 34 | wrapper.setState({ focused: true }) 35 | expect(getInputProps().value).toBe(undefined) 36 | expect(getInputProps()).toMatchSnapshot() 37 | }) 38 | 39 | test('getIncrementProps', () => { 40 | const {getIncrementProps} = setup() 41 | expect(getIncrementProps()).toMatchSnapshot() 42 | }) 43 | 44 | test('getDecrementProps', () => { 45 | const {getDecrementProps} = setup() 46 | expect(getDecrementProps()).toMatchSnapshot() 47 | }) 48 | 49 | test('setValue is between [min, max]', () => { 50 | const {wrapper, setValue} = setup({min: 0, max: 1}) 51 | 52 | setValue(2) 53 | expect(wrapper.state('value')).toBe(1) 54 | setValue(-1) 55 | expect(wrapper.state('value')).toBe(0) 56 | setValue(1) 57 | expect(wrapper.state('value')).toBe(1) 58 | setValue(0) 59 | expect(wrapper.state('value')).toBe(0) 60 | }) 61 | 62 | test('increment/decrement are capped at min/max', () => { 63 | const {wrapper, increment, decrement} = setup({min: 0, max: 1}) 64 | 65 | expect(wrapper.state('value')).toBe(0) 66 | increment() 67 | expect(wrapper.state('value')).toBe(1) 68 | increment() 69 | expect(wrapper.state('value')).toBe(1) 70 | decrement() 71 | expect(wrapper.state('value')).toBe(0) 72 | decrement() 73 | expect(wrapper.state('value')).toBe(0) 74 | }) 75 | 76 | describe('enableReinitialize', () => { 77 | test('true: value is updated to new default if defaultValue changes and value has not been modified', () => { 78 | const {wrapper} = setup({defaultValue: 33, enableReinitialize: true}) 79 | 80 | expect(wrapper.state('value')).toBe(33) 81 | wrapper.setProps({defaultValue: 42}) 82 | expect(wrapper.state('value')).toBe(42) 83 | }) 84 | 85 | test('true: value is not updated to new default if defaultValue changes and value has been modified', () => { 86 | const {wrapper} = setup({defaultValue: 33, enableReinitialize: true}) 87 | 88 | expect(wrapper.state('value')).toBe(33) 89 | wrapper.setState({value: 418}) 90 | wrapper.setProps({defaultValue: 42}) 91 | expect(wrapper.state('value')).toBe(418) 92 | }) 93 | 94 | test('false: value remains unchanged if defaultValue changes', () => { 95 | const {wrapper} = setup({defaultValue: 33}) 96 | 97 | expect(wrapper.state('value')).toBe(33) 98 | wrapper.setProps({defaultValue: 42}) 99 | expect(wrapper.state('value')).toBe(33) 100 | }) 101 | }) 102 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const React = require('react') 4 | const PropTypes = require('prop-types') 5 | const numericPattern = require('numeric-pattern') 6 | 7 | const callAll = (...fns) => arg => fns.forEach(fn => fn && fn(arg)) 8 | 9 | module.exports = exports.default = class StepperPrimitive extends React.Component { 10 | static propTypes = { 11 | defaultValue: PropTypes.number, 12 | value: PropTypes.number, 13 | step: PropTypes.number, 14 | min: PropTypes.number, 15 | max: PropTypes.number, 16 | onChange: PropTypes.func, 17 | render: PropTypes.func.isRequired, 18 | enableReinitialize: PropTypes.bool 19 | } 20 | 21 | static defaultProps = { 22 | defaultValue: 0, 23 | step: 1, 24 | min: -Number.MAX_VALUE, 25 | max: Number.MAX_VALUE, 26 | onChange: () => {}, 27 | enableReinitialize: false 28 | } 29 | 30 | state = { 31 | value: this.getValue({value: this.props.defaultValue}) 32 | } 33 | 34 | componentDidUpdate (prevProps, prevState) { 35 | if ( 36 | this.props.enableReinitialize && 37 | prevProps.defaultValue !== this.props.defaultValue && 38 | prevProps.defaultValue === prevState.value 39 | ) { 40 | this.setValue(this.props.defaultValue) 41 | } 42 | } 43 | 44 | isControlled () { 45 | return this.props.value !== undefined 46 | } 47 | 48 | getValue (state = this.state) { 49 | return this.isControlled() ? this.props.value : state.value 50 | } 51 | 52 | setValue = (value) => { 53 | value = Math.min(this.props.max, Math.max(value, this.props.min)) 54 | if (this.isControlled()) { 55 | this.props.onChange(value) 56 | } else { 57 | this.setState({value}, () => this.props.onChange(this.getValue())) 58 | } 59 | } 60 | 61 | increment = () => { 62 | this.setValue(this.getValue() + this.props.step) 63 | } 64 | 65 | decrement = () => { 66 | this.setValue(this.getValue() - this.props.step) 67 | } 68 | 69 | handleSubmit = ev => { 70 | ev.preventDefault() 71 | if (this.input) this.input.blur() 72 | } 73 | 74 | handleInputRef = node => { 75 | if (!node) return 76 | this.input = node 77 | } 78 | 79 | handleBlur = () => { 80 | if (!this.input) return 81 | this.input.blur() 82 | this.setState({ focused: false }) 83 | 84 | let value = parseFloat(this.input.value) 85 | if (isNaN(value) || value === this.getValue()) return 86 | 87 | this.setValue(value) 88 | } 89 | 90 | handleFocus = () => { 91 | if (!this.input) return 92 | this.setState({ focused: true }, () => { 93 | this.input.value = this.getValue() 94 | this.input.setSelectionRange(0, 9999) 95 | }) 96 | } 97 | 98 | getFormProps = (props = {}) => { 99 | return { 100 | onSubmit: this.handleSubmit 101 | } 102 | } 103 | 104 | getIncrementProps = (props = {}) => { 105 | return { 106 | onClick: callAll(props.onClick, this.increment) 107 | } 108 | } 109 | 110 | getDecrementProps = (props = {}) => { 111 | return { 112 | onClick: callAll(props.onClick, this.decrement) 113 | } 114 | } 115 | 116 | getInputProps = (props = {}) => { 117 | return { 118 | type: 'text', 119 | ref: callAll(props.ref, this.handleInputRef), 120 | pattern: numericPattern, 121 | onBlur: callAll(props.onBlur, this.handleBlur), 122 | onFocus: callAll(props.onFocus, this.handleFocus), 123 | focused: this.state.focused, 124 | // When the input is focused, let the user type freely. 125 | // When the input isn't, lock it to the current value 126 | ...this.state.focused ? {} : { 127 | value: this.getValue() 128 | } 129 | } 130 | } 131 | 132 | render () { 133 | return this.props.render({ 134 | value: this.getValue(), 135 | focused: this.state.focused, 136 | increment: this.increment, 137 | decrement: this.decrement, 138 | setValue: this.setValue, 139 | getFormProps: this.getFormProps, 140 | getInputProps: this.getInputProps, 141 | getIncrementProps: this.getIncrementProps, 142 | getDecrementProps: this.getDecrementProps 143 | }) 144 | } 145 | } 146 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | # react-stepper-primitive [![Build Status](https://travis-ci.org/ajoslin/react-stepper-primitive.svg?branch=master)](https://travis-ci.org/ajoslin/react-stepper-primitive) 2 | 3 | > React primitives for a "stepper" component. 4 | 5 | So you can build this: 6 | 7 | ![](http://ajoslin.co/xNFW9Y/3K1juxob+) 8 | 9 | ```jsx 10 | ReactDOM.render( 11 | 20 |
21 | 24 | 25 | 28 |
} 29 | />, 30 | document.body 31 | ) 32 | ``` 33 | 34 | 35 | ### Why? 36 | 37 | Because a stepper (minus button, input, plus button) is non-trivial. There's a lot to manage: there's a minimum and maximum. There's the input displaying the current value. There's the input allowing free-type while the user focuses the input, then interpreting the user's value once they blur it. 38 | 39 | These primitives manage the data manipulation for you so you only have to worry about the styling. 40 | 41 | ## Install 42 | 43 | ``` 44 | $ npm install --save react-stepper-primitive 45 | ``` 46 | 47 | ## API 48 | 49 | ## `` 50 | 51 | #### Props 52 | 53 | #### defaultValue 54 | 55 | > `number` | default 0 | optional 56 | 57 | The initial value. 58 | 59 | 60 | #### onChange 61 | 62 | > `function` | optional 63 | 64 | Called when the value changes, with the new value as the only argument. 65 | 66 | #### value 67 | 68 | > `number` | optional 69 | 70 | The value. If no value is passed in, the stepper will manage its value via its own internal state. 71 | 72 | If value is passed in, the stepper becomes a "controlled component". 73 | 74 | The `onChange` function passed in will be called whenever value changes, whether you pass it in or not. 75 | 76 | > Note: This is very similar to how normal controlled components work elsewhere 77 | > in react (like ``). 78 | 79 | #### min 80 | 81 | > `number` | optional, no default 82 | 83 | The value cannot go below this minimum. 84 | 85 | #### max 86 | 87 | > `number` | optional, no default 88 | 89 | The value cannot go above this maximum. 90 | 91 | #### step 92 | 93 | > `number` | default 1 | optional 94 | 95 | Every click on the increment or decrement button increases the value by `step`. 96 | 97 | #### render 98 | 99 | > `function()` | *required* 100 | 101 | `
} />` 102 | 103 | The `render` prop function is called with the following object: 104 | 105 | | property | category | type | description | 106 | |-------------------|-------------|----------|-------------------------------------------------------------------------------------------------------------------------| 107 | | value | state | number | The current value of the stepper | 108 | | focused | state | boolean | Whether the input is currently focused. | 109 | | getFormProps | prop getter | function | Returns the props you should apply to a form element (for submit handling) | 110 | | getInputProps | prop getter | function | Returns the props you should apply to an input element (for displaying and free-form modification of the current value) | 111 | | getDecrementProps | prop getter | function | Returns the props you should apply to a decrement button | 112 | | getIncrementProps | prop getter | function | Returns the props you should apply to an increment button | 113 | | increment | setter | function | Increment the value by one. Value cannot go under props.min. | 114 | | decrement | setter | function | Decrement the value by one. Value cannot go over props.max. | 115 | | setValue | setter | function | Set a new value. Value is coerced to stay between props.min and props.max. | 116 | 117 | #### enableReinitialize 118 | 119 | > `boolean` | default false | optional 120 | 121 | Control whether the current value (if unchanged) will update to the new default if `defaultValue` changes. 122 | 123 | ## Related Work 124 | 125 | Thanks to [Kent C Dodds](github.com/kentcdodds) for formalizing the "prop getters" idea in [downshift](https://github.com/paypal/downshift). And for the readme formatting, which I've stolen. 126 | 127 | ## License 128 | 129 | MIT © [Andrew Joslin](http://ajoslin.com) 130 | --------------------------------------------------------------------------------