{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "masked-input",
  "title": "Masked Input",
  "type": "registry:ui",
  "files": [
    {
      "path": "packages/registry-react/components/masked-input/masked-input.tsx",
      "content": "'use client'\n\nimport * as React from 'react'\nimport { cn } from '@/lib/utils'\n\nexport interface MaskedInputProps\n  extends Omit<React.InputHTMLAttributes<HTMLInputElement>, 'value' | 'defaultValue' | 'onChange'> {\n  /** Controlled masked value (e.g. `(212) 555-____`). */\n  value?: string\n  defaultValue?: string\n  /** Mask template — `replacement` chars are editable, everything else is literal. */\n  mask: string\n  /** The character in `mask` that marks an editable slot. */\n  replacement?: string\n  /** Character shown for unfilled editable slots when `showMask` is on. */\n  placeholderChar?: string\n  /** Render the literal mask (separators + placeholders) even when empty. */\n  showMask?: boolean\n  /** Fires with the masked string on every edit. */\n  onValueChange?: (value: string) => void\n  /** Fires with the masked string once every editable slot is filled. */\n  onComplete?: (value: string) => void\n  className?: string\n}\n\nconst MaskedInput = React.forwardRef<HTMLInputElement, MaskedInputProps>(\n  (\n    {\n      value,\n      defaultValue,\n      mask,\n      replacement = '#',\n      placeholderChar = '_',\n      showMask = true,\n      onValueChange,\n      onComplete,\n      disabled,\n      readOnly,\n      className,\n      ...rest\n    },\n    ref,\n  ) => {\n    const innerRef = React.useRef<HTMLInputElement | null>(null)\n    const setRefs = React.useCallback(\n      (node: HTMLInputElement | null) => {\n        innerRef.current = node\n        if (typeof ref === 'function') ref(node)\n        else if (ref) (ref as React.MutableRefObject<HTMLInputElement | null>).current = node\n      },\n      [ref],\n    )\n\n    const isControlled = value !== undefined\n    const [internal, setInternal] = React.useState<string>(defaultValue ?? '')\n    const modelValue = isControlled ? value : internal\n\n    // Positions in the mask that are editable (not separators).\n    const editablePositions = React.useMemo(() => {\n      const positions: number[] = []\n      for (let i = 0; i < mask.length; i++) {\n        if (mask[i] === replacement) positions.push(i)\n      }\n      return positions\n    }, [mask, replacement])\n\n    const maxLength = editablePositions.length\n\n    const isSeparatorChar = React.useCallback(\n      (char: string): boolean => {\n        for (let i = 0; i < mask.length; i++) {\n          if (mask[i] !== replacement && mask[i] === char) return true\n        }\n        return false\n      },\n      [mask, replacement],\n    )\n\n    const unmask = React.useCallback(\n      (val: string): string => {\n        let result = ''\n        for (const char of val) {\n          if (char !== placeholderChar && char !== ' ' && !isSeparatorChar(char)) result += char\n        }\n        return result\n      },\n      [placeholderChar, isSeparatorChar],\n    )\n\n    const applyMask = React.useCallback(\n      (rawValue: string): string => {\n        const chars = rawValue.split('')\n        let result = ''\n        let charIndex = 0\n        for (let i = 0; i < mask.length; i++) {\n          if (mask[i] === replacement) {\n            if (charIndex < chars.length) {\n              result += chars[charIndex]\n              charIndex++\n            } else if (showMask) {\n              result += placeholderChar\n            } else {\n              break\n            }\n          } else {\n            result += mask[i]\n          }\n        }\n        return result\n      },\n      [mask, replacement, showMask, placeholderChar],\n    )\n\n    const getNextEditablePos = React.useCallback(\n      (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      [editablePositions, mask.length],\n    )\n\n    const getPrevEditablePos = React.useCallback(\n      (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      [editablePositions],\n    )\n\n    const findRawIndexAtCursor = React.useCallback(\n      (cursorPos: number): number => {\n        let rawIndex = 0\n        for (let i = 0; i < cursorPos && i < mask.length; i++) {\n          if (mask[i] === replacement) rawIndex++\n        }\n        return rawIndex\n      },\n      [mask, replacement],\n    )\n\n    const findCursorPosFromRaw = React.useCallback(\n      (rawIndex: number): number => {\n        let count = 0\n        for (let i = 0; i < mask.length; i++) {\n          if (mask[i] === replacement) {\n            if (count === rawIndex) return i\n            count++\n          }\n        }\n        return mask.length\n      },\n      [mask, replacement],\n    )\n\n    const emit = React.useCallback(\n      (next: string) => {\n        if (!isControlled) setInternal(next)\n        onValueChange?.(next)\n        if (unmask(next).length === maxLength) onComplete?.(next)\n      },\n      [isControlled, onValueChange, onComplete, unmask, maxLength],\n    )\n\n    const setCursor = React.useCallback((pos: number) => {\n      // Defer to after the controlled re-render commits the new value.\n      requestAnimationFrame(() => {\n        innerRef.current?.setSelectionRange(pos, pos)\n      })\n    }, [])\n\n    function handleChange(event: React.ChangeEvent<HTMLInputElement>) {\n      const target = event.target\n      const oldValue = modelValue ?? ''\n      const newValue = target.value\n      const cursorPos = target.selectionStart ?? 0\n\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      emit(masked)\n      setCursor(newCursorPos)\n    }\n\n    function handleKeyDown(event: React.KeyboardEvent<HTMLInputElement>) {\n      const target = event.currentTarget\n      const cursorPos = target.selectionStart ?? 0\n\n      if (event.key === 'Backspace') {\n        const raw = unmask(modelValue ?? '')\n        const rawIndex = findRawIndexAtCursor(cursorPos)\n        if (rawIndex > 0) {\n          const newRaw = raw.slice(0, rawIndex - 1) + raw.slice(rawIndex)\n          emit(applyMask(newRaw))\n          setCursor(findCursorPosFromRaw(rawIndex - 1) + 1)\n        }\n        event.preventDefault()\n      } else if (event.key === 'Delete') {\n        const raw = unmask(modelValue ?? '')\n        const rawIndex = findRawIndexAtCursor(cursorPos)\n        if (rawIndex < raw.length) {\n          const newRaw = raw.slice(0, rawIndex) + raw.slice(rawIndex + 1)\n          emit(applyMask(newRaw))\n          setCursor(findCursorPosFromRaw(rawIndex) + 1)\n        }\n        event.preventDefault()\n      } else if (event.key === 'ArrowLeft') {\n        const newPos = getPrevEditablePos(cursorPos)\n        setCursor(newPos + 1)\n        event.preventDefault()\n      } else if (event.key === 'ArrowRight') {\n        const newPos = getNextEditablePos(cursorPos + 1)\n        setCursor(newPos + 1)\n        event.preventDefault()\n      }\n    }\n\n    function handleFocus(event: React.FocusEvent<HTMLInputElement>) {\n      if (!modelValue && showMask) emit(applyMask(''))\n      const firstEditable = editablePositions[0] ?? 0\n      setCursor(firstEditable + 1)\n      rest.onFocus?.(event)\n    }\n\n    function handlePaste(event: React.ClipboardEvent<HTMLInputElement>) {\n      event.preventDefault()\n      const pasted = event.clipboardData?.getData('text') ?? ''\n      const raw = unmask(modelValue ?? '')\n      const cursorPos = innerRef.current?.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)\n      emit(applyMask(clamped))\n      setCursor(findCursorPosFromRaw(Math.min(rawIndex + unmask(pasted).length, maxLength)) + 1)\n    }\n\n    const displayValue = !modelValue && !showMask ? '' : (modelValue ?? '')\n\n    return (\n      <input\n        {...rest}\n        ref={setRefs}\n        value={displayValue}\n        data-uipkge=\"\"\n        data-slot=\"masked-input\"\n        disabled={disabled}\n        readOnly={readOnly}\n        onChange={handleChange}\n        onKeyDown={handleKeyDown}\n        onFocus={handleFocus}\n        onPaste={handlePaste}\n        className={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          className,\n        )}\n      />\n    )\n  },\n)\nMaskedInput.displayName = 'MaskedInput'\n\nexport { MaskedInput }\n",
      "type": "registry:ui",
      "target": "~/components/ui/masked-input/masked-input.tsx"
    },
    {
      "path": "packages/registry-react/components/masked-input/index.ts",
      "content": "export { MaskedInput, type MaskedInputProps } from './masked-input'\n",
      "type": "registry:ui",
      "target": "~/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"
  ]
}