DEV Community

Cover image for Enhancing User Experience with a Custom Number Input Component in React
Joaquin Pereira
Joaquin Pereira

Posted on • Edited on

Enhancing User Experience with a Custom Number Input Component in React

In web development, especially for applications that require frequent interactions with numerical data, it is crucial to provide a smooth and error-free user experience. A well-designed number input component not only facilitates user interaction but also ensures that the entered data is valid and properly formatted. In this article, we will explore a React component called CustomNumberInput, designed to handle numerical inputs efficiently and improve the user experience.

What Does the CustomNumberInput Component Do?

The CustomNumberInput component is a custom input field that allows users to enter numbers, including decimal and negative values. It ensures that the entered data adheres to specific formatting rules, such as the smaximum number of integer and decimal digits. Additionally, it automatically handles validation and formatting of the entered values, reducing the likelihood of errors and improving usability.

Here’s a quick example of how you can use the CustomNumberInput component in your application:

import { CustomNumberInput } from './CustomNumberInput';

function App() {
  const handleNumberChange = (value: string | null) => {
    console.log("Entered value:", value);
  };

  return (
    <div>
      <CustomNumberInput
        decimalPrecision={{ integerDigits: 5, decimalDigits: 2 }}
        onChange={handleNumberChange}
        placeholder="Enter a number"
      />
    </div>
  );
}

export default App;
Enter fullscreen mode Exit fullscreen mode

In this example, the CustomNumberInput component is used to allow users to enter a number with up to 5 integer digits and 2 decimal digits. The onChange prop is used to handle the entered value, which is logged to the console.

Key Features

  1. Number Formatting: The component ensures that the entered numbers are properly formatted, including handling negative and positive signs, as well as decimal separators.
  2. Input Validation: Only numeric characters, plus and minus signs, and decimal separators are allowed. This prevents users from entering invalid characters.
  3. Digit Control: The component allows you to define the maximum number of integer and decimal digits that can be entered, which is useful for applications requiring numerical precision.
  4. Event Handling: The component handles keyboard (onKeyDown) and change (onChange) events to ensure that user input is validated and formatted in real-time.

Component Logic / Value Formatting

The formatValue function is the core of the component. It formats the user-entered value according to the defined rules. Here’s a breakdown of what it does:

  • Removing Invalid Characters: It uses a regular expression to remove any character that is not a digit, a plus or minus sign, or a decimal separator.
    let formatted = input.replace(/[^\d-+,.]/, "")

  • Handling Negative and Positive Signs: It ensures that the negative sign is correctly positioned and that positive signs do not affect the final value.

if (formatted.includes("-")) {
  formatted = formatted.replace(/-/g, "")
  formatted = formatted.startsWith("-") ? formatted : `-${formatted}`
}
Enter fullscreen mode Exit fullscreen mode
  • Handling Decimal Separators: It converts any decimal separator (dot or comma) into a standard decimal point and limits the number of decimal digits based on the defined precision.
if (formatted.includes(".") || formatted.includes(",")) {
  formatted = formatted.replace(".", DECIMAL_SEPARATOR)
  formatted = formatted.replace(",", DECIMAL_SEPARATOR)

  const [intPart, ...rest] = formatted.split(DECIMAL_SEPARATOR)
  const decPart = rest.join("")

  const limitedDecPart = decPart.slice(0, decimalPrecision.decimalDigits)
  const formattedIntPart = !intPart || intPart === "-" ? `0${intPart}` : intPart

  formatted = `${formattedIntPart}${DECIMAL_SEPARATOR}${limitedDecPart}`
}
Enter fullscreen mode Exit fullscreen mode
  • Limiting Integer Digits: It limits the number of integer digits that can be entered, ensuring that the defined limit is not exceeded.
const isNegative = formatted.startsWith("-")
const digits = isNegative ? formatted.slice(1) : formatted
if (digits.length > decimalPrecision.integerDigits) {
  formatted = isNegative
    ? `-${digits.slice(0, decimalPrecision.integerDigits)}`
    : digits.slice(0, decimalPrecision.integerDigits)
}
Enter fullscreen mode Exit fullscreen mode

Keyboard Event Handling

The handleKeyDown function ensures that only specific keys related to numerical input are allowed. This includes navigation keys (like left and right arrows), editing keys (like Backspace and Delete), and numeric characters. If the user tries to enter a disallowed character, the event is prevented, and the character does not appear in the input field.

const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
  const allowedKeys = [
    "Backspace", "Delete", "ArrowLeft", "ArrowRight",
    "Tab", "Home", "End", "-", "+", ".", ",", "0",
    "1", "2", "3", "4", "5", "6", "7", "8", "9"
  ]

  if (!allowedKeys.includes(e.key)) {
    e.preventDefault()
  } else if (valueInput.includes(DECIMAL_SEPARATOR) && (e.key === "." || e.key === ",")) {
    e.preventDefault()
  }
}
Enter fullscreen mode Exit fullscreen mode

Change Handling

The handleChange function is triggered whenever the user modifies the input field's value. This function calls formatValue to format the entered value and then updates the component's state with the formatted value. Additionally, it notifies the parent component (via the onChange prop) of the new value, allowing for seamless integration with other components.

const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
  const newValue = formatValue(e.target.value)
  setValueInput(newValue)
  onChange?.(newValue === '' ? null : newValue)
}
Enter fullscreen mode Exit fullscreen mode

Enhancing User Experience

The CustomNumberInput component was created to improve the user experience when entering numbers in an application. By automatically handling validation and formatting, it reduces the cognitive load on the user and minimizes input errors. This is especially useful in financial, scientific, or any other context where numerical precision is critical.

You can adjust the number of allowed integer and decimal digits, and you can extend its functionality to suit specific needs.

I am open to suggestions and improvements for this component. If you have any ideas or feedback, feel free to share them!.

Full Code

Here is the complete code for the CustomNumberInput component:

import * as React from "react"
import { type ClassValue, clsx } from "clsx";
import { twMerge } from "tailwind-merge";

type DecimalPrecision = {
  integerDigits: number
  decimalDigits: number
}

export interface CustomNumberInputProps
  extends Omit<React.InputHTMLAttributes<HTMLInputElement>, 'onChange'> {
    className?: string,
    decimalPrecision?: DecimalPrecision
    onChange?: (value: string | null) => void
}

const DEFAULT_PRECISION: DecimalPrecision = {
  integerDigits: 10,
  decimalDigits: 2
}

const DECIMAL_SEPARATOR = "."

const CustomNumberInput = React.forwardRef<HTMLInputElement, CustomNumberInputProps>(
  ({
    className,
    decimalPrecision = DEFAULT_PRECISION,
    onChange,
    ...props
  }, ref) => {
    const [valueInput, setValueInput] = React.useState("")

    const formatValue = React.useCallback((input: string): string => {
      let formatted = input.replace(/[^\d-+,.]/, "")

      if (formatted.includes("-")) {
        formatted = formatted.replace(/-/g, "")
        formatted = formatted.startsWith("-") ? formatted : `-${formatted}`
      }

      if (formatted.includes("+")) {
        formatted = formatted.replace(/-/g, "")
        formatted = formatted.replace(/\+/g, "")
      }

      if (formatted.includes(".") || formatted.includes(",")) {
        formatted = formatted.replace(".", DECIMAL_SEPARATOR)
        formatted = formatted.replace(",", DECIMAL_SEPARATOR)

        const [intPart, ...rest] = formatted.split(DECIMAL_SEPARATOR)
        const decPart = rest.join("")

        const limitedDecPart = decPart.slice(0, decimalPrecision.decimalDigits)
        const formattedIntPart = !intPart || intPart === "-" ? `0${intPart}` : intPart

        formatted = `${formattedIntPart}${DECIMAL_SEPARATOR}${limitedDecPart}`
      } else {
        const isNegative = formatted.startsWith("-")
        const digits = isNegative ? formatted.slice(1) : formatted
        if (digits.length > decimalPrecision.integerDigits) {
          formatted = isNegative
            ? `-${digits.slice(0, decimalPrecision.integerDigits)}`
            : digits.slice(0, decimalPrecision.integerDigits)
        }
      }

      return formatted
    }, [decimalPrecision])

    const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
      const allowedKeys = [
        "Backspace", "Delete", "ArrowLeft", "ArrowRight",
        "Tab", "Home", "End", "-", "+", ".", ",", "0",
        "1", "2", "3", "4", "5", "6", "7", "8", "9"
      ]

      if (!allowedKeys.includes(e.key)) {
        e.preventDefault()
      } else if (valueInput.includes(DECIMAL_SEPARATOR) && (e.key === "." || e.key === ",")) {
        e.preventDefault()
      }
    }

    const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
      const newValue = formatValue(e.target.value)
      setValueInput(newValue)
      onChange?.(newValue === '' ? null : newValue)
    }

    const cn = (...inputs: ClassValue[]) => {
      return twMerge(clsx(inputs));
    }

    return (
      <input
        type="text"
        className={cn(
          "flex h-10 w-full rounded-md border border-input border-gray-400 bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
          className
        )}
        value={valueInput}
        onKeyDown={handleKeyDown}
        onChange={handleChange}
        ref={ref}
        {...props}
      />
    )
  }
)

CustomNumberInput.displayName = "CustomNumberInput"

export { CustomNumberInput }
Enter fullscreen mode Exit fullscreen mode

Top comments (0)