{
  "$schema": "https://uipkge.dev/schema/registry-item.json",
  "name": "masked-input",
  "title": "Masked Input",
  "type": "registry:ui",
  "files": [
    {
      "path": "packages/registry-svelte/components/masked-input/MaskedInput.svelte",
      "content": "<script lang=\"ts\" module>\n  import type { HTMLInputAttributes } from 'svelte/elements'\n\n  export type MaskTokens = Record<string, RegExp>\n\n  export interface MaskedInputValidatePayload {\n    isValid: boolean\n    isComplete: boolean\n    rawValue: string\n    maskedValue: string\n  }\n\n  export interface MaskedInputProps extends Omit<HTMLInputAttributes, 'value' | 'placeholder'> {\n    value?: string\n    mask: string\n    replacement?: string\n    tokens?: MaskTokens\n    placeholderChar?: string\n    placeholder?: string\n    showMask?: boolean\n    invalid?: boolean\n    error?: string | boolean\n    errorMessage?: string\n    validate?: (masked: string, raw: string) => boolean | string\n    onValueChange?: (value: string) => void\n    oncomplete?: (value: string) => void\n    onvalidate?: (payload: MaskedInputValidatePayload) => void\n    ref?: HTMLInputElement | null\n  }\n</script>\n\n<script lang=\"ts\">\n  import { tick } from 'svelte'\n  import { cn } from '$lib/utils'\n\n  const DEFAULT_TOKENS: MaskTokens = {\n    '#': /^[0-9]$/,\n    A: /^[a-zA-Z]$/,\n    '*': /^[a-zA-Z0-9]$/,\n  }\n\n  let {\n    class: className,\n    value = $bindable(''),\n    mask,\n    replacement = '#',\n    tokens,\n    placeholderChar = '_',\n    placeholder,\n    showMask = true,\n    disabled,\n    readonly,\n    invalid,\n    error,\n    errorMessage,\n    validate,\n    onValueChange,\n    oncomplete,\n    onvalidate,\n    oninput,\n    onkeydown,\n    onfocus,\n    onblur,\n    onpaste,\n    ref = $bindable(null),\n    ...restProps\n  }: MaskedInputProps = $props()\n\n  let inputEl: HTMLInputElement | null = $state(null)\n  let isFocused = $state(false)\n\n  $effect(() => {\n    ref = inputEl\n  })\n\n  const activeTokens = $derived<MaskTokens>({ ...DEFAULT_TOKENS, ...(tokens ?? {}) })\n\n  // Mapping of mask index -> token character (for editable slots)\n  const editableSlots = $derived.by(() => {\n    const slots: Array<{ index: number; tokenChar: string }> = []\n    for (let i = 0; i < mask.length; i++) {\n      const char = mask[i]!\n      if (char === replacement || activeTokens[char]) {\n        slots.push({ index: i, tokenChar: char === replacement ? replacement : char })\n      }\n    }\n    return slots\n  })\n\n  const editablePositions = $derived(editableSlots.map((s) => s.index))\n  const maxLength = $derived(editablePositions.length)\n\n  function getSlotPattern(slotIndex: number): RegExp {\n    const slot = editableSlots[slotIndex]\n    if (!slot) return /.*/\n    return activeTokens[slot.tokenChar] ?? activeTokens[replacement] ?? /.*/\n  }\n\n  function isValidCharForSlot(char: string, slotIndex: number): boolean {\n    return getSlotPattern(slotIndex).test(char)\n  }\n\n  function unmask(input: string): string {\n    let result = ''\n    let slotIndex = 0\n    for (const char of input) {\n      if (char === placeholderChar || char === ' ') continue\n      if (slotIndex < maxLength && isValidCharForSlot(char, slotIndex)) {\n        result += char\n        slotIndex++\n      }\n    }\n    return result\n  }\n\n  function applyMask(rawValue: string): string {\n    let result = ''\n    let rawIndex = 0\n\n    for (let i = 0; i < mask.length; i++) {\n      const isEditable = editablePositions.includes(i)\n      if (isEditable) {\n        const slotIdx = editablePositions.indexOf(i)\n        if (rawIndex < rawValue.length && isValidCharForSlot(rawValue[rawIndex]!, slotIdx)) {\n          result += rawValue[rawIndex]\n          rawIndex++\n        } else if (showMask && (isFocused || !placeholder || value)) {\n          result += placeholderChar\n        } else {\n          break\n        }\n      } else {\n        result += mask[i]\n      }\n    }\n\n    return result\n  }\n\n  const isComplete = $derived(unmask(value ?? '').length === maxLength)\n\n  const validationError = $derived.by(() => {\n    if (!validate) return null\n    const currentMasked = value ?? ''\n    const currentRaw = unmask(currentMasked)\n    const result = validate(currentMasked, currentRaw)\n    if (typeof result === 'string') return result\n    if (result === false) return 'Invalid format'\n    return null\n  })\n\n  const isInvalid = $derived(\n    Boolean(invalid || error === true || (typeof error === 'string' && error.length > 0) || validationError),\n  )\n\n  const displayErrorMessage = $derived(\n    typeof error === 'string' && error.length > 0 ? error : (errorMessage ?? validationError),\n  )\n\n  // Completion + validation notifications (mirror the Vue watchers)\n  let wasComplete = false\n  $effect(() => {\n    if (isComplete && !wasComplete) oncomplete?.(value ?? '')\n    wasComplete = isComplete\n  })\n  $effect(() => {\n    const current = value ?? ''\n    onvalidate?.({\n      isValid: !isInvalid,\n      isComplete: isComplete,\n      rawValue: unmask(current),\n      maskedValue: current,\n    })\n  })\n\n  function getNextEditablePos(currentPos: number): number {\n    for (const pos of editablePositions) {\n      if (pos >= currentPos) return pos\n    }\n    return editablePositions[editablePositions.length - 1] ?? mask.length\n  }\n\n  function getPrevEditablePos(currentPos: number): number {\n    for (let i = editablePositions.length - 1; i >= 0; i--) {\n      const p = editablePositions[i]\n      if (p !== undefined && p < currentPos) return p\n    }\n    return editablePositions[0] ?? 0\n  }\n\n  function findRawIndexAtCursor(cursorPos: number): number {\n    let rawIndex = 0\n    for (let i = 0; i < cursorPos && i < mask.length; i++) {\n      if (editablePositions.includes(i)) rawIndex++\n    }\n    return rawIndex\n  }\n\n  function findCursorPosFromRaw(rawIndex: number): number {\n    if (rawIndex >= editablePositions.length) return mask.length\n    return editablePositions[rawIndex] ?? mask.length\n  }\n\n  function updateValue(next: string) {\n    value = next\n    onValueChange?.(next)\n  }\n\n  function handleInput(event: Event) {\n    const target = event.target as HTMLInputElement\n    const oldValue = value ?? ''\n    const newValue = target.value\n    const cursorPos = target.selectionStart ?? 0\n\n    // Filter raw characters strictly through token validation\n    const rawNew = unmask(newValue)\n    const rawOld = unmask(oldValue)\n    const clampedRaw = rawNew.slice(0, maxLength)\n    const masked = applyMask(clampedRaw)\n\n    let newCursorPos: number\n    if (clampedRaw.length > rawOld.length) {\n      const addedIndex = clampedRaw.length - 1\n      newCursorPos = findCursorPosFromRaw(addedIndex) + 1\n      newCursorPos = getNextEditablePos(newCursorPos)\n    } else if (clampedRaw.length < rawOld.length) {\n      newCursorPos = getPrevEditablePos(cursorPos) + 1\n    } else {\n      newCursorPos = cursorPos\n    }\n\n    updateValue(masked)\n    // Keep the DOM in sync when the masked output equals the old value (Svelte\n    // only writes back on change).\n    target.value = masked\n\n    tick().then(() => {\n      inputEl?.setSelectionRange(newCursorPos, newCursorPos)\n    })\n  }\n\n  function handleKeydown(event: KeyboardEvent) {\n    const target = event.target as HTMLInputElement\n    const cursorPos = target.selectionStart ?? 0\n\n    // Block non-matching characters right away if single printable key\n    if (event.key.length === 1 && !event.ctrlKey && !event.metaKey && !event.altKey) {\n      const rawIndex = findRawIndexAtCursor(cursorPos)\n      if (rawIndex >= maxLength) {\n        event.preventDefault()\n        return\n      }\n      if (!isValidCharForSlot(event.key, rawIndex)) {\n        event.preventDefault()\n        return\n      }\n    }\n\n    if (event.key === 'Backspace') {\n      const raw = unmask(value ?? '')\n      const rawIndex = findRawIndexAtCursor(cursorPos)\n\n      if (rawIndex > 0) {\n        const newRaw = raw.slice(0, rawIndex - 1) + raw.slice(rawIndex)\n        updateValue(applyMask(newRaw))\n\n        const newPos = findCursorPosFromRaw(rawIndex - 1)\n        tick().then(() => {\n          inputEl?.setSelectionRange(newPos, newPos)\n        })\n      }\n      event.preventDefault()\n    } else if (event.key === 'Delete') {\n      const raw = unmask(value ?? '')\n      const rawIndex = findRawIndexAtCursor(cursorPos)\n\n      if (rawIndex < raw.length) {\n        const newRaw = raw.slice(0, rawIndex) + raw.slice(rawIndex + 1)\n        updateValue(applyMask(newRaw))\n\n        const newPos = findCursorPosFromRaw(rawIndex)\n        tick().then(() => {\n          inputEl?.setSelectionRange(newPos, newPos)\n        })\n      }\n      event.preventDefault()\n    } else if (event.key === 'ArrowLeft') {\n      const newPos = getPrevEditablePos(cursorPos)\n      tick().then(() => {\n        inputEl?.setSelectionRange(newPos, newPos)\n      })\n      event.preventDefault()\n    } else if (event.key === 'ArrowRight') {\n      const newPos = getNextEditablePos(cursorPos + 1)\n      tick().then(() => {\n        inputEl?.setSelectionRange(newPos, newPos)\n      })\n      event.preventDefault()\n    }\n  }\n\n  function handleFocus() {\n    isFocused = true\n    if (!value && showMask) {\n      updateValue(applyMask(''))\n    }\n    tick().then(() => {\n      const firstEditable = editablePositions[0] ?? 0\n      inputEl?.setSelectionRange(firstEditable, firstEditable)\n    })\n  }\n\n  function handleBlur() {\n    isFocused = false\n  }\n\n  function handlePaste(event: ClipboardEvent) {\n    event.preventDefault()\n    const pasted = event.clipboardData?.getData('text') ?? ''\n    const raw = unmask(value ?? '')\n    const cursorPos = inputEl?.selectionStart ?? 0\n    const rawIndex = findRawIndexAtCursor(cursorPos)\n\n    // Filter pasted content strictly\n    let filteredPasted = ''\n    let currSlot = rawIndex\n    for (const char of pasted) {\n      if (currSlot < maxLength && isValidCharForSlot(char, currSlot)) {\n        filteredPasted += char\n        currSlot++\n      }\n    }\n\n    const newRaw = (raw.slice(0, rawIndex) + filteredPasted + raw.slice(rawIndex)).slice(0, maxLength)\n    updateValue(applyMask(newRaw))\n\n    const newPos = findCursorPosFromRaw(Math.min(rawIndex + filteredPasted.length, maxLength))\n    tick().then(() => {\n      inputEl?.setSelectionRange(newPos, newPos)\n    })\n  }\n\n  const displayValue = $derived.by(() => {\n    if (!value && !isFocused && placeholder) return ''\n    if (!value && !showMask) return ''\n    return value ?? ''\n  })\n</script>\n\n<div class=\"relative w-full\" data-slot=\"masked-input-wrapper\">\n  <input\n    bind:this={inputEl}\n    value={displayValue}\n    {placeholder}\n    data-uipkge=\"\"\n    data-slot=\"masked-input\"\n    {disabled}\n    {readonly}\n    aria-invalid={isInvalid ? 'true' : undefined}\n    class={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      isInvalid && 'border-destructive focus-visible:border-destructive focus-visible:ring-destructive/20 text-destructive',\n      className,\n    )}\n    oninput={(e) => {\n      handleInput(e)\n      oninput?.(e)\n    }}\n    onkeydown={(e) => {\n      handleKeydown(e)\n      onkeydown?.(e)\n    }}\n    onfocus={(e) => {\n      handleFocus()\n      onfocus?.(e)\n    }}\n    onblur={(e) => {\n      handleBlur()\n      onblur?.(e)\n    }}\n    onpaste={(e) => {\n      handlePaste(e)\n      onpaste?.(e)\n    }}\n    {...restProps}\n  />\n  {#if displayErrorMessage}\n    <p data-slot=\"masked-input-error\" class=\"text-destructive mt-1.5 text-xs font-medium\" role=\"alert\">\n      {displayErrorMessage}\n    </p>\n  {/if}\n</div>\n",
      "type": "registry:ui",
      "target": "~/src/lib/components/ui/masked-input/MaskedInput.svelte"
    },
    {
      "path": "packages/registry-svelte/components/masked-input/index.ts",
      "content": "export {\n  default as MaskedInput,\n  type MaskedInputProps,\n  type MaskedInputValidatePayload,\n  type MaskTokens,\n} from './MaskedInput.svelte'\n",
      "type": "registry:ui",
      "target": "~/src/lib/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"
  ]
}