{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "masked-input",
  "type": "registry:ui",
  "title": "Masked input",
  "description": "Formats a value with a mask, e.g. applyMask(\"(999) 999-9999\", \"5550102030\") gives \"(555) 010-2030\".",
  "dependencies": [
    "lucide-react"
  ],
  "registryDependencies": [
    "https://ui.minidev.pro/r/input.json",
    "utils"
  ],
  "files": [
    {
      "path": "registry/ui/masked-input.tsx",
      "type": "registry:ui",
      "target": "components/ui/masked-input.tsx",
      "content": "\"use client\"\nimport * as React from \"react\"\nimport { CheckIcon } from \"lucide-react\"\nimport { cn } from \"@/lib/utils\"\nimport { inputVariants } from \"@/components/ui/input\"\n\ntype Token = { kind: \"slot\"; test: RegExp } | { kind: \"lit\"; ch: string }\n\nconst SLOT: Record<string, RegExp> = { \"9\": /^\\d$/, A: /^\\p{L}$/u, \"*\": /^[\\p{L}\\d]$/u }\n\n// \"9\" digit, \"A\" letter, \"*\" letter or digit. Anything else is a literal; \"\\\\9\" escapes a token.\nfunction parseMask(mask: string): Token[] {\n  const out: Token[] = []\n  for (let i = 0; i < mask.length; i++) {\n    const c = mask[i]\n    if (c === \"\\\\\" && i + 1 < mask.length) out.push({ kind: \"lit\", ch: mask[++i] })\n    else if (SLOT[c]) out.push({ kind: \"slot\", test: SLOT[c] })\n    else out.push({ kind: \"lit\", ch: c })\n  }\n  return out\n}\n\n// Fits characters into slots in order, dropping any that do not fit.\nfunction fit(tokens: Token[], chars: string, upper: boolean) {\n  const slots = tokens.filter((t): t is Extract<Token, { kind: \"slot\" }> => t.kind === \"slot\")\n  let raw = \"\"\n  for (const ch0 of chars) {\n    if (raw.length >= slots.length) break\n    const ch = upper ? ch0.toUpperCase() : ch0\n    if (slots[raw.length].test.test(ch)) raw += ch\n  }\n  return raw\n}\n\n// Literals are written only when a character follows them (or, while typing, right after the last one).\nfunction render(tokens: Token[], raw: string, eager: boolean) {\n  let out = \"\"\n  let r = 0\n  for (const t of tokens) {\n    if (t.kind === \"lit\") {\n      if (r < raw.length || (eager && raw.length > 0)) out += t.ch\n      else break\n    } else {\n      if (r >= raw.length) break\n      out += raw[r++]\n    }\n  }\n  return out\n}\n\n/** Formats a value with a mask, e.g. applyMask(\"(999) 999-9999\", \"5550102030\") gives \"(555) 010-2030\". */\nfunction applyMask(mask: string, value: string, uppercase = false) {\n  const tokens = parseMask(mask)\n  return render(tokens, fit(tokens, value, uppercase), false)\n}\n\ntype MaskedValue = { masked: string; raw: string; complete: boolean }\n\ntype MaskedInputExample = {\n  label: string\n  mask: string\n  guide?: boolean | string\n  defaultValue?: string\n  uppercase?: boolean\n  hint?: string\n  autoComplete?: string\n}\n\nconst EXAMPLES: MaskedInputExample[] = [\n  { label: \"Phone\", mask: \"(999) 999-9999\", defaultValue: \"4155550142\", autoComplete: \"tel-national\", hint: \"US numbers only\" },\n  { label: \"Invoice date\", mask: \"99/99/9999\", guide: \"MM/DD/YYYY\", defaultValue: \"0918\" },\n  { label: \"Fleet plate\", mask: \"AAA-9999\", guide: \"ABC-1234\", uppercase: true, hint: \"Letters are capitalised for you\" },\n  { label: \"IBAN\", mask: \"AA99 **** 9999 9999 9999 99\", uppercase: true, hint: \"Paste it with or without spaces\" },\n]\n\n/**\n * A text input that formats as you type from a mask such as \"(999) 999-9999\"\n * (\"9\" digit, \"A\" letter, \"*\" letter or digit, \"\\\\\" escapes). The caret stays\n * where you expect on insert, delete and paste, pasted text is refitted to the\n * mask, and an optional guide shows the characters still to come. onChange\n * returns the masked string, the raw characters and whether the mask is full.\n * Pass `mask` for a single field; without it the component renders `examples`.\n */\ntype MaskedInputProps = {\n  /** The mask. When omitted, the `examples` list is rendered instead. */\n  mask?: string\n  label?: React.ReactNode\n  /** Raw value (slot characters only), for a controlled field. */\n  value?: string\n  defaultValue?: string\n  onChange?: (value: MaskedValue) => void\n  /** Show the remaining mask while typing. `true` uses `guideChar`; a string is a template as long as the mask, e.g. \"MM/DD/YYYY\". */\n  guide?: boolean | string\n  guideChar?: string\n  /** Capitalise letters as they are typed. */\n  uppercase?: boolean\n  placeholder?: string\n  hint?: React.ReactNode\n  /** Show a check once every slot is filled. */\n  showComplete?: boolean\n  /** Show the raw value (slot characters only) under the field. */\n  showRaw?: boolean\n  invalid?: boolean\n  disabled?: boolean\n  required?: boolean\n  id?: string\n  name?: string\n  autoComplete?: string\n  inputMode?: React.HTMLAttributes<HTMLInputElement>[\"inputMode\"]\n  /** Fields rendered when no `mask` is passed. */\n  examples?: MaskedInputExample[]\n  className?: string\n}\n\nfunction MaskedInput({\n  mask,\n  label,\n  value,\n  defaultValue = \"\",\n  onChange,\n  guide = true,\n  guideChar = \"_\",\n  uppercase = false,\n  placeholder,\n  hint,\n  showComplete = true,\n  showRaw = true,\n  invalid = false,\n  disabled = false,\n  required = false,\n  id,\n  name,\n  autoComplete = \"off\",\n  inputMode,\n  examples = EXAMPLES,\n  className,\n}: MaskedInputProps) {\n  if (mask === undefined) {\n    return (\n      <div data-slot=\"masked-input\" className={cn(\"flex w-full max-w-sm flex-col gap-5\", className)}>\n        {examples.map((ex) => (\n          <MaskedField key={ex.label} guide={guide} guideChar={guideChar} {...ex} />\n        ))}\n      </div>\n    )\n  }\n  return (\n    <MaskedField\n      mask={mask}\n      label={label}\n      value={value}\n      defaultValue={defaultValue}\n      onChange={onChange}\n      guide={guide}\n      guideChar={guideChar}\n      uppercase={uppercase}\n      placeholder={placeholder}\n      hint={hint}\n      showComplete={showComplete}\n      showRaw={showRaw}\n      invalid={invalid}\n      disabled={disabled}\n      required={required}\n      id={id}\n      name={name}\n      autoComplete={autoComplete}\n      inputMode={inputMode}\n      className={cn(\"w-full max-w-sm\", className)}\n    />\n  )\n}\n\ntype FieldProps = Omit<MaskedInputProps, \"mask\" | \"examples\"> & { mask: string }\n\nfunction MaskedField({\n  mask,\n  label,\n  value,\n  defaultValue = \"\",\n  onChange,\n  guide = true,\n  guideChar = \"_\",\n  uppercase = false,\n  placeholder,\n  hint,\n  showComplete = true,\n  showRaw = true,\n  invalid,\n  disabled,\n  required,\n  id,\n  name,\n  autoComplete = \"off\",\n  inputMode,\n  className,\n}: FieldProps) {\n  const tokens = React.useMemo(() => parseMask(mask), [mask])\n  const slotPos = React.useMemo(() => tokens.flatMap((t, i) => (t.kind === \"slot\" ? [i] : [])), [tokens])\n  const [inner, setInner] = React.useState(() => fit(tokens, defaultValue, uppercase))\n  const raw = value !== undefined ? fit(tokens, value, uppercase) : inner\n  const [eager, setEager] = React.useState(false)\n  const [focused, setFocused] = React.useState(false)\n  const [, force] = React.useReducer((n: number) => n + 1, 0)\n  const ref = React.useRef<HTMLInputElement>(null)\n  const caret = React.useRef<number | null>(null)\n  const auto = React.useId()\n  const fieldId = id ?? auto\n  const display = render(tokens, raw, eager)\n  const complete = raw.length === slotPos.length\n\n  const template = React.useMemo(() => {\n    const t = tokens.map((tk) => (tk.kind === \"lit\" ? tk.ch : guideChar)).join(\"\")\n    return typeof guide === \"string\" && guide.length === t.length ? guide : t\n  }, [tokens, guide, guideChar])\n  const showGuide = guide !== false && (focused || raw.length > 0)\n  const numeric = tokens.every((t) => t.kind === \"lit\" || t.test === SLOT[\"9\"])\n\n  React.useLayoutEffect(() => {\n    const el = ref.current\n    if (caret.current === null || !el || document.activeElement !== el) return\n    el.setSelectionRange(caret.current, caret.current)\n    caret.current = null\n  })\n\n  // Raw index of a position in the displayed string (display is always a prefix of the mask).\n  const rawAt = (pos: number) => Math.min(raw.length, slotPos.filter((p) => p < pos).length)\n\n  const handle = (e: React.ChangeEvent<HTMLInputElement>) => {\n    const next = e.target.value\n    const prev = display\n    const sel = e.target.selectionStart ?? next.length\n    const type = (e.nativeEvent as InputEvent).inputType ?? \"\"\n    let p = 0\n    while (p < prev.length && p < next.length && p < sel && prev[p] === next[p]) p++\n    let q = 0\n    while (q < prev.length - p && q < next.length - Math.max(p, sel) && prev[prev.length - 1 - q] === next[next.length - 1 - q]) q++\n    const inserted = next.slice(p, next.length - q)\n    const removed = prev.slice(p, prev.length - q)\n    let rs = rawAt(p)\n    let re = rawAt(prev.length - q)\n    if (!inserted && removed && rs === re && re < raw.length) {\n      // Only literals were removed in the middle: remove the neighbouring character instead.\n      if (type.includes(\"Forward\")) re += 1\n      else rs = Math.max(0, rs - 1)\n    }\n    const accepted: string[] = []\n    let m = p\n    for (const ch0 of inserted) {\n      while (m < tokens.length && tokens[m].kind === \"lit\" && (tokens[m] as { ch: string }).ch !== ch0) m++\n      if (m >= tokens.length) break\n      const t = tokens[m]\n      if (t.kind === \"lit\") {\n        m++\n        continue\n      }\n      const ch = uppercase ? ch0.toUpperCase() : ch0\n      if (t.test.test(ch)) {\n        accepted.push(ch)\n        m++\n      }\n    }\n    const nextRaw = fit(tokens, raw.slice(0, rs) + accepted.join(\"\") + raw.slice(re), uppercase)\n    const rc = Math.min(nextRaw.length, rs + accepted.length)\n    const nextEager = inserted.length > 0 && rc >= nextRaw.length\n    const shown = render(tokens, nextRaw, nextEager)\n    if (inserted) caret.current = rc >= nextRaw.length ? shown.length : slotPos[rc]\n    else caret.current = rc === 0 ? (nextRaw.length ? slotPos[0] : 0) : slotPos[rc - 1] + 1\n    setEager(nextEager)\n    if (value === undefined) setInner(nextRaw)\n    force()\n    if (nextRaw !== raw) onChange?.({ masked: render(tokens, nextRaw, false), raw: nextRaw, complete: nextRaw.length === slotPos.length })\n  }\n\n  const hintId = hint ? `${fieldId}-hint` : undefined\n\n  return (\n    <div data-slot=\"masked-input-field\" className={cn(\"flex flex-col gap-2\", className)}>\n      {label ? (\n        <label htmlFor={fieldId} className=\"text-sm font-medium text-fg\">\n          {label}\n        </label>\n      ) : null}\n      <div className=\"relative\">\n        <input\n          ref={ref}\n          id={fieldId}\n          name={name}\n          type=\"text\"\n          value={display}\n          onChange={handle}\n          onFocus={() => setFocused(true)}\n          onBlur={() => {\n            setFocused(false)\n            setEager(false)\n          }}\n          placeholder={showGuide ? undefined : (placeholder ?? template)}\n          inputMode={inputMode ?? (numeric ? \"numeric\" : \"text\")}\n          autoComplete={autoComplete}\n          autoCapitalize={uppercase ? \"characters\" : \"off\"}\n          autoCorrect=\"off\"\n          spellCheck={false}\n          disabled={disabled}\n          required={required}\n          aria-invalid={invalid || undefined}\n          aria-describedby={hintId}\n          data-complete={complete || undefined}\n          className={cn(inputVariants({ size: \"default\" }), \"pr-9 font-mono tabular-nums\")}\n        />\n        {showGuide ? (\n          <span aria-hidden className=\"pointer-events-none absolute inset-y-0 right-px left-px flex items-center overflow-hidden px-3 font-mono text-sm whitespace-pre tabular-nums\">\n            <span className=\"invisible\">{display}</span>\n            <span className=\"text-fg-subtle opacity-70\">{template.slice(display.length)}</span>\n          </span>\n        ) : null}\n        {showComplete ? (\n          <span\n            aria-hidden\n            className={cn(\n              \"pointer-events-none absolute top-1/2 right-2.5 grid size-4 -translate-y-1/2 place-items-center rounded-full bg-success text-on-accent\",\n              \"transition-[opacity,transform] duration-[140ms] ease-hairline\",\n              complete && !invalid ? \"scale-100 opacity-100\" : \"scale-75 opacity-0\"\n            )}\n          >\n            <CheckIcon className=\"size-2.5\" strokeWidth={3.5} />\n          </span>\n        ) : null}\n      </div>\n      {hint || showRaw ? (\n        <div className=\"flex min-h-5 items-baseline justify-between gap-3\">\n          {hint ? (\n            <p id={hintId} className={cn(\"text-xs\", invalid ? \"text-danger\" : \"text-fg-subtle\")}>\n              {hint}\n            </p>\n          ) : (\n            <span />\n          )}\n          {showRaw && raw ? (\n            <span className=\"shrink-0 truncate font-mono text-2xs text-fg-subtle tabular-nums\">\n              <span className=\"sr-only\">Raw value </span>\n              {raw}\n            </span>\n          ) : null}\n        </div>\n      ) : null}\n    </div>\n  )\n}\n\nexport { MaskedInput, applyMask }\nexport type { MaskedInputProps, MaskedValue, MaskedInputExample }\n"
    }
  ]
}
