{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "input-otp",
  "type": "registry:component",
  "title": "One-time code",
  "description": "Collects a one-time code in one cell per character. Supports auto-advance, backspace, and paste.",
  "registryDependencies": [
    "https://vlak.dev/r/vlak-base.json",
    "https://vlak.dev/r/inter.json",
    "https://vlak.dev/r/vlak-lib.json",
    "https://vlak.dev/r/field.json"
  ],
  "dependencies": [
    "@stylexjs/stylex"
  ],
  "devDependencies": [
    "@stylexjs/babel-plugin"
  ],
  "docs": "Vlak leaves are StyleX. Compile them with @stylexjs/babel-plugin (Vite: @stylexjs/unplugin, Next: @stylexjs/nextjs-plugin). If you would rather not run a compiler, import @noorddev/vlak-react instead: it ships precompiled with one stylesheet.",
  "files": [
    {
      "path": "vlak/input-otp.tsx",
      "content": "\"use client\";\n\nimport * as React from \"react\";\nimport * as stylex from \"@stylexjs/stylex\";\nimport { vlak, mq } from \"./tokens.stylex\";\nimport { rs } from \"./rs\";\nimport { useFieldControl } from \"./field\";\n\nexport interface InputOTPProps extends Omit<React.HTMLAttributes<HTMLDivElement>, \"onChange\" | \"defaultValue\"> {\n  length?: number;\n  value?: string;\n  defaultValue?: string;\n  onValueChange?: (code: string) => void;\n  name?: string;\n  disabled?: boolean;\n  readOnly?: boolean;\n  /** @deprecated Use onValueChange. */\n  onChange?: (code: string) => void;\n  /** Called once every cell is filled. */\n  onComplete?: (code: string) => void;\n  \"aria-label\"?: string;\n}\n\nconst styles = stylex.create({\n  otp: {\n    display: \"flex\",\n    gap: \"0.5rem\",\n    width: {\n      default: null,\n      [mq.phone]: \"100%\",\n    },\n  },\n  cell: {\n    boxSizing: \"border-box\",\n    width: {\n      default: vlak.hit,\n      [mq.phone]: \"auto\",\n    },\n    flexGrow: {\n      default: null,\n      [mq.phone]: 1,\n    },\n    minWidth: {\n      default: vlak.hit,\n      [mq.phone]: vlak.hit,\n    },\n    height: {\n      default: \"3rem\",\n      [mq.phone]: vlak.hit,\n    },\n    minHeight: {\n      default: null,\n      [mq.phone]: vlak.hit,\n    },\n    textAlign: \"center\",\n    fontSize: \"1.125rem\",\n    fontWeight: 600,\n    fontVariantNumeric: \"tabular-nums\",\n    appearance: \"none\",\n    WebkitAppearance: \"none\",\n    color: \"var(--text)\",\n    caretColor: \"var(--text)\",\n    backgroundColor: \"var(--bg)\",\n    borderWidth: vlak.hairline,\n    borderStyle: \"solid\",\n    borderColor: {\n      default: vlak.controlBorder,\n      \":focus\": vlak.accent,\n    },\n    borderRadius: {\n      default: vlak.radiusSm,\n      [mq.phone]: vlak.radiusSm,\n    },\n    outlineWidth: {\n      default: 0,\n      \":focus-visible\": 2,\n    },\n    outlineStyle: {\n      default: \"none\",\n      \":focus-visible\": \"solid\",\n    },\n    outlineColor: vlak.ink,\n    outlineOffset: 2,\n    fontFamily: \"inherit\",\n    padding: 0,\n    \":-webkit-autofill\": {\n      WebkitTextFillColor: \"var(--text)\",\n      caretColor: \"var(--text)\",\n      backgroundColor: \"var(--bg)\",\n      boxShadow: \"inset 0 0 0 1000px var(--bg)\",\n    },\n  },\n  invalid: {\n    borderColor: vlak.ink,\n  },\n});\n\n/** One cell per character. Auto-advance, backspace, paste. */\nexport const InputOTP = React.forwardRef<HTMLDivElement, InputOTPProps>(function InputOTP({\n  length: requestedLength = 6,\n  value,\n  defaultValue = \"\",\n  onValueChange,\n  name,\n  disabled = false,\n  readOnly = false,\n  onChange,\n  onComplete,\n  className,\n  style,\n  \"aria-label\": ariaLabel = \"One-time code\",\n  ...props\n}, ref) {\n  const length = Number.isFinite(requestedLength) ? Math.max(1, Math.min(12, Math.floor(requestedLength))) : 6;\n  const normalize = (code: string) => Array.from({ length }, (_, i) => code.replace(/\\D/g, \"\")[i] ?? \"\");\n  const [inner, setChars] = React.useState<string[]>(() => normalize(defaultValue));\n  const chars = value === undefined ? Array.from({ length }, (_, i) => inner[i] ?? \"\") : normalize(value);\n  const lastComplete = React.useRef<string | undefined>(undefined);\n  const refs = React.useRef<Array<HTMLInputElement | null>>([]);\n  const field = useFieldControl(props);\n  const invalid = field.invalid;\n\n  const commit = (next: string[]) => {\n    if (disabled || readOnly) return;\n    if (value === undefined) setChars(next);\n    const code = next.join(\"\");\n    onValueChange?.(code);\n    onChange?.(code);\n    if (code.length === length && lastComplete.current !== code) onComplete?.(code);\n    lastComplete.current = code.length === length ? code : undefined;\n  };\n\n  const setAt = (index: number, value: string) => {\n    const next = [...chars];\n    next[index] = value;\n    commit(next);\n  };\n\n  const paste = (index: number, text: string) => {\n    const clean = text.replace(/\\D/g, \"\").slice(0, length - index);\n    if (!clean) return;\n    const next = [...chars];\n    for (let i = 0; i < clean.length; i++) next[index + i] = clean[i]!;\n    commit(next);\n    refs.current[Math.min(index + clean.length, length - 1)]?.focus();\n  };\n\n  const sx = rs([\"rs-otp\", className], styles.otp);\n  const cell = rs([\"rs-otp-cell\", invalid && \"rs-otp-cell-invalid\"], styles.cell, invalid && styles.invalid);\n\n  return (\n    <div\n      ref={ref}\n      className={sx.className}\n      style={{ ...sx.style, ...style }}\n      role=\"group\"\n      aria-label={ariaLabel}\n      {...props}\n      aria-describedby={field[\"aria-describedby\"]}\n    >\n      {name && <input type=\"hidden\" name={name} value={chars.join(\"\")} disabled={disabled} />}\n      {chars.map((char, index) => (\n        <input\n          key={index}\n          ref={(el) => {\n            refs.current[index] = el;\n          }}\n          inputMode=\"numeric\"\n          disabled={disabled}\n          readOnly={readOnly}\n          autoComplete={index === 0 ? \"one-time-code\" : \"off\"}\n          maxLength={1}\n          value={char}\n          aria-label={`Digit ${index + 1}`}\n          aria-invalid={field[\"aria-invalid\"]}\n          className={cell.className}\n          style={cell.style}\n          onFocus={(e) => e.currentTarget.select()}\n          onChange={(e) => {\n            const value = e.target.value.replace(/\\D/g, \"\");\n            if (value.length > 1) {\n              paste(index, value);\n              return;\n            }\n            setAt(index, value);\n            if (value) refs.current[index + 1]?.focus();\n          }}\n          onKeyDown={(e) => {\n            if (readOnly || disabled) return;\n            if (e.key === \"Backspace\" && !chars[index] && index > 0) {\n              e.preventDefault();\n              refs.current[index - 1]?.focus();\n              setAt(index - 1, \"\");\n            }\n            if (e.key === \"ArrowLeft\") refs.current[index - 1]?.focus();\n            if (e.key === \"ArrowRight\") refs.current[index + 1]?.focus();\n          }}\n          onPaste={(e) => {\n            e.preventDefault();\n            paste(index, e.clipboardData.getData(\"text\"));\n          }}\n        />\n      ))}\n    </div>\n  );\n});\n",
      "type": "registry:component",
      "target": "components/vlak/input-otp.tsx"
    },
    {
      "path": "vlak/styles/input-otp.css",
      "content": "/* ── input-otp: generated from packages/react/src/components/input-otp.tsx ── */\n.rs-otp{display:flex;gap:0.5rem}\n@media (max-width: 640px){.rs-otp{width:100%}}\n.rs-otp-cell{box-sizing:border-box;width:var(--hit);min-width:var(--hit);height:3rem;text-align:center;font-size:1.125rem;font-weight:600;font-variant-numeric:tabular-nums;appearance:none;-webkit-appearance:none;color:var(--text);caret-color:var(--text);background-color:var(--bg);border-width:1px;border-style:solid;border-color:var(--control-border);border-radius:var(--radius-sm);outline-width:0;outline-style:none;outline-color:var(--text);outline-offset:2px;font-family:inherit;padding:0}\n.rs-otp-cell:focus{border-color:var(--accent)}\n.rs-otp-cell:focus-visible{outline-width:2px;outline-style:solid}\n.rs-otp-cell:-webkit-autofill{-webkit-text-fill-color:var(--text);caret-color:var(--text);background-color:var(--bg);box-shadow:inset 0 0 0 1000px var(--bg)}\n@media (max-width: 640px){.rs-otp-cell{width:auto;flex-grow:1;min-width:var(--hit);height:var(--hit);min-height:var(--hit);border-radius:var(--radius-sm)}}\n.rs-otp-cell-invalid{border-color:var(--text)}\n",
      "type": "registry:file",
      "target": "styles/vlak/input-otp.css"
    }
  ],
  "meta": {
    "vlak": {
      "category": "forms",
      "classes": [
        "rs-otp",
        "rs-otp-cell",
        "rs-otp-cell-invalid"
      ],
      "snippet": "<div class=\"rs-otp\" role=\"group\" aria-label=\"One-time code\"><input class=\"rs-otp-cell\" maxlength=\"1\" value=\"8\" aria-label=\"Digit 1\" /><input class=\"rs-otp-cell\" maxlength=\"1\" value=\"2\" aria-label=\"Digit 2\" /><input class=\"rs-otp-cell\" maxlength=\"1\" aria-label=\"Digit 3\" /><input class=\"rs-otp-cell\" maxlength=\"1\" aria-label=\"Digit 4\" /></div>",
      "cssOnly": false,
      "registryDependencies": [
        "vlak-lib",
        "field"
      ],
      "aliases": [
        "One-time code",
        "OTP input",
        "InputOTP",
        "PIN input",
        "Verification code"
      ],
      "example": "import { InputOTP } from \"@noorddev/vlak-react\";\n\n<InputOTP length={6} aria-label=\"One-time code\" onComplete={(code) => verify(code)} />",
      "usage": {
        "use": [
          "Numeric verification codes from SMS, mail, or an authenticator.",
          "onComplete to submit as soon as the last cell fills."
        ],
        "avoid": [
          "Alphanumeric codes; the cells strip non-digits.",
          "Passwords; use Input type=\"password\"."
        ]
      },
      "keyboard": [
        {
          "keys": "Type a digit",
          "does": "Fills the cell and moves to the next"
        },
        {
          "keys": "Backspace",
          "does": "Clears the cell; on an empty cell moves back and clears the previous one"
        },
        {
          "keys": "Arrow left, Arrow right",
          "does": "Moves between cells"
        },
        {
          "keys": "Paste",
          "does": "Fills from the current cell onward"
        }
      ],
      "a11y": [
        "Renders role=\"group\" named by aria-label (\"One-time code\" by default); each cell is an <input> named \"Digit n\".",
        "inputMode=\"numeric\" and autoComplete=\"one-time-code\" on the first cell let phones offer the code.",
        "Inside Field, hint and error reach the group and cells through aria-describedby and aria-invalid.",
        "Controlled with value and onValueChange, or uncontrolled with defaultValue; name submits one hidden complete-code value. disabled and readOnly apply to every cell.",
        "Each cell has a 44px target and 4px corners. length is clamped to 1–12; onComplete fires once per distinct complete code until it changes."
      ]
    }
  }
}
