{ "name": "mask-input", "type": "registry:ui", "title": "Mask Input", "description": "Formats as you type — card numbers, phone numbers, dates. Hands back the raw characters alongside the formatted text, so a form does not submit \"(555) 010-9999\" to an API expecting digits.", "category": "Forms", "dependencies": [], "registryDependencies": [ "lib-styles", "lib-utils" ], "files": [ { "path": "components/ui/mask-input.tsx", "type": "registry:ui", "content": "'use client'\n\nimport { useRef, useState, type ComponentProps } from 'react'\nimport {\n fieldBase,\n fieldInput,\n fieldSize,\n} from '@/lib/styles'\nimport { cn } from '@/lib/utils'\n\n/**\n * A text field that formats as you type — card numbers, phone numbers, dates.\n *\n * The mask is a pattern of placeholders: `#` for a digit, `A` for a letter, `*`\n * for either. Everything else is a literal that the component inserts.\n *\n * Two details make the difference between a mask that helps and one that\n * fights you. Deleting a literal deletes the character before it, so backspace\n * over the space in \"4242 4242\" removes a digit rather than sticking. And the\n * caller receives the raw value alongside the formatted one, because a form\n * that submits \"(555) 010-9999\" to an API expecting digits has just moved the\n * parsing problem downstream.\n */\nconst TOKENS: Record = {\n '#': /\\d/,\n A: /[a-z]/i,\n '*': /[a-z0-9]/i,\n}\n\n/** Apply the mask, returning the formatted text and the raw characters kept. */\nfunction applyMask(input: string, mask: string) {\n let formatted = ''\n let raw = ''\n let index = 0\n\n for (const slot of mask) {\n if (index >= input.length) break\n const token = TOKENS[slot]\n\n if (token) {\n // Skip anything that does not fit this slot rather than stopping, so\n // pasting \"4242-4242\" into a digits mask keeps the digits.\n while (index < input.length && !token.test(input[index])) index++\n if (index >= input.length) break\n formatted += input[index]\n raw += input[index]\n index++\n } else {\n formatted += slot\n // A literal typed by the user is consumed rather than duplicated.\n if (input[index] === slot) index++\n }\n }\n\n return { formatted, raw }\n}\n\nfunction MaskInput({\n mask,\n value: valueProp,\n defaultValue = '',\n onValueChange,\n size = 'md',\n variant = 'default',\n error = false,\n disabled = false,\n placeholder,\n className,\n ...props\n}: Omit, 'value' | 'defaultValue' | 'onChange' | 'size'> & {\n /** `#` digit, `A` letter, `*` either; anything else is a literal. */\n mask: string\n value?: string\n defaultValue?: string\n /** Receives both the formatted text and the raw characters. */\n onValueChange?: (formatted: string, raw: string) => void\n size?: keyof typeof fieldSize\n variant?: 'default' | 'secondary' | 'ghost'\n error?: boolean\n}) {\n const controlled = valueProp !== undefined\n const [uncontrolled, setUncontrolled] = useState(\n () => applyMask(defaultValue, mask).formatted,\n )\n const value = controlled ? valueProp : uncontrolled\n const previous = useRef(value)\n\n const VARIANT = {\n default: 'border-border bg-background border',\n secondary: 'bg-secondary border border-transparent',\n ghost: 'border border-transparent bg-transparent',\n }[variant]\n\n return (\n \n {\n let next = event.target.value\n\n // Deleting into a literal removes the character before it too,\n // otherwise backspace appears to do nothing.\n if (next.length < previous.current.length) {\n const trimmed = next.replace(/[^#A*]$/, '')\n if (trimmed !== next && !TOKENS[mask[next.length]]) {\n next = next.slice(0, -1)\n }\n }\n\n const { formatted, raw } = applyMask(next, mask)\n previous.current = formatted\n if (!controlled) setUncontrolled(formatted)\n onValueChange?.(formatted, raw)\n }}\n className={cn(fieldInput, 'tabular-nums')}\n {...props}\n />\n \n )\n}\n\nexport { MaskInput, applyMask }\n" } ] }