diff --git a/design/2026-06-30-input-otp-design.md b/design/2026-06-30-input-otp-design.md
new file mode 100644
index 00000000..24a9a14e
--- /dev/null
+++ b/design/2026-06-30-input-otp-design.md
@@ -0,0 +1,98 @@
+# InputOtp — design spec
+
+Date: 2026-06-30
+Status: approved (pending final review)
+
+## Problem
+
+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.
+
+## Architecture
+
+Single real ` `, 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 `
`s (`aria-hidden="true"`) that mirror the input's value, one character each, painted by the Stimulus controller.
+
+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.
+
+Confirmed behavior requirements (these must all work, not just be "supported in theory"):
+- Typing a digit appends it and auto-advances to the next slot.
+- Backspace deletes the current slot's char and moves back.
+- ArrowLeft/ArrowRight move the active slot.
+- ArrowUp/ArrowDown also move the active slot (non-native — a single-line `
` 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.
+- Paste distributes characters across slots from the caret position, filtered by `pattern`.
+
+## File layout
+
+```
+gem/lib/ruby_ui/input_otp/
+ input_otp.rb # root: container div + the real input
+ input_otp_group.rb # div, visual grouping of slots (e.g. 3+3)
+ input_otp_slot.rb # div, index:, renders mirrored char + fake caret
+ input_otp_separator.rb # div role="separator", inline minus-icon svg
+ input_otp_controller.js
+ input_otp_docs.rb
+```
+
+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`.
+
+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`.
+
+## Component API (compound, mirrors shadcn 1:1)
+
+```ruby
+InputOtp(length: 6, name: "otp", value: nil, pattern: nil, disabled: false) do
+ InputOtpGroup do
+ InputOtpSlot(index: 0)
+ InputOtpSlot(index: 1)
+ InputOtpSlot(index: 2)
+ end
+ InputOtpSeparator()
+ InputOtpGroup do
+ InputOtpSlot(index: 3)
+ InputOtpSlot(index: 4)
+ InputOtpSlot(index: 5)
+ end
+end
+```
+
+- `length:` (required) — number of characters, drives `maxlength` on the real input and the Stimulus `length` value.
+- `pattern:` (optional regex string) — default is digits-only (`inputmode="numeric"`). Passed to the controller as a value, validated on input/paste.
+- `name:`, `value:`, `disabled:` — forwarded to the real input for normal Rails form semantics (no separate hidden field needed, unlike `MaskedInput`).
+- `InputOtpSlot(index:)` — explicit index, same as shadcn's `InputOTPSlot index={n}`. No implicit ordering/context magic.
+- First/last rounded corners are handled per-group automatically via `first:`/`last:` Tailwind pseudo-classes — works because each `InputOtpGroup` is its own flex container.
+
+## Stimulus controller (`ruby-ui--input-otp`)
+
+Targets: `input` (the real input), `slot` (each `InputOtpSlot`).
+Values: `length` (Number), `pattern` (String).
+
+- `input` event → filter out characters not matching `pattern`, truncate to `length`, repaint all slots from the new value.
+- `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.
+- `keydown` → intercept `ArrowLeft`/`ArrowRight`/`ArrowUp`/`ArrowDown`, `preventDefault`, move the selection to the adjacent slot explicitly.
+- `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).
+- `focus`/`blur` → toggle a focused state on the container (for ring/outline styling).
+- 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.
+
+Out of scope for v1 (flagged in the GH issue as possible follow-up, not blocking): password-manager badge width-push hack, `
` 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.
+
+## Accessibility
+
+- The real ` ` is the only element in the accessibility tree carrying the value — label it normally (`aria-label` or wrapping ``), no different from any other text input.
+- Slots are `aria-hidden="true"` — purely decorative, prevents double-announcing characters to screen readers.
+- `autocomplete="one-time-code"` on the real input for SMS autofill.
+- `inputmode="numeric"` by default (digits-only pattern); becomes `inputmode="text"` if a custom `pattern` is supplied that isn't digit-only.
+
+## Tests (`gem/test/ruby_ui/input_otp_test.rb`)
+
+- Renders the real input with `name`, `value`, `maxlength` matching `length`.
+- Renders the correct number of `InputOtpSlot`s with `aria-hidden`.
+- `pattern:` shows up as the controller's pattern value / input `pattern` attribute.
+- `InputOtpGroup`/`InputOtpSeparator` render with expected structure/classes.
+- (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.)
+
+## Docs page (`docs/app/views/docs/input_otp.rb`)
+
+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`.
+
+## GitHub issue
+
+Filed in `ruby-ui/ruby_ui`, referencing the shadcn docs page and demo file, summarizing this design and the confirmed keyboard-behavior requirements.
diff --git a/design/plans/2026-06-30-input-otp-implementation.md b/design/plans/2026-06-30-input-otp-implementation.md
new file mode 100644
index 00000000..102f78d2
--- /dev/null
+++ b/design/plans/2026-06-30-input-otp-implementation.md
@@ -0,0 +1,785 @@
+# 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 ` ` (transparent, absolutely positioned, stays in the a11y tree) drives state; decorative `aria-hidden` slot ``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.
+
+## Global Constraints
+
+- Component class names: `InputOtp`, `InputOtpGroup`, `InputOtpSlot`, `InputOtpSeparator` — no `OTP` acronym casing (matches `NativeSelect`/`DataTable` precedent; the gem's test loader derives class names by capitalizing each underscore-separated segment of the filename, so `input_otp.rb` → `InputOtp` automatically).
+- No new entry in `gem/lib/generators/ruby_ui/dependencies.yml` — no new gem, no new JS package, no new component dependency.
+- Every Ruby component file ends with a matching update to its test file and, where applicable, the docs wiring — per `gem/AGENTS.md` and root `CLAUDE.md` ("Don't edit a component without updating its docs page in the same PR").
+- Run `cd gem && bundle exec rake` (tests + standardrb) after every Ruby task; it must stay green throughout.
+- Use `mise exec --
` (or ensure the `mise`-managed Ruby 3.4.7 / Node from `gem/.tool-versions` is on `PATH`) for every Ruby/bundle/pnpm command — the system Ruby (2.6) cannot run this gem's bundler lockfile.
+- Work happens in the git worktree at `.claude/worktrees/input-otp` (branch `worktree-input-otp`), already set up with `bundle install` run and a clean baseline (`252 runs, 0 failures` before this work started).
+
+---
+
+### Task 1: `InputOtp` root component
+
+**Files:**
+- Create: `gem/lib/ruby_ui/input_otp/input_otp.rb`
+- Test: `gem/test/ruby_ui/input_otp_test.rb`
+
+**Interfaces:**
+- Produces: `RubyUI::InputOtp.new(length:, pattern: nil, **attrs)` / Phlex::Kit call `InputOtp(length:, pattern: nil, **attrs, &block)`. Renders a wrapper `div` containing the yielded block (groups/slots) and a real `input` carrying `**attrs` (so `name:`, `value:`, `disabled:` etc. land on the real input, same convention as `RubyUI::Switch`).
+- The real input carries `data-controller="ruby-ui--input-otp"`, Stimulus values `length` (Number) and `char-class` (String, defaults to `"[0-9]"` when no `pattern:` given), and target name `input`. Task 4's Stimulus controller and Task 3's `InputOtpSlot` both depend on this exact target/value naming — do not rename.
+
+- [ ] **Step 1: Write the failing test**
+
+Create `gem/test/ruby_ui/input_otp_test.rb`:
+
+```ruby
+# frozen_string_literal: true
+
+require "test_helper"
+
+class RubyUI::InputOtpTest < ComponentTest
+ def test_render_wires_stimulus_controller_and_values
+ output = phlex { RubyUI.InputOtp(length: 6, name: "otp") }
+
+ assert_match(/data-controller="ruby-ui--input-otp"/, output)
+ assert_match(/data-ruby-ui--input-otp-length-value="6"/, output)
+ assert_match(/data-ruby-ui--input-otp-char-class-value="\[0-9\]"/, output)
+ end
+
+ def test_render_forwards_form_attrs_to_real_input
+ output = phlex { RubyUI.InputOtp(length: 6, name: "otp", value: "123") }
+
+ assert_match(/name="otp"/, output)
+ assert_match(/value="123"/, output)
+ assert_match(/maxlength="6"/, output)
+ assert_match(/autocomplete="one-time-code"/, output)
+ end
+
+ def test_render_defaults_to_numeric_inputmode_and_digit_pattern
+ output = phlex { RubyUI.InputOtp(length: 4, name: "otp") }
+
+ assert_match(/inputmode="numeric"/, output)
+ assert_match(/pattern="\^\(\?:\[0-9\]\)\{4\}\$"/, output)
+ end
+
+ def test_render_with_custom_pattern_uses_text_inputmode
+ output = phlex { RubyUI.InputOtp(length: 4, name: "otp", pattern: "[0-9A-Za-z]") }
+
+ assert_match(/inputmode="text"/, output)
+ assert_match(/data-ruby-ui--input-otp-char-class-value="\[0-9A-Za-z\]"/, output)
+ assert_match(/pattern="\^\(\?:\[0-9A-Za-z\]\)\{4\}\$"/, output)
+ end
+
+ def test_render_yields_block_content
+ output = phlex { RubyUI.InputOtp(length: 1, name: "otp") { div(id: "marker") } }
+
+ assert_match(/id="marker"/, output)
+ end
+end
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `cd gem && mise exec -- bundle exec rake test TEST=test/ruby_ui/input_otp_test.rb`
+Expected: FAIL — `NameError` / `uninitialized constant RubyUI::InputOtp` (or similar autoload failure), since the component doesn't exist yet.
+
+- [ ] **Step 3: Write the implementation**
+
+Create `gem/lib/ruby_ui/input_otp/input_otp.rb`:
+
+```ruby
+# frozen_string_literal: true
+
+module RubyUI
+ class InputOtp < Base
+ def initialize(length:, pattern: nil, **attrs)
+ @length = length
+ @char_class = pattern || "[0-9]"
+ super(**attrs)
+ end
+
+ def view_template(&block)
+ div(class: "relative inline-flex items-center has-disabled:opacity-50") do
+ div(class: "flex items-center gap-2", &block)
+ input(**attrs)
+ end
+ end
+
+ private
+
+ def default_attrs
+ {
+ type: "text",
+ inputmode: (@char_class == "[0-9]") ? "numeric" : "text",
+ pattern: "^(?:#{@char_class}){#{@length}}$",
+ maxlength: @length,
+ autocomplete: "one-time-code",
+ data: {
+ controller: "ruby-ui--input-otp",
+ ruby_ui__input_otp_length_value: @length,
+ ruby_ui__input_otp_char_class_value: @char_class,
+ ruby_ui__input_otp_target: "input",
+ action: "input->ruby-ui--input-otp#onInput keydown->ruby-ui--input-otp#onKeydown paste->ruby-ui--input-otp#onPaste focus->ruby-ui--input-otp#onFocus blur->ruby-ui--input-otp#onBlur"
+ },
+ class: "absolute inset-0 h-full w-full cursor-text border-0 bg-transparent p-0 text-transparent caret-transparent outline-none selection:bg-transparent disabled:cursor-not-allowed"
+ }
+ end
+ end
+end
+```
+
+- [ ] **Step 4: Run test to verify it passes**
+
+Run: `cd gem && mise exec -- bundle exec rake test TEST=test/ruby_ui/input_otp_test.rb`
+Expected: `5 runs, ... 0 failures, 0 errors`
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add gem/lib/ruby_ui/input_otp/input_otp.rb gem/test/ruby_ui/input_otp_test.rb
+git commit -m "[Feature] Add InputOtp root component"
+```
+
+---
+
+### Task 2: `InputOtpGroup` and `InputOtpSeparator`
+
+**Files:**
+- Create: `gem/lib/ruby_ui/input_otp/input_otp_group.rb`
+- Create: `gem/lib/ruby_ui/input_otp/input_otp_separator.rb`
+- Modify (append to): `gem/test/ruby_ui/input_otp_test.rb`
+
+**Interfaces:**
+- Produces: `InputOtpGroup(**attrs, &block)` — plain flex wrapper `div`, no Stimulus involvement.
+- Produces: `InputOtpSeparator(**attrs, &block)` — `div[role=separator]` rendering an inline minus-icon SVG by default, or the given block instead.
+- Consumes nothing from Task 1; purely visual siblings inside `InputOtp`'s yielded block.
+
+- [ ] **Step 1: Write the failing tests**
+
+Append to `gem/test/ruby_ui/input_otp_test.rb` (inside the existing `RubyUI::InputOtpTest` class, before the final `end`):
+
+```ruby
+
+ def test_group_renders_flex_wrapper
+ output = phlex { RubyUI.InputOtpGroup { div(id: "marker") } }
+
+ assert_match(/class="flex items-center"/, output)
+ assert_match(/id="marker"/, output)
+ end
+
+ def test_separator_renders_role_with_default_icon
+ output = phlex { RubyUI.InputOtpSeparator() }
+
+ assert_match(/role="separator"/, output)
+ assert_match(/"`. No block/content — Task 4's Stimulus controller sets `textContent` and toggles `data-active`/`data-caret` on it at runtime.
+- Consumes: the `ruby-ui--input-otp` controller scope from Task 1 (must be rendered inside an `InputOtp` block to be wired up — this component renders standalone for testing purposes, but is only functional nested inside `InputOtp`).
+
+- [ ] **Step 1: Write the failing tests**
+
+Append to `gem/test/ruby_ui/input_otp_test.rb`:
+
+```ruby
+
+ def test_slot_renders_aria_hidden_with_index_and_target
+ output = phlex { RubyUI.InputOtpSlot(index: 3) }
+
+ assert_match(/aria-hidden="true"/, output)
+ assert_match(/data-index="3"/, output)
+ assert_match(/data-ruby-ui--input-otp-target="slot"/, output)
+ end
+
+ def test_slot_renders_first_last_rounding_classes
+ output = phlex { RubyUI.InputOtpSlot(index: 0) }
+
+ assert_match(/first:rounded-l-md/, output)
+ assert_match(/last:rounded-r-md/, output)
+ end
+```
+
+- [ ] **Step 2: Run tests to verify they fail**
+
+Run: `cd gem && mise exec -- bundle exec rake test TEST=test/ruby_ui/input_otp_test.rb`
+Expected: FAIL — `uninitialized constant RubyUI::InputOtpSlot`.
+
+- [ ] **Step 3: Write the implementation**
+
+Create `gem/lib/ruby_ui/input_otp/input_otp_slot.rb`:
+
+```ruby
+# frozen_string_literal: true
+
+module RubyUI
+ class InputOtpSlot < Base
+ def initialize(index:, **attrs)
+ @index = index
+ super(**attrs)
+ end
+
+ def view_template
+ div(**attrs)
+ end
+
+ private
+
+ def default_attrs
+ {
+ aria_hidden: "true",
+ data: {
+ ruby_ui__input_otp_target: "slot",
+ index: @index
+ },
+ class: [
+ "relative flex h-9 w-9 items-center justify-center border-y border-r border-input text-sm shadow-xs transition-all",
+ "first:rounded-l-md first:border-l last:rounded-r-md",
+ "data-[active=true]:z-10 data-[active=true]:border-ring data-[active=true]:ring-2 data-[active=true]:ring-ring/50",
+ "data-[caret=true]:after:content-[''] data-[caret=true]:after:absolute data-[caret=true]:after:h-4 data-[caret=true]:after:w-px data-[caret=true]:after:animate-caret-blink data-[caret=true]:after:bg-foreground"
+ ]
+ }
+ end
+ end
+end
+```
+
+- [ ] **Step 4: Run tests to verify they pass**
+
+Run: `cd gem && mise exec -- bundle exec rake test TEST=test/ruby_ui/input_otp_test.rb`
+Expected: `10 runs, ... 0 failures, 0 errors`
+
+- [ ] **Step 5: Run full gem suite + lint, then commit**
+
+Run: `cd gem && mise exec -- bundle exec rake`
+Expected: all existing tests still pass (`262 runs` — 252 baseline + 10 new — `0 failures, 0 errors`), `no offenses detected`.
+
+```bash
+git add gem/lib/ruby_ui/input_otp/input_otp_slot.rb gem/test/ruby_ui/input_otp_test.rb
+git commit -m "[Feature] Add InputOtpSlot"
+```
+
+---
+
+### Task 4: Stimulus controller (`ruby-ui--input-otp`)
+
+**Files:**
+- Create: `gem/lib/ruby_ui/input_otp/input_otp_controller.js`
+
+**Interfaces:**
+- Consumes: target `input` (Task 1's real ` `), target array `slot` (Task 3's `InputOtpSlot` divs, each carrying `data-index`), Stimulus values `lengthValue` (Number), `charClassValue` (String).
+- Produces: custom events `ruby-ui--input-otp:input` and `ruby-ui--input-otp:complete`, both with `detail: { value }`, dispatched on the controller's element (bubbles by default via Stimulus `dispatch`) — this is the integration point for Hotwire/Turbo consumers (e.g. auto-submitting a form on complete).
+- No Minitest coverage — this codebase has no JS test runner (verified: no `package.json` test script, no `.test.js`/`.spec.js` files anywhere in the repo). Behavior is verified manually in Task 6's browser check, consistent with how every other Stimulus controller in this gem is validated.
+
+- [ ] **Step 1: Write the implementation**
+
+> **Superseded:** the draft below is what Task 4 originally shipped. Manual browser testing (Task 7) and PR review surfaced real bugs in it — collapsed-caret arrow navigation, no replace-and-advance after editing a full value, no sanitization of a prefilled `value:` on connect — all fixed in follow-up commits. Treat `gem/lib/ruby_ui/input_otp/input_otp_controller.js` as the source of truth, not this snapshot.
+
+Create `gem/lib/ruby_ui/input_otp/input_otp_controller.js`:
+
+```js
+import { Controller } from "@hotwired/stimulus"
+
+// Connects to data-controller="ruby-ui--input-otp"
+export default class extends Controller {
+ static targets = ["input", "slot"]
+ static values = { length: Number, charClass: String }
+
+ connect() {
+ this.paint()
+ this.boundOnSelectionChange = this.onSelectionChange.bind(this)
+ document.addEventListener("selectionchange", this.boundOnSelectionChange)
+ }
+
+ disconnect() {
+ document.removeEventListener("selectionchange", this.boundOnSelectionChange)
+ }
+
+ onInput() {
+ const filtered = this.filter(this.inputTarget.value).slice(0, this.lengthValue)
+ if (filtered !== this.inputTarget.value) this.inputTarget.value = filtered
+
+ this.paint()
+ this.dispatch("input", { detail: { value: filtered } })
+ if (filtered.length === this.lengthValue) {
+ this.dispatch("complete", { detail: { value: filtered } })
+ }
+ }
+
+ onFocus() {
+ const end = this.inputTarget.value.length
+ const start = Math.min(end, this.lengthValue - 1)
+ this.inputTarget.setSelectionRange(start, end)
+ this.paint()
+ }
+
+ onBlur() {
+ this.paint()
+ }
+
+ onKeydown(event) {
+ const moves = { ArrowLeft: -1, ArrowUp: -1, ArrowRight: 1, ArrowDown: 1 }
+ if (!(event.key in moves)) return
+
+ 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()
+ }
+
+ onPaste(event) {
+ event.preventDefault()
+ const pasted = this.filter(event.clipboardData.getData("text/plain"))
+ if (!pasted) return
+
+ const start = this.inputTarget.selectionStart ?? 0
+ const end = this.inputTarget.selectionEnd ?? start
+ const current = this.inputTarget.value
+ const merged = (current.slice(0, start) + pasted + current.slice(end)).slice(0, this.lengthValue)
+
+ this.inputTarget.value = merged
+ const caret = Math.min(merged.length, this.lengthValue - 1)
+ this.inputTarget.setSelectionRange(caret, merged.length)
+
+ this.paint()
+ this.dispatch("input", { detail: { value: merged } })
+ if (merged.length === this.lengthValue) this.dispatch("complete", { detail: { value: merged } })
+ }
+
+ onSelectionChange() {
+ if (document.activeElement !== this.inputTarget) return
+ this.paint()
+ }
+
+ filter(raw) {
+ const re = new RegExp(this.charClassValue)
+ return raw.split("").filter((char) => re.test(char)).join("")
+ }
+
+ paint() {
+ const value = this.inputTarget.value
+ const isFocused = document.activeElement === this.inputTarget
+ const start = this.inputTarget.selectionStart ?? value.length
+ const end = this.inputTarget.selectionEnd ?? value.length
+ const activeIndex = Math.min(start, this.lengthValue - 1)
+
+ this.slotTargets.forEach((slot) => {
+ const index = Number(slot.dataset.index)
+ const char = value[index] ?? ""
+ const isActive = isFocused && ((start === end && index === activeIndex) || (index >= start && index < end))
+
+ slot.textContent = char
+ slot.dataset.active = isActive ? "true" : "false"
+ slot.dataset.caret = isActive && char === "" ? "true" : "false"
+ })
+ }
+}
+```
+
+- [ ] **Step 2: Run the full gem suite to confirm nothing broke**
+
+Run: `cd gem && mise exec -- bundle exec rake`
+Expected: unchanged — `262 runs, ... 0 failures, 0 errors`, `no offenses detected` (this file isn't Ruby, so `rake` won't execute it, but it must not break file discovery — `gem/test/test_helper.rb`'s `Dir.glob("lib/ruby_ui/**/*.rb")` only globs `.rb` files, so the `.js` file is inert here by design).
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add gem/lib/ruby_ui/input_otp/input_otp_controller.js
+git commit -m "[Feature] Add InputOtp Stimulus controller"
+```
+
+---
+
+### Task 5: Gem docs template (`input_otp_docs.rb`)
+
+**Files:**
+- Create: `gem/lib/ruby_ui/input_otp/input_otp_docs.rb`
+
+**Interfaces:**
+- Produces: `Views::Docs::InputOtp`, a Phlex view class. Excluded from the test loader (`gem/test/test_helper.rb` rejects `_docs.rb` files) and excluded from the consumer-app generator by default (`gem/lib/generators/ruby_ui/component_generator.rb` filters `_docs.rb` files unless `--with-docs` is passed) — this file is documentation-as-installable-template, not exercised by `rake test`.
+- This file's content must be byte-identical to `docs/app/views/docs/input_otp.rb` created in Task 6 (matches the established `masked_input` precedent, verified via `diff` — both copies are intentionally kept in sync).
+
+- [ ] **Step 1: Write the file**
+
+Create `gem/lib/ruby_ui/input_otp/input_otp_docs.rb`:
+
+```ruby
+# frozen_string_literal: true
+
+class Views::Docs::InputOtp < Views::Base
+ def view_template
+ component = "InputOtp"
+
+ div(class: "max-w-2xl mx-auto w-full py-10 space-y-10") do
+ render Docs::Header.new(title: "Input OTP", description: "Accessible one-time-password input with keyboard navigation and paste support.")
+
+ Heading(level: 2) { "Usage" }
+
+ render Docs::VisualCodeExample.new(title: "Example", context: self) do
+ <<~RUBY
+ InputOtp(length: 6, name: "otp") do
+ InputOtpGroup do
+ InputOtpSlot(index: 0)
+ InputOtpSlot(index: 1)
+ InputOtpSlot(index: 2)
+ InputOtpSlot(index: 3)
+ InputOtpSlot(index: 4)
+ InputOtpSlot(index: 5)
+ end
+ end
+ RUBY
+ end
+
+ Heading(level: 2) { "Grouped with separator" }
+
+ render Docs::VisualCodeExample.new(title: "Example", context: self) do
+ <<~RUBY
+ InputOtp(length: 6, name: "otp") do
+ InputOtpGroup do
+ InputOtpSlot(index: 0)
+ InputOtpSlot(index: 1)
+ InputOtpSlot(index: 2)
+ end
+ InputOtpSeparator()
+ InputOtpGroup do
+ InputOtpSlot(index: 3)
+ InputOtpSlot(index: 4)
+ InputOtpSlot(index: 5)
+ end
+ end
+ RUBY
+ end
+
+ Heading(level: 2) { "Custom pattern" }
+
+ Text { "Pass pattern: with a single-character regex class (default is \"[0-9]\") to accept other characters, e.g. letters and digits." }
+
+ render Docs::VisualCodeExample.new(title: "Example", context: self) do
+ <<~RUBY
+ InputOtp(length: 6, name: "otp", pattern: "[0-9A-Za-z]") do
+ InputOtpGroup do
+ InputOtpSlot(index: 0)
+ InputOtpSlot(index: 1)
+ InputOtpSlot(index: 2)
+ InputOtpSlot(index: 3)
+ InputOtpSlot(index: 4)
+ InputOtpSlot(index: 5)
+ end
+ end
+ RUBY
+ end
+
+ Heading(level: 2) { "Reacting to completion" }
+
+ Text { "The controller dispatches a ruby-ui--input-otp:complete custom event (detail: { value }) once the value reaches length characters, and a ruby-ui--input-otp:input event on every change. Wire a Stimulus action on a parent element to react — for example, to auto-submit a form:" }
+
+ Codeblock(<<~JS, syntax: :javascript)
+ // app/javascript/controllers/otp_form_controller.js
+ import { Controller } from "@hotwired/stimulus"
+
+ export default class extends Controller {
+ submit() {
+ this.element.requestSubmit()
+ }
+ }
+ JS
+
+ Codeblock(<<~HTML, syntax: :html)
+
+ HTML
+
+ render Components::ComponentSetup::Tabs.new(component_name: component)
+
+ render Docs::ComponentsTable.new(component_files(component))
+ end
+ end
+end
+```
+
+- [ ] **Step 2: Run the full gem suite**
+
+Run: `cd gem && mise exec -- bundle exec rake`
+Expected: still `262 runs, ... 0 failures, 0 errors`, `no offenses detected` — `_docs.rb` files are excluded from both the autoload-as-class-under-test path and standardrb's component checks are still subject to standard Ruby lint, so confirm no offenses were introduced.
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add gem/lib/ruby_ui/input_otp/input_otp_docs.rb
+git commit -m "[Documentation] Add InputOtp docs template"
+```
+
+---
+
+### Task 6: Wire into the `docs/` Rails app
+
+**Files:**
+- Create: `docs/app/views/docs/input_otp.rb` (identical copy of Task 5's file, just confirm it stays byte-identical)
+- Create: `docs/app/javascript/controllers/ruby_ui/input_otp_controller.js` (identical copy of Task 4's file)
+- Modify: `docs/app/javascript/controllers/index.js`
+- Modify: `docs/app/controllers/docs_controller.rb`
+- Modify: `docs/config/routes.rb`
+- Modify: `docs/app/components/shared/components_list.rb`
+- Modify: `docs/app/lib/site_files.rb`
+
+**Interfaces:**
+- Consumes: `Views::Docs::InputOtp` (Task 5), `ruby-ui--input-otp` controller (Task 4).
+- Produces: route `docs_input_otp_path` → `GET /docs/input_otp` → `DocsController#input_otp` → renders `Views::Docs::InputOtp.new`; sidebar entry "Input OTP"; registered Stimulus controller so the JS actually runs in the docs app (the gem's autoloader only loads `.rb` files directly from the gem path — `.js` controllers must be physically present under `docs/app/javascript/controllers/`, confirmed by the existing `masked_input_controller.js` being duplicated there).
+
+- [ ] **Step 1: Copy the docs view**
+
+```bash
+cp gem/lib/ruby_ui/input_otp/input_otp_docs.rb docs/app/views/docs/input_otp.rb
+diff gem/lib/ruby_ui/input_otp/input_otp_docs.rb docs/app/views/docs/input_otp.rb
+```
+Expected: `diff` prints nothing (files identical).
+
+- [ ] **Step 2: Copy the Stimulus controller**
+
+```bash
+mkdir -p docs/app/javascript/controllers/ruby_ui
+cp gem/lib/ruby_ui/input_otp/input_otp_controller.js docs/app/javascript/controllers/ruby_ui/input_otp_controller.js
+diff gem/lib/ruby_ui/input_otp/input_otp_controller.js docs/app/javascript/controllers/ruby_ui/input_otp_controller.js
+```
+Expected: `diff` prints nothing.
+
+- [ ] **Step 3: Register the controller**
+
+In `docs/app/javascript/controllers/index.js`, insert between the existing `HoverCard` block and the `MaskedInput` block (alphabetical placement, matches surrounding convention):
+
+```js
+import RubyUi__HoverCardController from "./ruby_ui/hover_card_controller"
+application.register("ruby-ui--hover-card", RubyUi__HoverCardController)
+
+import RubyUi__InputOtpController from "./ruby_ui/input_otp_controller"
+application.register("ruby-ui--input-otp", RubyUi__InputOtpController)
+
+import RubyUi__MaskedInputController from "./ruby_ui/masked_input_controller"
+application.register("ruby-ui--masked-input", RubyUi__MaskedInputController)
+```
+
+- [ ] **Step 4: Add the controller action**
+
+In `docs/app/controllers/docs_controller.rb`, insert between the existing `input` action and `link` action:
+
+```ruby
+ def input
+ render Views::Docs::Input.new
+ end
+
+ def input_otp
+ render Views::Docs::InputOtp.new
+ end
+
+ def link
+ render Views::Docs::Link.new
+ end
+```
+
+- [ ] **Step 5: Add the route**
+
+In `docs/config/routes.rb`, insert between the existing `input` and `link` routes (line 52/53 before this change):
+
+```ruby
+ get "input", to: "docs#input", as: :docs_input
+ get "input_otp", to: "docs#input_otp", as: :docs_input_otp
+ get "link", to: "docs#link", as: :docs_link
+```
+
+- [ ] **Step 6: Add the sidebar/components-list entry**
+
+In `docs/app/components/shared/components_list.rb`, insert between `Input` and `Link`:
+
+```ruby
+ {name: "Input", path: docs_input_path},
+ {name: "Input OTP", path: docs_input_otp_path},
+ {name: "Link", path: docs_link_path},
+```
+
+- [ ] **Step 7: Add the site_files entry**
+
+In `docs/app/lib/site_files.rb`, insert between `Input` and `Link`:
+
+```ruby
+ {title: "Input", path: "/docs/input", description: "Styled input field primitive."},
+ {title: "Input OTP", path: "/docs/input_otp", description: "One-time-password input with keyboard navigation and paste support."},
+ {title: "Link", path: "/docs/link", description: "Link component with button-like and underline variants."},
+```
+
+- [ ] **Step 8: Verify routes resolve**
+
+Run: `cd docs && mise exec -- bin/rails routes -g input_otp`
+Expected: prints the new `docs_input_otp GET /docs/input_otp docs#input_otp` line, no errors.
+
+- [ ] **Step 9: Commit**
+
+```bash
+git add docs/app/views/docs/input_otp.rb docs/app/javascript/controllers/ruby_ui/input_otp_controller.js docs/app/javascript/controllers/index.js docs/app/controllers/docs_controller.rb docs/config/routes.rb docs/app/components/shared/components_list.rb docs/app/lib/site_files.rb
+git commit -m "[Feature] Wire InputOtp into the docs site"
+```
+
+---
+
+### Task 7: Manual browser verification + final check
+
+**Files:** none (verification only).
+
+**Interfaces:** none — this task exercises Tasks 1–6 end-to-end through a running server. There is no automated coverage for keyboard navigation, paste, or the `complete` event (see Task 4) — this is the only verification they get, so it is not optional.
+
+- [ ] **Step 1: Build docs assets and boot the server**
+
+```bash
+cd docs
+mise exec -- bundle install
+mise exec -- pnpm install
+mise exec -- pnpm build && mise exec -- pnpm build:css
+mise exec -- bin/rails db:prepare
+mise exec -- bin/rails server -b 0.0.0.0 -p 3001 &
+```
+Expected: server starts without errors, listening on port 3001 (use 3001 if 3000 is occupied locally, per root `CLAUDE.local.md` convention — `gem` is consumed via the monorepo's `path: "../gem"` Gemfile entry, so no extra Gemfile edits are needed).
+
+- [ ] **Step 2: Open the docs page in a browser and check rendering**
+
+Navigate to `http://localhost:3001/docs/input_otp`. Confirm:
+- The "Usage" example renders 6 visible slot boxes in a single row.
+- The "Grouped with separator" example renders two groups of 3 with a minus-icon separator between them.
+- No console errors on load.
+
+- [ ] **Step 3: Exercise keyboard behavior**
+
+Click the first slot, then:
+- Type 6 digits — confirm each keystroke advances to the next slot and all 6 render.
+- Press Backspace twice — confirm the last two slots clear and the active slot moves back.
+- Press ArrowLeft/ArrowRight — confirm the active-slot highlight (ring) moves accordingly.
+- Press ArrowUp/ArrowDown — confirm these also move the active-slot highlight (this does not happen natively on a single-line input — if it doesn't move, Task 4's `onKeydown` arrow handling is broken).
+- Select all (Cmd/Ctrl+A) and type a replacement digit — confirm it overwrites from the start.
+
+- [ ] **Step 4: Exercise paste**
+
+Copy a 6-digit string (e.g. from a text editor), click slot 1, paste. Confirm all 6 slots fill and the caret highlight lands after the last filled slot (or on the last slot if the pasted string is exactly 6 chars).
+
+- [ ] **Step 5: Exercise the custom event**
+
+Open the browser devtools console on the docs page and run:
+```js
+document.querySelector('[data-controller="ruby-ui--input-otp"]')
+ .addEventListener('ruby-ui--input-otp:complete', (e) => console.log('complete', e.detail))
+```
+Then type a full 6-digit code into the first example's slots. Confirm `complete {value: "..."}` is logged exactly once when the 6th digit lands.
+
+- [ ] **Step 6: Run the full gem suite one last time**
+
+Run: `cd gem && mise exec -- bundle exec rake`
+Expected: `262 runs, 1106+N assertions, 0 failures, 0 errors, 0 skips`, `no offenses detected`.
+
+- [ ] **Step 7: Stop the dev server**
+
+```bash
+kill %1
+```
+(or whatever job/PID the Task 7 Step 1 server is running as).
+
+No commit for this task — it's verification only. If any check in Steps 2–5 fails, fix the relevant Task (1–6) file, re-run that task's tests, then re-run this task's checks from Step 2.
diff --git a/docs/app/components/shared/components_list.rb b/docs/app/components/shared/components_list.rb
index eae5c84d..b692e549 100644
--- a/docs/app/components/shared/components_list.rb
+++ b/docs/app/components/shared/components_list.rb
@@ -33,6 +33,7 @@ def components
{name: "Form", path: docs_form_path},
{name: "Hover Card", path: docs_hover_card_path},
{name: "Input", path: docs_input_path},
+ {name: "Input OTP", path: docs_input_otp_path},
{name: "Link", path: docs_link_path},
{name: "Masked Input", path: masked_input_path},
{name: "Message", path: docs_message_path},
diff --git a/docs/app/controllers/docs_controller.rb b/docs/app/controllers/docs_controller.rb
index d5e8473d..e5f78a02 100644
--- a/docs/app/controllers/docs_controller.rb
+++ b/docs/app/controllers/docs_controller.rb
@@ -158,6 +158,10 @@ def input
render Views::Docs::Input.new
end
+ def input_otp
+ render Views::Docs::InputOtp.new
+ end
+
def link
render Views::Docs::Link.new
end
diff --git a/docs/app/javascript/controllers/index.js b/docs/app/javascript/controllers/index.js
index 0dc2eb27..f52b2774 100644
--- a/docs/app/javascript/controllers/index.js
+++ b/docs/app/javascript/controllers/index.js
@@ -73,6 +73,9 @@ application.register("ruby-ui--form-field", RubyUi__FormFieldController)
import RubyUi__HoverCardController from "./ruby_ui/hover_card_controller"
application.register("ruby-ui--hover-card", RubyUi__HoverCardController)
+import RubyUi__InputOtpController from "./ruby_ui/input_otp_controller"
+application.register("ruby-ui--input-otp", RubyUi__InputOtpController)
+
import RubyUi__MaskedInputController from "./ruby_ui/masked_input_controller"
application.register("ruby-ui--masked-input", RubyUi__MaskedInputController)
diff --git a/docs/app/javascript/controllers/ruby_ui/input_otp_controller.js b/docs/app/javascript/controllers/ruby_ui/input_otp_controller.js
new file mode 100644
index 00000000..45854327
--- /dev/null
+++ b/docs/app/javascript/controllers/ruby_ui/input_otp_controller.js
@@ -0,0 +1,128 @@
+import { Controller } from "@hotwired/stimulus"
+
+// Connects to data-controller="ruby-ui--input-otp"
+export default class extends Controller {
+ static targets = ["input", "slot"]
+ static values = { length: Number, charClass: String }
+
+ connect() {
+ // A server-rendered value (prefilled from a previous submission, a
+ // validation error, etc.) may exceed length or contain characters that
+ // fail pattern. Sanitize it up front so the hidden slots never mask
+ // an invalid/oversized value that would otherwise still get submitted.
+ this.sanitizeValue()
+ this.paint()
+ this.boundOnSelectionChange = this.onSelectionChange.bind(this)
+ document.addEventListener("selectionchange", this.boundOnSelectionChange)
+ }
+
+ disconnect() {
+ document.removeEventListener("selectionchange", this.boundOnSelectionChange)
+ }
+
+ onInput() {
+ this.sanitizeValue()
+ const filtered = this.inputTarget.value
+
+ this.normalizeSelection()
+ this.paint()
+ this.dispatch("input", { detail: { value: filtered } })
+ if (filtered.length === this.lengthValue) {
+ this.dispatch("complete", { detail: { value: filtered } })
+ }
+ }
+
+ onFocus() {
+ const end = this.inputTarget.value.length
+ const start = Math.min(end, this.lengthValue - 1)
+ this.inputTarget.setSelectionRange(start, end)
+ this.paint()
+ }
+
+ onBlur() {
+ this.paint()
+ }
+
+ onKeydown(event) {
+ const moves = { ArrowLeft: -1, ArrowUp: -1, ArrowRight: 1, ArrowDown: 1 }
+ if (!(event.key in moves)) return
+
+ event.preventDefault()
+ const current = this.inputTarget.selectionStart ?? 0
+ const next = Math.min(Math.max(current + moves[event.key], 0), this.lengthValue - 1)
+ const hasChar = next < this.inputTarget.value.length
+ this.inputTarget.setSelectionRange(next, hasChar ? next + 1 : next)
+ this.paint()
+ }
+
+ onPaste(event) {
+ event.preventDefault()
+ const pasted = this.filter(event.clipboardData.getData("text/plain"))
+ if (!pasted) return
+
+ const start = this.inputTarget.selectionStart ?? 0
+ const end = this.inputTarget.selectionEnd ?? start
+ const current = this.inputTarget.value
+ const merged = (current.slice(0, start) + pasted + current.slice(end)).slice(0, this.lengthValue)
+
+ this.inputTarget.value = merged
+ const caret = Math.min(merged.length, this.lengthValue - 1)
+ this.inputTarget.setSelectionRange(caret, merged.length)
+
+ this.paint()
+ this.dispatch("input", { detail: { value: merged } })
+ if (merged.length === this.lengthValue) this.dispatch("complete", { detail: { value: merged } })
+ }
+
+ onSelectionChange() {
+ if (document.activeElement !== this.inputTarget) return
+ this.paint()
+ }
+
+ // After typing, replacing, or deleting, the browser leaves a collapsed
+ // caret. If it landed on a slot that already has a character (not the
+ // true insert-mode end of the value), re-select that character as a
+ // 1-char range so the next keystroke replaces it instead of being
+ // silently dropped by the native maxlength/no-selection behavior.
+ normalizeSelection() {
+ const input = this.inputTarget
+ const value = input.value
+ const s = input.selectionStart
+ const e = input.selectionEnd
+ if (s === null || e === null || s !== e) return
+
+ const isInsertMode = s === value.length && value.length < this.lengthValue
+ if (isInsertMode) return
+
+ const index = Math.min(s, this.lengthValue - 1)
+ input.setSelectionRange(index, index < value.length ? index + 1 : index)
+ }
+
+ filter(raw) {
+ const re = new RegExp(this.charClassValue)
+ return raw.split("").filter((char) => re.test(char)).join("")
+ }
+
+ sanitizeValue() {
+ const filtered = this.filter(this.inputTarget.value).slice(0, this.lengthValue)
+ if (filtered !== this.inputTarget.value) this.inputTarget.value = filtered
+ }
+
+ paint() {
+ const value = this.inputTarget.value
+ const isFocused = document.activeElement === this.inputTarget
+ const start = this.inputTarget.selectionStart ?? value.length
+ const end = this.inputTarget.selectionEnd ?? value.length
+ const activeIndex = Math.min(start, this.lengthValue - 1)
+
+ this.slotTargets.forEach((slot) => {
+ const index = Number(slot.dataset.index)
+ const char = value[index] ?? ""
+ const isActive = isFocused && ((start === end && index === activeIndex) || (index >= start && index < end))
+
+ slot.textContent = char
+ slot.dataset.active = isActive ? "true" : "false"
+ slot.dataset.caret = isActive && char === "" ? "true" : "false"
+ })
+ }
+}
diff --git a/docs/app/lib/site_files.rb b/docs/app/lib/site_files.rb
index b0984020..d2f1bd5f 100644
--- a/docs/app/lib/site_files.rb
+++ b/docs/app/lib/site_files.rb
@@ -106,6 +106,7 @@ class SiteFiles
{title: "Form", path: "/docs/form", description: "Form fields with built-in client-side validations."},
{title: "Hover Card", path: "/docs/hover_card", description: "Preview content exposed behind a link or trigger."},
{title: "Input", path: "/docs/input", description: "Styled input field primitive."},
+ {title: "Input OTP", path: "/docs/input_otp", description: "One-time-password input with keyboard navigation and paste support."},
{title: "Link", path: "/docs/link", description: "Link component with button-like and underline variants."},
{title: "Masked Input", path: "/docs/masked_input", description: "Form input with an applied mask."},
{title: "Message", path: "/docs/message", description: "Chat message layout pairing an avatar with bubbles, headers, and footers."},
diff --git a/docs/app/views/docs/input_otp.rb b/docs/app/views/docs/input_otp.rb
new file mode 100644
index 00000000..a7fa5b0f
--- /dev/null
+++ b/docs/app/views/docs/input_otp.rb
@@ -0,0 +1,213 @@
+# frozen_string_literal: true
+
+class Views::Docs::InputOtp < Views::Base
+ def view_template
+ component = "InputOtp"
+
+ div(class: "max-w-2xl mx-auto w-full py-10 space-y-10") do
+ render Docs::Header.new(title: "Input OTP", description: "Accessible one-time-password input with keyboard navigation and paste support.")
+
+ Heading(level: 2) { "Usage" }
+
+ render Docs::VisualCodeExample.new(title: "Example", context: self) do
+ <<~RUBY
+ InputOtp(length: 6, name: "otp") do
+ InputOtpGroup do
+ InputOtpSlot(index: 0)
+ InputOtpSlot(index: 1)
+ InputOtpSlot(index: 2)
+ InputOtpSlot(index: 3)
+ InputOtpSlot(index: 4)
+ InputOtpSlot(index: 5)
+ end
+ end
+ RUBY
+ end
+
+ Heading(level: 2) { "Composition" }
+
+ Text { "InputOtpGroup and InputOtpSeparator compose freely — split slots into however many groups make sense, with a separator between each." }
+
+ render Docs::VisualCodeExample.new(title: "Example", context: self) do
+ <<~RUBY
+ InputOtp(length: 6, name: "otp") do
+ InputOtpGroup do
+ InputOtpSlot(index: 0)
+ InputOtpSlot(index: 1)
+ end
+ InputOtpSeparator()
+ InputOtpGroup do
+ InputOtpSlot(index: 2)
+ InputOtpSlot(index: 3)
+ end
+ InputOtpSeparator()
+ InputOtpGroup do
+ InputOtpSlot(index: 4)
+ InputOtpSlot(index: 5)
+ end
+ end
+ RUBY
+ end
+
+ Heading(level: 2) { "Pattern" }
+
+ Text { "Pass pattern: with a single-character regex class to define what InputOtp accepts. The default is \"[0-9]\" (digits only)." }
+
+ render Docs::VisualCodeExample.new(title: "Alphanumeric", context: self) do
+ <<~RUBY
+ InputOtp(length: 6, name: "otp", pattern: "[0-9A-Za-z]") do
+ InputOtpGroup do
+ InputOtpSlot(index: 0)
+ InputOtpSlot(index: 1)
+ InputOtpSlot(index: 2)
+ InputOtpSlot(index: 3)
+ InputOtpSlot(index: 4)
+ InputOtpSlot(index: 5)
+ end
+ end
+ RUBY
+ end
+
+ Heading(level: 2) { "Four digits" }
+
+ Text { "A common pattern for PIN codes — just pass a shorter length." }
+
+ render Docs::VisualCodeExample.new(title: "Example", context: self) do
+ <<~RUBY
+ InputOtp(length: 4, name: "pin") do
+ InputOtpGroup do
+ InputOtpSlot(index: 0)
+ InputOtpSlot(index: 1)
+ InputOtpSlot(index: 2)
+ InputOtpSlot(index: 3)
+ end
+ end
+ RUBY
+ end
+
+ Heading(level: 2) { "Disabled" }
+
+ render Docs::VisualCodeExample.new(title: "Example", context: self) do
+ <<~RUBY
+ InputOtp(length: 6, name: "otp", disabled: true) do
+ InputOtpGroup do
+ InputOtpSlot(index: 0)
+ InputOtpSlot(index: 1)
+ InputOtpSlot(index: 2)
+ InputOtpSlot(index: 3)
+ InputOtpSlot(index: 4)
+ InputOtpSlot(index: 5)
+ end
+ end
+ RUBY
+ end
+
+ Heading(level: 2) { "Invalid" }
+
+ Text { "Pass aria_invalid: \"true\" to each InputOtpSlot to show an error state." }
+
+ render Docs::VisualCodeExample.new(title: "Example", context: self) do
+ <<~RUBY
+ InputOtp(length: 6, name: "otp") do
+ InputOtpGroup do
+ InputOtpSlot(index: 0, aria_invalid: "true")
+ InputOtpSlot(index: 1, aria_invalid: "true")
+ InputOtpSlot(index: 2, aria_invalid: "true")
+ InputOtpSlot(index: 3, aria_invalid: "true")
+ InputOtpSlot(index: 4, aria_invalid: "true")
+ InputOtpSlot(index: 5, aria_invalid: "true")
+ end
+ end
+ RUBY
+ end
+
+ Heading(level: 2) { "Form" }
+
+ Text { "A full example combining InputOtp with Card, Button, and InlineLink — bigger slots via class:, a resend action, and fallback links." }
+
+ render Docs::VisualCodeExample.new(title: "Verify your login", context: self) do
+ <<~RUBY
+ Card(class: "mx-auto max-w-md") do
+ CardHeader do
+ CardTitle { "Verify your login" }
+ CardDescription do
+ plain "Enter the verification code we sent to your email address: "
+ span(class: "font-medium") { "m@example.com" }
+ plain "."
+ end
+ end
+ CardContent(class: "space-y-4") do
+ div(class: "flex items-center justify-between") do
+ label(class: "text-sm font-medium") { "Verification code" }
+ Button(variant: :outline, size: :sm) do
+ svg(
+ xmlns: "http://www.w3.org/2000/svg",
+ viewbox: "0 0 24 24",
+ fill: "none",
+ stroke: "currentColor",
+ stroke_width: "2",
+ stroke_linecap: "round",
+ stroke_linejoin: "round",
+ class: "w-4 h-4 mr-2"
+ ) do |s|
+ s.path(d: "M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8")
+ s.path(d: "M21 3v5h-5")
+ s.path(d: "M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16")
+ s.path(d: "M8 16H3v5")
+ end
+ plain "Resend Code"
+ end
+ end
+ InputOtp(length: 6, name: "otp", required: true) do
+ InputOtpGroup do
+ InputOtpSlot(index: 0, class: "h-12 w-11 text-xl")
+ InputOtpSlot(index: 1, class: "h-12 w-11 text-xl")
+ InputOtpSlot(index: 2, class: "h-12 w-11 text-xl")
+ end
+ InputOtpSeparator(class: "mx-2")
+ InputOtpGroup do
+ InputOtpSlot(index: 3, class: "h-12 w-11 text-xl")
+ InputOtpSlot(index: 4, class: "h-12 w-11 text-xl")
+ InputOtpSlot(index: 5, class: "h-12 w-11 text-xl")
+ end
+ end
+ InlineLink(href: "#") { "I no longer have access to this email address." }
+ end
+ CardFooter(class: "flex flex-col items-stretch gap-2") do
+ Button(class: "w-full") { "Verify" }
+ Text(size: "sm", weight: "muted") do
+ plain "Having trouble signing in? "
+ InlineLink(href: "#") { "Contact support" }
+ end
+ end
+ end
+ RUBY
+ end
+
+ Heading(level: 2) { "Reacting to completion" }
+
+ Text { "The controller dispatches a ruby-ui--input-otp:complete custom event (detail: { value }) once the value reaches length characters, and a ruby-ui--input-otp:input event on every change. Wire a Stimulus action on a parent element to react — for example, to auto-submit a form:" }
+
+ Codeblock(<<~JS, syntax: :javascript)
+ // app/javascript/controllers/otp_form_controller.js
+ import { Controller } from "@hotwired/stimulus"
+
+ export default class extends Controller {
+ submit() {
+ this.element.requestSubmit()
+ }
+ }
+ JS
+
+ Codeblock(<<~HTML, syntax: :html)
+
+ HTML
+
+ render Components::ComponentSetup::Tabs.new(component_name: component)
+
+ render Docs::ComponentsTable.new(component_files(component))
+ end
+ end
+end
diff --git a/docs/config/routes.rb b/docs/config/routes.rb
index 59702c7f..a0e4ae94 100644
--- a/docs/config/routes.rb
+++ b/docs/config/routes.rb
@@ -50,6 +50,7 @@
get "form", to: "docs#form", as: :docs_form
get "hover_card", to: "docs#hover_card", as: :docs_hover_card
get "input", to: "docs#input", as: :docs_input
+ get "input_otp", to: "docs#input_otp", as: :docs_input_otp
get "link", to: "docs#link", as: :docs_link
get "masked_input", to: "docs#masked_input", as: :masked_input
get "message", to: "docs#message", as: :docs_message
diff --git a/docs/public/llms-full.txt b/docs/public/llms-full.txt
index 3bd2e280..98a32994 100644
--- a/docs/public/llms-full.txt
+++ b/docs/public/llms-full.txt
@@ -218,6 +218,11 @@ This file expands the curated /llms.txt map into a compact reference that can be
- URL: https://rubyui.com/docs/input
- Summary: Styled input field primitive.
+### Input OTP
+
+- URL: https://rubyui.com/docs/input_otp
+- Summary: One-time-password input with keyboard navigation and paste support.
+
### Link
- URL: https://rubyui.com/docs/link
diff --git a/docs/public/llms.txt b/docs/public/llms.txt
index 722ad3ac..fd859327 100644
--- a/docs/public/llms.txt
+++ b/docs/public/llms.txt
@@ -48,6 +48,7 @@ Use the core docs first for installation, theming, dark mode, and customization
- [Form](https://rubyui.com/docs/form): Form fields with built-in client-side validations.
- [Hover Card](https://rubyui.com/docs/hover_card): Preview content exposed behind a link or trigger.
- [Input](https://rubyui.com/docs/input): Styled input field primitive.
+- [Input OTP](https://rubyui.com/docs/input_otp): One-time-password input with keyboard navigation and paste support.
- [Link](https://rubyui.com/docs/link): Link component with button-like and underline variants.
- [Masked Input](https://rubyui.com/docs/masked_input): Form input with an applied mask.
- [Message](https://rubyui.com/docs/message): Chat message layout pairing an avatar with bubbles, headers, and footers.
diff --git a/docs/public/sitemap.xml b/docs/public/sitemap.xml
index e3f5c942..51eba359 100644
--- a/docs/public/sitemap.xml
+++ b/docs/public/sitemap.xml
@@ -195,6 +195,11 @@
monthly
0.7
+
+ https://rubyui.com/docs/input_otp
+ monthly
+ 0.7
+
https://rubyui.com/docs/link
monthly
diff --git a/gem/lib/ruby_ui/input_otp/input_otp.rb b/gem/lib/ruby_ui/input_otp/input_otp.rb
new file mode 100644
index 00000000..3e5e4b52
--- /dev/null
+++ b/gem/lib/ruby_ui/input_otp/input_otp.rb
@@ -0,0 +1,42 @@
+# frozen_string_literal: true
+
+module RubyUI
+ class InputOtp < Base
+ def initialize(length:, pattern: nil, **attrs)
+ @length = length
+ @char_class = pattern || "[0-9]"
+ super(**attrs)
+ end
+
+ def view_template(&block)
+ div(
+ data: {
+ controller: "ruby-ui--input-otp",
+ ruby_ui__input_otp_length_value: @length,
+ ruby_ui__input_otp_char_class_value: @char_class
+ },
+ class: "relative flex items-center has-disabled:opacity-50"
+ ) do
+ div(class: "flex items-center gap-2", &block)
+ input(**attrs)
+ end
+ end
+
+ private
+
+ def default_attrs
+ {
+ type: "text",
+ inputmode: (@char_class == "[0-9]") ? "numeric" : "text",
+ pattern: "^(?:#{@char_class}){#{@length}}$",
+ maxlength: @length,
+ autocomplete: "one-time-code",
+ data: {
+ ruby_ui__input_otp_target: "input",
+ action: "input->ruby-ui--input-otp#onInput keydown->ruby-ui--input-otp#onKeydown paste->ruby-ui--input-otp#onPaste focus->ruby-ui--input-otp#onFocus blur->ruby-ui--input-otp#onBlur"
+ },
+ class: "absolute inset-0 h-full w-full cursor-text appearance-none border-0 bg-transparent p-0 text-transparent caret-transparent shadow-none outline-none selection:bg-transparent focus:outline-none focus:ring-0 focus-visible:outline-none focus-visible:ring-0 disabled:cursor-not-allowed"
+ }
+ end
+ end
+end
diff --git a/gem/lib/ruby_ui/input_otp/input_otp_controller.js b/gem/lib/ruby_ui/input_otp/input_otp_controller.js
new file mode 100644
index 00000000..45854327
--- /dev/null
+++ b/gem/lib/ruby_ui/input_otp/input_otp_controller.js
@@ -0,0 +1,128 @@
+import { Controller } from "@hotwired/stimulus"
+
+// Connects to data-controller="ruby-ui--input-otp"
+export default class extends Controller {
+ static targets = ["input", "slot"]
+ static values = { length: Number, charClass: String }
+
+ connect() {
+ // A server-rendered value (prefilled from a previous submission, a
+ // validation error, etc.) may exceed length or contain characters that
+ // fail pattern. Sanitize it up front so the hidden slots never mask
+ // an invalid/oversized value that would otherwise still get submitted.
+ this.sanitizeValue()
+ this.paint()
+ this.boundOnSelectionChange = this.onSelectionChange.bind(this)
+ document.addEventListener("selectionchange", this.boundOnSelectionChange)
+ }
+
+ disconnect() {
+ document.removeEventListener("selectionchange", this.boundOnSelectionChange)
+ }
+
+ onInput() {
+ this.sanitizeValue()
+ const filtered = this.inputTarget.value
+
+ this.normalizeSelection()
+ this.paint()
+ this.dispatch("input", { detail: { value: filtered } })
+ if (filtered.length === this.lengthValue) {
+ this.dispatch("complete", { detail: { value: filtered } })
+ }
+ }
+
+ onFocus() {
+ const end = this.inputTarget.value.length
+ const start = Math.min(end, this.lengthValue - 1)
+ this.inputTarget.setSelectionRange(start, end)
+ this.paint()
+ }
+
+ onBlur() {
+ this.paint()
+ }
+
+ onKeydown(event) {
+ const moves = { ArrowLeft: -1, ArrowUp: -1, ArrowRight: 1, ArrowDown: 1 }
+ if (!(event.key in moves)) return
+
+ event.preventDefault()
+ const current = this.inputTarget.selectionStart ?? 0
+ const next = Math.min(Math.max(current + moves[event.key], 0), this.lengthValue - 1)
+ const hasChar = next < this.inputTarget.value.length
+ this.inputTarget.setSelectionRange(next, hasChar ? next + 1 : next)
+ this.paint()
+ }
+
+ onPaste(event) {
+ event.preventDefault()
+ const pasted = this.filter(event.clipboardData.getData("text/plain"))
+ if (!pasted) return
+
+ const start = this.inputTarget.selectionStart ?? 0
+ const end = this.inputTarget.selectionEnd ?? start
+ const current = this.inputTarget.value
+ const merged = (current.slice(0, start) + pasted + current.slice(end)).slice(0, this.lengthValue)
+
+ this.inputTarget.value = merged
+ const caret = Math.min(merged.length, this.lengthValue - 1)
+ this.inputTarget.setSelectionRange(caret, merged.length)
+
+ this.paint()
+ this.dispatch("input", { detail: { value: merged } })
+ if (merged.length === this.lengthValue) this.dispatch("complete", { detail: { value: merged } })
+ }
+
+ onSelectionChange() {
+ if (document.activeElement !== this.inputTarget) return
+ this.paint()
+ }
+
+ // After typing, replacing, or deleting, the browser leaves a collapsed
+ // caret. If it landed on a slot that already has a character (not the
+ // true insert-mode end of the value), re-select that character as a
+ // 1-char range so the next keystroke replaces it instead of being
+ // silently dropped by the native maxlength/no-selection behavior.
+ normalizeSelection() {
+ const input = this.inputTarget
+ const value = input.value
+ const s = input.selectionStart
+ const e = input.selectionEnd
+ if (s === null || e === null || s !== e) return
+
+ const isInsertMode = s === value.length && value.length < this.lengthValue
+ if (isInsertMode) return
+
+ const index = Math.min(s, this.lengthValue - 1)
+ input.setSelectionRange(index, index < value.length ? index + 1 : index)
+ }
+
+ filter(raw) {
+ const re = new RegExp(this.charClassValue)
+ return raw.split("").filter((char) => re.test(char)).join("")
+ }
+
+ sanitizeValue() {
+ const filtered = this.filter(this.inputTarget.value).slice(0, this.lengthValue)
+ if (filtered !== this.inputTarget.value) this.inputTarget.value = filtered
+ }
+
+ paint() {
+ const value = this.inputTarget.value
+ const isFocused = document.activeElement === this.inputTarget
+ const start = this.inputTarget.selectionStart ?? value.length
+ const end = this.inputTarget.selectionEnd ?? value.length
+ const activeIndex = Math.min(start, this.lengthValue - 1)
+
+ this.slotTargets.forEach((slot) => {
+ const index = Number(slot.dataset.index)
+ const char = value[index] ?? ""
+ const isActive = isFocused && ((start === end && index === activeIndex) || (index >= start && index < end))
+
+ slot.textContent = char
+ slot.dataset.active = isActive ? "true" : "false"
+ slot.dataset.caret = isActive && char === "" ? "true" : "false"
+ })
+ }
+}
diff --git a/gem/lib/ruby_ui/input_otp/input_otp_docs.rb b/gem/lib/ruby_ui/input_otp/input_otp_docs.rb
new file mode 100644
index 00000000..a7fa5b0f
--- /dev/null
+++ b/gem/lib/ruby_ui/input_otp/input_otp_docs.rb
@@ -0,0 +1,213 @@
+# frozen_string_literal: true
+
+class Views::Docs::InputOtp < Views::Base
+ def view_template
+ component = "InputOtp"
+
+ div(class: "max-w-2xl mx-auto w-full py-10 space-y-10") do
+ render Docs::Header.new(title: "Input OTP", description: "Accessible one-time-password input with keyboard navigation and paste support.")
+
+ Heading(level: 2) { "Usage" }
+
+ render Docs::VisualCodeExample.new(title: "Example", context: self) do
+ <<~RUBY
+ InputOtp(length: 6, name: "otp") do
+ InputOtpGroup do
+ InputOtpSlot(index: 0)
+ InputOtpSlot(index: 1)
+ InputOtpSlot(index: 2)
+ InputOtpSlot(index: 3)
+ InputOtpSlot(index: 4)
+ InputOtpSlot(index: 5)
+ end
+ end
+ RUBY
+ end
+
+ Heading(level: 2) { "Composition" }
+
+ Text { "InputOtpGroup and InputOtpSeparator compose freely — split slots into however many groups make sense, with a separator between each." }
+
+ render Docs::VisualCodeExample.new(title: "Example", context: self) do
+ <<~RUBY
+ InputOtp(length: 6, name: "otp") do
+ InputOtpGroup do
+ InputOtpSlot(index: 0)
+ InputOtpSlot(index: 1)
+ end
+ InputOtpSeparator()
+ InputOtpGroup do
+ InputOtpSlot(index: 2)
+ InputOtpSlot(index: 3)
+ end
+ InputOtpSeparator()
+ InputOtpGroup do
+ InputOtpSlot(index: 4)
+ InputOtpSlot(index: 5)
+ end
+ end
+ RUBY
+ end
+
+ Heading(level: 2) { "Pattern" }
+
+ Text { "Pass pattern: with a single-character regex class to define what InputOtp accepts. The default is \"[0-9]\" (digits only)." }
+
+ render Docs::VisualCodeExample.new(title: "Alphanumeric", context: self) do
+ <<~RUBY
+ InputOtp(length: 6, name: "otp", pattern: "[0-9A-Za-z]") do
+ InputOtpGroup do
+ InputOtpSlot(index: 0)
+ InputOtpSlot(index: 1)
+ InputOtpSlot(index: 2)
+ InputOtpSlot(index: 3)
+ InputOtpSlot(index: 4)
+ InputOtpSlot(index: 5)
+ end
+ end
+ RUBY
+ end
+
+ Heading(level: 2) { "Four digits" }
+
+ Text { "A common pattern for PIN codes — just pass a shorter length." }
+
+ render Docs::VisualCodeExample.new(title: "Example", context: self) do
+ <<~RUBY
+ InputOtp(length: 4, name: "pin") do
+ InputOtpGroup do
+ InputOtpSlot(index: 0)
+ InputOtpSlot(index: 1)
+ InputOtpSlot(index: 2)
+ InputOtpSlot(index: 3)
+ end
+ end
+ RUBY
+ end
+
+ Heading(level: 2) { "Disabled" }
+
+ render Docs::VisualCodeExample.new(title: "Example", context: self) do
+ <<~RUBY
+ InputOtp(length: 6, name: "otp", disabled: true) do
+ InputOtpGroup do
+ InputOtpSlot(index: 0)
+ InputOtpSlot(index: 1)
+ InputOtpSlot(index: 2)
+ InputOtpSlot(index: 3)
+ InputOtpSlot(index: 4)
+ InputOtpSlot(index: 5)
+ end
+ end
+ RUBY
+ end
+
+ Heading(level: 2) { "Invalid" }
+
+ Text { "Pass aria_invalid: \"true\" to each InputOtpSlot to show an error state." }
+
+ render Docs::VisualCodeExample.new(title: "Example", context: self) do
+ <<~RUBY
+ InputOtp(length: 6, name: "otp") do
+ InputOtpGroup do
+ InputOtpSlot(index: 0, aria_invalid: "true")
+ InputOtpSlot(index: 1, aria_invalid: "true")
+ InputOtpSlot(index: 2, aria_invalid: "true")
+ InputOtpSlot(index: 3, aria_invalid: "true")
+ InputOtpSlot(index: 4, aria_invalid: "true")
+ InputOtpSlot(index: 5, aria_invalid: "true")
+ end
+ end
+ RUBY
+ end
+
+ Heading(level: 2) { "Form" }
+
+ Text { "A full example combining InputOtp with Card, Button, and InlineLink — bigger slots via class:, a resend action, and fallback links." }
+
+ render Docs::VisualCodeExample.new(title: "Verify your login", context: self) do
+ <<~RUBY
+ Card(class: "mx-auto max-w-md") do
+ CardHeader do
+ CardTitle { "Verify your login" }
+ CardDescription do
+ plain "Enter the verification code we sent to your email address: "
+ span(class: "font-medium") { "m@example.com" }
+ plain "."
+ end
+ end
+ CardContent(class: "space-y-4") do
+ div(class: "flex items-center justify-between") do
+ label(class: "text-sm font-medium") { "Verification code" }
+ Button(variant: :outline, size: :sm) do
+ svg(
+ xmlns: "http://www.w3.org/2000/svg",
+ viewbox: "0 0 24 24",
+ fill: "none",
+ stroke: "currentColor",
+ stroke_width: "2",
+ stroke_linecap: "round",
+ stroke_linejoin: "round",
+ class: "w-4 h-4 mr-2"
+ ) do |s|
+ s.path(d: "M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8")
+ s.path(d: "M21 3v5h-5")
+ s.path(d: "M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16")
+ s.path(d: "M8 16H3v5")
+ end
+ plain "Resend Code"
+ end
+ end
+ InputOtp(length: 6, name: "otp", required: true) do
+ InputOtpGroup do
+ InputOtpSlot(index: 0, class: "h-12 w-11 text-xl")
+ InputOtpSlot(index: 1, class: "h-12 w-11 text-xl")
+ InputOtpSlot(index: 2, class: "h-12 w-11 text-xl")
+ end
+ InputOtpSeparator(class: "mx-2")
+ InputOtpGroup do
+ InputOtpSlot(index: 3, class: "h-12 w-11 text-xl")
+ InputOtpSlot(index: 4, class: "h-12 w-11 text-xl")
+ InputOtpSlot(index: 5, class: "h-12 w-11 text-xl")
+ end
+ end
+ InlineLink(href: "#") { "I no longer have access to this email address." }
+ end
+ CardFooter(class: "flex flex-col items-stretch gap-2") do
+ Button(class: "w-full") { "Verify" }
+ Text(size: "sm", weight: "muted") do
+ plain "Having trouble signing in? "
+ InlineLink(href: "#") { "Contact support" }
+ end
+ end
+ end
+ RUBY
+ end
+
+ Heading(level: 2) { "Reacting to completion" }
+
+ Text { "The controller dispatches a ruby-ui--input-otp:complete custom event (detail: { value }) once the value reaches length characters, and a ruby-ui--input-otp:input event on every change. Wire a Stimulus action on a parent element to react — for example, to auto-submit a form:" }
+
+ Codeblock(<<~JS, syntax: :javascript)
+ // app/javascript/controllers/otp_form_controller.js
+ import { Controller } from "@hotwired/stimulus"
+
+ export default class extends Controller {
+ submit() {
+ this.element.requestSubmit()
+ }
+ }
+ JS
+
+ Codeblock(<<~HTML, syntax: :html)
+
+ HTML
+
+ render Components::ComponentSetup::Tabs.new(component_name: component)
+
+ render Docs::ComponentsTable.new(component_files(component))
+ end
+ end
+end
diff --git a/gem/lib/ruby_ui/input_otp/input_otp_group.rb b/gem/lib/ruby_ui/input_otp/input_otp_group.rb
new file mode 100644
index 00000000..0fc08855
--- /dev/null
+++ b/gem/lib/ruby_ui/input_otp/input_otp_group.rb
@@ -0,0 +1,15 @@
+# frozen_string_literal: true
+
+module RubyUI
+ class InputOtpGroup < Base
+ def view_template(&block)
+ div(**attrs, &block)
+ end
+
+ private
+
+ def default_attrs
+ {class: "flex items-center"}
+ end
+ end
+end
diff --git a/gem/lib/ruby_ui/input_otp/input_otp_separator.rb b/gem/lib/ruby_ui/input_otp/input_otp_separator.rb
new file mode 100644
index 00000000..88ce0db7
--- /dev/null
+++ b/gem/lib/ruby_ui/input_otp/input_otp_separator.rb
@@ -0,0 +1,39 @@
+# frozen_string_literal: true
+
+module RubyUI
+ class InputOtpSeparator < Base
+ def view_template(&block)
+ div(**attrs) do
+ if block
+ block.call
+ else
+ icon
+ end
+ end
+ end
+
+ def icon
+ svg(
+ xmlns: "http://www.w3.org/2000/svg",
+ viewbox: "0 0 24 24",
+ fill: "none",
+ stroke: "currentColor",
+ stroke_width: "2",
+ stroke_linecap: "round",
+ stroke_linejoin: "round",
+ class: "h-4 w-4"
+ ) do |s|
+ s.path(d: "M5 12h14")
+ end
+ end
+
+ private
+
+ def default_attrs
+ {
+ role: "separator",
+ class: "text-muted-foreground"
+ }
+ end
+ end
+end
diff --git a/gem/lib/ruby_ui/input_otp/input_otp_slot.rb b/gem/lib/ruby_ui/input_otp/input_otp_slot.rb
new file mode 100644
index 00000000..b1d78b7f
--- /dev/null
+++ b/gem/lib/ruby_ui/input_otp/input_otp_slot.rb
@@ -0,0 +1,33 @@
+# frozen_string_literal: true
+
+module RubyUI
+ class InputOtpSlot < Base
+ def initialize(index:, **attrs)
+ @index = index
+ super(**attrs)
+ end
+
+ def view_template
+ div(**attrs)
+ end
+
+ private
+
+ def default_attrs
+ {
+ aria_hidden: "true",
+ data: {
+ ruby_ui__input_otp_target: "slot",
+ index: @index
+ },
+ class: [
+ "relative flex h-9 w-9 items-center justify-center border-y border-r border-input text-sm shadow-xs transition-all",
+ "first:rounded-l-md first:border-l last:rounded-r-md",
+ "data-[active=true]:z-10 data-[active=true]:border-ring data-[active=true]:ring-2 data-[active=true]:ring-ring/50",
+ "data-[caret=true]:after:content-[''] data-[caret=true]:after:absolute data-[caret=true]:after:h-4 data-[caret=true]:after:w-px data-[caret=true]:after:animate-caret-blink data-[caret=true]:after:bg-foreground",
+ "aria-invalid:border-destructive data-[active=true]:aria-invalid:border-destructive data-[active=true]:aria-invalid:ring-destructive/20 dark:data-[active=true]:aria-invalid:ring-destructive/40"
+ ]
+ }
+ end
+ end
+end
diff --git a/gem/test/ruby_ui/input_otp_test.rb b/gem/test/ruby_ui/input_otp_test.rb
new file mode 100644
index 00000000..f4e5c4d3
--- /dev/null
+++ b/gem/test/ruby_ui/input_otp_test.rb
@@ -0,0 +1,97 @@
+# frozen_string_literal: true
+
+require "test_helper"
+
+class RubyUI::InputOtpTest < ComponentTest
+ def test_render_wires_stimulus_controller_and_values
+ output = phlex { RubyUI.InputOtp(length: 6, name: "otp") }
+
+ assert_match(/data-controller="ruby-ui--input-otp"/, output)
+ assert_match(/data-ruby-ui--input-otp-length-value="6"/, output)
+ assert_match(/data-ruby-ui--input-otp-char-class-value="\[0-9\]"/, output)
+ end
+
+ def test_render_forwards_form_attrs_to_real_input
+ output = phlex { RubyUI.InputOtp(length: 6, name: "otp", value: "123") }
+
+ assert_match(/name="otp"/, output)
+ assert_match(/value="123"/, output)
+ assert_match(/maxlength="6"/, output)
+ assert_match(/autocomplete="one-time-code"/, output)
+ end
+
+ def test_render_defaults_to_numeric_inputmode_and_digit_pattern
+ output = phlex { RubyUI.InputOtp(length: 4, name: "otp") }
+
+ assert_match(/inputmode="numeric"/, output)
+ assert_match(/pattern="\^\(\?:\[0-9\]\)\{4\}\$"/, output)
+ end
+
+ def test_render_with_custom_pattern_uses_text_inputmode
+ output = phlex { RubyUI.InputOtp(length: 4, name: "otp", pattern: "[0-9A-Za-z]") }
+
+ assert_match(/inputmode="text"/, output)
+ assert_match(/data-ruby-ui--input-otp-char-class-value="\[0-9A-Za-z\]"/, output)
+ assert_match(/pattern="\^\(\?:\[0-9A-Za-z\]\)\{4\}\$"/, output)
+ end
+
+ def test_render_yields_block_content
+ output = phlex { RubyUI.InputOtp(length: 1, name: "otp") { RubyUI.Badge { "marker" } } }
+
+ assert_match(/marker/, output)
+ end
+
+ def test_group_renders_flex_wrapper
+ output = phlex { RubyUI.InputOtpGroup { RubyUI.Badge { "marker" } } }
+
+ assert_match(/class="flex items-center"/, output)
+ assert_match(/marker/, output)
+ end
+
+ def test_separator_renders_role_with_default_icon
+ output = phlex { RubyUI.InputOtpSeparator() }
+
+ assert_match(/role="separator"/, output)
+ assert_match(/ruby-ui--input-otp#onInput keydown->ruby-ui--input-otp#onKeydown paste->ruby-ui--input-otp#onPaste focus->ruby-ui--input-otp#onFocus blur->ruby-ui--input-otp#onBlur\"\n },\n class: \"absolute inset-0 h-full w-full cursor-text appearance-none border-0 bg-transparent p-0 text-transparent caret-transparent shadow-none outline-none selection:bg-transparent focus:outline-none focus:ring-0 focus-visible:outline-none focus-visible:ring-0 disabled:cursor-not-allowed\"\n }\n end\n end\nend\n"
+ },
+ {
+ "path": "input_otp_controller.js",
+ "content": "import { Controller } from \"@hotwired/stimulus\"\n\n// Connects to data-controller=\"ruby-ui--input-otp\"\nexport default class extends Controller {\n static targets = [\"input\", \"slot\"]\n static values = { length: Number, charClass: String }\n\n connect() {\n // A server-rendered value (prefilled from a previous submission, a\n // validation error, etc.) may exceed length or contain characters that\n // fail pattern. Sanitize it up front so the hidden slots never mask\n // an invalid/oversized value that would otherwise still get submitted.\n this.sanitizeValue()\n this.paint()\n this.boundOnSelectionChange = this.onSelectionChange.bind(this)\n document.addEventListener(\"selectionchange\", this.boundOnSelectionChange)\n }\n\n disconnect() {\n document.removeEventListener(\"selectionchange\", this.boundOnSelectionChange)\n }\n\n onInput() {\n this.sanitizeValue()\n const filtered = this.inputTarget.value\n\n this.normalizeSelection()\n this.paint()\n this.dispatch(\"input\", { detail: { value: filtered } })\n if (filtered.length === this.lengthValue) {\n this.dispatch(\"complete\", { detail: { value: filtered } })\n }\n }\n\n onFocus() {\n const end = this.inputTarget.value.length\n const start = Math.min(end, this.lengthValue - 1)\n this.inputTarget.setSelectionRange(start, end)\n this.paint()\n }\n\n onBlur() {\n this.paint()\n }\n\n onKeydown(event) {\n const moves = { ArrowLeft: -1, ArrowUp: -1, ArrowRight: 1, ArrowDown: 1 }\n if (!(event.key in moves)) return\n\n event.preventDefault()\n const current = this.inputTarget.selectionStart ?? 0\n const next = Math.min(Math.max(current + moves[event.key], 0), this.lengthValue - 1)\n const hasChar = next < this.inputTarget.value.length\n this.inputTarget.setSelectionRange(next, hasChar ? next + 1 : next)\n this.paint()\n }\n\n onPaste(event) {\n event.preventDefault()\n const pasted = this.filter(event.clipboardData.getData(\"text/plain\"))\n if (!pasted) return\n\n const start = this.inputTarget.selectionStart ?? 0\n const end = this.inputTarget.selectionEnd ?? start\n const current = this.inputTarget.value\n const merged = (current.slice(0, start) + pasted + current.slice(end)).slice(0, this.lengthValue)\n\n this.inputTarget.value = merged\n const caret = Math.min(merged.length, this.lengthValue - 1)\n this.inputTarget.setSelectionRange(caret, merged.length)\n\n this.paint()\n this.dispatch(\"input\", { detail: { value: merged } })\n if (merged.length === this.lengthValue) this.dispatch(\"complete\", { detail: { value: merged } })\n }\n\n onSelectionChange() {\n if (document.activeElement !== this.inputTarget) return\n this.paint()\n }\n\n // After typing, replacing, or deleting, the browser leaves a collapsed\n // caret. If it landed on a slot that already has a character (not the\n // true insert-mode end of the value), re-select that character as a\n // 1-char range so the next keystroke replaces it instead of being\n // silently dropped by the native maxlength/no-selection behavior.\n normalizeSelection() {\n const input = this.inputTarget\n const value = input.value\n const s = input.selectionStart\n const e = input.selectionEnd\n if (s === null || e === null || s !== e) return\n\n const isInsertMode = s === value.length && value.length < this.lengthValue\n if (isInsertMode) return\n\n const index = Math.min(s, this.lengthValue - 1)\n input.setSelectionRange(index, index < value.length ? index + 1 : index)\n }\n\n filter(raw) {\n const re = new RegExp(this.charClassValue)\n return raw.split(\"\").filter((char) => re.test(char)).join(\"\")\n }\n\n sanitizeValue() {\n const filtered = this.filter(this.inputTarget.value).slice(0, this.lengthValue)\n if (filtered !== this.inputTarget.value) this.inputTarget.value = filtered\n }\n\n paint() {\n const value = this.inputTarget.value\n const isFocused = document.activeElement === this.inputTarget\n const start = this.inputTarget.selectionStart ?? value.length\n const end = this.inputTarget.selectionEnd ?? value.length\n const activeIndex = Math.min(start, this.lengthValue - 1)\n\n this.slotTargets.forEach((slot) => {\n const index = Number(slot.dataset.index)\n const char = value[index] ?? \"\"\n const isActive = isFocused && ((start === end && index === activeIndex) || (index >= start && index < end))\n\n slot.textContent = char\n slot.dataset.active = isActive ? \"true\" : \"false\"\n slot.dataset.caret = isActive && char === \"\" ? \"true\" : \"false\"\n })\n }\n}\n"
+ },
+ {
+ "path": "input_otp_group.rb",
+ "content": "# frozen_string_literal: true\n\nmodule RubyUI\n class InputOtpGroup < Base\n def view_template(&block)\n div(**attrs, &block)\n end\n\n private\n\n def default_attrs\n {class: \"flex items-center\"}\n end\n end\nend\n"
+ },
+ {
+ "path": "input_otp_separator.rb",
+ "content": "# frozen_string_literal: true\n\nmodule RubyUI\n class InputOtpSeparator < Base\n def view_template(&block)\n div(**attrs) do\n if block\n block.call\n else\n icon\n end\n end\n end\n\n def icon\n svg(\n xmlns: \"http://www.w3.org/2000/svg\",\n viewbox: \"0 0 24 24\",\n fill: \"none\",\n stroke: \"currentColor\",\n stroke_width: \"2\",\n stroke_linecap: \"round\",\n stroke_linejoin: \"round\",\n class: \"h-4 w-4\"\n ) do |s|\n s.path(d: \"M5 12h14\")\n end\n end\n\n private\n\n def default_attrs\n {\n role: \"separator\",\n class: \"text-muted-foreground\"\n }\n end\n end\nend\n"
+ },
+ {
+ "path": "input_otp_slot.rb",
+ "content": "# frozen_string_literal: true\n\nmodule RubyUI\n class InputOtpSlot < Base\n def initialize(index:, **attrs)\n @index = index\n super(**attrs)\n end\n\n def view_template\n div(**attrs)\n end\n\n private\n\n def default_attrs\n {\n aria_hidden: \"true\",\n data: {\n ruby_ui__input_otp_target: \"slot\",\n index: @index\n },\n class: [\n \"relative flex h-9 w-9 items-center justify-center border-y border-r border-input text-sm shadow-xs transition-all\",\n \"first:rounded-l-md first:border-l last:rounded-r-md\",\n \"data-[active=true]:z-10 data-[active=true]:border-ring data-[active=true]:ring-2 data-[active=true]:ring-ring/50\",\n \"data-[caret=true]:after:content-[''] data-[caret=true]:after:absolute data-[caret=true]:after:h-4 data-[caret=true]:after:w-px data-[caret=true]:after:animate-caret-blink data-[caret=true]:after:bg-foreground\",\n \"aria-invalid:border-destructive data-[active=true]:aria-invalid:border-destructive data-[active=true]:aria-invalid:ring-destructive/20 dark:data-[active=true]:aria-invalid:ring-destructive/40\"\n ]\n }\n end\n end\nend\n"
+ }
+ ],
+ "dependencies": {
+ "components": [],
+ "js_packages": [],
+ "gems": []
+ },
+ "install_command": "rails g ruby_ui:component InputOtp",
+ "docs_markdown": "# Input OTP\n\nAccessible one-time-password input with keyboard navigation and paste support.\n\n## Usage\n\n### Example\n\n```ruby\nInputOtp(length: 6, name: \"otp\") do\n InputOtpGroup do\n InputOtpSlot(index: 0)\n InputOtpSlot(index: 1)\n InputOtpSlot(index: 2)\n InputOtpSlot(index: 3)\n InputOtpSlot(index: 4)\n InputOtpSlot(index: 5)\n end\nend\n```\n\n## Composition\n\n### Example\n\n```ruby\nInputOtp(length: 6, name: \"otp\") do\n InputOtpGroup do\n InputOtpSlot(index: 0)\n InputOtpSlot(index: 1)\n end\n InputOtpSeparator()\n InputOtpGroup do\n InputOtpSlot(index: 2)\n InputOtpSlot(index: 3)\n end\n InputOtpSeparator()\n InputOtpGroup do\n InputOtpSlot(index: 4)\n InputOtpSlot(index: 5)\n end\nend\n```\n\n## Pattern\n\n### Alphanumeric\n\n```ruby\nInputOtp(length: 6, name: \"otp\", pattern: \"[0-9A-Za-z]\") do\n InputOtpGroup do\n InputOtpSlot(index: 0)\n InputOtpSlot(index: 1)\n InputOtpSlot(index: 2)\n InputOtpSlot(index: 3)\n InputOtpSlot(index: 4)\n InputOtpSlot(index: 5)\n end\nend\n```\n\n## Four digits\n\n### Example\n\n```ruby\nInputOtp(length: 4, name: \"pin\") do\n InputOtpGroup do\n InputOtpSlot(index: 0)\n InputOtpSlot(index: 1)\n InputOtpSlot(index: 2)\n InputOtpSlot(index: 3)\n end\nend\n```\n\n## Disabled\n\n### Example\n\n```ruby\nInputOtp(length: 6, name: \"otp\", disabled: true) do\n InputOtpGroup do\n InputOtpSlot(index: 0)\n InputOtpSlot(index: 1)\n InputOtpSlot(index: 2)\n InputOtpSlot(index: 3)\n InputOtpSlot(index: 4)\n InputOtpSlot(index: 5)\n end\nend\n```\n\n## Invalid\n\n### Example\n\n```ruby\nInputOtp(length: 6, name: \"otp\") do\n InputOtpGroup do\n InputOtpSlot(index: 0, aria_invalid: \"true\")\n InputOtpSlot(index: 1, aria_invalid: \"true\")\n InputOtpSlot(index: 2, aria_invalid: \"true\")\n InputOtpSlot(index: 3, aria_invalid: \"true\")\n InputOtpSlot(index: 4, aria_invalid: \"true\")\n InputOtpSlot(index: 5, aria_invalid: \"true\")\n end\nend\n```\n\n## Form\n\n### Verify your login\n\n```ruby\nCard(class: \"mx-auto max-w-md\") do\n CardHeader do\n CardTitle { \"Verify your login\" }\n CardDescription do\n plain \"Enter the verification code we sent to your email address: \"\n span(class: \"font-medium\") { \"m@example.com\" }\n plain \".\"\n end\n end\n CardContent(class: \"space-y-4\") do\n div(class: \"flex items-center justify-between\") do\n label(class: \"text-sm font-medium\") { \"Verification code\" }\n Button(variant: :outline, size: :sm) do\n svg(\n xmlns: \"http://www.w3.org/2000/svg\",\n viewbox: \"0 0 24 24\",\n fill: \"none\",\n stroke: \"currentColor\",\n stroke_width: \"2\",\n stroke_linecap: \"round\",\n stroke_linejoin: \"round\",\n class: \"w-4 h-4 mr-2\"\n ) do |s|\n s.path(d: \"M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8\")\n s.path(d: \"M21 3v5h-5\")\n s.path(d: \"M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16\")\n s.path(d: \"M8 16H3v5\")\n end\n plain \"Resend Code\"\n end\n end\n InputOtp(length: 6, name: \"otp\", required: true) do\n InputOtpGroup do\n InputOtpSlot(index: 0, class: \"h-12 w-11 text-xl\")\n InputOtpSlot(index: 1, class: \"h-12 w-11 text-xl\")\n InputOtpSlot(index: 2, class: \"h-12 w-11 text-xl\")\n end\n InputOtpSeparator(class: \"mx-2\")\n InputOtpGroup do\n InputOtpSlot(index: 3, class: \"h-12 w-11 text-xl\")\n InputOtpSlot(index: 4, class: \"h-12 w-11 text-xl\")\n InputOtpSlot(index: 5, class: \"h-12 w-11 text-xl\")\n end\n end\n InlineLink(href: \"#\") { \"I no longer have access to this email address.\" }\n end\n CardFooter(class: \"flex flex-col items-stretch gap-2\") do\n Button(class: \"w-full\") { \"Verify\" }\n Text(size: \"sm\", weight: \"muted\") do\n plain \"Having trouble signing in? \"\n InlineLink(href: \"#\") { \"Contact support\" }\n end\n end\nend\n```\n\n## Reacting to completion",
+ "examples": [
+ {
+ "title": "Example",
+ "code": "InputOtp(length: 6, name: \"otp\") do\n InputOtpGroup do\n InputOtpSlot(index: 0)\n InputOtpSlot(index: 1)\n InputOtpSlot(index: 2)\n InputOtpSlot(index: 3)\n InputOtpSlot(index: 4)\n InputOtpSlot(index: 5)\n end\nend\n",
+ "language": "ruby"
+ },
+ {
+ "title": "Example",
+ "code": "InputOtp(length: 6, name: \"otp\") do\n InputOtpGroup do\n InputOtpSlot(index: 0)\n InputOtpSlot(index: 1)\n end\n InputOtpSeparator()\n InputOtpGroup do\n InputOtpSlot(index: 2)\n InputOtpSlot(index: 3)\n end\n InputOtpSeparator()\n InputOtpGroup do\n InputOtpSlot(index: 4)\n InputOtpSlot(index: 5)\n end\nend\n",
+ "language": "ruby"
+ },
+ {
+ "title": "Alphanumeric",
+ "code": "InputOtp(length: 6, name: \"otp\", pattern: \"[0-9A-Za-z]\") do\n InputOtpGroup do\n InputOtpSlot(index: 0)\n InputOtpSlot(index: 1)\n InputOtpSlot(index: 2)\n InputOtpSlot(index: 3)\n InputOtpSlot(index: 4)\n InputOtpSlot(index: 5)\n end\nend\n",
+ "language": "ruby"
+ },
+ {
+ "title": "Example",
+ "code": "InputOtp(length: 4, name: \"pin\") do\n InputOtpGroup do\n InputOtpSlot(index: 0)\n InputOtpSlot(index: 1)\n InputOtpSlot(index: 2)\n InputOtpSlot(index: 3)\n end\nend\n",
+ "language": "ruby"
+ },
+ {
+ "title": "Example",
+ "code": "InputOtp(length: 6, name: \"otp\", disabled: true) do\n InputOtpGroup do\n InputOtpSlot(index: 0)\n InputOtpSlot(index: 1)\n InputOtpSlot(index: 2)\n InputOtpSlot(index: 3)\n InputOtpSlot(index: 4)\n InputOtpSlot(index: 5)\n end\nend\n",
+ "language": "ruby"
+ },
+ {
+ "title": "Example",
+ "code": "InputOtp(length: 6, name: \"otp\") do\n InputOtpGroup do\n InputOtpSlot(index: 0, aria_invalid: \"true\")\n InputOtpSlot(index: 1, aria_invalid: \"true\")\n InputOtpSlot(index: 2, aria_invalid: \"true\")\n InputOtpSlot(index: 3, aria_invalid: \"true\")\n InputOtpSlot(index: 4, aria_invalid: \"true\")\n InputOtpSlot(index: 5, aria_invalid: \"true\")\n end\nend\n",
+ "language": "ruby"
+ },
+ {
+ "title": "Verify your login",
+ "code": "Card(class: \"mx-auto max-w-md\") do\n CardHeader do\n CardTitle { \"Verify your login\" }\n CardDescription do\n plain \"Enter the verification code we sent to your email address: \"\n span(class: \"font-medium\") { \"m@example.com\" }\n plain \".\"\n end\n end\n CardContent(class: \"space-y-4\") do\n div(class: \"flex items-center justify-between\") do\n label(class: \"text-sm font-medium\") { \"Verification code\" }\n Button(variant: :outline, size: :sm) do\n svg(\n xmlns: \"http://www.w3.org/2000/svg\",\n viewbox: \"0 0 24 24\",\n fill: \"none\",\n stroke: \"currentColor\",\n stroke_width: \"2\",\n stroke_linecap: \"round\",\n stroke_linejoin: \"round\",\n class: \"w-4 h-4 mr-2\"\n ) do |s|\n s.path(d: \"M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8\")\n s.path(d: \"M21 3v5h-5\")\n s.path(d: \"M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16\")\n s.path(d: \"M8 16H3v5\")\n end\n plain \"Resend Code\"\n end\n end\n InputOtp(length: 6, name: \"otp\", required: true) do\n InputOtpGroup do\n InputOtpSlot(index: 0, class: \"h-12 w-11 text-xl\")\n InputOtpSlot(index: 1, class: \"h-12 w-11 text-xl\")\n InputOtpSlot(index: 2, class: \"h-12 w-11 text-xl\")\n end\n InputOtpSeparator(class: \"mx-2\")\n InputOtpGroup do\n InputOtpSlot(index: 3, class: \"h-12 w-11 text-xl\")\n InputOtpSlot(index: 4, class: \"h-12 w-11 text-xl\")\n InputOtpSlot(index: 5, class: \"h-12 w-11 text-xl\")\n end\n end\n InlineLink(href: \"#\") { \"I no longer have access to this email address.\" }\n end\n CardFooter(class: \"flex flex-col items-stretch gap-2\") do\n Button(class: \"w-full\") { \"Verify\" }\n Text(size: \"sm\", weight: \"muted\") do\n plain \"Having trouble signing in? \"\n InlineLink(href: \"#\") { \"Contact support\" }\n end\n end\nend\n",
+ "language": "ruby"
+ }
+ ]
+ },
"link": {
"name": "Link",
"description": "Displays a link that looks like a button or underline link.",