DEV Community

Cover image for What’s wrong with HTML number input
Maxim Titov
Maxim Titov

Posted on

What’s wrong with HTML number input

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:

  1. 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.
  2. The value can be changed with the help of arrow buttons, arrow keys, or manually typed.
  3. 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 (-).
  4. 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:

  1. 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.
  2. 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.

  3. 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.

    Value and validation message of invalid number input in Firefox

    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.

  4. 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>
  );
};
Enter fullscreen mode Exit fullscreen mode

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);
};
Enter fullscreen mode Exit fullscreen mode

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]);
Enter fullscreen mode Exit fullscreen mode

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);
  }
};
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)

Collapse
 
__51c65214 profile image
Fifi

Great job! Very useful for me!

Collapse
 
fruntend profile image
fruntend

Сongratulations 🥳! Your article hit the top posts for the week - dev.to/fruntend/top-10-posts-for-f...
Keep it up 👍

Collapse
 
alxnddr profile image
Alexander Lesnenko

Great and very detailed overview 👏

Collapse
 
lisenish profile image
Dmitry Ivanov

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!