{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "mask-input",
  "title": "Mask Input",
  "description": "Formats dates, phone numbers, cards and your own patterns as you type. The caret stays put in the middle and on paste, and deleting across a dash works.",
  "registryDependencies": [
    "utils",
    "https://miseui.junsu4621.workers.dev/r/input.json"
  ],
  "files": [
    {
      "path": "registry/miseui/ui/mask-input.tsx",
      "content": "import type * as React from \"react\"\nimport { useRef } from \"react\"\nimport { Input } from \"@/registry/miseui/ui/input\"\nimport { caretFromBoxes, CharacterBoxes, GroupBoxes } from \"@/registry/miseui/ui/mask-input-boxes\"\nimport { isComplete, isNumericMask, placeholderFor } from \"@/registry/miseui/ui/mask-input-math\"\nimport { useMaskState, type MaskStateOptions } from \"@/registry/miseui/ui/mask-input-state\"\nimport { cn } from \"@/registry/miseui/lib/utils\"\n\ntype MaskInputVariant = \"plain\" | \"grouped\" | \"compact\" | \"otp-like\"\n\ntype MaskInputProps = Omit<React.ComponentProps<\"input\">, \"value\" | \"defaultValue\" | \"type\" | \"onChange\"> &\n  MaskStateOptions & {\n    /** `plain` one field, `grouped` one box per group, `compact` a small field sized to the mask, `otp-like` one box per character. */\n    variant?: MaskInputVariant\n  }\n\nfunction MaskInput({ mask, tokens, value, defaultValue, onValueChange, onComplete, variant = \"plain\", name, placeholder, className, style, \"aria-invalid\": ariaInvalid, ...props }: MaskInputProps) {\n  const state = useMaskState({ mask, tokens, value, defaultValue, onValueChange, onComplete })\n  const rootRef = useRef<HTMLDivElement>(null)\n  const boxed = variant === \"grouped\" || variant === \"otp-like\"\n  const attributes = {\n    \"data-slot\": \"mask-input-field\",\n    value: state.masked,\n    type: \"text\",\n    inputMode: isNumericMask(state.slots) ? (\"numeric\" as const) : undefined,\n    autoComplete: \"off\",\n    spellCheck: false,\n    \"aria-invalid\": ariaInvalid,\n    ...state.handlers,\n    ...props,\n  }\n  const onPress = (event: React.MouseEvent<HTMLInputElement>) => {\n    const position = rootRef.current ? caretFromBoxes(rootRef.current, event.clientX) : null\n    if (position !== null) state.moveCaret(event.currentTarget, position)\n  }\n  const boxProps = { raw: state.raw, slots: state.slots, caret: state.caret, focused: state.focused, maskedLength: state.masked.length }\n  return (\n    <div\n      ref={rootRef}\n      data-slot=\"mask-input\"\n      data-variant={variant}\n      data-complete={isComplete(state.raw, state.slots) ? \"true\" : \"false\"}\n      data-invalid={ariaInvalid ? \"true\" : undefined}\n      className={cn(\"group/mask relative min-w-0\", variant === \"compact\" ? \"inline-block max-w-full\" : \"w-full\", className)}\n    >\n      {/* The server wants the raw value; the visible field holds the formatted one. */}\n      {name ? <input type=\"hidden\" name={name} value={state.raw} /> : null}\n      {variant === \"grouped\" ? <GroupBoxes {...boxProps} /> : null}\n      {variant === \"otp-like\" ? <CharacterBoxes {...boxProps} /> : null}\n      {boxed ? (\n        // Sits over the boxes and stays invisible: clicks, long-press paste and autofill all reach the real field.\n        <input {...attributes} className=\"absolute inset-0 size-full cursor-text text-base opacity-0 outline-none disabled:cursor-not-allowed\" onClick={onPress} />\n      ) : (\n        <Input\n          {...attributes}\n          placeholder={placeholder ?? placeholderFor(state.slots)}\n          style={variant === \"compact\" ? { ...style, width: `calc(${state.slots.length}ch + 20px)` } : style}\n          className={cn(\"tabular-nums\", variant === \"compact\" && \"h-8 max-w-full rounded-sm px-2 text-base pointer-coarse:h-10 md:text-[13px]\")}\n        />\n      )}\n    </div>\n  )\n}\n\nexport { MaskInput, type MaskInputProps, type MaskInputVariant }\n",
      "type": "registry:ui"
    },
    {
      "path": "registry/miseui/ui/mask-input-boxes.tsx",
      "content": "import { rawIndexAt } from \"@/registry/miseui/ui/mask-input-edit\"\nimport { activeGroupIndex, maskGroups, type MaskSlot } from \"@/registry/miseui/ui/mask-input-math\"\nimport { cn } from \"@/registry/miseui/lib/utils\"\n\nconst BOX_CLASS = [\n  \"flex min-w-0 items-center justify-center rounded-md border border-input bg-transparent shadow-xs tabular-nums\",\n  \"transition-[border-color,box-shadow] duration-(--duration-fast) ease-(--ease-mise)\",\n  \"data-[active=true]:border-ring data-[active=true]:ring-[3px] data-[active=true]:ring-ring/50\",\n  \"group-data-[invalid=true]/mask:border-destructive\",\n]\n\n/** Quiet marks for what is still to fill, so the box shows how much it wants. */\nconst EMPTY_MARK = \"•\"\n\ninterface BoxesProps {\n  raw: string\n  slots: readonly MaskSlot[]\n  caret: number\n  focused: boolean\n  maskedLength: number\n}\n\nfunction GroupBoxes({ raw, slots, caret, focused, maskedLength }: BoxesProps) {\n  const groups = maskGroups(raw, slots)\n  const activeIndex = focused ? activeGroupIndex(groups, caret, maskedLength) : -1\n  return (\n    <div aria-hidden=\"true\" className=\"grid h-9 gap-2 pointer-coarse:h-10\" style={{ gridTemplateColumns: groups.map((group) => `minmax(0,${group.size}fr)`).join(\" \") }}>\n      {groups.map((group, index) => (\n        <span\n          key={group.start}\n          data-slot=\"mask-input-group\"\n          data-active={index === activeIndex ? \"true\" : \"false\"}\n          // Where the caret goes when this box is pressed: the end of what is already typed in it.\n          data-caret={Math.min(group.start + group.text.length, maskedLength)}\n          className={cn(BOX_CLASS, \"overflow-hidden text-sm\")}\n        >\n          <span className=\"truncate\">\n            {group.text}\n            <span className=\"text-muted-foreground/60\">{EMPTY_MARK.repeat(group.size - group.text.length)}</span>\n          </span>\n        </span>\n      ))}\n    </div>\n  )\n}\n\nfunction CharacterBoxes({ raw, slots, caret, focused, maskedLength }: BoxesProps) {\n  const characters = Array.from(raw)\n  const caretRaw = rawIndexAt(caret, slots)\n  const capacityLeft = characters.length < slots.filter((slot) => slot.kind === \"token\").length\n  return (\n    <div aria-hidden=\"true\" className=\"flex min-w-0 items-center gap-1\">\n      {slots.map((slot, position) => {\n        if (slot.kind === \"literal\") {\n          // A space is only a gap here, while a dash or slash is part of how the value reads.\n          return (\n            <span key={position} data-slot=\"mask-input-literal\" className=\"flex w-2 shrink-0 justify-center text-sm text-muted-foreground\">\n              {slot.character.trim()}\n            </span>\n          )\n        }\n        const index = rawIndexAt(position, slots)\n        const active = focused && (caretRaw === index || (!capacityLeft && index === characters.length - 1))\n        return (\n          <span\n            key={position}\n            data-slot=\"mask-input-cell\"\n            data-active={active ? \"true\" : \"false\"}\n            data-caret={Math.min(position + 1, maskedLength)}\n            className={cn(BOX_CLASS, \"h-10 flex-1 basis-8 text-sm font-medium\")}\n          >\n            {characters[index] ?? \"\"}\n          </span>\n        )\n      })}\n    </div>\n  )\n}\n\n/** The real input lies invisibly over the boxes, so the browser places its caret by text it cannot see. This corrects it to the box that was pressed. */\nfunction caretFromBoxes(root: HTMLElement, clientX: number) {\n  const boxes = Array.from(root.querySelectorAll<HTMLElement>(\"[data-caret]\"))\n  const pressed = boxes.find((box) => {\n    const { left, right } = box.getBoundingClientRect()\n    return clientX >= left && clientX <= right\n  })\n  return pressed ? Number(pressed.dataset.caret) : null\n}\n\nexport { CharacterBoxes, GroupBoxes, caretFromBoxes }\n",
      "type": "registry:ui"
    },
    {
      "path": "registry/miseui/ui/mask-input-edit.ts",
      "content": "import { acceptCharacters, capacity, conform, formatMasked, parseFormatted, type MaskSlot } from \"@/registry/miseui/ui/mask-input-math\"\n\nfunction rawIndexAt(position: number, slots: readonly MaskSlot[]) {\n  return slots.slice(0, Math.max(0, position)).filter((slot) => slot.kind === \"token\").length\n}\n\n/** The masked position just after the raw character before `rawPosition`. `skip` moves past literals, for the next typed key. */\nfunction caretForRaw(rawPosition: number, slots: readonly MaskSlot[], maskedLength: number, skip: boolean) {\n  let seen = 0\n  let position = 0\n  while (position < slots.length && seen < rawPosition) {\n    if (slots[position].kind === \"token\") seen += 1\n    position += 1\n  }\n  if (skip || rawPosition === 0) {\n    while (position < slots.length && slots[position].kind === \"literal\") position += 1\n  }\n  return Math.min(position, maskedLength)\n}\n\ninterface MaskEdit {\n  start: number\n  end: number\n  inserted: string\n}\n\n/**\n * Works out the edit from the text and caret after it happened. The text after the caret cannot have changed, which\n * covers typing, paste, cut, word delete and autofill alike, and phones that never send a usable key name for Backspace.\n */\nfunction diffEdit(previous: string, next: string, caret: number): MaskEdit {\n  const end = Math.max(0, previous.length - (next.length - caret))\n  const limit = Math.min(caret, end)\n  let start = 0\n  while (start < limit && previous[start] === next[start]) start += 1\n  return { start, end, inserted: next.slice(start, caret) }\n}\n\ninterface MaskResult {\n  raw: string\n  masked: string\n  caret: number\n}\n\nfunction finish(raw: string, slots: readonly MaskSlot[], rawCaret: number, skip: boolean): MaskResult {\n  const fitted = conform(raw, slots)\n  const masked = formatMasked(fitted, slots)\n  const clamped = Math.min(rawCaret, Array.from(fitted).length)\n  return { raw: fitted, masked, caret: caretForRaw(clamped, slots, masked.length, skip) }\n}\n\n/** Applies what the browser just did to the field, returning the corrected value and where the caret belongs. */\nfunction applyEdit(rawBefore: string, slots: readonly MaskSlot[], text: string, caret: number, inputType = \"\"): MaskResult {\n  const previous = formatMasked(rawBefore, slots)\n  const { start, end, inserted } = diffEdit(previous, text, caret)\n  const raw = Array.from(rawBefore)\n  const rawStart = rawIndexAt(start, slots)\n  const rawEnd = rawIndexAt(end, slots)\n  if (inserted === \"\" && start !== end && rawStart === rawEnd) {\n    // Only literals were removed, and they would just come back. The person meant the character beside them.\n    if (inputType === \"deleteContentForward\") {\n      return finish([...raw.slice(0, rawStart), ...raw.slice(rawStart + 1)].join(\"\"), slots, rawStart, false)\n    }\n    return finish([...raw.slice(0, Math.max(0, rawStart - 1)), ...raw.slice(rawStart)].join(\"\"), slots, Math.max(0, rawStart - 1), false)\n  }\n  const kept = [...raw.slice(0, rawStart), ...raw.slice(rawEnd)]\n  const free = capacity(slots) - kept.length\n  const source = parseFormatted(inserted, slots) ?? inserted\n  const accepted = Array.from(acceptCharacters(source, slots, rawStart, free))\n  if (inserted !== \"\" && accepted.length === 0 && start === end) {\n    // A rejected key: nothing moved, so neither does the caret.\n    return { raw: rawBefore, masked: previous, caret: start }\n  }\n  const nextRaw = [...raw.slice(0, rawStart), ...accepted, ...raw.slice(rawEnd)].join(\"\")\n  return finish(nextRaw, slots, rawStart + accepted.length, accepted.length > 0)\n}\n\nexport { applyEdit, caretForRaw, diffEdit, rawIndexAt, type MaskResult }\n",
      "type": "registry:ui"
    },
    {
      "path": "registry/miseui/ui/mask-input-math.ts",
      "content": "/**\n * A mask is a row of slots, one per character. That one-to-one mapping is what keeps the maths simple: position N in\n * the masked text is slot N, so a caret can be translated to a place in the raw value by counting token slots.\n */\ninterface MaskToken {\n  test: (character: string) => boolean\n  /** Must return one character, or positions stop lining up. */\n  transform?: (character: string) => string\n  /** Lets the field ask for the number keyboard on a phone. */\n  numeric?: boolean\n}\n\ntype MaskTokens = Readonly<Record<string, MaskToken>>\ntype MaskSlot = { kind: \"token\"; token: MaskToken } | { kind: \"literal\"; character: string }\n\nconst DEFAULT_TOKENS: MaskTokens = {\n  \"#\": { test: (character) => /^[0-9]$/.test(character), numeric: true },\n  A: { test: (character) => /^\\p{L}$/u.test(character), transform: (character) => character.toUpperCase() },\n  a: { test: (character) => /^\\p{L}$/u.test(character) },\n  \"*\": { test: (character) => /^[\\p{L}\\p{N}]$/u.test(character) },\n}\n\nconst MASK_PRESETS: Readonly<Record<string, string>> = {\n  date: \"##/##/####\",\n  time: \"##:##\",\n  phone: \"(###) ###-####\",\n  card: \"#### #### #### ####\",\n}\n\nfunction parseMask(pattern: string, tokens: MaskTokens = DEFAULT_TOKENS): readonly MaskSlot[] {\n  const characters = Array.from(pattern)\n  const slots: MaskSlot[] = []\n  for (let index = 0; index < characters.length; index += 1) {\n    const character = characters[index]\n    // A backslash makes the next character literal, so a mask can contain a real # or A.\n    if (character === \"\\\\\" && index + 1 < characters.length) {\n      index += 1\n      slots.push({ kind: \"literal\", character: characters[index] })\n    } else {\n      const token = tokens[character]\n      slots.push(token ? { kind: \"token\", token } : { kind: \"literal\", character })\n    }\n  }\n  return slots\n}\n\nfunction tokenSlots(slots: readonly MaskSlot[]) {\n  return slots.filter((slot): slot is Extract<MaskSlot, { kind: \"token\" }> => slot.kind === \"token\")\n}\n\nconst capacity = (slots: readonly MaskSlot[]) => tokenSlots(slots).length\nconst isNumericMask = (slots: readonly MaskSlot[]) => tokenSlots(slots).every(({ token }) => token.numeric === true)\nconst isComplete = (raw: string, slots: readonly MaskSlot[]) => Array.from(raw).length === capacity(slots) && capacity(slots) > 0\n\nconst shape = (token: MaskToken, character: string) => token.transform?.(character) ?? character\n\n/** Characters that fit, starting at raw position `from`, up to `limit`. Anything else is skipped, which is how hyphens in a paste disappear. */\nfunction acceptCharacters(text: string, slots: readonly MaskSlot[], from: number, limit: number) {\n  const tokens = tokenSlots(slots)\n  let accepted = \"\"\n  let count = 0\n  for (const character of Array.from(text)) {\n    if (count >= limit) break\n    const slot = tokens[from + count]\n    if (!slot) break\n    if (slot.token.test(character)) {\n      accepted += shape(slot.token, character)\n      count += 1\n    }\n  }\n  return accepted\n}\n\n/** Raw values may arrive from outside or shift after a delete, so they are re-fitted to the slots every time. */\nfunction conform(raw: string, slots: readonly MaskSlot[]) {\n  return acceptCharacters(raw, slots, 0, capacity(slots))\n}\n\n/** Literals appear only up to the last typed character: a trailing one could not be deleted, because nothing would change. */\nfunction formatMasked(raw: string, slots: readonly MaskSlot[]) {\n  const characters = Array.from(raw)\n  if (characters.length === 0) return \"\"\n  let masked = \"\"\n  let used = 0\n  for (const slot of slots) {\n    if (used >= characters.length) break\n    if (slot.kind === \"literal\") {\n      masked += slot.character\n    } else {\n      masked += characters[used]\n      used += 1\n    }\n  }\n  return masked\n}\n\n/** Reads text already shaped like this mask, returning its raw value, or null if it is not. \"+82 10\" must not yield raw 8210. */\nfunction parseFormatted(text: string, slots: readonly MaskSlot[]) {\n  const characters = Array.from(text)\n  if (characters.length > slots.length) return null\n  let raw = \"\"\n  let sawLiteral = false\n  for (let index = 0; index < characters.length; index += 1) {\n    const slot = slots[index]\n    const character = characters[index]\n    if (slot.kind === \"literal\") {\n      if (character !== slot.character) return null\n      sawLiteral = true\n    } else {\n      if (!slot.token.test(character)) return null\n      raw += shape(slot.token, character)\n    }\n  }\n  return sawLiteral ? raw : null\n}\n\ninterface MaskGroup {\n  /** Masked position of the first character in this group. */\n  start: number\n  size: number\n  text: string\n}\n\n/** Runs of token slots between literals: one box per group in `grouped`. */\nfunction maskGroups(raw: string, slots: readonly MaskSlot[]): readonly MaskGroup[] {\n  const characters = Array.from(raw)\n  const groups: MaskGroup[] = []\n  let used = 0\n  let current: MaskGroup | null = null\n  slots.forEach((slot, position) => {\n    if (slot.kind === \"literal\") {\n      current = null\n      return\n    }\n    if (current === null) {\n      current = { start: position, size: 0, text: \"\" }\n      groups.push(current)\n    }\n    current.size += 1\n    current.text += characters[used] ?? \"\"\n    used += 1\n  })\n  return groups\n}\n\n/** Which group takes the next key. A full group with nothing after it hands over, so the highlight is ahead of the finger. */\nfunction activeGroupIndex(groups: readonly MaskGroup[], caret: number, maskedLength: number) {\n  const found = groups.findIndex((group) => caret <= group.start + group.size)\n  if (found < 0) return groups.length - 1\n  const group = groups[found]\n  const handsOver = caret === maskedLength && caret === group.start + group.size && found < groups.length - 1\n  return handsOver ? found + 1 : found\n}\n\nfunction placeholderFor(slots: readonly MaskSlot[], character = \"_\") {\n  return slots.map((slot) => (slot.kind === \"literal\" ? slot.character : character)).join(\"\")\n}\n\nexport {\n  activeGroupIndex,\n  acceptCharacters,\n  capacity,\n  conform,\n  formatMasked,\n  isComplete,\n  isNumericMask,\n  maskGroups,\n  parseFormatted,\n  parseMask,\n  placeholderFor,\n  DEFAULT_TOKENS,\n  MASK_PRESETS,\n  type MaskGroup,\n  type MaskSlot,\n  type MaskToken,\n  type MaskTokens,\n}\n",
      "type": "registry:ui"
    },
    {
      "path": "registry/miseui/ui/mask-input-state.ts",
      "content": "import type * as React from \"react\"\nimport { useMemo, useState } from \"react\"\nimport { applyEdit } from \"@/registry/miseui/ui/mask-input-edit\"\nimport {\n  conform,\n  formatMasked,\n  isComplete,\n  parseFormatted,\n  parseMask,\n  DEFAULT_TOKENS,\n  MASK_PRESETS,\n  type MaskTokens,\n} from \"@/registry/miseui/ui/mask-input-math\"\n\ninterface MaskStateOptions {\n  /** A preset name (date, time, phone, card) or a pattern. `#` digit, `A` letter in capitals, `a` letter, `*` either, `\\` escapes. */\n  mask: string\n  /** Extra tokens, merged over the built-in ones. */\n  tokens?: MaskTokens\n  /** The raw value, without literals. */\n  value?: string\n  defaultValue?: string\n  onValueChange?: (raw: string, masked: string) => void\n  /** Fires when the last slot is filled. */\n  onComplete?: (raw: string, masked: string) => void\n}\n\nfunction useMaskState({ mask, tokens, value, defaultValue = \"\", onValueChange, onComplete }: MaskStateOptions) {\n  const pattern = MASK_PRESETS[mask] ?? mask\n  const slots = useMemo(() => parseMask(pattern, tokens ? { ...DEFAULT_TOKENS, ...tokens } : DEFAULT_TOKENS), [pattern, tokens])\n  const [uncontrolled, setUncontrolled] = useState(defaultValue)\n  const given = value ?? uncontrolled\n  // A saved value may already carry the literals.\n  const raw = conform(parseFormatted(given, slots) ?? given, slots)\n  const masked = formatMasked(raw, slots)\n  const [caret, setCaret] = useState(0)\n  const [focused, setFocused] = useState(false)\n\n  const onChange = (event: React.ChangeEvent<HTMLInputElement>) => {\n    const input = event.currentTarget\n    const inputType = (event.nativeEvent as InputEvent).inputType\n    const result = applyEdit(raw, slots, input.value, input.selectionStart ?? input.value.length, inputType)\n    // Written straight to the element: React only restores a value that differs, so the caret set here survives, even when a key was rejected and no state changes.\n    input.value = result.masked\n    input.setSelectionRange(result.caret, result.caret)\n    setCaret(result.caret)\n    if (result.raw === raw) return\n    setUncontrolled(result.raw)\n    onValueChange?.(result.raw, result.masked)\n    if (isComplete(result.raw, slots)) onComplete?.(result.raw, result.masked)\n  }\n  /** Setting the range from code does not reliably raise a select event, so the state is told directly. */\n  const moveCaret = (input: HTMLInputElement, position: number) => {\n    input.setSelectionRange(position, position)\n    setCaret(position)\n  }\n  const syncCaret = (event: React.SyntheticEvent<HTMLInputElement>) => setCaret(event.currentTarget.selectionStart ?? 0)\n  const handlers = {\n    onChange,\n    onSelect: syncCaret,\n    onFocus: () => setFocused(true),\n    onBlur: () => setFocused(false),\n  }\n  return { slots, raw, masked, caret, focused, handlers, moveCaret }\n}\n\nexport { useMaskState, type MaskStateOptions }\n",
      "type": "registry:ui"
    }
  ],
  "categories": [
    "forms"
  ],
  "type": "registry:ui"
}