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
formathard-codes a comma every three digits and a dot for decimals, and drops anything else. In Berlin,1250,50becomes125,050.- Each keystroke replaces the value with a new string, so the caret jumps to the end. An empty field is sent as
0. - Each box holds one character. A pasted
482913, or the code the phone offers from the SMS, lands in the first box as4. - 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 typesfunction 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 handfunction 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 itselfexport 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
CurrencyInputtakes the grouping, the decimal mark and the symbol's place fromIntl.NumberFormatfor thelocaleit is given:1.250,50 €.- The caret stays where the customer put it while the value reformats.
onChangegets a number, ornullwhile the field is empty. - One real
<input>sits under the six slots, so a paste fills all six, andautocomplete="one-time-code"is set for SMS autofill by default. - Backspace, arrows and selection come from the platform.
labelnames the one field, andonCompletefires 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> )}