[Feature] Add InputOtp component#456
Conversation
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.
There was a problem hiding this comment.
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 | |||
There was a problem hiding this comment.
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>
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
| 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) |
There was a problem hiding this comment.
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>
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
cirdes
left a comment
There was a problem hiding this comment.
@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.
|
@cirdes addressed the cubic findings in f0d8982 — replied inline on each:
262→265 tests, 0 failures, standardrb clean. CI green. |
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.InputOtp/InputOtpGroup/InputOtpSlot/InputOtpSlot(index:)/InputOtpSeparator.<input>(transparent, absolutely positioned, kept in the a11y tree — notdisplay:none) drives state; decorativearia-hiddenslot<div>s mirror its value/selection via theruby-ui--input-otpStimulus controller.pattern:(default"[0-9]", a single-character regex class).ruby-ui--input-otp:completeandruby-ui--input-otp:inputcustom events (bubbles,detail: { value }) for Hotwire/Turbo integration (e.g. auto-submitting a form on complete).autocomplete="one-time-code"for SMS autofill,inputmode="numeric"by default,aria-invalidstyling hook on slots.No new dependency — no JS package, no new gem, nothing added to
dependencies.yml.animate-caret-blinkcomes fromtw-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.completeevent, 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).inline-flex, upstream usesflex).Summary by cubic
Adds
InputOtp, a Phlex/Stimulus OTP input with keyboard navigation, paste support, and custom events, plus a new/docs/input_otppage. No new dependencies.New Features
InputOtp,InputOtpGroup,InputOtpSlot(index:),InputOtpSeparator.ruby-ui--input-otp.ruby-ui--input-otp:inputandruby-ui--input-otp:completewith{ value }.pattern:filter (default"[0-9]"),autocomplete="one-time-code",inputmodeset based on pattern./docs/input_otpwith examples (composition, pattern, 4-digit, disabled, invalid, form), plus updates to sidebar, sitemap, LLMS lists, andmcp/data/registry.json.Bug Fixes
flexto match shadcn and avoid inline wrapping issues.aria-invalidstyling hook on slots.Written for commit f0d8982. Summary will update on new commits.