{
  "$schema": "https://uipkge.dev/schema/registry-item.json",
  "name": "masked-input",
  "title": "Masked Input",
  "type": "registry:ui",
  "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"
  ],
  "files": [
    {
      "path": "packages/elements-lit/src/components/masked-input/masked-input.ts",
      "content": "import { LitElement, css, html, nothing } from 'lit'\nimport { live } from 'lit/directives/live.js'\nimport { cn } from '../../lib/utils'\nimport { tailwind } from '../../lib/styles'\nimport { ThemeController } from '../../lib/theme'\n\nexport type MaskTokens = Record<string, RegExp>\n\nconst DEFAULT_TOKENS: MaskTokens = {\n  '#': /^[0-9]$/,\n  A: /^[a-zA-Z]$/,\n  '*': /^[a-zA-Z0-9]$/,\n}\n\nconst trueUnlessFalse = {\n  fromAttribute: (v: string | null) => v !== 'false',\n}\n\n/**\n * <uip-masked-input> — the registry MaskedInput as a web component.\n *\n * Form-associated: submits `name=value` with its <form>.\n */\nexport class UipMaskedInput extends LitElement {\n  static formAssociated = true\n  static shadowRootOptions = { ...LitElement.shadowRootOptions, delegatesFocus: true }\n  static styles = [tailwind, css`:host { display: block; }`]\n\n  static properties = {\n    value: { reflect: true },\n    defaultValue: { attribute: 'default-value' },\n    mask: {},\n    replacement: {},\n    tokens: { type: Object },\n    placeholderChar: { attribute: 'placeholder-char' },\n    placeholder: {},\n    showMask: { converter: trueUnlessFalse, attribute: 'show-mask' },\n    invalid: { type: Boolean, reflect: true },\n    errorMessage: { attribute: 'error-message' },\n    disabled: { type: Boolean, reflect: true },\n    readOnly: { type: Boolean, attribute: 'readonly', reflect: true },\n    name: { reflect: true },\n    accessibleLabel: { attribute: 'aria-label' },\n    isFocused: { state: true },\n  }\n\n  value = ''\n  defaultValue?: string\n  mask = ''\n  replacement = '#'\n  tokens?: MaskTokens\n  placeholderChar = '_'\n  placeholder?: string\n  showMask = true\n  invalid = false\n  errorMessage?: string\n  disabled = false\n  readOnly = false\n  name?: string\n  accessibleLabel?: string\n\n  private isFocused = false\n  private initialValue = ''\n  private internals = this.attachInternals()\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', 'masked-input')\n    if (this.defaultValue !== undefined && this.getAttribute('value') === null) {\n      this.value = this.defaultValue\n    }\n    this.initialValue = this.value\n  }\n\n  protected willUpdate(changed: Map<string, unknown>) {\n    if (changed.has('value')) {\n      this.internals.setFormValue(this.value ?? '')\n    }\n  }\n\n  formResetCallback() {\n    this.value = this.initialValue\n    this.updateValue(this.initialValue)\n  }\n\n  formDisabledCallback(disabled: boolean) {\n    this.disabled = disabled\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 slots: Array<{ index: number; tokenChar: string }> = []\n    const tokens = this.activeTokens\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 getSlotPattern(slotIndex: number): RegExp {\n    const slot = this.editableSlots[slotIndex]\n    if (!slot) return /.*/\n    const tokens = this.activeTokens\n    return tokens[slot.tokenChar] ?? tokens[this.replacement] ?? /.*/\n  }\n\n  private isValidCharForSlot(char: string, slotIndex: number): boolean {\n    const regex = this.getSlotPattern(slotIndex)\n    return regex.test(char)\n  }\n\n  private unmask(val: string): string {\n    let result = ''\n    let slotIndex = 0\n    for (const char of val) {\n      if (char === this.placeholderChar || char === ' ') continue\n      if (slotIndex < this.maxLength && this.isValidCharForSlot(char, slotIndex)) {\n        result += char\n        slotIndex++\n      }\n    }\n    return result\n  }\n\n  private applyMask(rawValue: string): string {\n    let result = ''\n    let rawIndex = 0\n    const positions = this.editablePositions\n    for (let i = 0; i < this.mask.length; i++) {\n      const isEditable = positions.includes(i)\n      if (isEditable) {\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 && (this.isFocused || !this.placeholder || this.value)) {\n          result += this.placeholderChar\n        } else {\n          break\n        }\n      } else {\n        result += this.mask[i]\n      }\n    }\n    return result\n  }\n\n  private getNextEditablePos(currentPos: number): number {\n    const positions = this.editablePositions\n    for (const pos of positions) {\n      if (pos >= currentPos) return pos\n    }\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    let rawIndex = 0\n    const positions = this.editablePositions\n    for (let i = 0; i < cursorPos && i < this.mask.length; i++) {\n      if (positions.includes(i)) rawIndex++\n    }\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 updateValue(next: string) {\n    const prev = this.value\n    this.value = next\n    if (next !== prev) {\n      this.dispatchEvent(new Event('input', { bubbles: true, composed: true }))\n      this.dispatchEvent(new Event('change', { bubbles: true, composed: true }))\n      this.dispatchEvent(new CustomEvent('value-change', { detail: { value: next }, bubbles: true, composed: true }))\n\n      const raw = this.unmask(next)\n      const complete = raw.length === this.maxLength\n      if (complete) {\n        this.dispatchEvent(new CustomEvent('complete', { detail: { value: next }, bubbles: true, composed: true }))\n      }\n      this.dispatchEvent(\n        new CustomEvent('validate', {\n          detail: {\n            isValid: !this.invalid,\n            isComplete: complete,\n            rawValue: raw,\n            maskedValue: next,\n          },\n          bubbles: true,\n          composed: true,\n        }),\n      )\n    }\n  }\n\n  private get input(): HTMLInputElement | null {\n    return this.renderRoot?.querySelector('input')\n  }\n\n  private handleInput = (event: Event) => {\n    const target = event.target as HTMLInputElement\n    const oldValue = this.value ?? ''\n    const newValue = target.value\n    const cursorPos = target.selectionStart ?? 0\n\n    const rawNew = this.unmask(newValue)\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      const addedIndex = clampedRaw.length - 1\n      newCursorPos = this.findCursorPosFromRaw(addedIndex) + 1\n      newCursorPos = this.getNextEditablePos(newCursorPos)\n    } else if (clampedRaw.length < rawOld.length) {\n      newCursorPos = this.getPrevEditablePos(cursorPos) + 1\n    } else {\n      newCursorPos = cursorPos\n    }\n\n    this.updateValue(masked)\n\n    requestAnimationFrame(() => {\n      this.input?.setSelectionRange(newCursorPos, newCursorPos)\n    })\n  }\n\n  private handleKeyDown = (event: KeyboardEvent) => {\n    const target = event.currentTarget as HTMLInputElement\n    const cursorPos = target.selectionStart ?? 0\n\n    // Block 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.value ?? '')\n      const rawIndex = this.findRawIndexAtCursor(cursorPos)\n      if (rawIndex > 0) {\n        const newRaw = raw.slice(0, rawIndex - 1) + raw.slice(rawIndex)\n        const masked = this.applyMask(newRaw)\n        this.updateValue(masked)\n        const newPos = this.findCursorPosFromRaw(rawIndex - 1)\n        requestAnimationFrame(() => {\n          this.input?.setSelectionRange(newPos, newPos)\n        })\n      }\n      event.preventDefault()\n    } else if (event.key === 'Delete') {\n      const raw = this.unmask(this.value ?? '')\n      const rawIndex = this.findRawIndexAtCursor(cursorPos)\n      if (rawIndex < raw.length) {\n        const newRaw = raw.slice(0, rawIndex) + raw.slice(rawIndex + 1)\n        const masked = this.applyMask(newRaw)\n        this.updateValue(masked)\n        const newPos = this.findCursorPosFromRaw(rawIndex)\n        requestAnimationFrame(() => {\n          this.input?.setSelectionRange(newPos, newPos)\n        })\n      }\n      event.preventDefault()\n    } else if (event.key === 'ArrowLeft') {\n      const newPos = this.getPrevEditablePos(cursorPos)\n      requestAnimationFrame(() => {\n        this.input?.setSelectionRange(newPos, newPos)\n      })\n      event.preventDefault()\n    } else if (event.key === 'ArrowRight') {\n      const newPos = this.getNextEditablePos(cursorPos + 1)\n      requestAnimationFrame(() => {\n        this.input?.setSelectionRange(newPos, newPos)\n      })\n      event.preventDefault()\n    }\n  }\n\n  private handlePaste = (event: ClipboardEvent) => {\n    event.preventDefault()\n    const pasted = event.clipboardData?.getData('text') ?? ''\n    const raw = this.unmask(this.value ?? '')\n    const cursorPos = this.input?.selectionStart ?? 0\n    const rawIndex = this.findRawIndexAtCursor(cursorPos)\n\n    let filteredPasted = ''\n    let currSlot = rawIndex\n    for (const char of pasted) {\n      if (currSlot < this.maxLength && this.isValidCharForSlot(char, currSlot)) {\n        filteredPasted += char\n        currSlot++\n      }\n    }\n\n    const newRaw = (raw.slice(0, rawIndex) + filteredPasted + raw.slice(rawIndex)).slice(0, this.maxLength)\n    const masked = this.applyMask(newRaw)\n    this.updateValue(masked)\n\n    const newPos = this.findCursorPosFromRaw(Math.min(rawIndex + filteredPasted.length, this.maxLength))\n    requestAnimationFrame(() => {\n      this.input?.setSelectionRange(newPos, newPos)\n    })\n  }\n\n  private handleFocus = () => {\n    this.isFocused = true\n    if (!this.value && this.showMask) {\n      this.updateValue(this.applyMask(''))\n    }\n    requestAnimationFrame(() => {\n      const firstEditable = this.editablePositions[0] ?? 0\n      this.input?.setSelectionRange(firstEditable, firstEditable)\n    })\n  }\n\n  private handleBlur = () => {\n    this.isFocused = false\n  }\n\n  render() {\n    const displayValue =\n      !this.value && !this.isFocused && this.placeholder\n        ? ''\n        : !this.value && !this.showMask\n          ? ''\n          : (this.value ?? '')\n\n    const isInvalid = this.invalid\n\n    return html`\n      <div part=\"base\" class=\"relative w-full\" data-slot=\"masked-input-wrapper\">\n        <input\n          part=\"input\"\n          .value=${live(displayValue)}\n          placeholder=${this.placeholder ?? nothing}\n          data-uipkge=\"\"\n          data-slot=\"masked-input\"\n          name=${this.name ?? nothing}\n          ?disabled=${this.disabled}\n          ?readonly=${this.readOnly}\n          aria-invalid=${isInvalid ? 'true' : nothing}\n          aria-label=${this.accessibleLabel ?? nothing}\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 &&\n              'border-destructive focus-visible:border-destructive focus-visible:ring-destructive/20 text-destructive',\n          )}\n          @input=${this.handleInput}\n          @keydown=${this.handleKeyDown}\n          @focus=${this.handleFocus}\n          @blur=${this.handleBlur}\n          @paste=${this.handlePaste}\n        />\n        ${this.errorMessage\n          ? html`\n              <p data-slot=\"masked-input-error\" class=\"text-destructive mt-1.5 text-xs font-medium\" role=\"alert\">\n                ${this.errorMessage}\n              </p>\n            `\n          : nothing}\n      </div>\n    `\n  }\n}\n\ncustomElements.get('uip-masked-input') || customElements.define('uip-masked-input', UipMaskedInput)\n\ndeclare global {\n  interface HTMLElementTagNameMap {\n    'uip-masked-input': UipMaskedInput\n  }\n}\n",
      "type": "registry:file",
      "target": "src/uip/components/masked-input/masked-input.ts"
    }
  ],
  "dependencies": [
    "lit"
  ],
  "registryDependencies": [
    "https://uipkge.dev/r/lit/lit-core.json"
  ]
}
