{
  "$schema": "https://uipkge.dev/schema/registry-item.json",
  "name": "pin-input",
  "title": "Pin Input",
  "type": "registry:ui",
  "description": "One-time-code input — N separate boxes that auto-advance and accept paste. Use for SMS verification, 2FA, and short numeric codes. Length, masking, and per-slot status all configurable.",
  "categories": [
    "form"
  ],
  "files": [
    {
      "path": "packages/elements-lit/src/components/pin-input/pin-input.ts",
      "content": "import { LitElement, css, html, nothing, type PropertyValues } from 'lit'\nimport { live } from 'lit/directives/live.js'\nimport { Minus } from 'lucide'\nimport { cn } from '../../lib/utils'\nimport { tailwind } from '../../lib/styles'\nimport { ThemeController } from '../../lib/theme'\nimport { icon } from '../../lib/icon'\n\ntype PinInputStatus = 'error' | 'warning' | 'success' | 'default'\ntype PinInputSize = 'sm' | 'md' | 'lg'\n\nconst sizeClassMap: Record<PinInputSize, string> = {\n  sm: 'h-8 w-8 text-sm',\n  lg: 'h-12 w-12 text-xl',\n  md: 'h-10 w-10 text-base',\n}\n\nconst statusClassMap: Record<PinInputStatus, string> = {\n  error: 'border-destructive focus-within:border-destructive focus-within:ring-destructive/40 text-destructive',\n  warning: 'border-warning focus-within:border-warning focus-within:ring-warning/40 text-warning',\n  success: 'border-success focus-within:border-success focus-within:ring-success/40 text-success',\n  default: '',\n}\n\n// React's injected @keyframes, played with the Web Animations API (no custom CSS).\nconst POP_EASE = 'cubic-bezier(0.22, 1.25, 0.36, 1)'\nconst slotPop: Keyframe[] = [\n  { transform: 'scale(1)', zIndex: 1 },\n  { transform: 'scale(1.06)', zIndex: 1, offset: 0.4 },\n  { transform: 'scale(1)', zIndex: 1 },\n]\nconst charPop: Keyframe[] = [\n  { opacity: 0.55, transform: 'scale(0.88)' },\n  { opacity: 1, transform: 'scale(1.06)', offset: 0.55 },\n  { opacity: 1, transform: 'scale(1)' },\n]\nconst shake: Keyframe[] = [\n  { transform: 'translateX(0)' },\n  { transform: 'translateX(-5px)', offset: 0.2 },\n  { transform: 'translateX(5px)', offset: 0.4 },\n  { transform: 'translateX(-3px)', offset: 0.6 },\n  { transform: 'translateX(3px)', offset: 0.8 },\n  { transform: 'translateX(0)' },\n]\n\n/** Layout marker: a run of slots (React `PinInputGroup`). Read by <uip-pin-input>, not rendered. */\nexport class UipPinInputGroup extends HTMLElement {}\n/** Layout marker: one slot (React `PinInputSlot`); `index`, optional `mask` override. */\nexport class UipPinInputSlot extends HTMLElement {}\n/** Layout marker: a separator (React `PinInputSeparator`); its children replace the default minus icon. */\nexport class UipPinInputSeparator extends HTMLElement {}\n\ntype Part =\n  | { kind: 'group'; slots: { index: number; mask?: boolean }[] }\n  | { kind: 'separator'; el: UipPinInputSeparator; custom: boolean }\n\n/**\n * <uip-pin-input> — the registry PinInput (input-otp) as ONE web component.\n *\n * One real <input> (transparent, over the slots — input-otp's model) owns the value,\n * so auto-advance, backspace, arrow keys, paste and SMS autofill (`autocomplete=one-time-code`)\n * all work, and the slots are just the visible rendering.\n *\n * Layout: with no children, one group of `max-length` slots. For React's composed layout\n * add marker children (read, not rendered — like <option> in <uip-select>):\n *\n *   <uip-pin-input max-length=\"6\">\n *     <uip-pin-input-group><uip-pin-input-slot index=\"0\"></uip-pin-input-slot>…</uip-pin-input-group>\n *     <uip-pin-input-separator></uip-pin-input-separator>\n *     <uip-pin-input-group>…</uip-pin-input-group>\n *   </uip-pin-input>\n *\n * Props: `value`, `max-length` (6), `mask`, `status`, `size` ('sm' | 'md' | 'lg'), `disabled`,\n * `pattern` (regex source), `autocomplete`, `inputmode` (native attribute; default numeric),\n * `name`, `required`.\n *\n * Events (bubble, composed): `input` and `change` on every edit (React `onChange`),\n * `complete` (detail: string) once every slot is filled (React `onComplete`).\n * Form-associated: submits `name=value`; `required` = every slot filled.\n */\nexport class UipPinInput extends LitElement {\n  static formAssociated = true\n  static shadowRootOptions: ShadowRootInit = {\n    ...LitElement.shadowRootOptions,\n    delegatesFocus: true,\n    slotAssignment: 'manual',\n  }\n  // :host display can't be a utility class (the host has no template of its own).\n  static styles = [tailwind, css`:host { display: inline-block; }`]\n\n  static properties = {\n    value: {},\n    maxLength: { type: Number, attribute: 'max-length' },\n    mask: { type: Boolean, reflect: true },\n    status: { reflect: true },\n    size: { reflect: true },\n    disabled: { type: Boolean, reflect: true },\n    pattern: {},\n    autocomplete: {},\n    name: { reflect: true },\n    required: { type: Boolean, reflect: true },\n    accessibleLabel: { attribute: 'aria-label' },\n    parts: { state: true },\n    focused: { state: true },\n    mss: { state: true },\n    mse: { state: true },\n  }\n\n  value = ''\n  maxLength = 6\n  mask = false\n  status: PinInputStatus = 'default'\n  size: PinInputSize = 'md'\n  disabled = false\n  pattern?: string\n  autocomplete?: string\n  name?: string\n  required = false\n  accessibleLabel?: string\n  private parts: Part[] = []\n  private focused = false\n  private mss: number | null = null\n  private mse: number | null = null\n\n  private defaultValue = ''\n  private popping = new Set<number>()\n  private shakeNext = false\n  private internals = this.attachInternals()\n  private childObserver?: MutationObserver\n\n  constructor() {\n    super()\n    new ThemeController(this)\n  }\n\n  connectedCallback() {\n    super.connectedCallback()\n    this.setAttribute('data-uipkge', '')\n    this.setAttribute('data-slot', 'pin-input')\n    this.defaultValue = this.value\n    this.readParts()\n    this.childObserver = new MutationObserver(() => this.readParts())\n    this.childObserver.observe(this, { childList: true, subtree: true, attributes: true, attributeFilter: ['index', 'mask'] })\n  }\n\n  disconnectedCallback() {\n    super.disconnectedCallback()\n    this.childObserver?.disconnect()\n  }\n\n  private readParts() {\n    const parts: Part[] = []\n    for (const el of this.children) {\n      if (el.localName === 'uip-pin-input-group') {\n        parts.push({\n          kind: 'group',\n          slots: [...el.querySelectorAll('uip-pin-input-slot')].map((s) => ({\n            index: Number(s.getAttribute('index') ?? 0),\n            mask: s.hasAttribute('mask') ? s.getAttribute('mask') !== 'false' : undefined,\n          })),\n        })\n      } else if (el.localName === 'uip-pin-input-separator') {\n        parts.push({ kind: 'separator', el: el as UipPinInputSeparator, custom: el.childNodes.length > 0 })\n      }\n    }\n    this.parts = parts\n  }\n\n  private get layout(): Part[] {\n    if (this.parts.some((p) => p.kind === 'group')) return this.parts\n    return [{ kind: 'group', slots: Array.from({ length: this.maxLength }, (_, index) => ({ index })) }]\n  }\n\n  private get input() {\n    return this.renderRoot?.querySelector<HTMLInputElement>('input')\n  }\n\n  protected willUpdate(changed: PropertyValues<this>) {\n    if (changed.has('value')) {\n      const prev = (changed.get('value') as string | undefined) ?? ''\n      if (this.value.length > this.maxLength) this.value = this.value.slice(0, this.maxLength)\n      if (this.hasUpdated) {\n        for (let i = 0; i < this.value.length; i++) if (this.value[i] !== prev[i]) this.popping.add(i)\n      }\n    }\n    // One-shot shake when status transitions into error (not on mount).\n    if (changed.has('status') && this.hasUpdated && this.status === 'error' && changed.get('status') !== 'error') {\n      this.shakeNext = true\n    }\n    if (changed.has('value') || changed.has('required') || changed.has('maxLength')) {\n      this.internals.setFormValue(this.value || null)\n      if (this.required && !this.value) {\n        this.internals.setValidity({ valueMissing: true }, 'Please fill in this field.', this.input ?? undefined)\n      } else if (this.required && this.value.length < this.maxLength) {\n        this.internals.setValidity({ tooShort: true }, `Please enter all ${this.maxLength} characters.`, this.input ?? undefined)\n      } else this.internals.setValidity({})\n    }\n  }\n\n  protected updated() {\n    const custom = this.renderRoot.querySelectorAll<HTMLSlotElement>('slot[data-separator]')\n    const seps = this.layout.filter((p): p is Extract<Part, { kind: 'separator' }> => p.kind === 'separator' && p.custom)\n    custom.forEach((slot, i) => seps[i] && slot.assign(seps[i].el))\n\n    const reduce = matchMedia('(prefers-reduced-motion: reduce)').matches\n    if (this.popping.size && !reduce) {\n      for (const i of this.popping) {\n        const slot = this.renderRoot.querySelector<HTMLElement>(`[data-slot=pin-input-slot][data-index=\"${i}\"]`)\n        slot?.animate(slotPop, { duration: 200, easing: POP_EASE })\n        slot?.firstElementChild?.animate(charPop, { duration: 200, easing: POP_EASE })\n      }\n    }\n    this.popping.clear()\n    if (this.shakeNext && !reduce) {\n      this.renderRoot\n        .querySelector('[data-input-otp-container]')\n        ?.animate(shake, { duration: 380, easing: 'cubic-bezier(0.36, 0.07, 0.19, 0.97)' })\n    }\n    this.shakeNext = false\n  }\n\n  // --- form callbacks -------------------------------------------------------\n  formResetCallback() {\n    this.value = this.defaultValue\n  }\n  formDisabledCallback(disabled: boolean) {\n    this.disabled = disabled\n  }\n\n  // --- selection model (input-otp): one character selected, or the caret after the last ---\n  private select(p: number) {\n    const input = this.input\n    if (!input) return\n    const len = this.value.length\n    const max = this.maxLength\n    let start: number\n    let end: number\n    if (len >= max && p >= max - 1) [start, end] = [max - 1, max]\n    else if (p < len) [start, end] = [Math.max(0, p), Math.max(0, p) + 1]\n    else [start, end] = [len, len]\n    input.setSelectionRange(start, end)\n    this.mss = start\n    this.mse = end\n  }\n\n  private commit(next: string) {\n    if (next === this.value) return\n    this.value = next\n    this.dispatchEvent(new Event('input', { bubbles: true, composed: true }))\n    this.dispatchEvent(new Event('change', { bubbles: true, composed: true }))\n    if (next.length === this.maxLength) {\n      this.dispatchEvent(new CustomEvent('complete', { detail: next, bubbles: true, composed: true }))\n    }\n  }\n\n  private fitsPattern(v: string) {\n    if (!this.pattern || !v.length) return true\n    try {\n      return new RegExp(this.pattern).test(v)\n    } catch {\n      return true\n    }\n  }\n\n  private onInput(e: Event) {\n    // The inner input's own `input` event is composed; re-dispatch ours instead.\n    e.stopPropagation()\n    const input = e.target as HTMLInputElement\n    const next = input.value.slice(0, this.maxLength)\n    if (!this.fitsPattern(next)) {\n      input.value = this.value\n      this.select(this.mss ?? this.value.length)\n      return\n    }\n    this.commit(next)\n    this.select(input.selectionStart ?? next.length)\n  }\n\n  private onPaste(e: ClipboardEvent) {\n    const input = e.target as HTMLInputElement\n    const content = e.clipboardData?.getData('text/plain') ?? ''\n    e.preventDefault()\n    const start = input.selectionStart ?? this.value.length\n    const end = input.selectionEnd ?? start\n    const v = this.value\n    const next = (start !== end ? v.slice(0, start) + content + v.slice(end) : v.slice(0, start) + content + v.slice(start)).slice(\n      0,\n      this.maxLength,\n    )\n    if (!this.fitsPattern(next)) return\n    input.value = next\n    this.commit(next)\n    this.select(next.length)\n  }\n\n  private onKeyDown(e: KeyboardEvent) {\n    const start = this.mss ?? 0\n    const len = this.value.length\n    let p: number\n    switch (e.key) {\n      case 'ArrowLeft':\n        p = start - 1\n        break\n      case 'ArrowRight':\n        p = start + 1\n        break\n      case 'Home':\n      case 'ArrowUp':\n        p = 0\n        break\n      case 'End':\n      case 'ArrowDown':\n        p = len\n        break\n      default:\n        return\n    }\n    e.preventDefault()\n    this.select(Math.max(0, Math.min(len, p)))\n  }\n\n  private onFocus() {\n    this.focused = true\n    this.select(this.value.length)\n  }\n\n  private onBlur() {\n    this.focused = false\n  }\n\n  private renderSlot(index: number, maskOverride?: boolean) {\n    const char = this.value[index] ?? null\n    const { mss, mse } = this\n    const isActive = this.focused && mss !== null && mse !== null && ((mss === mse && index === mss) || (index >= mss && index < mse))\n    const effectiveMask = maskOverride ?? this.mask\n    return html`<div\n      data-uipkge=\"\"\n      data-slot=\"pin-input-slot\"\n      data-index=${index}\n      ?data-active=${isActive}\n      class=${cn(\n        'border-input bg-background text-foreground relative -ml-px flex items-center justify-center border text-center shadow-xs outline-none first:ml-0 first:rounded-l-md last:rounded-r-md',\n        'transition-[border-color,box-shadow,color,transform] duration-150 ease-out',\n        'focus-within:border-ring focus-within:ring-ring/40 focus-within:relative focus-within:z-10 focus-within:ring-2',\n        'disabled:cursor-not-allowed disabled:opacity-50',\n        isActive && 'border-ring ring-ring/40 z-10 ring-2',\n        sizeClassMap[this.size] ?? sizeClassMap.md,\n        statusClassMap[this.status] ?? '',\n      )}\n    >\n      ${char != null\n        ? effectiveMask\n          ? html`<span class=\"bg-foreground size-2 rounded-full\"></span>`\n          : html`<span>${char}</span>`\n        : nothing}\n    </div>`\n  }\n\n  render() {\n    const inputMode = this.getAttribute('inputmode') ?? 'numeric'\n    return html`<div\n      part=\"container\"\n      data-input-otp-container\n      translate=\"no\"\n      class=${cn(\n        'flex items-center gap-2 has-disabled:opacity-50',\n        // input-otp's container inline styles, as utilities.\n        'relative select-none pointer-events-none',\n        this.disabled ? 'cursor-default' : 'cursor-text',\n      )}\n    >\n      ${this.layout.map((part) =>\n        part.kind === 'group'\n          ? html`<div data-uipkge=\"\" data-slot=\"pin-input-group\" class=\"flex items-center\">\n              ${part.slots.map((s) => this.renderSlot(s.index, s.mask))}\n            </div>`\n          : html`<div data-uipkge=\"\" data-slot=\"pin-input-separator\" role=\"separator\">\n              ${part.custom ? html`<slot data-separator></slot>` : icon(Minus, 'minus')}\n            </div>`,\n      )}\n      <div class=\"absolute inset-0 pointer-events-none\">\n        <input\n          part=\"input\"\n          data-uipkge=\"\"\n          data-slot=\"pin-input\"\n          data-status=${this.status === 'default' ? nothing : this.status}\n          data-input-otp=\"true\"\n          ?data-input-otp-placeholder-shown=${this.value.length === 0}\n          data-input-otp-mss=${this.mss ?? nothing}\n          data-input-otp-mse=${this.mse ?? nothing}\n          autocomplete=${this.autocomplete ?? 'one-time-code'}\n          inputmode=${inputMode}\n          pattern=${this.pattern ?? nothing}\n          maxlength=${this.maxLength}\n          spellcheck=\"false\"\n          aria-label=${this.accessibleLabel ?? nothing}\n          aria-required=${this.required ? 'true' : nothing}\n          ?disabled=${this.disabled}\n          .value=${live(this.value)}\n          class=${cn(\n            'disabled:cursor-not-allowed',\n            // input-otp's input inline styles, as utilities.\n            'absolute inset-0 flex h-full w-full border-0 border-solid border-transparent bg-transparent text-left font-mono text-[length:var(--root-height,16px)] leading-none tracking-[-.5em] text-transparent tabular-nums caret-transparent opacity-100 shadow-none outline-0 outline-transparent pointer-events-auto',\n          )}\n          @input=${this.onInput}\n          @paste=${this.onPaste}\n          @keydown=${this.onKeyDown}\n          @focus=${this.onFocus}\n          @blur=${this.onBlur}\n          @click=${() => this.select(this.value.length)}\n        />\n      </div>\n    </div>`\n  }\n}\n\ncustomElements.get('uip-pin-input-group') || customElements.define('uip-pin-input-group', UipPinInputGroup)\ncustomElements.get('uip-pin-input-slot') || customElements.define('uip-pin-input-slot', UipPinInputSlot)\ncustomElements.get('uip-pin-input-separator') || customElements.define('uip-pin-input-separator', UipPinInputSeparator)\ncustomElements.get('uip-pin-input') || customElements.define('uip-pin-input', UipPinInput)\n\ndeclare global {\n  interface HTMLElementTagNameMap {\n    'uip-pin-input': UipPinInput\n    'uip-pin-input-group': UipPinInputGroup\n    'uip-pin-input-slot': UipPinInputSlot\n    'uip-pin-input-separator': UipPinInputSeparator\n  }\n}\n",
      "type": "registry:file",
      "target": "src/uip/components/pin-input/pin-input.ts"
    }
  ],
  "dependencies": [
    "lit",
    "lucide"
  ],
  "registryDependencies": [
    "https://uipkge.dev/r/lit/lit-core.json"
  ]
}
