Creating an Amount Input with Unit Adornment using React and TypeScript
Published: 2023-01-25 04:04:33

✨ [**Watch on YouTube**](https://youtu.be/RH0Z8AdPXHY)  |  🐙 [GitHub](https://github.com/RodionChachura/react-toolkit)  |  🎮 [Demo](https://reactkit.radzion.com/amount-input)

Let's create an input for entering amounts or numbers using React and TypeScript. The UI element will have an adornment to show the unit, such as a dollar or percentage sign, and an optional suggested amount that could be useful when there is a maximum amount for a given field.

![](input.png)

Here's an example of how we can use the input. It receives a `value` that could be either a `number` or `undefined`, a `label`, a `callback` for value changes, a `unit` (which would be an SVG icon), and an optional `suggestion` component (which would likely be a maximum amount).

```tsx
const AmountInputPage: NextPage = () => {
  const [value, setValue] = useState<number | undefined>(80000)

  return (
    <AmountTextInput
      value={value}
      label="Salary"
      onValueChange={setValue}
      unit={<DollarIcon />}
      suggestion={
        <AmountSuggestion name="Max" value={100000} onSelect={setValue} />
      }
    />
  )
}
```

We build `AmountTextInput` on top of the `TextInput` component, and extend its `props` by changing the types of `value` and `onValueChange` to `number`, adding a `unit` to show before the input value, a `shouldBePositive` flag to force the input to be a positive value, and an optional `suggestion` element.

```tsx
import { Ref, forwardRef, ReactNode, useRef } from "react"
import styled from "styled-components"
import { HStack } from "../Stack"
import { centerContentCSS } from "../utils/centerContentCSS"

import { TextInput, TextInputProps } from "./TextInput"

type AmountTextInputProps = Omit<TextInputProps, "value" | "onValueChange"> & {
  value: number | undefined
  onValueChange?: (value: number | undefined) => void
  unit: ReactNode
  shouldBePositive?: boolean
  suggestion?: ReactNode
}

const UnitContainer = styled.div`
  border-radius: 8px;
  position: absolute;
  left: 12px;
  ${centerContentCSS};
`

const Input = styled(TextInput)`
  padding-left: 36px;
`

export const AmountTextInput = forwardRef(function AmountInputInner(
  {
    onValueChange,
    onChange,
    max,
    inputOverlay,
    unit,
    value,
    shouldBePositive,
    suggestion,
    label,
    placeholder,
    type = "number",
    ...props
  }: AmountTextInputProps,
  ref: Ref<HTMLInputElement> | null
) {
  const valueAsString = value?.toString() ?? ""

  const inputValue = useRef<string>(valueAsString)

  return (
    <Input
      {...props}
      type={type}
      label={
        <HStack
          alignItems="center"
          justifyContent="space-between"
          gap={16}
          fullWidth
        >
          {label}
          {suggestion}
        </HStack>
      }
      placeholder={placeholder ?? "Enter amount"}
      value={valueAsString === inputValue.current ? inputValue.current : value}
      ref={ref}
      inputOverlay={unit ? <UnitContainer>{unit}</UnitContainer> : undefined}
      onValueChange={(value) => {
        const valueAsNumber = Number(value)
        if (Number.isNaN(valueAsNumber)) {
          return
        }

        if (shouldBePositive && valueAsNumber < 0) {
          return
        }

        inputValue.current = value
        onValueChange?.(value === "" ? undefined : valueAsNumber)
      }}
    />
  )
})
```

We store the value as a string with the `useRef` hook so that when the user types `0.0` or clears the input, we won't reset the value to `0.` To handle the change, we use the `onValueChange` callback where we try to convert the input to a number and, if it's valid, we update the `ref` and propagate the value to the parent component. We show the suggestion on the same line as the label. To place the adornment at the beginning of the input, we pass the element to the `inputOverlay` prop, which in turn positions it absolutely above the input.

To learn more about the implementation of the TextInput component, check out [this video](https://youtu.be/V3scoHuQ19s).

The `AmountSuggestion` component receives a `name`, a `value`, a `callback` for accepting the suggestion, and an optional `renderValue` function to format the value. We use the `ShyTextButton` component to make the suggested amount look like a clickable element.

```tsx
import { ReactNode } from "react"
import { ShyTextButton } from "../buttons/ShyTextButton"
import { HStack } from "../Stack"
import { Text } from "../Text"

interface AmountSuggestionProps {
  name: ReactNode
  value: number
  renderValue?: (value: number) => ReactNode
  onSelect: (value: number) => void
}

export const AmountSuggestion = ({
  name,
  value,
  onSelect,
  renderValue = (value) => value.toString(),
}: AmountSuggestionProps) => {
  return (
    <HStack alignItems="center" gap={4}>
      <Text size={14}>{name}:</Text>
      <ShyTextButton
        as="div"
        onClick={() => onSelect(value)}
        text={renderValue(value)}
      />
    </HStack>
  )
}
```