-
Notifications
You must be signed in to change notification settings - Fork 3
/
NumberField.tsx
177 lines (162 loc) · 5.03 KB
/
NumberField.tsx
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
import { InputAdornment, TextField } from "@mui/material";
import React, { ChangeEvent, useEffect, useState } from "react";
import { NumberFieldProps } from "./NumberField.types";
/**
* Number fields let users enter and edit numbers.
*/
export default function NumberField(props: NumberFieldProps) {
// destructure custom props and create defaults different from the MUI TextField component
// spread the rest of the mui component props
const {
endAdornment,
error,
helperText,
margin = "normal",
max = +Infinity,
min = -Infinity,
onChange,
showMinMaxErrorMessage = true,
startAdornment,
step,
stepper = true,
value,
variant = "outlined",
...rest
} = props;
// state to keep track of the value of the number field even when invalid
const [currentValue, setCurrentValue] = useState(value);
useEffect(() => {
setCurrentValue(value);
}, [value, min, max]);
// method to return value validity and error message
const isValidValue = (value?: number | null) => {
if (value === null || value === undefined) {
// accept empty string as null
return [true, ""];
} else if (value < min) {
// check if value meets min requirement
return [
false,
showMinMaxErrorMessage ? `Must be greater than or equal to ${min}.` : ""
];
} else if (value > max) {
// check if value meets max requirement
return [
false,
showMinMaxErrorMessage ? `Must be less than or equal to ${max}.` : ""
];
} else if (step !== undefined && !isStep(value, step)) {
// check if value meets step requirement
const options = getNearestSteps(value, step);
return [
false,
`Must be a multiple of ${step}. Try ${options[0]} or ${options[1]}.`
];
} else {
// otherwise value is valid
return [true, undefined];
}
};
// check the validity of the current value
const [isValid, validationText] = isValidValue(currentValue);
// callback for value changing
const handleChange = (event: ChangeEvent<HTMLInputElement>) => {
// get the new value
const newValue = string2number(event.target.value);
const [isValid] = isValidValue(newValue);
// update the current value
setCurrentValue(newValue);
// call the onChange callback if the value is valid
if (onChange && isValid) {
isValid &&
onChange({ ...event, target: { ...event.target, value: newValue } });
}
};
// return a MUI TextField component
// explicitly declare custom props and defaults
// spread the rest of the mui component props
return (
<TextField
error={!isValid || error}
fullWidth
{...rest}
helperText={validationText || helperText}
margin={margin}
variant={variant}
InputLabelProps={{ shrink: true }}
InputProps={{
...(endAdornment && {
endAdornment: (
<InputAdornment position="end">{endAdornment}</InputAdornment>
)
}),
...(startAdornment && {
startAdornment: (
<InputAdornment position="start">{startAdornment}</InputAdornment>
)
})
}}
onChange={handleChange}
sx={
!stepper
? {
"& input::-webkit-clear-button, & input::-webkit-outer-spin-button, & input::-webkit-inner-spin-button":
{ display: "none" }
}
: {
"& input::-webkit-outer-spin-button, & input::-webkit-inner-spin-button":
{ opacity: 1 }
}
}
type="number"
value={currentValue}
/>
);
}
/**
* Converts a string to a number.
* @param value - The value to convert.
* @returns The converted value (either a number or null)
*/
const string2number = (value: string) => {
if (value === "") {
return null;
} else {
return Number(value);
}
};
/**
* Returns the precision of a number.
* @param value - The value to get the precision of.
* @returns The precision of the value.
*/
const precision = (value: number) => {
if (Math.floor(value) === value) return 0;
return value.toString().split(".")[1].length || 0;
};
/**
* Returns the two nearest steps to a value.
* @param value - The value to get the nearest steps for.
* @param step - The step size.
* @returns The two nearest steps.
*/
const getNearestSteps = (value: number, step: number) => {
// handle floating point math with a conversion to integer math
const precisionValue = precision(step);
const m = Math.pow(10, precisionValue);
const lower = Math.floor(value / step) * step * m;
const upper = lower + step * m;
return [lower, upper].map(v => v / m);
};
/**
* Returns whether a value is a step.
* @param value - The value to check.
* @param step - The step size.
* @returns Whether the value is a step.
*/
const isStep = (value: number, step: number) => {
// handle floating point math with a conversion to integer math
const precisionValue = precision(step);
const m = Math.pow(10, precisionValue);
return ((value * m) % (step * m)) / m === 0;
};