Skip to content

Commit 505dc60

Browse files
authored
[Feature] Add InputOtp component (#456)
* [Documentation] Add InputOtp design spec 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. * [Documentation] Add InputOtp implementation plan Task-by-task TDD plan covering the compound component, Stimulus controller, and docs-site wiring. * [Feature] Add InputOtp root component * [Feature] Add InputOtpGroup and InputOtpSeparator * [Feature] Add InputOtpSlot * [Feature] Add InputOtp Stimulus controller * [Documentation] Add InputOtp docs template * [Feature] Wire InputOtp into the docs site 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. * [Bug Fix] Move ruby-ui--input-otp controller to the wrapper div 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. * [Bug Fix] Fix keyboard replace-when-full and cross-browser focus ring 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. * [Feature] Match shadcn's InputOTP example set and fix flex layout bug 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. * [Bug Fix] Normalize selection after every input so replace-and-advance 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. * [Bug Fix] Rebuild MCP registry after post-review InputOtp fixes * [Bug Fix] Address cubic review: sanitize prefilled value, fix docs typo - 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.
1 parent c7fcc90 commit 505dc60

20 files changed

Lines changed: 1882 additions & 0 deletions
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
# InputOtp — design spec
2+
3+
Date: 2026-06-30
4+
Status: approved (pending final review)
5+
6+
## Problem
7+
8+
RubyUI has no OTP / PIN-code input. Reference: [shadcn/ui InputOTP](https://ui.shadcn.com/docs/components/radix/input-otp), built on [`input-otp`](https://github.com/guilhermerodz/input-otp) (React-only). We port the behavior to Phlex + Stimulus — no React dependency available, so this is a from-scratch reimplementation, not a wrapped npm package.
9+
10+
## Architecture
11+
12+
Single real `<input>`, not N per-slot inputs. The input is visually transparent (`color: transparent`, `caret-color: transparent`), absolutely positioned over the slot row, but **not** `display:none` — it stays in the accessibility tree as the one real control (matches the upstream lib's approach and keeps screen readers/autofill working). Visible slots are plain decorative `<div>`s (`aria-hidden="true"`) that mirror the input's value, one character each, painted by the Stimulus controller.
13+
14+
Trade-off vs. upstream lib: upstream maps native click x/y to a caret column via a monospace + negative-letter-spacing CSS hack. We skip that — each slot has a click handler that calls `input.focus()` + `setSelectionRange(i, i)` directly. Less fragile (no font-metrics dependency), at the cost of one mechanism the original lib doesn't need.
15+
16+
Confirmed behavior requirements (these must all work, not just be "supported in theory"):
17+
- Typing a digit appends it and auto-advances to the next slot.
18+
- Backspace deletes the current slot's char and moves back.
19+
- ArrowLeft/ArrowRight move the active slot.
20+
- ArrowUp/ArrowDown also move the active slot (non-native — a single-line `<input>` doesn't react to vertical arrows by default, so the controller intercepts `keydown` for all four arrow keys and calls `setSelectionRange` explicitly rather than relying on the browser's native caret movement). This keeps behavior deterministic instead of depending on browser-specific selection-collapse quirks.
21+
- Paste distributes characters across slots from the caret position, filtered by `pattern`.
22+
23+
## File layout
24+
25+
```
26+
gem/lib/ruby_ui/input_otp/
27+
input_otp.rb # root: container div + the real input
28+
input_otp_group.rb # div, visual grouping of slots (e.g. 3+3)
29+
input_otp_slot.rb # div, index:, renders mirrored char + fake caret
30+
input_otp_separator.rb # div role="separator", inline minus-icon svg
31+
input_otp_controller.js
32+
input_otp_docs.rb
33+
```
34+
35+
Naming: `InputOtp`, not `InputOTP`. No acronym casing — matches existing precedent (`NativeSelect`, `DataTable`) and avoids a Zeitwerk inflector override in `docs/config/initializers/ruby_ui.rb`.
36+
37+
No new JS package dependency. `input-otp` (the JS package) is React-only, so it can't be wrapped — Stimulus controller reimplements the behavior directly. `animate-caret-blink` is already available via `tw-animate-css`, already a baseline install (other components use `animate-in`/`fade-in-0` etc. the same way), so no new entry in `dependencies.yml`.
38+
39+
## Component API (compound, mirrors shadcn 1:1)
40+
41+
```ruby
42+
InputOtp(length: 6, name: "otp", value: nil, pattern: nil, disabled: false) do
43+
InputOtpGroup do
44+
InputOtpSlot(index: 0)
45+
InputOtpSlot(index: 1)
46+
InputOtpSlot(index: 2)
47+
end
48+
InputOtpSeparator()
49+
InputOtpGroup do
50+
InputOtpSlot(index: 3)
51+
InputOtpSlot(index: 4)
52+
InputOtpSlot(index: 5)
53+
end
54+
end
55+
```
56+
57+
- `length:` (required) — number of characters, drives `maxlength` on the real input and the Stimulus `length` value.
58+
- `pattern:` (optional regex string) — default is digits-only (`inputmode="numeric"`). Passed to the controller as a value, validated on input/paste.
59+
- `name:`, `value:`, `disabled:` — forwarded to the real input for normal Rails form semantics (no separate hidden field needed, unlike `MaskedInput`).
60+
- `InputOtpSlot(index:)` — explicit index, same as shadcn's `InputOTPSlot index={n}`. No implicit ordering/context magic.
61+
- First/last rounded corners are handled per-group automatically via `first:`/`last:` Tailwind pseudo-classes — works because each `InputOtpGroup` is its own flex container.
62+
63+
## Stimulus controller (`ruby-ui--input-otp`)
64+
65+
Targets: `input` (the real input), `slot` (each `InputOtpSlot`).
66+
Values: `length` (Number), `pattern` (String).
67+
68+
- `input` event → filter out characters not matching `pattern`, truncate to `length`, repaint all slots from the new value.
69+
- `document` `selectionchange` listener (only while this controller's input is focused) → recompute which slot is "active" from `selectionStart`/`selectionEnd`, toggle `data-active` on the corresponding slot, show the blinking fake caret (`animate-caret-blink`) only on an active *empty* slot.
70+
- `keydown` → intercept `ArrowLeft`/`ArrowRight`/`ArrowUp`/`ArrowDown`, `preventDefault`, move the selection to the adjacent slot explicitly.
71+
- `paste` → read `clipboardData`, filter by `pattern`, truncate to `length`, set value, move caret to the end of the pasted content (or to the first empty slot).
72+
- `focus`/`blur` → toggle a focused state on the container (for ring/outline styling).
73+
- Dispatches a custom event `ruby-ui--input-otp:complete` (bubbles, `detail: { value }`) when the value reaches `length` characters — this is the Hotwire integration point (e.g. a Stimulus action elsewhere can do `data-action="ruby-ui--input-otp:complete->form#requestSubmit"` to auto-submit). Also dispatches `ruby-ui--input-otp:input` on every change for consumers that want live validation feedback.
74+
75+
Out of scope for v1 (flagged in the GH issue as possible follow-up, not blocking): password-manager badge width-push hack, `<noscript>` CSS fallback, iOS letter-spacing/font hacks from the upstream lib. These are polish from the original React lib's edge-case handling, not core OTP behavior.
76+
77+
## Accessibility
78+
79+
- The real `<input>` is the only element in the accessibility tree carrying the value — label it normally (`aria-label` or wrapping `<label for>`), no different from any other text input.
80+
- Slots are `aria-hidden="true"` — purely decorative, prevents double-announcing characters to screen readers.
81+
- `autocomplete="one-time-code"` on the real input for SMS autofill.
82+
- `inputmode="numeric"` by default (digits-only pattern); becomes `inputmode="text"` if a custom `pattern` is supplied that isn't digit-only.
83+
84+
## Tests (`gem/test/ruby_ui/input_otp_test.rb`)
85+
86+
- Renders the real input with `name`, `value`, `maxlength` matching `length`.
87+
- Renders the correct number of `InputOtpSlot`s with `aria-hidden`.
88+
- `pattern:` shows up as the controller's pattern value / input `pattern` attribute.
89+
- `InputOtpGroup`/`InputOtpSeparator` render with expected structure/classes.
90+
- (JS behavior — auto-advance, arrow nav, paste, complete event — is not covered by Minitest; documented as manually verified in the docs page demo, consistent with how other Stimulus-heavy components in this gem are tested.)
91+
92+
## Docs page (`docs/app/views/docs/input_otp.rb`)
93+
94+
Two examples mirroring the shadcn demo: single group of 6 slots, and a 3+3 grouped example with `InputOtpSeparator`. Wiring: `docs_input_otp` route, `docs#input_otp` controller action, entry in `components_list.rb` and `site_files.rb`, controller import/registration in `docs/app/javascript/controllers/index.js`.
95+
96+
## GitHub issue
97+
98+
Filed in `ruby-ui/ruby_ui`, referencing the shadcn docs page and demo file, summarizing this design and the confirmed keyboard-behavior requirements.

0 commit comments

Comments
 (0)