Repository navigation
feat(ui): rebuild InputOtp on a single input with slot motion - #49
Conversation
|
Review complete. 🟡 2 medium 💬 Inline comments (2)
🧹 Nitpicks (1) — 🟢 1 low
The change replaces the six-input OTP grid with one transparent full-row input that renders mirrored slots, adds a
Reviewed commit: aad32c1 |
aad32c1 to
89209b5
Compare
Replace the per-digit inputs with one transparent field over drawn slots that mirror its selection. This gives one tab stop and one labelled field, cross-slot selection and clipboard, and room for glyph motion: digits rise in, a drawn caret blinks, the focus ring glides via FluidHighlight, and the row shakes once when the code turns invalid. onComplete now fires only when an edit produces a changed full code.
89209b5 to
a1927e0
Compare
There was a problem hiding this comment.
This PR refactors InputOTP from a per-slot multi-input architecture to a single transparent input with drawn slots, adds motion-driven caret/shake/FluidHighlight effects, and rewrites the tests for the new model.
Key findings
- 🟡 Paste places caret at value end, ignoring insertion point — input-otp.tsx:201
- 🟡 Direct
input.valuewrite in paste breaks controlled restore — input-otp.tsx:199
| const caret = Math.min(next.length, length - 1); | ||
| input.setSelectionRange(caret, next.length); | ||
| mirror(caret, next.length); |
There was a problem hiding this comment.
🟡 bug · medium
Paste places caret at value end, ignoring insertion point
onPaste computes the post-paste caret as Math.min(next.length, length - 1) (packages/ui/src/components/input-otp.tsx:201), which is only correct for a complete-code replacement. For a partial paste inserted mid-code — caret at 0 in 12, paste 9 → 912 — the caret lands at the end of the value instead of after the inserted digit, so the user's next keystroke fills the wrong slot. The same teleport happens when pasting a single digit into a selected slot of a full code.
📋 Prompt for AI Agents
In packages/ui/src/components/input-otp.tsx, in onPaste (lines 194-203), branch the caret computation: when pasted.length >= length keep setSelectionRange(length - 1, next.length); otherwise set the selection to (Math.min(start + pasted.length, next.length), Math.min(start + pasted.length, next.length)) so the caret lands right after the inserted digits, and mirror those values via mirror(...).
| input.value = next; | ||
| commit(next); |
There was a problem hiding this comment.
🟡 bug · medium
Direct input.value write in paste breaks controlled restore
onPaste assigns input.value = next directly (packages/ui/src/components/input-otp.tsx:199) after event.preventDefault(), so no native change event accompanies the write and React's controlled-input restoration never runs for this edit. If the host's onChange ignores, clamps, or asynchronously rejects the paste (leaving the value prop unchanged), the transparent field keeps the pasted ghost value while the slots render the stale controlled code, and the next keystroke composes against the divergent DOM string — the user can submit a code different from what the slots display.
📋 Prompt for AI Agents
In packages/ui/src/components/input-otp.tsx onPaste (lines 187-204), remove the direct input.value = next assignment; keep event.preventDefault() and the computed next, call commit(next), and move the value/selection reconciliation into the existing RESYNC useEffect (lines 158-171) so it resets input.value to the rendered code whenever they differ after the controlled re-render, keeping React's controlled value the single source of truth.
Rebuilds
InputOtpon a single-input model and adds slot-level motion. No new dependency; props are unchanged.flowchart LR subgraph Before A1[input] --- A2[input] --- A3[input] --- A4["… ×N"] end subgraph After I["one transparent <input><br/>value · caret · selection"] -->|selectionchange mirror| S["drawn slots"] S --> R["FluidHighlight ring"] end<input>over the row owns value, caret and selection; slots mirror it. One tab stop and one labelled field instead of N "Digit i of N" boxes.starting:transition), a drawn caret blinks in the empty active slot, the focus ring glides via the existingFluidHighlight, and the row shakes once whenisInvalidturns on. Everything stands still under reduced motion.onComplete: fires only when an edit produces a full code that differs from the previous one (no double verify; correcting a digit still re-verifies).input-otpnow declaresmotionandfluid-highlight.motionwas already a peer dependency of@constructive-io/ui.translate="no"against Chrome Translate.Try it in Storybook (UI/InputOtp → VerifyForm) or the docs site at
/blocks/account/account-phone-numbers(Live scenario).