Rxova
← react-inputs

A transfer form, filled in from Berlin

A customer sends money in euros and confirms it with the code her bank texts her. The amount has to read the way she writes numbers, and the code has to go in however her phone offers it.

By hand

  1. format hard-codes a comma every three digits and a dot for decimals, and drops anything else. In Berlin, 1250,50 becomes 125,050.
  2. Each keystroke replaces the value with a new string, so the caret jumps to the end. An empty field is sent as 0.
  3. Each box holds one character. A pasted 482913, or the code the phone offers from the SMS, lands in the first box as 4.
  4. Focus is moved by hand on every digit and on Backspace, and the six boxes have no name, so a screen reader reads out six unlabelled fields.
import { useRef, useState, type ChangeEvent, type KeyboardEvent } from 'react'
// amount.tsx: group the thousands as the customer types
function format(raw: string) {
const [whole, fraction] = raw.replace(/[^\d.]/g, '').split('.')
const grouped = whole.replace(/\B(?=(\d{3})+(?!\d))/g, ',')
return fraction === undefined ? grouped : `${grouped}.${fraction.slice(0, 2)}`
}
function AmountField({ onAmount }: { onAmount: (amount: number) => void }) {
const [text, setText] = useState('')
const onChange = (e: ChangeEvent<HTMLInputElement>) => {
const next = format(e.target.value) // '1250,50' in Berlin becomes '125,050'
setText(next) // a new string, so the caret jumps to the end
onAmount(Number(next.replace(/,/g, ''))) // and an empty field is 0
}
return (
<span>
€ <input id="amount" inputMode="decimal" value={text} onChange={onChange} />
</span>
)
}
// code.tsx: one box per digit, focus moved by hand
function CodeField({ onCode }: { onCode: (code: string) => void }) {
const [digits, setDigits] = useState(['', '', '', '', '', ''])
const boxes = useRef<(HTMLInputElement | null)[]>([])
const onDigit = (i: number, e: ChangeEvent<HTMLInputElement>) => {
const digit = e.target.value.replace(/\D/g, '').slice(-1)
const next = digits.map((old, j) => (j === i ? digit : old))
setDigits(next)
if (digit && i < 5) boxes.current[i + 1]?.focus()
if (next.every(Boolean)) onCode(next.join(''))
}
const onKeyDown = (i: number, e: KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Backspace' && !digits[i] && i > 0) boxes.current[i - 1]?.focus()
}
return (
<div>
{digits.map((digit, i) => (
<input
key={i}
ref={(el) => {
boxes.current[i] = el
}}
value={digit}
maxLength={1}
inputMode="numeric"
onChange={(e) => onDigit(i, e)}
onKeyDown={(e) => onKeyDown(i, e)}
/>
))}
</div>
)
}
// transfer.tsx: the form itself
export function Transfer({ send }: { send: (amount: number, code: string) => void }) {
const [amount, setAmount] = useState(0)
return (
<form>
<label htmlFor="amount">Amount</label>
<AmountField onAmount={setAmount} />
<p>Enter the code we sent you by SMS</p>
<CodeField onCode={(code) => send(amount, code)} />
</form>
)
}

With react-inputs

  1. CurrencyInput takes the grouping, the decimal mark and the symbol's place from Intl.NumberFormat for the locale it is given: 1.250,50 €.
  2. The caret stays where the customer put it while the value reformats. onChange gets a number, or null while the field is empty.
  3. One real <input> sits under the six slots, so a paste fills all six, and autocomplete="one-time-code" is set for SMS autofill by default.
  4. Backspace, arrows and selection come from the platform. label names the one field, and onComplete fires once all six digits are in.
import { useState } from 'react'
import { CurrencyInput } from '@rxova/react-intl-currency-input'
import { OtpInput } from '@rxova/react-otp-input'
type Props = {
locale: string // the customer's, e.g. 'de-DE' or 'en-IE'
send: (amount: number, code: string) => void
}
export function Transfer({ locale, send }: Props) {
const [amount, setAmount] = useState<number | null>(null)
const [code, setCode] = useState('')
return (
<form>
<label htmlFor="amount">Amount</label>
<CurrencyInput
id="amount"
locale={locale}
currency="EUR"
value={amount} // de-DE: typing 1250,50 shows '1.250,50 €'
onChange={setAmount} // 1250.5, or null while the field is empty
/>
<OtpInput
length={6}
value={code}
onChange={setCode}
onComplete={(final) => {
if (amount !== null) send(amount, final)
}}
label="Code we sent you by SMS"
/>
</form>
)
}