11import React , { Component } from 'react' ;
22import PropTypes from 'prop-types' ;
3+ import { Decimal } from 'decimal.js' ;
34
45
56const propTypes = {
67value : PropTypes . number . isRequired ,
7- step : PropTypes . number ,
8- min : PropTypes . number ,
98max : PropTypes . number ,
9+ min : PropTypes . number ,
1010decimals : PropTypes . number ,
11+ step : PropTypes . number ,
12+ showTrailingZeros : PropTypes . bool ,
13+ snapToStep : PropTypes . bool ,
1114onBlur : PropTypes . func ,
1215onChange : PropTypes . func
1316} ;
1417
1518const defaultProps = {
16- step : 1 ,
17- min : null ,
1819max : null ,
19- decimals : 0 ,
20+ min : null ,
21+ decimals : 2 ,
22+ step : 1 ,
23+ showTrailingZeros : false ,
24+ snapToStep : false ,
2025onBlur : null ,
2126onChange : null
2227} ;
2328
2429export default class InputNumeric extends Component {
30+ /**
31+ * Validate programmatically changed values
32+ */
2533static getDerivedStateFromProps ( nextProps , prevState ) {
26- const newValue = nextProps . value ;
27- if ( newValue !== prevState . value ) {
34+ const newValue = new Decimal ( nextProps . value ) ;
35+ // Only perform validation if the entered value is different than the one stored in state
36+ if ( ! newValue . equals ( prevState . value ) ) {
2837return {
29- newValue
38+ value : InputNumeric . validate ( newValue , nextProps )
3039} ;
3140} else {
3241return null ;
3342}
3443}
3544
3645/**
37- * Round number to specified precision (and avoid the floating-number arithmetic issue)
38- * Source: MDN
46+ * Value validation:
47+ * - Make sure it the number lies between props.min and props.max (if specified)
48+ * - Snap manually entered values to the nearest step (if specified)
3949 */
40- static round ( number , decimals ) {
41- // Shift the decimals to get a whole number, then round using Math.round() and move the number
42- // of whole numbers, based on the specified precision, back to decimals
43- return this . shift ( Math . round ( this . shift ( number , decimals , false ) ) , decimals , true ) ;
44- }
50+ static validate ( value , props ) {
51+ let newValue = value ;
52+
53+ // Make sure value is in specified range (between min and max)
54+ if ( props . min !== null && newValue . lessThanOrEqualTo ( props . min ) ) {
55+ // Value is min or smaller: set to min
56+ return new Decimal ( props . min ) ;
57+ } else if ( props . max !== null && newValue . greaterThanOrEqualTo ( props . max ) ) {
58+ // Value is max or larger: set to max
59+ return new Decimal ( props . max ) ;
60+ }
4561
46- /**
47- * Helper function for rounding: Move the number of decimals, based on the specified precision, to
48- * a whole number
49- * @param {boolean } reverseShift If true, round to one decimal place; if false, round to the tens
50- * Source: MDN
51- */
52- static shift ( number , decimals , reverseShift ) {
53- let precision = decimals ;
54- if ( reverseShift ) {
55- precision = - precision ;
62+ // Snap to step if option is enabled
63+ if ( props . snapToStep === true ) {
64+ newValue = newValue . toNearest ( props . step ) ;
5665}
57- const numArray = ( ` ${ number } ` ) . split ( 'e' ) ;
58- return + ( ` ${ numArray [ 0 ] } e ${ numArray [ 1 ] ? ( + numArray [ 1 ] + precision ) : precision } ` ) ;
66+
67+ return newValue ;
5968}
6069
6170constructor ( props ) {
6271super ( props ) ;
63- this . mouseDownDelay = 250 ;
64- this . mouseDownInterval = 75 ;
72+ this . mouseDownDelay = 250 ; // duration until mouseDown is treated as such (instead of click)
73+ this . mouseDownInterval = 75 ; // interval for increasing/decreasing value on mouseDown
6574
6675this . state = {
67- newValue : this . props . value
76+ value : new Decimal ( this . props . value ) , // final, validated number
77+ valueEntered : '' // temporary, unvalidated number displayed while user is editing the input
6878} ;
6979}
7080
71- onChange ( e ) {
81+ onInputChange ( e ) {
82+ // Temporarily save the new value entered in the input field
7283this . setState ( {
73- newValue : e . target . value
84+ valueEntered : e . target . value
7485} ) ;
7586}
7687
77- onBlur ( ) {
78- const newValue = this . validate ( this . state . newValue ) ;
88+ onInputBlur ( ) {
89+ // Validate and save this.state.valueEntered, execute this.props.onChange and this.props.onBlur
90+ const value = InputNumeric . validate ( new Decimal ( this . state . valueEntered ) , this . props ) ;
91+ this . setState ( {
92+ value,
93+ valueEntered : ''
94+ } ) ;
7995if ( this . props . onChange ) {
80- this . props . onChange ( newValue ) ;
96+ this . props . onChange ( value . toNumber ( ) ) ;
8197}
8298if ( this . props . onBlur ) {
83- this . props . onBlur ( newValue ) ;
84- }
85- }
86-
87- validate ( newValue ) {
88- if ( newValue === '' ) {
89- // no input: use previous value
90- return this . state . newValue ;
91- }
92- const newNumber = parseFloat ( newValue ) ;
93- if ( this . props . min && newNumber <= this . props . min ) {
94- // value is min or smaller: set to min
95- return this . props . min ;
96- } else if ( this . props . max && newNumber >= this . props . max ) {
97- // value is max or larger: set to max
98- return this . props . max ;
99- } else {
100- // value is between min and max: round number to specified number of decimals
101- return InputNumeric . round ( newNumber , this . props . decimals ) ;
99+ this . props . onBlur ( value . toNumber ( ) ) ;
102100}
103101}
104102
105103/**
106104 * Decrement the input field's value by one step (this.props.step)
107105 */
108106decrement ( ) {
109- const newValue = this . validate ( this . state . newValue - this . props . step ) ;
110- this . setState ( {
111- newValue
112- } ) ;
107+ const oldValue = this . state . value ;
108+ if ( oldValue . modulo ( this . props . step ) . isZero ( ) ) {
109+ // If current value is divisible by step: Subtract step
110+ this . setState ( {
111+ value : InputNumeric . validate ( oldValue . minus ( this . props . step ) , this . props )
112+ } ) ;
113+ } else {
114+ // If current value is not divisible by step: Round to nearest lower multiple of step
115+ this . setState ( {
116+ value : oldValue . toNearest ( this . props . step , Decimal . ROUND_DOWN )
117+ } ) ;
118+ }
113119}
114120
115121/**
116122 * Increment the input field's value by one step (this.props.step)
117123 */
118124increment ( ) {
119- const newValue = this . validate ( this . state . newValue + this . props . step ) ;
120- this . setState ( {
121- newValue
122- } ) ;
125+ const oldValue = this . state . value ;
126+ if ( oldValue . modulo ( this . props . step ) . isZero ( ) ) {
127+ // If current value is divisible by step: Add step
128+ this . setState ( {
129+ value : InputNumeric . validate ( oldValue . plus ( this . props . step ) , this . props )
130+ } ) ;
131+ } else {
132+ // If current value is not divisible by step: Round to nearest higher multiple of step
133+ this . setState ( {
134+ value : oldValue . toNearest ( this . props . step , Decimal . ROUND_UP )
135+ } ) ;
136+ }
123137}
124138
125139/**
@@ -157,14 +171,27 @@ export default class InputNumeric extends Component {
157171clearInterval ( this . interval ) ;
158172}
159173if ( this . props . onChange ) {
160- this . props . onChange ( this . state . newValue ) ;
174+ this . props . onChange ( this . state . value . toNumber ( ) ) ;
161175}
162176if ( this . props . onBlur ) {
163- this . props . onBlur ( this . state . newValue ) ;
177+ this . props . onBlur ( this . state . value . toNumber ( ) ) ;
164178}
165179}
166180
167181render ( ) {
182+ // Determine value to be displayed in the input field
183+ let displayedValue ;
184+ if ( this . state . valueEntered !== '' ) {
185+ // Display entered (non-validated) value while input field is being edited
186+ displayedValue = this . state . valueEntered ;
187+ } else if ( this . props . showTrailingZeros === true ) {
188+ // Add trailing zeros if option is enabled
189+ displayedValue = this . state . value . toFixed ( this . props . decimals ) ;
190+ } else {
191+ // Round to specified number of decimals
192+ displayedValue = this . state . value . toDecimalPlaces ( this . props . decimals ) ;
193+ }
194+
168195return (
169196< div className = "number-input" >
170197< button
@@ -177,10 +204,9 @@ export default class InputNumeric extends Component {
177204</ button >
178205< input
179206type = "number"
180- value = { this . state . newValue }
181- step = { this . props . step }
182- onChange = { e => this . onChange ( e ) }
183- onBlur = { ( ) => this . onBlur ( ) }
207+ value = { displayedValue }
208+ onChange = { e => this . onInputChange ( e ) }
209+ onBlur = { ( ) => this . onInputBlur ( ) }
184210/>
185211< button
186212type = "button"
0 commit comments