What’s wrong with HTML number input
Tags: webdev, html, frontend, programming
Published: 2023-03-31 06:39:03

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](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/9ezwgdoi04no2n0mqk16.png)

    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”](https://www.w3.org/TR/2011/WD-html5-20110525/common-input-element-attributes.html#attr-input-step) 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.

```jsx
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](https://www.w3.org/TR/2011/WD-html5-20110525/common-input-element-attributes.html#dom-input-stepdown) `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.

```jsx
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. 

```jsx
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.

```jsx
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.

```jsx
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](https://github.com/titovmx) with the demonstration code that is also published with [Vercel](https://number-input-demo.vercel.app/) 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!