Skip to content

[Feature] Add InputOtp component#456

Open
djalmaaraujo wants to merge 14 commits into
mainfrom
worktree-input-otp
Open

[Feature] Add InputOtp component#456
djalmaaraujo wants to merge 14 commits into
mainfrom
worktree-input-otp

Conversation

@djalmaaraujo

@djalmaaraujo djalmaaraujo commented Jul 1, 2026

Copy link
Copy Markdown
Contributor

Summary

Closes #449. Adds InputOtp — a Phlex/Stimulus port of shadcn's InputOTP. The underlying JS lib (input-otp) is React-only, so this is a from-scratch Stimulus reimplementation, not a wrapped package.

  • Compound API mirroring shadcn 1:1: InputOtp / InputOtpGroup / InputOtpSlot / InputOtpSlot(index:) / InputOtpSeparator.
  • Single real <input> (transparent, absolutely positioned, kept in the a11y tree — not display:none) drives state; decorative aria-hidden slot <div>s mirror its value/selection via the ruby-ui--input-otp Stimulus controller.
  • Keyboard: typing auto-advances, backspace works, all four arrow keys move the active slot (Up/Down are non-native — handled explicitly), and replacing a character re-selects the next one so repeated typing keeps replacing forward without needing an arrow key in between.
  • Paste distributes characters across slots, filtered by pattern: (default "[0-9]", a single-character regex class).
  • Dispatches ruby-ui--input-otp:complete and ruby-ui--input-otp:input custom events (bubbles, detail: { value }) for Hotwire/Turbo integration (e.g. auto-submitting a form on complete).
  • Accessibility: real input is the sole control with a value in the a11y tree, autocomplete="one-time-code" for SMS autofill, inputmode="numeric" by default, aria-invalid styling hook on slots.
  • Docs page mirrors shadcn's example set: Usage, Composition, Pattern, Four digits, Disabled, Invalid, Form (Card + resend + bigger slots), Reacting to completion.

No new dependency — no JS package, no new gem, nothing added to dependencies.yml. animate-caret-blink comes from tw-animate-css, already a baseline install used by other components the same way.

Design/plan docs for context: design/2026-06-30-input-otp-design.md, design/plans/2026-06-30-input-otp-implementation.md.

Test plan

  • cd gem && bundle exec rake — 265 tests, 0 failures, standardrb clean.
  • Manually verified in the docs app (Chrome): typing/backspace/all four arrows, paste, the complete event, and the reported bug where replacing a filled slot then typing again did nothing (fixed by re-selecting the next character after every input instead of leaving a collapsed caret).
  • Compared the rendered docs page side-by-side against the live shadcn docs page to match the example set and catch a layout bug (wrapper was inline-flex, upstream uses flex).

Summary by cubic

Adds InputOtp, a Phlex/Stimulus OTP input with keyboard navigation, paste support, and custom events, plus a new /docs/input_otp page. No new dependencies.

  • New Features

    • Compound API: InputOtp, InputOtpGroup, InputOtpSlot(index:), InputOtpSeparator.
    • Single real input drives decorative slots via ruby-ui--input-otp.
    • Typing auto-advances; backspace and all arrow keys move between slots; paste distributes characters.
    • Emits ruby-ui--input-otp:input and ruby-ui--input-otp:complete with { value }.
    • pattern: filter (default "[0-9]"), autocomplete="one-time-code", inputmode set based on pattern.
    • Docs: new /docs/input_otp with examples (composition, pattern, 4-digit, disabled, invalid, form), plus updates to sidebar, sitemap, LLMS lists, and mcp/data/registry.json.
  • Bug Fixes

    • Moved controller to the wrapper so slot targets are in scope.
    • Normalized selection after input and on arrow keys to enable replace-and-advance when full.
    • Removed native focus ring on the transparent input across browsers.
    • Fixed wrapper layout to flex to match shadcn and avoid inline wrapping issues.
    • Added aria-invalid styling hook on slots.
    • Sanitizes prefilled values on connect (enforces length/pattern before first paint/submit) and fixes a minor docs typo.

Written for commit f0d8982. Summary will update on new commits.

Review in cubic

Design doc for a Phlex/Stimulus port of shadcn's InputOTP, covering
the single-hidden-input architecture, compound component API, and
keyboard/paste behavior requirements.
Task-by-task TDD plan covering the compound component, Stimulus
controller, and docs-site wiring.
Adds the route, controller action, sidebar/components-list entry,
Stimulus controller registration, and regenerates llms.txt/
llms-full.txt/sitemap.xml + the MCP registry to include InputOtp.
data-controller was on the real <input>, a leaf element, so the
sibling slot divs (the controller's slot targets) were outside its
scope and never got painted. Moving controller+values to the
outer wrapper brings the input and all slots into the same subtree.
Found via manual browser verification (Task 7) — typing didn't
update any slot.
Manual testing surfaced two bugs:
- Arrow-key navigation selected a collapsed caret instead of a
  1-character range, so typing a digit over a filled slot did
  nothing (native replace-selection only works with a real range).
- Safari kept its native focus ring on the transparent input despite
  outline-none; add appearance-none/shadow-none/focus:ring-0 to kill
  it across browsers.

Also adds aria-invalid styling to InputOtpSlot (ported from upstream
shadcn, missed in the first pass) and expands the docs page with
Composition, Pattern, Four digits, Disabled, and Invalid examples
mirroring shadcn's InputOTP docs.
Fetched shadcn's actual example sources (Composition, Pattern, Form,
etc.) via github raw + rendered the live docs page in Chrome to
compare visually instead of relying on text alone. Adds the Form
example (Card + resend button + bigger slots via class: + fallback
links), matching https://ui.shadcn.com/docs/components/radix/input-otp#form.

Fixed a real layout bug found in the process: the wrapper div was
inline-flex instead of flex (shadcn/upstream uses flex), so an
inline sibling like the "I no longer have access" link rendered on
the same line as the slots instead of wrapping below them.
…e chains

After replacing a filled slot, the browser leaves a collapsed caret
right after the edited character. Since the value is at (or stays
at) maxlength, further typing with a collapsed caret and no
selection is a no-op — the user has to press an arrow key again
between every replacement.

Fix: after onInput, re-select the character at the new caret
position as a 1-char range whenever one exists there (i.e. we're
not in true insert-mode at the end of a not-yet-full value). This
mirrors the onKeydown/onPaste selection logic so typing keeps
replacing forward without needing arrow keys in between.
@djalmaaraujo djalmaaraujo requested a review from cirdes as a code owner July 1, 2026 03:52

@cubic-dev-ai cubic-dev-ai 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.

4 issues found across 20 files

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="design/plans/2026-06-30-input-otp-implementation.md">

<violation number="1" location="design/plans/2026-06-30-input-otp-implementation.md:1">
P1: Missing `normalizeSelection()` method — without it, replacing a filled slot then typing again overwrites the same position instead of advancing</violation>

<violation number="2" location="design/plans/2026-06-30-input-otp-implementation.md:426">
P2: Arrow-key navigation collapses selection on filled slots — arrowing into an occupied slot places caret before the character instead of selecting it for replacement</violation>
</file>

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

@@ -0,0 +1,783 @@
# InputOtp Implementation Plan

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1: Missing normalizeSelection() method — without it, replacing a filled slot then typing again overwrites the same position instead of advancing

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At design/plans/2026-06-30-input-otp-implementation.md:

<comment>Missing `normalizeSelection()` method — without it, replacing a filled slot then typing again overwrites the same position instead of advancing</comment>

<file context>
@@ -0,0 +1,783 @@
+# InputOtp Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Add a Phlex/Stimulus `InputOtp` compound component (root + `InputOtpGroup`/`InputOtpSlot`/`InputOtpSeparator`) to the `ruby_ui` gem, ported behaviorally from shadcn's InputOTP, and wire it into the `docs/` Rails app.
+
+**Architecture:** One real `<input>` (transparent, absolutely positioned, stays in the a11y tree) drives state; decorative `aria-hidden` slot `<div>`s mirror its value/selection via a Stimulus controller. No new JS package — `input-otp` (the upstream JS lib) is React-only and can't be wrapped, so the controller reimplements the behavior directly.
+
+**Tech Stack:** Phlex (Ruby views), Tailwind v4 (`tw-animate-css` already provides `animate-caret-blink`), Stimulus, Minitest.
</file context>

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Valid, but stale: this flags the plan doc (design/plans/2026-06-30-input-otp-implementation.md), which is a point-in-time snapshot from before bug-fix commits. The shipped controller already has normalizeSelection() (gem/lib/ruby_ui/input_otp/input_otp_controller.js:87) — added in 866fe7d after this exact bug surfaced in manual testing. Added a note in the plan pointing to the real file so it doesn't mislead future readers/reviewers.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

You're right — this plan doc is stale. The actual gem/lib/ruby_ui/input_otp/input_otp_controller.js already has normalizeSelection(), so the parent comment is wrong for this PR. I’ve pointed future readers at the real controller file instead.

Comment thread docs/app/javascript/controllers/ruby_ui/input_otp_controller.js
event.preventDefault()
const current = this.inputTarget.selectionStart ?? 0
const next = Math.min(Math.max(current + moves[event.key], 0), this.lengthValue - 1)
this.inputTarget.setSelectionRange(next, 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.

P2: Arrow-key navigation collapses selection on filled slots — arrowing into an occupied slot places caret before the character instead of selecting it for replacement

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At design/plans/2026-06-30-input-otp-implementation.md, line 426:

<comment>Arrow-key navigation collapses selection on filled slots — arrowing into an occupied slot places caret before the character instead of selecting it for replacement</comment>

<file context>
@@ -0,0 +1,783 @@
+    event.preventDefault()
+    const current = this.inputTarget.selectionStart ?? 0
+    const next = Math.min(Math.max(current + moves[event.key], 0), this.lengthValue - 1)
+    this.inputTarget.setSelectionRange(next, next)
+    this.paint()
+  }
</file context>

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Valid, but stale: same as the normalizeSelection comment above — this flags the plan doc's original Task 4 draft. The shipped onKeydown (gem/lib/ruby_ui/input_otp/input_otp_controller.js:46-56) already selects a 1-char range on a filled slot (hasChar check), added in 711269e after this was reported. Noted the staleness in the plan doc.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

The staleness note is fair; the parent comment was too broad for this PR because gem/lib/ruby_ui/input_otp/input_otp_controller.js already selects a 1-char range on filled slots via the hasChar check.

Comment thread gem/lib/ruby_ui/input_otp/input_otp_docs.rb Outdated

@cirdes cirdes left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

@djalmaaraujo, please fix cubic issues!

- connect() now runs the same filter+truncate as onInput before the
  first paint, so a server-rendered value: that exceeds length or
  contains characters outside pattern can't hide behind the slots
  and get submitted as-is.
- Fix "length:." double-punctuation typo in the Four digits example.
- Note in the plan doc that its Task 4 controller snapshot predates
  later bug-fix commits (normalizeSelection, arrow-key range
  selection) — cubic flagged both as "missing" because it reviewed
  the plan, not gem/lib/ruby_ui/input_otp/input_otp_controller.js
  where they already exist.
@djalmaaraujo

Copy link
Copy Markdown
Contributor Author

@cirdes addressed the cubic findings in f0d8982 — replied inline on each:

  • Real bugs, fixed: prefilled value: now gets sanitized (filtered + truncated) on connect() before the first paint, so an oversized/invalid server-rendered value can't hide behind the slots and get submitted as-is. Also fixed a docs typo (length:.length.).
  • Stale findings: 2 of the 4 flagged design/plans/2026-06-30-input-otp-implementation.md — that's a point-in-time planning snapshot from before later bug-fix commits (866fe7d, 711269e). The actual shipped controller already has both normalizeSelection() and range-based arrow-key selection; added a note in the plan pointing to the real file so it doesn't mislead future reviewers again.

262→265 tests, 0 failures, standardrb clean. CI green.

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.

[Feature] Add InputOtp component

2 participants