{
"files": [
{
"content": "defmodule Shadix.Components.InputOtp do\n @moduledoc \"\"\"\n A segmented one-time-code input.\n\n The shadcn/ui original wraps the `input-otp` npm library; this is a minimal\n reimplementation in pure Phoenix + a small LiveView hook. `input_otp/1` renders\n `@length` single-character text slots (`inputmode=\"numeric\"`, `maxlength=\"1\"`)\n inside a flex row plus one hidden input (`data-otp-value`) carrying the\n concatenated value for form submission.\n\n The `ShadixInputOtp` hook (assets/ts/input_otp.ts) lives on the container and\n drives the behavior JS commands can't: typing a digit advances focus to the\n next slot, `Backspace` on an empty slot moves back, arrow keys move between\n slots, paste fills across slots, and the hidden input is kept in sync (with an\n `input` event dispatched so LiveView/forms observe changes).\n \"\"\"\n use Phoenix.Component\n\n import Shadix.Cn\n\n @slot_class \"size-9 rounded-md border border-input bg-transparent text-center text-sm shadow-xs transition-[color,box-shadow] outline-none focus-visible:z-10 focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 dark:bg-input/30\"\n\n @doc \"\"\"\n Renders a segmented OTP input made of `@length` single-character slots and a\n hidden input (`name={@name}`) holding the concatenated value.\n \"\"\"\n attr(:id, :string, required: true)\n attr(:length, :integer, default: 6)\n attr(:name, :string, default: nil)\n attr(:label, :string, default: \"One-time code\")\n attr(:class, :string, default: nil)\n\n def input_otp(assigns) do\n assigns = assign(assigns, :slot_class, @slot_class)\n\n ~H\"\"\"\n <div\n id={@id}\n phx-hook=\"ShadixInputOtp\"\n data-slot=\"input-otp\"\n role=\"group\"\n aria-label={@label}\n class={cn([\"flex items-center gap-2 has-disabled:opacity-50\", @class])}\n >\n <input\n :for={i <- 0..(@length - 1)}\n type=\"text\"\n inputmode=\"numeric\"\n autocomplete={if(i == 0, do: \"one-time-code\", else: \"off\")}\n maxlength=\"1\"\n data-slot=\"input-otp-slot\"\n data-otp-index={i}\n aria-label={\"Digit #{i + 1}\"}\n tabindex={if(i == 0, do: \"0\", else: \"-1\")}\n class={@slot_class}\n />\n <input type=\"hidden\" name={@name} data-otp-value value=\"\" />\n </div>\n \"\"\"\n end\nend\n",
"path": "input_otp.ex"
}
],
"hooks": [
{
"content": "interface InputOtpHook {\n el: HTMLElement;\n mounted(): void;\n destroyed(): void;\n}\n\nexport const ShadixInputOtp = {\n mounted(this: InputOtpHook) {\n const el = this.el;\n const slots = () =>\n Array.from(el.querySelectorAll<HTMLInputElement>(\"[data-otp-index]\"));\n const hidden = el.querySelector<HTMLInputElement>(\"[data-otp-value]\");\n\n const sync = () => {\n const value = slots()\n .map((s) => s.value)\n .join(\"\");\n if (hidden && hidden.value !== value) {\n hidden.value = value;\n hidden.dispatchEvent(new Event(\"input\", { bubbles: true }));\n }\n };\n\n // Roving tabindex: only the active slot is in the tab order, so the widget\n // is a single tab stop (per the WAI-ARIA composite-widget pattern).\n const setActive = (i: number) => {\n const list = slots();\n list.forEach((s, idx) => {\n s.tabIndex = idx === i ? 0 : -1;\n });\n };\n\n const focusSlot = (i: number) => {\n const list = slots();\n if (i >= 0 && i < list.length) {\n const s = list[i];\n setActive(i);\n s.focus();\n s.select();\n }\n };\n\n const indexOf = (target: EventTarget | null) =>\n slots().indexOf(target as HTMLInputElement);\n\n const onInput = (e: Event) => {\n const target = e.target as HTMLInputElement;\n const i = indexOf(target);\n if (i < 0) return;\n // Keep only the last digit typed; ignore non-digits.\n const digits = target.value.replace(/\\D/g, \"\");\n target.value = digits.slice(-1);\n if (target.value) focusSlot(i + 1);\n sync();\n };\n\n const onKeydown = (e: KeyboardEvent) => {\n const target = e.target as HTMLInputElement;\n const i = indexOf(target);\n if (i < 0) return;\n switch (e.key) {\n case \"Backspace\":\n if (!target.value) {\n e.preventDefault();\n focusSlot(i - 1);\n const prev = slots()[i - 1];\n if (prev) {\n prev.value = \"\";\n sync();\n }\n }\n break;\n case \"ArrowLeft\":\n e.preventDefault();\n focusSlot(i - 1);\n break;\n case \"ArrowRight\":\n e.preventDefault();\n focusSlot(i + 1);\n break;\n case \"ArrowUp\":\n e.preventDefault();\n focusSlot(0);\n break;\n case \"ArrowDown\":\n e.preventDefault();\n focusSlot(slots().length - 1);\n break;\n case \"Home\":\n e.preventDefault();\n focusSlot(0);\n break;\n case \"End\":\n e.preventDefault();\n focusSlot(slots().length - 1);\n break;\n }\n };\n\n const onPaste = (e: ClipboardEvent) => {\n e.preventDefault();\n const i = Math.max(0, indexOf(e.target));\n const digits = (e.clipboardData?.getData(\"text\") ?? \"\").replace(/\\D/g, \"\");\n if (!digits) return;\n const list = slots();\n let pos = i;\n for (const ch of digits) {\n if (pos >= list.length) break;\n list[pos].value = ch;\n pos++;\n }\n focusSlot(Math.min(pos, list.length - 1));\n sync();\n };\n\n const onFocus = (e: Event) => {\n const i = indexOf(e.target);\n if (i >= 0) setActive(i);\n (e.target as HTMLInputElement).select();\n };\n\n el.addEventListener(\"input\", onInput);\n el.addEventListener(\"keydown\", onKeydown);\n el.addEventListener(\"paste\", onPaste);\n el.addEventListener(\"focus\", onFocus, true);\n\n (el as unknown as { _cleanup?: () => void })._cleanup = () => {\n el.removeEventListener(\"input\", onInput);\n el.removeEventListener(\"keydown\", onKeydown);\n el.removeEventListener(\"paste\", onPaste);\n el.removeEventListener(\"focus\", onFocus, true);\n };\n },\n\n destroyed(this: InputOtpHook) {\n (this.el as unknown as { _cleanup?: () => void })._cleanup?.();\n },\n};\n",
"name": "ShadixInputOtp",
"path": "input_otp.ts"
}
],
"name": "input_otp",
"npm_deps": [],
"registry_deps": [
"cn"
]
}