Skip to main content

priv/registry/input_otp.json

{
  "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"
  ]
}