Skip to content

feat(ui): rebuild InputOtp on a single input with slot motion - #49

Merged
yyyyaaa merged 2 commits into
mainfrom
phat/otp-input-eval
Oct 5, 2026
Merged

yyyyaaa merged 2 commits into
mainfrom
phat/otp-input-eval

Conversation

@yyyyaaa

@yyyyaaa yyyyaaa commented Oct 5, 2026 •

Copy link
Copy Markdown
Contributor

Rebuilds InputOtp on 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 &lt;input&gt;<br/>value · caret · selection"] -->|selectionchange mirror| S["drawn slots"]
    S --> R["FluidHighlight ring"]
  end
Loading
  • One field: a transparent <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.
  • Text behaviour: ⌘A / Shift+arrows select across slots, so cut/copy/paste work on the whole code or a slice; Backspace deletes like a text field; paste strips separators and a full code replaces the value wherever the caret is.
  • Motion: digits rise in (starting: transition), a drawn caret blinks in the empty active slot, the focus ring glides via the existing FluidHighlight, and the row shakes once when isInvalid turns 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).
  • Registry: input-otp now declares motion and fluid-highlight. motion was already a peer dependency of @constructive-io/ui.
  • Browser quirks: Chrome autofill style reset, iOS glyph/paste handling, translate="no" against Chrome Translate.

Try it in Storybook (UI/InputOtp → VerifyForm) or the docs site at /blocks/account/account-phone-numbers (Live scenario).

@tenki-reviewer

tenki-reviewer Bot commented Oct 5, 2026 •

Copy link
Copy Markdown

Review complete. 🟡 2 medium

💬 Inline comments (2)

  • 🟡 Paste places caret at value end, ignoring insertion point — input-otp.tsx:201
  • 🟡 Direct input.value write in paste breaks controlled restore — input-otp.tsx:199
🧹 Nitpicks (1) — 🟢 1 low
  • 🟢 onChange value shape drops interior separators (input-otp.tsx:64) — The rewrite changes the emitted value contract: the previous implementation preserved interior placeholder spaces when a slot was cleared, while the new toCode (packages/ui/src/components/input-otp.tsx:64) strips every non-digit before truncation, so onChange now always emits a compact digits-only string.

The change replaces the six-input OTP grid with one transparent full-row input that renders mirrored slots, adds a selectionchange-driven selection widener, paste/autofill resync handling, and motion-based caret blink, shake, and gliding highlight. The registry manifest and behavioral tests are updated to match the new single-field model.

Files Change
packages/ui/src/components/input-otp.tsx Rewrite: single hidden input over aria-hidden slots, selection management, paste handling, and motion effects.
packages/ui/registry.json Registry entry updated for the new motion dependency and fluid-highlight registry dependency.
packages/ui/test/input-otp.test.tsx Tests rewritten to exercise the single-field selection, paste, and commit behavior.

Reviewed commit: aad32c1

@yyyyaaa
yyyyaaa force-pushed the phat/otp-input-eval branch from aad32c1 to 89209b5 Compare October 5, 2026 04:00
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.
@yyyyaaa
yyyyaaa force-pushed the phat/otp-input-eval branch from 89209b5 to a1927e0 Compare October 5, 2026 04:01

@tenki-reviewer tenki-reviewer Bot left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.value write in paste breaks controlled restore — input-otp.tsx:199

Comment on lines +201 to +203
const caret = Math.min(next.length, length - 1);
input.setSelectionRange(caret, next.length);
mirror(caret, next.length);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 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(...).

Comment on lines +199 to +200
input.value = next;
commit(next);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 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.

@yyyyaaa
yyyyaaa merged commit f32a1b0 into main Oct 5, 2026
6 checks passed
@yyyyaaa
yyyyaaa deleted the phat/otp-input-eval branch October 5, 2026 04:23
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant