{
  "$schema": "https://uipkge.dev/schema/registry-item.json",
  "name": "masked-input",
  "title": "Masked Input",
  "type": "registry:ui",
  "files": [
    {
      "path": "packages/registry-angular/components/masked-input/masked-input.component.ts",
      "content": "import {\n  type AfterViewChecked,\n  ChangeDetectorRef,\n  Component,\n  ElementRef,\n  EventEmitter,\n  Input,\n  Output,\n  ViewChild,\n  booleanAttribute,\n  effect,\n  forwardRef,\n  inject,\n  signal,\n  untracked,\n  ChangeDetectionStrategy,\n} from '@angular/core'\nimport { NG_VALUE_ACCESSOR, type ControlValueAccessor } from '@angular/forms'\nimport { cn } from '@/lib/utils'\n\nexport type MaskTokens = Record<string, RegExp>\n\nexport interface MaskedInputValidatePayload {\n  isValid: boolean\n  isComplete: boolean\n  rawValue: string\n  maskedValue: string\n}\n\nconst DEFAULT_TOKENS: MaskTokens = {\n  '#': /^[0-9]$/,\n  A: /^[a-zA-Z]$/,\n  '*': /^[a-zA-Z0-9]$/,\n}\n\n/**\n * Angular port of the React MaskedInput. `<ui-masked-input>` is React's\n * `relative w-full` wrapper (data-slot=\"masked-input-wrapper\") around the fully styled\n * native input and the role=alert error line.\n *\n * Mask engine as React: `replacement` (or token) chars in `mask` are editable slots, the\n * rest are literals; keystrokes that do not match the slot's token are blocked, Backspace /\n * Delete skip literals, arrows jump between slots, paste is filtered slot by slot, and the\n * caret is placed after every edit. With a `placeholder` the mask only shows while focused.\n *\n * Model is React's `value` / `defaultValue` / `valueChange` (React `onValueChange`, the\n * masked string); `complete` fires once every slot is filled, `validation` (React\n * `onValidate`) reports validity. NG_VALUE_ACCESSOR is provided for formControl / ngModel.\n */\n@Component({\n  changeDetection: ChangeDetectionStrategy.Eager,\n  selector: 'ui-masked-input',\n  standalone: true,\n  exportAs: 'uiMaskedInput',\n  providers: [{ provide: NG_VALUE_ACCESSOR, useExisting: forwardRef(() => UiMaskedInputComponent), multi: true }],\n  host: {\n    '[attr.class]': '\"block relative w-full\"',\n    'data-slot': 'masked-input-wrapper',\n    // These belong to the native <input>.\n    '[attr.id]': 'null',\n    '[attr.placeholder]': 'null',\n    '[attr.aria-label]': 'null',\n    '[attr.aria-describedby]': 'null',\n  },\n  template: `\n    <input\n      #inputEl\n      [value]=\"displayValue\"\n      [attr.placeholder]=\"placeholder ?? null\"\n      data-uipkge=\"true\"\n      data-slot=\"masked-input\"\n      [disabled]=\"disabled\"\n      [readOnly]=\"readOnly\"\n      [attr.aria-invalid]=\"isInvalid ? 'true' : null\"\n      [attr.id]=\"id ?? null\"\n      [attr.name]=\"name ?? null\"\n      [attr.autocomplete]=\"autoComplete ?? null\"\n      [attr.inputmode]=\"inputMode ?? null\"\n      [attr.aria-label]=\"ariaLabel ?? null\"\n      [attr.aria-describedby]=\"ariaDescribedby ?? null\"\n      [required]=\"required\"\n      [class]=\"inputClass\"\n      (input)=\"handleInput($event)\"\n      (keydown)=\"handleKeyDown($event)\"\n      (focus)=\"handleFocus($event)\"\n      (blur)=\"handleBlur($event)\"\n      (paste)=\"handlePaste($event)\"\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    }\n  `,\n})\nexport class UiMaskedInputComponent implements ControlValueAccessor, AfterViewChecked {\n  // Zoneless-safe: form writes happen outside template events, so schedule a repaint.\n  private readonly cdr = inject(ChangeDetectorRef)\n  @ViewChild('inputEl', { static: true }) inputEl!: ElementRef<HTMLInputElement>\n\n  private readonly _valueProp = signal<string | undefined>(undefined)\n  private readonly _internal = signal<string | null>(null)\n  readonly isFocused = signal(false)\n\n  /** Controlled masked value (React `value`, e.g. `(212) 555-____`). Reading returns the current value. */\n  @Input()\n  set value(v: string | null | undefined) {\n    this._valueProp.set(v === undefined ? undefined : (v ?? ''))\n  }\n  get value(): string {\n    return this.modelValue\n  }\n  @Input() defaultValue?: string\n  /** React `onValueChange`: the masked string on every edit. */\n  @Output() valueChange = new EventEmitter<string>()\n  /** React `onComplete`: the masked string once every editable slot is filled. */\n  @Output() complete = new EventEmitter<string>()\n  /** React `onValidate` (renamed: `validate` is the validator input, as in React). */\n  @Output() validation = new EventEmitter<MaskedInputValidatePayload>()\n\n  /** Mask template -- `replacement` chars are editable, everything else is literal. */\n  @Input() mask = ''\n  @Input() replacement = '#'\n  @Input() tokens?: MaskTokens\n  @Input() placeholderChar = '_'\n  @Input() placeholder?: string\n  @Input({ transform: booleanAttribute }) showMask = true\n  @Input({ transform: booleanAttribute }) invalid = false\n  @Input() error?: string | boolean\n  @Input() errorMessage?: string\n  /** Custom validation (React `validate` prop): true / false / an error message. */\n  @Input() validate?: (masked: string, raw: string) => boolean | string\n  @Input({ transform: booleanAttribute }) disabled = false\n  @Input({ transform: booleanAttribute }) readOnly = false\n  /** Lower-case HTML spelling of `readOnly`. */\n  @Input({ alias: 'readonly', transform: booleanAttribute }) set readonlyAlias(v: boolean) {\n    this.readOnly = v\n  }\n  @Input({ transform: booleanAttribute }) required = false\n  @Input() id?: string\n  @Input() name?: string\n  @Input() autoComplete?: string\n  @Input() inputMode?: string\n  @Input('aria-label') ariaLabel?: string\n  @Input('aria-describedby') ariaDescribedby?: string\n  @Input('class') className?: string\n  @Output('focus') focusEvent = new EventEmitter<FocusEvent>()\n  @Output('blur') blurEvent = new EventEmitter<FocusEvent>()\n\n  private onChange: (v: string) => void = () => {}\n  private onTouched: () => void = () => {}\n  private lastValidateKey: string | null = null\n\n  constructor() {\n    // React effects: onComplete once complete (and on each edit while complete), onValidate on change.\n    effect(() => {\n      const current = this.modelValue\n      untracked(() => {\n        const complete = this.isComplete\n        if (complete) this.complete.emit(current)\n        const payload: MaskedInputValidatePayload = {\n          isValid: !this.isInvalid,\n          isComplete: complete,\n          rawValue: this.unmask(current),\n          maskedValue: current,\n        }\n        const key = JSON.stringify(payload)\n        if (key !== this.lastValidateKey) {\n          this.lastValidateKey = key\n          this.validation.emit(payload)\n        }\n      })\n    })\n  }\n\n  get isControlled(): boolean {\n    return this._valueProp() !== undefined\n  }\n  /** The current masked string (React `modelValue`). */\n  get modelValue(): string {\n    return this._valueProp() ?? this._internal() ?? this.defaultValue ?? ''\n  }\n\n  private get activeTokens(): MaskTokens {\n    return { ...DEFAULT_TOKENS, ...(this.tokens ?? {}) }\n  }\n\n  private get editableSlots(): Array<{ index: number; tokenChar: string }> {\n    const tokens = this.activeTokens\n    const slots: Array<{ index: number; tokenChar: string }> = []\n    for (let i = 0; i < this.mask.length; i++) {\n      const char = this.mask[i]!\n      if (char === this.replacement || tokens[char]) {\n        slots.push({ index: i, tokenChar: char === this.replacement ? this.replacement : char })\n      }\n    }\n    return slots\n  }\n\n  private get editablePositions(): number[] {\n    return this.editableSlots.map((s) => s.index)\n  }\n\n  private get maxLength(): number {\n    return this.editablePositions.length\n  }\n\n  private isValidCharForSlot(char: string, slotIndex: number): boolean {\n    const slot = this.editableSlots[slotIndex]\n    const tokens = this.activeTokens\n    const regex = !slot ? /.*/ : (tokens[slot.tokenChar] ?? tokens[this.replacement] ?? /.*/)\n    return regex.test(char)\n  }\n\n  unmask(val: string): string {\n    let result = ''\n    let slotIndex = 0\n    const max = this.maxLength\n    for (const char of val) {\n      if (char === this.placeholderChar || char === ' ') continue\n      if (slotIndex < max && this.isValidCharForSlot(char, slotIndex)) {\n        result += char\n        slotIndex++\n      }\n    }\n    return result\n  }\n\n  applyMask(rawValue: string, focused = this.isFocused()): string {\n    const positions = this.editablePositions\n    let result = ''\n    let rawIndex = 0\n    for (let i = 0; i < this.mask.length; i++) {\n      if (positions.includes(i)) {\n        const slotIdx = positions.indexOf(i)\n        if (rawIndex < rawValue.length && this.isValidCharForSlot(rawValue[rawIndex]!, slotIdx)) {\n          result += rawValue[rawIndex]\n          rawIndex++\n        } else if (this.showMask && (focused || !this.placeholder || this.modelValue)) {\n          result += this.placeholderChar\n        } else {\n          break\n        }\n      } else {\n        result += this.mask[i]\n      }\n    }\n    return result\n  }\n\n  get isComplete(): boolean {\n    return this.unmask(this.modelValue).length === this.maxLength\n  }\n\n  get validationError(): string | null {\n    if (!this.validate) return null\n    const current = this.modelValue\n    const res = this.validate(current, this.unmask(current))\n    if (typeof res === 'string') return res\n    if (res === false) return 'Invalid format'\n    return null\n  }\n\n  get isInvalid(): boolean {\n    const e = this.error\n    return Boolean(this.invalid || e === true || (typeof e === 'string' && e.length > 0) || this.validationError)\n  }\n\n  get displayErrorMessage(): string | null | undefined {\n    const e = this.error\n    return typeof e === 'string' && e.length > 0 ? e : this.errorMessage || this.validationError\n  }\n\n  get displayValue(): string {\n    const mv = this.modelValue\n    return !mv && !this.isFocused() && this.placeholder ? '' : !mv && !this.showMask ? '' : mv\n  }\n\n  get inputClass(): string {\n    return 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      this.isInvalid &&\n        'border-destructive focus-visible:border-destructive focus-visible:ring-destructive/20 text-destructive',\n      this.className,\n    )\n  }\n\n  private getNextEditablePos(currentPos: number): number {\n    const positions = this.editablePositions\n    for (const pos of positions) if (pos >= currentPos) return pos\n    return positions[positions.length - 1] ?? this.mask.length\n  }\n\n  private getPrevEditablePos(currentPos: number): number {\n    const positions = this.editablePositions\n    for (let i = positions.length - 1; i >= 0; i--) {\n      const p = positions[i]\n      if (p !== undefined && p < currentPos) return p\n    }\n    return positions[0] ?? 0\n  }\n\n  private findRawIndexAtCursor(cursorPos: number): number {\n    const positions = this.editablePositions\n    let rawIndex = 0\n    for (let i = 0; i < cursorPos && i < this.mask.length; i++) if (positions.includes(i)) rawIndex++\n    return rawIndex\n  }\n\n  private findCursorPosFromRaw(rawIndex: number): number {\n    const positions = this.editablePositions\n    if (rawIndex >= positions.length) return this.mask.length\n    return positions[rawIndex] ?? this.mask.length\n  }\n\n  private setCaret(pos: number): void {\n    requestAnimationFrame(() => this.inputEl?.nativeElement.setSelectionRange(pos, pos))\n  }\n\n  private updateValue(next: string): void {\n    if (!this.isControlled) this._internal.set(next)\n    this.onChange(next)\n    this.valueChange.emit(next)\n  }\n\n  handleInput(event: Event): void {\n    const target = event.target as HTMLInputElement\n    const oldValue = this.modelValue\n    const cursorPos = target.selectionStart ?? 0\n    const rawNew = this.unmask(target.value)\n    const rawOld = this.unmask(oldValue)\n    const clampedRaw = rawNew.slice(0, this.maxLength)\n    const masked = this.applyMask(clampedRaw)\n\n    let newCursorPos: number\n    if (clampedRaw.length > rawOld.length) {\n      newCursorPos = this.getNextEditablePos(this.findCursorPosFromRaw(clampedRaw.length - 1) + 1)\n    } else if (clampedRaw.length < rawOld.length) {\n      newCursorPos = this.getPrevEditablePos(cursorPos) + 1\n    } else {\n      newCursorPos = cursorPos\n    }\n    this.updateValue(masked)\n    this.setCaret(newCursorPos)\n  }\n\n  handleKeyDown(event: KeyboardEvent): void {\n    if (event.defaultPrevented) return\n    const cursorPos = (event.currentTarget as HTMLInputElement).selectionStart ?? 0\n\n    // Block a non-matching character immediately on keystroke.\n    if (event.key.length === 1 && !event.ctrlKey && !event.metaKey && !event.altKey) {\n      const rawIndex = this.findRawIndexAtCursor(cursorPos)\n      if (rawIndex >= this.maxLength || !this.isValidCharForSlot(event.key, rawIndex)) {\n        event.preventDefault()\n        return\n      }\n    }\n\n    if (event.key === 'Backspace') {\n      const raw = this.unmask(this.modelValue)\n      const rawIndex = this.findRawIndexAtCursor(cursorPos)\n      if (rawIndex > 0) {\n        this.updateValue(this.applyMask(raw.slice(0, rawIndex - 1) + raw.slice(rawIndex)))\n        this.setCaret(this.findCursorPosFromRaw(rawIndex - 1))\n      }\n      event.preventDefault()\n    } else if (event.key === 'Delete') {\n      const raw = this.unmask(this.modelValue)\n      const rawIndex = this.findRawIndexAtCursor(cursorPos)\n      if (rawIndex < raw.length) {\n        this.updateValue(this.applyMask(raw.slice(0, rawIndex) + raw.slice(rawIndex + 1)))\n        this.setCaret(this.findCursorPosFromRaw(rawIndex))\n      }\n      event.preventDefault()\n    } else if (event.key === 'ArrowLeft') {\n      this.setCaret(this.getPrevEditablePos(cursorPos))\n      event.preventDefault()\n    } else if (event.key === 'ArrowRight') {\n      this.setCaret(this.getNextEditablePos(cursorPos + 1))\n      event.preventDefault()\n    }\n  }\n\n  handlePaste(event: ClipboardEvent): void {\n    if (event.defaultPrevented) return\n    event.preventDefault()\n    const pasted = event.clipboardData?.getData('text') ?? ''\n    const raw = this.unmask(this.modelValue)\n    const rawIndex = this.findRawIndexAtCursor(this.inputEl?.nativeElement.selectionStart ?? 0)\n    let filtered = ''\n    let slot = rawIndex\n    for (const char of pasted) {\n      if (slot < this.maxLength && this.isValidCharForSlot(char, slot)) {\n        filtered += char\n        slot++\n      }\n    }\n    const newRaw = (raw.slice(0, rawIndex) + filtered + raw.slice(rawIndex)).slice(0, this.maxLength)\n    this.updateValue(this.applyMask(newRaw))\n    this.setCaret(this.findCursorPosFromRaw(Math.min(rawIndex + filtered.length, this.maxLength)))\n  }\n\n  handleFocus(event: FocusEvent): void {\n    this.isFocused.set(true)\n    if (!this.modelValue && this.showMask) this.updateValue(this.applyMask('', true))\n    this.setCaret(this.editablePositions[0] ?? 0)\n    this.focusEvent.emit(event)\n  }\n\n  handleBlur(event: FocusEvent): void {\n    this.isFocused.set(false)\n    this.onTouched()\n    this.blurEvent.emit(event)\n  }\n\n  /** The [value] binding misses edits that mask back to the previous string; sync the DOM. */\n  ngAfterViewChecked(): void {\n    const el = this.inputEl?.nativeElement\n    if (el && el.value !== this.displayValue) el.value = this.displayValue\n  }\n\n  writeValue(v: string | null): void {\n    this._internal.set(v ?? '')\n    this.cdr.markForCheck()\n  }\n\n  registerOnChange(fn: (v: string) => void): void {\n    this.onChange = fn\n  }\n\n  registerOnTouched(fn: () => void): void {\n    this.onTouched = fn\n  }\n\n  setDisabledState(isDisabled: boolean): void {\n    this.disabled = isDisabled\n    this.cdr.markForCheck()\n  }\n}\n",
      "type": "registry:ui",
      "target": "~/src/app/components/ui/masked-input/masked-input.component.ts"
    },
    {
      "path": "packages/registry-angular/components/masked-input/index.ts",
      "content": "export { UiMaskedInputComponent, type MaskTokens, type MaskedInputValidatePayload } from './masked-input.component'\n",
      "type": "registry:ui",
      "target": "~/src/app/components/ui/masked-input/index.ts"
    }
  ],
  "dependencies": [
    "@angular/forms"
  ],
  "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, replacement marker and tokens; blocks invalid keystrokes, filters paste, reports complete / validation, and shows an inline error.",
  "categories": [
    "form"
  ]
}