ef1498ba08
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
47 lines
1.3 KiB
TypeScript
47 lines
1.3 KiB
TypeScript
// Six-cell one-time-code input: one real <input> (invisible, full-bleed) carries the value
|
|
// and the OTC autofill semantics; the cells are presentation. Click anywhere to focus;
|
|
// paste works because the input is real.
|
|
import { useState } from "react";
|
|
|
|
const LENGTH = 6;
|
|
|
|
export default function CodeInput({
|
|
value,
|
|
onChange,
|
|
error = false,
|
|
}: {
|
|
value: string;
|
|
onChange: (code: string) => void;
|
|
error?: boolean;
|
|
}) {
|
|
const [focused, setFocused] = useState(false);
|
|
const digits = value.slice(0, LENGTH).split("");
|
|
const active = Math.min(digits.length, LENGTH - 1);
|
|
|
|
return (
|
|
<div className={`code${focused ? " code--focus" : ""}${error ? " code--error" : ""}`}>
|
|
<input
|
|
className="code__hidden"
|
|
value={value}
|
|
inputMode="numeric"
|
|
autoComplete="one-time-code"
|
|
aria-label="One-time code"
|
|
autoFocus
|
|
maxLength={LENGTH}
|
|
onFocus={() => setFocused(true)}
|
|
onBlur={() => setFocused(false)}
|
|
onChange={(ev) => onChange(ev.target.value.replace(/\D/g, "").slice(0, LENGTH))}
|
|
/>
|
|
{Array.from({ length: LENGTH }, (_, i) => (
|
|
<span
|
|
key={i}
|
|
aria-hidden="true"
|
|
className={`code__cell${i === active && focused ? " code__cell--active" : ""}`}
|
|
>
|
|
{digits[i] ?? ""}
|
|
</span>
|
|
))}
|
|
</div>
|
|
);
|
|
}
|