{
  "$schema": "https://shadcn-vue.com/schema/registry-item.json",
  "name": "masked-input",
  "title": "Masked Input",
  "type": "registry:ui",
  "files": [
    {
      "path": "packages/registry-vue/components/masked-input/MaskedInput.vue",
      "content": "<script setup lang=\"ts\">\nimport type { HTMLAttributes } from 'vue'\nimport { computed, ref, watch, nextTick } from 'vue'\nimport { cn } from '@/lib/utils'\n\nconst props = withDefaults(\n  defineProps<{\n    modelValue?: string\n    mask: string\n    replacement?: string\n    placeholderChar?: string\n    showMask?: boolean\n    disabled?: boolean\n    readonly?: boolean\n    class?: HTMLAttributes['class']\n  }>(),\n  {\n    replacement: '#',\n    placeholderChar: '_',\n    showMask: true,\n  },\n)\n\nconst emit = defineEmits<{\n  'update:modelValue': [value: string]\n  complete: [value: string]\n}>()\n\nconst inputRef = ref<HTMLInputElement>()\n\n// Positions in the mask that are editable (not separators)\nconst editablePositions = computed(() => {\n  const positions: number[] = []\n  for (let i = 0; i < props.mask.length; i++) {\n    if (props.mask[i] === props.replacement) positions.push(i)\n  }\n  return positions\n})\n\n// Total number of editable slots\nconst maxLength = computed(() => editablePositions.value.length)\n\n// Is the input fully filled?\nconst isComplete = computed(() => {\n  const raw = unmask(props.modelValue ?? '')\n  return raw.length === maxLength.value\n})\n\n// Watch for completion\nwatch(isComplete, (complete) => {\n  if (complete) emit('complete', props.modelValue ?? '')\n})\n\nfunction unmask(value: string): string {\n  let result = ''\n  for (const char of value) {\n    if (char !== props.placeholderChar && char !== ' ' && !isSeparatorChar(char)) {\n      result += char\n    }\n  }\n  return result\n}\n\nfunction isSeparatorChar(char: string): boolean {\n  // Check if char exists in mask at a non-replacement position\n  for (let i = 0; i < props.mask.length; i++) {\n    if (props.mask[i] !== props.replacement && props.mask[i] === char) {\n      return true\n    }\n  }\n  return false\n}\n\nfunction applyMask(rawValue: string): string {\n  const chars = rawValue.split('')\n  let result = ''\n  let charIndex = 0\n\n  for (let i = 0; i < props.mask.length; i++) {\n    if (props.mask[i] === props.replacement) {\n      if (charIndex < chars.length) {\n        result += chars[charIndex]\n        charIndex++\n      } else if (props.showMask) {\n        result += props.placeholderChar\n      } else {\n        break\n      }\n    } else {\n      result += props.mask[i]\n    }\n  }\n\n  return result\n}\n\nfunction getNextEditablePos(currentPos: number): number {\n  for (const pos of editablePositions.value) {\n    if (pos >= currentPos) return pos\n  }\n  return editablePositions.value[editablePositions.value.length - 1] ?? props.mask.length\n}\n\nfunction getPrevEditablePos(currentPos: number): number {\n  for (let i = editablePositions.value.length - 1; i >= 0; i--) {\n    const p = editablePositions.value[i]\n    if (p !== undefined && p < currentPos) return p\n  }\n  return editablePositions.value[0] ?? 0\n}\n\nfunction findRawIndexAtCursor(cursorPos: number): number {\n  let rawIndex = 0\n  for (let i = 0; i < cursorPos && i < props.mask.length; i++) {\n    if (props.mask[i] === props.replacement) rawIndex++\n  }\n  return rawIndex\n}\n\nfunction findCursorPosFromRaw(rawIndex: number): number {\n  let count = 0\n  for (let i = 0; i < props.mask.length; i++) {\n    if (props.mask[i] === props.replacement) {\n      if (count === rawIndex) return i\n      count++\n    }\n  }\n  return props.mask.length\n}\n\nfunction handleInput(event: Event) {\n  const target = event.target as HTMLInputElement\n  const oldValue = props.modelValue ?? ''\n  const newValue = target.value\n  const cursorPos = target.selectionStart ?? 0\n\n  // Extract raw characters from new value\n  const rawNew = unmask(newValue)\n  const rawOld = unmask(oldValue)\n\n  // Limit to max length\n  const clampedRaw = rawNew.slice(0, maxLength.value)\n\n  // Apply mask\n  const masked = applyMask(clampedRaw)\n\n  // Determine new cursor position\n  let newCursorPos: number\n\n  if (clampedRaw.length > rawOld.length) {\n    // Character added - move to next editable position\n    const addedIndex = clampedRaw.length - 1\n    newCursorPos = findCursorPosFromRaw(addedIndex) + 1\n    newCursorPos = getNextEditablePos(newCursorPos)\n  } else if (clampedRaw.length < rawOld.length) {\n    // Character removed\n    newCursorPos = getPrevEditablePos(cursorPos) + 1\n  } else {\n    // Same length, try to maintain relative position\n    newCursorPos = cursorPos\n  }\n\n  emit('update:modelValue', masked)\n\n  nextTick(() => {\n    if (inputRef.value) {\n      inputRef.value.setSelectionRange(newCursorPos, newCursorPos)\n    }\n  })\n}\n\nfunction handleKeydown(event: KeyboardEvent) {\n  const target = event.target as HTMLInputElement\n  const cursorPos = target.selectionStart ?? 0\n\n  if (event.key === 'Backspace') {\n    const raw = unmask(props.modelValue ?? '')\n    const rawIndex = findRawIndexAtCursor(cursorPos)\n\n    if (rawIndex > 0) {\n      const newRaw = raw.slice(0, rawIndex - 1) + raw.slice(rawIndex)\n      const masked = applyMask(newRaw)\n      emit('update:modelValue', masked)\n\n      const newPos = findCursorPosFromRaw(rawIndex - 1) + 1\n      nextTick(() => {\n        if (inputRef.value) {\n          inputRef.value.setSelectionRange(newPos, newPos)\n        }\n      })\n    }\n    event.preventDefault()\n  } else if (event.key === 'Delete') {\n    const raw = unmask(props.modelValue ?? '')\n    const rawIndex = findRawIndexAtCursor(cursorPos)\n\n    if (rawIndex < raw.length) {\n      const newRaw = raw.slice(0, rawIndex) + raw.slice(rawIndex + 1)\n      const masked = applyMask(newRaw)\n      emit('update:modelValue', masked)\n\n      const newPos = findCursorPosFromRaw(rawIndex) + 1\n      nextTick(() => {\n        if (inputRef.value) {\n          inputRef.value.setSelectionRange(newPos, newPos)\n        }\n      })\n    }\n    event.preventDefault()\n  } else if (event.key === 'ArrowLeft') {\n    const newPos = getPrevEditablePos(cursorPos)\n    nextTick(() => {\n      if (inputRef.value) {\n        inputRef.value.setSelectionRange(newPos + 1, newPos + 1)\n      }\n    })\n    event.preventDefault()\n  } else if (event.key === 'ArrowRight') {\n    const newPos = getNextEditablePos(cursorPos + 1)\n    nextTick(() => {\n      if (inputRef.value) {\n        inputRef.value.setSelectionRange(newPos + 1, newPos + 1)\n      }\n    })\n    event.preventDefault()\n  }\n}\n\nfunction handleFocus() {\n  if (!props.modelValue && props.showMask) {\n    emit('update:modelValue', applyMask(''))\n  }\n  nextTick(() => {\n    if (inputRef.value) {\n      const firstEditable = editablePositions.value[0] ?? 0\n      inputRef.value.setSelectionRange(firstEditable + 1, firstEditable + 1)\n    }\n  })\n}\n\nfunction handlePaste(event: ClipboardEvent) {\n  event.preventDefault()\n  const pasted = event.clipboardData?.getData('text') ?? ''\n  const raw = unmask(props.modelValue ?? '')\n  const cursorPos = inputRef.value?.selectionStart ?? 0\n  const rawIndex = findRawIndexAtCursor(cursorPos)\n  const newRaw = raw.slice(0, rawIndex) + unmask(pasted) + raw.slice(rawIndex)\n  const clamped = newRaw.slice(0, maxLength.value)\n  const masked = applyMask(clamped)\n  emit('update:modelValue', masked)\n\n  const newPos = findCursorPosFromRaw(Math.min(rawIndex + unmask(pasted).length, maxLength.value)) + 1\n  nextTick(() => {\n    if (inputRef.value) {\n      inputRef.value.setSelectionRange(newPos, newPos)\n    }\n  })\n}\n\nconst displayValue = computed(() => {\n  if (!props.modelValue && !props.showMask) return ''\n  return props.modelValue ?? ''\n})\n</script>\n\n<template>\n  <input\n    ref=\"inputRef\"\n    :value=\"displayValue\"\n    data-uipkge\n    data-slot=\"masked-input\"\n    :disabled=\"disabled\"\n    :readonly=\"readonly\"\n    :class=\"\n      cn(\n        'file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm',\n        'focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]',\n        'aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive',\n        props.class,\n      )\n    \"\n    @input=\"handleInput\"\n    @keydown=\"handleKeydown\"\n    @focus=\"handleFocus\"\n    @paste=\"handlePaste\"\n  />\n</template>\n",
      "type": "registry:ui",
      "target": "~/app/components/ui/masked-input/MaskedInput.vue"
    },
    {
      "path": "packages/registry-vue/components/masked-input/index.ts",
      "content": "export { default as MaskedInput } from './MaskedInput.vue'\n",
      "type": "registry:ui",
      "target": "~/app/components/ui/masked-input/index.ts"
    }
  ],
  "dependencies": [],
  "devDependencies": [],
  "registryDependencies": [],
  "description": "Input with a fixed format mask — phone numbers, credit cards, dates, postal codes. Pass a mask string (e.g. `(###) ###-####`) and the input enforces it as the user types. Customizable placeholder character and replacement marker.",
  "categories": [
    "form"
  ]
}