The software development job is a daily problem-solving process. Developers love picking up sophisticated issues from the backlog and avoiding trivial ones. However, any task can turn into an adventure with a ton of surprises and elements of a detective job when the developer is investigating a problem and discovering reasons for unexpected behavior.
In this article, I am sharing my personal experience in creating a configurable input to type numbers, facing pitfalls along the way, and eventually, I will suggest a solution for this problem.
Requirements
Let’s look at what actually had to be done. The task is to add number input. The context is that this input is a part of the application builder so users are creating their apps from a predefined set of components. The input has the following requirements:
- The component should be configurable. Particularly it has three essential properties:
-
defaultValue: number
- should be applied when the input is empty and not touched. -
allowDecimals: boolean
- should allow typing a decimal value, increasing and decreasing by arrows should add and subtract 1 accordingly and keep the decimal part. If the prop is switched from true to false decimal value should be floored. -
allowNegative: boolean
- should allow typing negative values. If the prop is false, the minimum possible value is 0. If the prop is switched from true to false negative value should be changed to 0.
-
- The value can be changed with the help of arrow buttons, arrow keys, or manually typed.
- Depending on chosen settings the input should not allow typing invalid numbers. In other words, allowed symbols are digits (0..9), optionally separators (,.) and a sign (-).
- The UI should be customized including arrow buttons.
Restrictions by the specification
The first idea is to use a usual HTML input with a type number. It already has min
, max
and step
properties that look exactly like what we need to customize behavior according to requirements. However, the HTML5 specification and browser implementations have a few peculiarities:
- Setting a minimum value restricts values that a user can choose using up and down arrows but it is still necessary to handle and validate manual user input. The same for typing decimal numbers.
-
Decimal separator
- Chrome works with both “,” and “.”
- Firefox and Safari require “.” as a separator, the value with a comma is considered to be invalid.
While English-speaking countries use a decimal point as a preferred separator between integer and fractional parts many countries use a comma as a separator as well. Most of the standards (System of Units, versions of ISO 8601 before 2019, ISO 80000-1) stipulate that both a point and a comma can be used equally. From this perspective, Chrome's behavior looks preferable and should be consistent among different browsers.
-
On type validation
- Chrome filters prohibited values such as letters and punctuation marks (except decimal separators).
- Firefox and Safari allow typing any symbols. Instead, they mark the input as invalid and the value is an empty string.
Again, the support of Chrome’s behavior looks preferable because it helps a user, doesn't provide opportunities for invalid input, and eliminates the necessity of additional validation messages.
By default increment/decrement of decimals works in a different way than what was expected. It always tries to round a number and ignores a fractional part. Let’s say we have 1.5, then adding one will result in 2, not 2.5. The step can have a value equal “any” that makes increment/decrement behavior as was expected. However, it doesn’t work in the current versions of Firefox.
All the things above are actually reasonable, Chrome’s input looks almost perfect so far. Despite Firefox and Safari providing worse UX they have lower usage, and these shortages could be ignored for the first version.
The worst thing is when the customization of up and down arrows comes. For this purpose input component was added along with a custom controls component.
export const NativeNumericInput = ({ value, onValueChange }) => {
const { min, allowDecimals } = useConfigContext();
const [displayValue, setDisplayValue] = useState('');
const inputRef = useRef();
const max = Number.MAX_SAFE_INTEGER;
const canIncrement = value < max;
const handleIncrement = () => {
inputRef.current.stepUp();
const newValue = inputRef.current.value;
onChange(Number(newValue));
};
const canDecrement = value > min;
const handleDecrement = () => {
inputRef.current.stepDown();
const newValue = inputRef.current.value;
onChange(Number(newValue));
};
const handleValueChange = (e) => {
const newValue = e.target.value;
setDisplayValue(newValue);
if (newValue === '') {
onValueChange(null);
return;
}
onValueChange(Number(newValue));
};
return (
<div className="native-input-container">
<label htmlFor="native-input">Native Input</label>
<br />
<input
ref={inputRef}
type="number"
id="native-input"
min={min}
step={allowDecimals ? 'any' : 1}
value={displayValue}
className="input-with-custom-controls"
onChange={handleValueChange}
/>
<NumericInputControls
canIncrement={canIncrement}
canDecrement={canDecrement}
onIncrement={handleIncrement}
onDecrement={handleDecrement}
/>
</div>
);
};
const NumericInputControls = ({ canIncrement, canDecrement, onIncrement, onDecrement }) => {
return (
<div className="numeric-input-controls">
<button onClick={onIncrement} disabled={!canIncrement}>
^
</button>
<button onClick={onDecrement} disabled={!canDecrement}>
^
</button>
</div>
);
};
The input component receives value
as a prop, min
property from the configuration which is 0 if allowNegative: false
and set max
to define when the buttons should be disabled. NumericInputControls
is just a presentation component. The increment and decrement handlers use stepUp
and stepDown
methods respectively. It works fine for integers and doesn’t work for decimals. The reason is that value of the step attribute is any, and these methods throw an error INVALID_STATE_ERR
. It is happening because their internal behavior multiplies the step value by n, where n is an argument of the function whose default value is 1.
This leads us to write our own implementation of increment and decrement functions. Also, it means that buttons will work the same way in all browsers including Firefox. But the keyboard events for ArrowDown and ArrowUp are still based on the native implementation of number input. Considering the fact that the benefits of specific input are not used anymore, it appears that rewriting the component with a standard text input is not much harder but allows to implement of consistent behavior for all necessary features: on type validation, decimal separators, increment and decrement for decimals.
Solution based on text input
Let’s first check how increment and decrement functions changed.
const handleIncrement = () => {
const newValue = preciseMathSum(value ?? defaultValue ?? 0, 1);
handleControlsValueChange(Math.min(max, newValue));
};
const handleDecrement = () => {
const newValue = preciseMathSubtract(value ?? defaultValue ?? 0, 1);
handleControlsValueChange(Math.max(min, newValue));
};
const handleControlsValueChange = (value) => {
setDisplayValue(value.toString());
onValueChange(value);
};
There are methods preciseMathSum
and preciseMathSubtract
to solve a floating number precision issue so fraction part length will be kept and expected. Also, the usage of Math.min
and Math.max
helps to avoid exiting the range of allowed values. The minimum value depends on allowNegative
setting so it is either 0 or Number.MIN_SAFE_INTEGER
. The maximum allows operating with numbers no more than Number.MAX_SAFE_INTEGER
accordingly.
The next step is to return the keyboard support that we lost by switching off the number input behavior.
useEffect(() => {
const handleKeydown = (e) => {
if (e.code === 'ArrowDown' && canDecrement) {
handleDecrement();
e.preventDefault();
} else if (e.code === 'ArrowUp' && canIncrement) {
handleIncrement();
e.preventDefault();
}
};
inputRef.current.addEventListener('keydown', handleKeydown);
return () => inputRef.current.removeEventListener('keydown', handleKeydown);
}, [canIncrement, canDecrement]);
It checks for the possibility to change the input and does the same increment and decrement operations on the keydown event only for two arrows.
The last step is to verify that the user is not allowed to put invalid values into an input. These restrictions are different depending on the component settings. The user is able to type “-”, “,” and “.” only if negative and decimal numbers are allowed.
const handleValueChange = (e) => {
const newValue = e.target.value;
if (!isValidNumberInput(newValue, { allowDecimals, allowNegative })) {
return;
}
setDisplayValue(newValue);
if (newValue === '') {
onValueChange(null);
return;
}
const newValueWithDecimalPoint = newValue.replace(',', '.');
const newNumericValue = Number(newValueWithDecimalPoint);
if (newValueWithDecimalPoint !== '' && !Number.isNaN(newNumericValue)) {
onValueChange(newNumericValue);
}
};
All the logic is encapsulated in isValidNumberInput
utility function. It checks an input to be either a valid number or unfinished input which could become a valid number. Internally it uses pattern matching and checking edge cases, also considering allowDecimals
and allowNegative
values. It would be more clear with examples.
isValidNumberInput('-1', { allowDecimals: false, allowNegative: false }); // returns false
isValidNumberInput('-1', { allowDecimals: false, allowNegative: true }); // returns true
isValidNumberInput('-', { allowDecimals: false, allowNegative: true }); // returns true
isValidNumberInput('--', { allowDecimals: false, allowNegative: true }); // returns false
isValidNumberInput('1.5', { allowDecimals: false, allowNegative: false }); // returns false
isValidNumberInput('1.5', { allowDecimals: true, allowNegative: false }); // returns true
isValidNumberInput('1,5', { allowDecimals: true, allowNegative: false }); // returns true
isValidNumberInput('1.5.', { allowDecimals: true, allowNegative: false }); // returns false
After this verification, the input will be converted to a number and committed if possible. If it is not a number yet it is only set as a display value for controlled input, the actual value is kept the same.
Note: alternatively it is possible to build one regex to check the input for necessary conditions and even add it as a pattern prop to the input. However, I personally prefer to have simpler regex and additional conditions that could be more readable. Also, putting this logic in the method allows us to cover it with unit tests.
Conclusion
There is a repository with the demonstration code that is also published with Vercel if you want to play around with this case.
Despite the fact that I had to implement this task twice it was a nice exercise and interesting diving into the HTML5 specification. I hope you also enjoyed this small journey and found something useful for you!
Top comments (4)
Great job! Very useful for me!
Сongratulations 🥳! Your article hit the top posts for the week - dev.to/fruntend/top-10-posts-for-f...
Keep it up 👍
Great and very detailed overview 👏
Thanks for sharing, I was actually considering should I use the native one or not when I found this article and now I know! Very helpful!