{ "name": "mask-input", "type": "registry:ui", "files": [ { "path": "ui/mask-input.tsx", "type": "registry:ui", "content": "\"use client\";\n\nimport { mergeProps } from \"@base-ui/react/merge-props\";\nimport { useRender } from \"@base-ui/react/use-render\";\nimport * as React from \"react\";\nimport { cn } from \"@/lib/utils\";\nimport { useComposedRefs } from \"@/registry/bases/base/lib/compose-refs\";\n\nconst PAST_YEARS_LIMIT = 120;\nconst FUTURE_YEARS_LIMIT = 10;\nconst DEFAULT_CURRENCY = \"USD\";\nconst DEFAULT_LOCALE = \"en-US\";\n\nconst NUMERIC_MASK_PATTERNS =\n /^(phone|zipCode|zipCodeExtended|ssn|ein|time|date|creditCard|creditCardExpiry)$/;\nconst CURRENCY_PERCENTAGE_SYMBOLS = /[€$%]/;\n\ninterface CurrencySymbols {\n currency: string;\n decimal: string;\n group: string;\n}\n\nconst formattersCache = new Map();\nconst currencyAtEndCache = new Map();\nconst currencySymbolsCache = new Map();\nconst daysInMonthCache = [\n 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31,\n] as const;\n\nconst REGEX_CACHE = {\n digitsOnly: /^\\d+$/,\n nonDigits: /\\D/g,\n nonAlphaNumeric: /[^A-Z0-9]/gi,\n nonNumericDot: /[^0-9.]/g,\n nonCurrencyChars: /[^\\d.,]/g,\n hashPattern: /#/g,\n currencyAtEnd: /\\d\\s*[^\\d\\s]+$/,\n percentageChars: /[^\\d.]/g,\n phone: /^\\d{10}$/,\n ssn: /^\\d{9}$/,\n zipCode: /^\\d{5}$/,\n zipCodeExtended: /^\\d{9}$/,\n isbn: /^\\d{13}$/,\n ein: /^\\d{9}$/,\n time: /^\\d{4}$/,\n creditCard: /^\\d{13,19}$/,\n creditCardExpiry: /^\\d{4}$/,\n licensePlate: /^[A-Z0-9]{6}$/,\n macAddress: /^[A-F0-9]{12}$/,\n currencyValidation: /^\\d+(\\.\\d{1,2})?$/,\n ipv4Segment: /^\\d{1,3}$/,\n} as const;\n\nfunction getCachedFormatter(\n locale: string | undefined,\n opts: Intl.NumberFormatOptions,\n): Intl.NumberFormat {\n const {\n currency,\n minimumFractionDigits = 0,\n maximumFractionDigits = 2,\n } = opts;\n\n const key = `${locale}|${currency}|${minimumFractionDigits}|${maximumFractionDigits}`;\n\n if (!formattersCache.has(key)) {\n try {\n formattersCache.set(\n key,\n new Intl.NumberFormat(locale, {\n style: \"currency\",\n currency,\n ...opts,\n }),\n );\n } catch {\n formattersCache.set(\n key,\n new Intl.NumberFormat(DEFAULT_LOCALE, {\n style: \"currency\",\n currency: DEFAULT_CURRENCY,\n ...opts,\n }),\n );\n }\n }\n\n const formatter = formattersCache.get(key);\n if (!formatter) {\n throw new Error(`Failed to create formatter for ${key}`);\n }\n return formatter;\n}\n\nfunction getCachedCurrencySymbols(opts: TransformOptions): CurrencySymbols {\n const { locale, currency } = opts;\n\n const key = `${locale}|${currency}`;\n const cached = currencySymbolsCache.get(key);\n if (cached) {\n return cached;\n }\n\n let currencySymbol = \"$\";\n let decimalSeparator = \".\";\n let groupSeparator = \",\";\n\n try {\n const formatter = getCachedFormatter(locale, {\n currency,\n minimumFractionDigits: 0,\n maximumFractionDigits: 2,\n });\n const parts = formatter.formatToParts(1234.5);\n const currencyPart = parts.find((part) => part.type === \"currency\");\n const decimalPart = parts.find((part) => part.type === \"decimal\");\n const groupPart = parts.find((part) => part.type === \"group\");\n\n if (currencyPart) currencySymbol = currencyPart.value;\n if (decimalPart) decimalSeparator = decimalPart.value;\n if (groupPart) groupSeparator = groupPart.value;\n } catch {\n // Keep defaults\n }\n\n const symbols: CurrencySymbols = {\n currency: currencySymbol,\n decimal: decimalSeparator,\n group: groupSeparator,\n };\n currencySymbolsCache.set(key, symbols);\n return symbols;\n}\n\nfunction isCurrencyAtEnd(opts: TransformOptions): boolean {\n const { locale, currency } = opts;\n\n const key = `${locale}|${currency}`;\n const cached = currencyAtEndCache.get(key);\n if (cached !== undefined) {\n return cached;\n }\n\n try {\n const formatter = getCachedFormatter(locale, {\n currency,\n minimumFractionDigits: 0,\n maximumFractionDigits: 0,\n });\n const sample = formatter.format(123);\n const result = REGEX_CACHE.currencyAtEnd.test(sample);\n currencyAtEndCache.set(key, result);\n return result;\n } catch {\n currencyAtEndCache.set(key, false);\n return false;\n }\n}\n\nfunction isCurrencyMask(opts: {\n mask: MaskPatternKey | MaskPattern | undefined;\n pattern?: string;\n}): boolean {\n const { mask, pattern } = opts;\n\n return (\n mask === \"currency\" ||\n Boolean(pattern && (pattern.includes(\"$\") || pattern.includes(\"€\")))\n );\n}\n\ninterface TransformOptions {\n currency?: string;\n locale?: string;\n}\n\ninterface ValidateOptions {\n min?: number;\n max?: number;\n}\n\ninterface MaskPattern {\n pattern: string;\n transform?: (value: string, opts?: TransformOptions) => string;\n validate?: (value: string, opts?: ValidateOptions) => boolean;\n}\n\ntype MaskPatternKey =\n | \"phone\"\n | \"ssn\"\n | \"date\"\n | \"time\"\n | \"creditCard\"\n | \"creditCardExpiry\"\n | \"zipCode\"\n | \"zipCodeExtended\"\n | \"currency\"\n | \"percentage\"\n | \"licensePlate\"\n | \"ipv4\"\n | \"macAddress\"\n | \"isbn\"\n | \"ein\";\n\nconst MASK_PATTERNS: Record = {\n phone: {\n pattern: \"(###) ###-####\",\n transform: (value) => value.replace(REGEX_CACHE.nonDigits, \"\"),\n validate: (value) =>\n REGEX_CACHE.phone.test(value.replace(REGEX_CACHE.nonDigits, \"\")),\n },\n ssn: {\n pattern: \"###-##-####\",\n transform: (value) => value.replace(REGEX_CACHE.nonDigits, \"\"),\n validate: (value) =>\n REGEX_CACHE.ssn.test(value.replace(REGEX_CACHE.nonDigits, \"\")),\n },\n date: {\n pattern: \"##/##/####\",\n transform: (value) => value.replace(REGEX_CACHE.nonDigits, \"\"),\n validate: (value) => {\n const cleaned = value.replace(REGEX_CACHE.nonDigits, \"\");\n if (cleaned.length !== 8) return false;\n const month = parseInt(cleaned.substring(0, 2), 10);\n const day = parseInt(cleaned.substring(2, 4), 10);\n const year = parseInt(cleaned.substring(4, 8), 10);\n\n const currentYear = new Date().getFullYear();\n const minYear = currentYear - PAST_YEARS_LIMIT;\n const maxYear = currentYear + FUTURE_YEARS_LIMIT;\n if (\n month < 1 ||\n month > 12 ||\n day < 1 ||\n year < minYear ||\n year > maxYear\n )\n return false;\n\n const maxDays =\n month === 2 &&\n ((year % 4 === 0 && year % 100 !== 0) || year % 400 === 0)\n ? 29\n : (daysInMonthCache[month - 1] ?? 31);\n\n return day <= maxDays;\n },\n },\n time: {\n pattern: \"##:##\",\n transform: (value) => value.replace(REGEX_CACHE.nonDigits, \"\"),\n validate: (value) => {\n const cleaned = value.replace(REGEX_CACHE.nonDigits, \"\");\n if (!REGEX_CACHE.time.test(cleaned)) return false;\n const hours = parseInt(cleaned.substring(0, 2), 10);\n const minutes = parseInt(cleaned.substring(2, 4), 10);\n return hours <= 23 && minutes <= 59;\n },\n },\n creditCard: {\n pattern: \"#### #### #### ####\",\n transform: (value) => value.replace(REGEX_CACHE.nonDigits, \"\"),\n validate: (value) => {\n const cleaned = value.replace(REGEX_CACHE.nonDigits, \"\");\n if (!REGEX_CACHE.creditCard.test(cleaned)) return false;\n\n let sum = 0;\n let isEven = false;\n for (let i = cleaned.length - 1; i >= 0; i--) {\n const digitChar = cleaned[i];\n if (!digitChar) continue;\n let digit = parseInt(digitChar, 10);\n if (isEven) {\n digit *= 2;\n if (digit > 9) {\n digit -= 9;\n }\n }\n sum += digit;\n isEven = !isEven;\n }\n return sum % 10 === 0;\n },\n },\n creditCardExpiry: {\n pattern: \"##/##\",\n transform: (value) => value.replace(REGEX_CACHE.nonDigits, \"\"),\n validate: (value) => {\n const cleaned = value.replace(REGEX_CACHE.nonDigits, \"\");\n if (!REGEX_CACHE.creditCardExpiry.test(cleaned)) return false;\n\n const month = parseInt(cleaned.substring(0, 2), 10);\n const year = parseInt(cleaned.substring(2, 4), 10);\n\n if (month < 1 || month > 12) return false;\n\n const now = new Date();\n const currentYear = now.getFullYear();\n const currentMonth = now.getMonth() + 1;\n\n const fullYear = year <= 75 ? 2000 + year : 1900 + year;\n\n if (\n fullYear < currentYear ||\n (fullYear === currentYear && month < currentMonth)\n ) {\n return false;\n }\n\n const maxYear = currentYear + 50;\n if (fullYear > maxYear) {\n return false;\n }\n\n return true;\n },\n },\n zipCode: {\n pattern: \"#####\",\n transform: (value) => value.replace(REGEX_CACHE.nonDigits, \"\"),\n validate: (value) =>\n REGEX_CACHE.zipCode.test(value.replace(REGEX_CACHE.nonDigits, \"\")),\n },\n zipCodeExtended: {\n pattern: \"#####-####\",\n transform: (value) => value.replace(REGEX_CACHE.nonDigits, \"\"),\n validate: (value) =>\n REGEX_CACHE.zipCodeExtended.test(\n value.replace(REGEX_CACHE.nonDigits, \"\"),\n ),\n },\n currency: {\n pattern: \"$###,###.##\",\n transform: (\n value,\n { currency = DEFAULT_CURRENCY, locale = DEFAULT_LOCALE } = {},\n ) => {\n let localeDecimalSeparator = \".\";\n\n try {\n const formatter = getCachedFormatter(locale, {\n currency,\n minimumFractionDigits: 0,\n maximumFractionDigits: 2,\n });\n const parts = formatter.formatToParts(1234.5);\n const decimalPart = parts.find((part) => part.type === \"decimal\");\n\n if (decimalPart) localeDecimalSeparator = decimalPart.value;\n } catch {\n // Keep defaults\n }\n\n const cleaned = value.replace(REGEX_CACHE.nonCurrencyChars, \"\");\n\n const dotIndex = cleaned.indexOf(\".\");\n const commaIndex = cleaned.indexOf(\",\");\n\n let hasDecimalSeparator = false;\n let decimalIndex = -1;\n\n if (localeDecimalSeparator === \",\") {\n const lastCommaIndex = cleaned.lastIndexOf(\",\");\n if (lastCommaIndex !== -1) {\n const afterComma = cleaned.substring(lastCommaIndex + 1);\n if (afterComma.length <= 2 && /^\\d*$/.test(afterComma)) {\n hasDecimalSeparator = true;\n decimalIndex = lastCommaIndex;\n }\n }\n\n if (!hasDecimalSeparator && dotIndex !== -1) {\n const afterDot = cleaned.substring(dotIndex + 1);\n if (afterDot.length <= 2 && /^\\d*$/.test(afterDot)) {\n hasDecimalSeparator = true;\n decimalIndex = dotIndex;\n }\n }\n\n if (!hasDecimalSeparator && cleaned.length >= 4) {\n const match = cleaned.match(/^(\\d+)\\.(\\d{3})(\\d{1,2})$/);\n if (match) {\n const [, beforeDot, thousandsPart, decimalPart] = match;\n const integerPart = (beforeDot ?? \"\") + (thousandsPart ?? \"\");\n const result = `${integerPart}.${decimalPart}`;\n return result;\n }\n }\n } else {\n const lastDotIndex = cleaned.lastIndexOf(\".\");\n if (lastDotIndex !== -1) {\n const afterDot = cleaned.substring(lastDotIndex + 1);\n if (afterDot.length <= 2 && /^\\d*$/.test(afterDot)) {\n hasDecimalSeparator = true;\n decimalIndex = lastDotIndex;\n }\n }\n\n if (!hasDecimalSeparator && commaIndex !== -1) {\n const afterComma = cleaned.substring(commaIndex + 1);\n const looksLikeThousands = commaIndex <= 3 && afterComma.length >= 3;\n if (\n !looksLikeThousands &&\n afterComma.length <= 2 &&\n /^\\d*$/.test(afterComma)\n ) {\n hasDecimalSeparator = true;\n decimalIndex = commaIndex;\n }\n }\n }\n\n if (hasDecimalSeparator && decimalIndex !== -1) {\n const beforeDecimal = cleaned\n .substring(0, decimalIndex)\n .replace(/[.,]/g, \"\");\n const afterDecimal = cleaned\n .substring(decimalIndex + 1)\n .replace(/[.,]/g, \"\");\n\n if (afterDecimal === \"\") {\n const result = `${beforeDecimal}.`;\n return result;\n }\n\n const result = `${beforeDecimal}.${afterDecimal.substring(0, 2)}`;\n return result;\n }\n\n const digitsOnly = cleaned.replace(/[.,]/g, \"\");\n return digitsOnly;\n },\n validate: (value) => {\n if (!REGEX_CACHE.currencyValidation.test(value)) return false;\n const num = parseFloat(value);\n return !Number.isNaN(num) && num >= 0;\n },\n },\n percentage: {\n pattern: \"##.##%\",\n transform: (value) => {\n const cleaned = value.replace(REGEX_CACHE.percentageChars, \"\");\n const parts = cleaned.split(\".\");\n if (parts.length > 2) {\n return `${parts[0]}.${parts.slice(1).join(\"\")}`;\n }\n if (parts[1] && parts[1].length > 2) {\n return `${parts[0]}.${parts[1].substring(0, 2)}`;\n }\n return cleaned;\n },\n validate: (value, opts = {}) => {\n const num = parseFloat(value);\n const min = opts.min ?? 0;\n const max = opts.max ?? 100;\n return !Number.isNaN(num) && num >= min && num <= max;\n },\n },\n licensePlate: {\n pattern: \"###-###\",\n transform: (value) =>\n value.replace(REGEX_CACHE.nonAlphaNumeric, \"\").toUpperCase(),\n validate: (value) => REGEX_CACHE.licensePlate.test(value),\n },\n ipv4: {\n pattern: \"###.###.###.###\",\n transform: (value) => value.replace(REGEX_CACHE.nonNumericDot, \"\"),\n validate: (value) => {\n if (value.includes(\".\")) {\n const segments = value.split(\".\");\n if (segments.length > 4) return false;\n\n return segments.every((segment) => {\n if (segment === \"\") return true;\n if (!REGEX_CACHE.ipv4Segment.test(segment)) return false;\n const num = parseInt(segment, 10);\n return num <= 255;\n });\n } else {\n if (!REGEX_CACHE.digitsOnly.test(value)) return false;\n if (value.length > 12) return false;\n\n const chunks = [];\n for (let i = 0; i < value.length; i += 3) {\n chunks.push(value.substring(i, i + 3));\n }\n\n if (chunks.length > 4) return false;\n\n return chunks.every((chunk) => {\n const num = parseInt(chunk, 10);\n return num >= 0 && num <= 255;\n });\n }\n },\n },\n macAddress: {\n pattern: \"##:##:##:##:##:##\",\n transform: (value) =>\n value.replace(REGEX_CACHE.nonAlphaNumeric, \"\").toUpperCase(),\n validate: (value) => REGEX_CACHE.macAddress.test(value),\n },\n isbn: {\n pattern: \"###-#-###-#####-#\",\n transform: (value) => value.replace(REGEX_CACHE.nonDigits, \"\"),\n validate: (value) =>\n REGEX_CACHE.isbn.test(value.replace(REGEX_CACHE.nonDigits, \"\")),\n },\n ein: {\n pattern: \"##-#######\",\n transform: (value) => value.replace(REGEX_CACHE.nonDigits, \"\"),\n validate: (value) =>\n REGEX_CACHE.ein.test(value.replace(REGEX_CACHE.nonDigits, \"\")),\n },\n};\n\nfunction applyMask(opts: {\n value: string;\n pattern: string;\n currency?: string;\n locale?: string;\n mask?: MaskPatternKey | MaskPattern;\n}): string {\n const { value, pattern, currency, locale, mask } = opts;\n\n const cleanValue = value;\n\n if (pattern.includes(\"$\") || pattern.includes(\"€\") || mask === \"currency\") {\n return applyCurrencyMask({\n value: cleanValue,\n currency: currency ?? DEFAULT_CURRENCY,\n locale: locale ?? DEFAULT_LOCALE,\n });\n }\n\n if (pattern.includes(\"%\")) {\n return applyPercentageMask(cleanValue);\n }\n\n if (mask === \"ipv4\") {\n return cleanValue;\n }\n\n const maskedChars: string[] = [];\n let valueIndex = 0;\n\n for (let i = 0; i < pattern.length && valueIndex < cleanValue.length; i++) {\n const patternChar = pattern[i];\n const valueChar = cleanValue[valueIndex];\n\n if (patternChar === \"#\" && valueChar) {\n maskedChars.push(valueChar);\n valueIndex++;\n } else if (patternChar) {\n maskedChars.push(patternChar);\n }\n }\n\n return maskedChars.join(\"\");\n}\n\nfunction applyCurrencyMask(opts: {\n value: string;\n currency?: string;\n locale?: string;\n}): string {\n const { value, currency = DEFAULT_CURRENCY, locale = DEFAULT_LOCALE } = opts;\n\n if (!value) return \"\";\n\n const {\n currency: currencySymbol,\n decimal: decimalSeparator,\n group: groupSeparator,\n } = getCachedCurrencySymbols({ locale, currency });\n\n const normalizedValue = value\n .replace(\n new RegExp(\n `\\\\${groupSeparator.replace(/[.*+?^${}()|[\\]\\\\]/g, \"\\\\$&\")}`,\n \"g\",\n ),\n \"\",\n )\n .replace(decimalSeparator, \".\");\n\n const parts = normalizedValue.split(\".\");\n const integerPart = parts[0] ?? \"\";\n const fractionalPart = parts[1] ?? \"\";\n\n if (!integerPart && !fractionalPart) return \"\";\n\n const intValue = integerPart ?? \"0\";\n const fracValue = fractionalPart.slice(0, 2);\n\n const num = Number(`${intValue}.${fracValue ?? \"\"}`);\n\n if (Number.isNaN(num)) {\n const cleanedDigits = value.replace(/[^\\d]/g, \"\");\n if (!cleanedDigits) return \"\";\n return `${currencySymbol}${cleanedDigits}`;\n }\n\n const hasExplicitDecimal =\n value.includes(\".\") || value.includes(decimalSeparator);\n\n try {\n const formatter = getCachedFormatter(locale, {\n currency,\n minimumFractionDigits: fracValue ? fracValue.length : 0,\n maximumFractionDigits: 2,\n });\n const result = formatter.format(num);\n\n if (hasExplicitDecimal && !fracValue) {\n if (result.match(/^[^\\d\\s]+/)) {\n const finalResult = result.replace(/(\\d)$/, `$1${decimalSeparator}`);\n return finalResult;\n } else {\n const finalResult = result.replace(\n /(\\d)(\\s*)([^\\d\\s]+)$/,\n `$1${decimalSeparator}$2$3`,\n );\n return finalResult;\n }\n }\n\n return result;\n } catch {\n const formattedInt = intValue.replace(\n /\\B(?=(\\d{3})+(?!\\d))/g,\n groupSeparator,\n );\n let result = `${currencySymbol}${formattedInt}`;\n if (hasExplicitDecimal) {\n result += `${decimalSeparator}${fracValue}`;\n }\n\n return result;\n }\n}\n\nfunction applyPercentageMask(value: string): string {\n if (!value) return \"\";\n\n const parts = value.split(\".\");\n let result = parts[0] ?? \"0\";\n\n if (value.includes(\".\")) {\n result += `.${(parts[1] ?? \"\").substring(0, 2)}`;\n }\n\n return `${result}%`;\n}\n\nfunction getUnmaskedValue(opts: {\n value: string;\n currency?: string;\n locale?: string;\n transform?: (value: string, opts?: TransformOptions) => string;\n}): string {\n const { value, transform, currency, locale } = opts;\n\n return transform\n ? transform(value, { currency, locale })\n : value.replace(REGEX_CACHE.nonDigits, \"\");\n}\n\nfunction toUnmaskedIndex(opts: {\n masked: string;\n pattern: string;\n caret: number;\n}): number {\n const { masked, pattern, caret } = opts;\n\n let idx = 0;\n for (let i = 0; i < caret && i < masked.length && i < pattern.length; i++) {\n if (pattern[i] === \"#\") {\n idx++;\n }\n }\n\n return idx;\n}\n\nfunction fromUnmaskedIndex(opts: {\n masked: string;\n pattern: string;\n unmaskedIndex: number;\n}): number {\n const { masked, pattern, unmaskedIndex } = opts;\n\n let seen = 0;\n for (let i = 0; i < masked.length && i < pattern.length; i++) {\n if (pattern[i] === \"#\") {\n seen++;\n if (seen === unmaskedIndex) {\n return i + 1;\n }\n }\n }\n\n return masked.length;\n}\n\nfunction getCurrencyCaretPosition(opts: {\n newValue: string;\n mask: MaskPatternKey | MaskPattern | undefined;\n transformOpts: TransformOptions;\n oldCursorPosition?: number;\n oldValue?: string;\n previousUnmasked?: string;\n}): number {\n const {\n newValue,\n mask,\n transformOpts,\n oldCursorPosition,\n oldValue,\n previousUnmasked,\n } = opts;\n\n if (\n oldCursorPosition !== undefined &&\n oldValue &&\n previousUnmasked !== undefined\n ) {\n if (oldCursorPosition < oldValue.length) {\n const digitsBeforeCursor = oldValue\n .substring(0, oldCursorPosition)\n .replace(/\\D/g, \"\").length;\n\n let digitCount = 0;\n for (let i = 0; i < newValue.length; i++) {\n if (/\\d/.test(newValue[i] ?? \"\")) {\n digitCount++;\n if (digitCount === digitsBeforeCursor) {\n return i + 1;\n }\n }\n }\n }\n }\n\n if (mask === \"currency\") {\n const currencyAtEnd = isCurrencyAtEnd(transformOpts);\n if (currencyAtEnd) {\n const match = newValue.match(/(\\d)\\s*([^\\d\\s]+)$/);\n if (match?.[1]) {\n return newValue.lastIndexOf(match[1]) + 1;\n } else {\n return newValue.length;\n }\n } else {\n return newValue.length;\n }\n } else {\n return newValue.length;\n }\n}\n\nfunction getPatternCaretPosition(opts: {\n newValue: string;\n maskPattern: MaskPattern;\n currentUnmasked: string;\n oldCursorPosition?: number;\n oldValue?: string;\n previousUnmasked?: string;\n}): number {\n const {\n newValue,\n maskPattern,\n currentUnmasked,\n oldCursorPosition,\n oldValue,\n previousUnmasked,\n } = opts;\n let position = 0;\n let unmaskedCount = 0;\n\n if (\n oldCursorPosition !== undefined &&\n oldValue &&\n previousUnmasked !== undefined\n ) {\n const oldUnmaskedIndex = toUnmaskedIndex({\n masked: oldValue,\n pattern: maskPattern.pattern,\n caret: oldCursorPosition,\n });\n\n if (oldCursorPosition < oldValue.length) {\n const targetUnmaskedIndex = Math.min(\n oldUnmaskedIndex,\n currentUnmasked.length,\n );\n\n for (\n let i = 0;\n i < maskPattern.pattern.length && i < newValue.length;\n i++\n ) {\n if (maskPattern.pattern[i] === \"#\") {\n unmaskedCount++;\n if (unmaskedCount <= targetUnmaskedIndex) {\n position = i + 1;\n }\n }\n }\n\n return position;\n }\n }\n\n for (let i = 0; i < maskPattern.pattern.length && i < newValue.length; i++) {\n if (maskPattern.pattern[i] === \"#\") {\n unmaskedCount++;\n if (unmaskedCount <= currentUnmasked.length) {\n position = i + 1;\n }\n }\n }\n\n return position;\n}\n\ntype InputElement = React.ComponentRef<\"input\">;\n\ninterface MaskInputProps\n extends React.ComponentProps<\"input\">,\n useRender.ComponentProps<\"input\"> {\n value?: string;\n defaultValue?: string;\n onValueChange?: (maskedValue: string, unmaskedValue: string) => void;\n onValidate?: (isValid: boolean, unmaskedValue: string) => void;\n validationMode?: \"onChange\" | \"onBlur\" | \"onSubmit\" | \"onTouched\" | \"all\";\n mask?: MaskPatternKey | MaskPattern;\n maskPlaceholder?: string;\n currency?: string;\n locale?: string;\n invalid?: boolean;\n withoutMask?: boolean;\n}\n\nfunction MaskInput(props: MaskInputProps) {\n const {\n value: valueProp,\n defaultValue,\n onValueChange: onValueChangeProp,\n onValidate,\n onBlur: onBlurProp,\n onFocus: onFocusProp,\n onKeyDown: onKeyDownProp,\n onPaste: onPasteProp,\n onCompositionStart: onCompositionStartProp,\n onCompositionEnd: onCompositionEndProp,\n validationMode = \"onChange\",\n mask,\n maskPlaceholder,\n currency = DEFAULT_CURRENCY,\n locale = DEFAULT_LOCALE,\n placeholder,\n inputMode,\n min,\n max,\n maxLength,\n disabled = false,\n invalid = false,\n readOnly = false,\n required = false,\n withoutMask = false,\n className,\n render,\n ref,\n ...inputProps\n } = props;\n\n const [internalValue, setInternalValue] = React.useState(defaultValue ?? \"\");\n const [focused, setFocused] = React.useState(false);\n const [composing, setComposing] = React.useState(false);\n const [touched, setTouched] = React.useState(false);\n const inputRef = React.useRef(null);\n const composedRef = useComposedRefs(ref, inputRef);\n\n const isControlled = valueProp !== undefined;\n const value = isControlled ? valueProp : internalValue;\n\n const maskPattern = React.useMemo(() => {\n if (typeof mask === \"string\") {\n return MASK_PATTERNS[mask];\n }\n return mask;\n }, [mask]);\n\n const transformOpts = React.useMemo(\n () => ({\n currency,\n locale,\n }),\n [currency, locale],\n );\n\n const placeholderValue = React.useMemo(() => {\n if (withoutMask) return placeholder;\n\n if (placeholder && maskPlaceholder) {\n return focused ? maskPlaceholder : placeholder;\n }\n\n if (maskPlaceholder) {\n return focused ? maskPlaceholder : undefined;\n }\n\n return placeholder;\n }, [placeholder, maskPlaceholder, focused, withoutMask]);\n\n const displayValue = React.useMemo(() => {\n if (withoutMask || !maskPattern || !value) return value ?? \"\";\n const unmasked = getUnmaskedValue({\n value,\n transform: maskPattern.transform,\n ...transformOpts,\n });\n return applyMask({\n value: unmasked,\n pattern: maskPattern.pattern,\n ...transformOpts,\n mask,\n });\n }, [value, maskPattern, withoutMask, transformOpts, mask]);\n\n const tokenCount = React.useMemo(() => {\n if (!maskPattern || CURRENCY_PERCENTAGE_SYMBOLS.test(maskPattern.pattern))\n return undefined;\n return maskPattern.pattern.match(REGEX_CACHE.hashPattern)?.length ?? 0;\n }, [maskPattern]);\n\n const calculatedMaxLength = tokenCount\n ? maskPattern?.pattern.length\n : maxLength;\n\n const calculatedInputMode = React.useMemo(() => {\n if (inputMode) return inputMode;\n if (!maskPattern) return undefined;\n\n if (mask === \"currency\" || mask === \"percentage\" || mask === \"ipv4\") {\n return \"decimal\";\n }\n\n if (typeof mask === \"string\" && NUMERIC_MASK_PATTERNS.test(mask)) {\n return \"numeric\";\n }\n return undefined;\n }, [maskPattern, mask, inputMode]);\n\n const shouldValidate = React.useCallback(\n (trigger: \"change\" | \"blur\") => {\n if (!onValidate || !maskPattern?.validate) return false;\n\n switch (validationMode) {\n case \"onChange\":\n return trigger === \"change\";\n case \"onBlur\":\n return trigger === \"blur\";\n case \"onSubmit\":\n return false;\n case \"onTouched\":\n return touched ? trigger === \"change\" : trigger === \"blur\";\n case \"all\":\n return true;\n default:\n return trigger === \"change\";\n }\n },\n [onValidate, maskPattern, validationMode, touched],\n );\n\n const validationOpts = React.useMemo(\n () => ({\n min: typeof min === \"string\" ? parseFloat(min) : min,\n max: typeof max === \"string\" ? parseFloat(max) : max,\n }),\n [min, max],\n );\n\n const onInputValidate = React.useCallback(\n (unmaskedValue: string) => {\n if (onValidate && maskPattern?.validate) {\n const isValid = maskPattern.validate(unmaskedValue, validationOpts);\n onValidate(isValid, unmaskedValue);\n }\n },\n [onValidate, maskPattern?.validate, validationOpts],\n );\n\n const onValueChange = React.useCallback(\n (event: React.ChangeEvent) => {\n const inputValue = event.target.value;\n let newValue = inputValue;\n let unmaskedValue = inputValue;\n\n if (composing) {\n if (!isControlled) setInternalValue(inputValue);\n return;\n }\n\n if (withoutMask || !maskPattern) {\n if (!isControlled) setInternalValue(inputValue);\n if (shouldValidate(\"change\")) onValidate?.(true, inputValue);\n onValueChangeProp?.(inputValue, inputValue);\n return;\n }\n\n if (maskPattern) {\n unmaskedValue = getUnmaskedValue({\n value: inputValue,\n transform: maskPattern.transform,\n ...transformOpts,\n });\n newValue = applyMask({\n value: unmaskedValue,\n pattern: maskPattern.pattern,\n ...transformOpts,\n mask,\n });\n\n if (inputRef.current && newValue !== inputValue) {\n const inputElement = inputRef.current;\n if (!(inputElement instanceof HTMLInputElement)) return;\n\n const oldCursorPosition = inputElement.selectionStart ?? 0;\n\n inputElement.value = newValue;\n\n const currentUnmasked = getUnmaskedValue({\n value: newValue,\n transform: maskPattern.transform,\n ...transformOpts,\n });\n\n let newCursorPosition: number;\n\n const previousUnmasked = getUnmaskedValue({\n value,\n transform: maskPattern.transform,\n ...transformOpts,\n });\n\n if (CURRENCY_PERCENTAGE_SYMBOLS.test(maskPattern.pattern)) {\n newCursorPosition = getCurrencyCaretPosition({\n newValue,\n mask,\n transformOpts,\n oldCursorPosition,\n oldValue: inputValue,\n previousUnmasked,\n });\n } else {\n newCursorPosition = getPatternCaretPosition({\n newValue,\n maskPattern,\n currentUnmasked,\n oldCursorPosition,\n oldValue: inputValue,\n previousUnmasked,\n });\n }\n\n if (isCurrencyMask({ mask, pattern: maskPattern.pattern })) {\n if (mask === \"currency\") {\n const currencyAtEnd = isCurrencyAtEnd(transformOpts);\n if (!currencyAtEnd) {\n newCursorPosition = Math.max(1, newCursorPosition);\n }\n } else {\n newCursorPosition = Math.max(1, newCursorPosition);\n }\n } else if (maskPattern.pattern.includes(\"%\")) {\n newCursorPosition = Math.min(\n newValue.length - 1,\n newCursorPosition,\n );\n }\n\n newCursorPosition = Math.min(newCursorPosition, newValue.length);\n\n inputElement.setSelectionRange(newCursorPosition, newCursorPosition);\n }\n }\n\n if (!isControlled) {\n setInternalValue(newValue);\n }\n\n if (shouldValidate(\"change\")) {\n onInputValidate(unmaskedValue);\n }\n\n onValueChangeProp?.(newValue, unmaskedValue);\n },\n [\n maskPattern,\n isControlled,\n onValueChangeProp,\n onValidate,\n onInputValidate,\n composing,\n shouldValidate,\n withoutMask,\n transformOpts,\n mask,\n value,\n ],\n );\n\n const onFocus = React.useCallback(\n (event: React.FocusEvent) => {\n onFocusProp?.(event);\n if (event.defaultPrevented) return;\n\n setFocused(true);\n },\n [onFocusProp],\n );\n\n const onBlur = React.useCallback(\n (event: React.FocusEvent) => {\n onBlurProp?.(event);\n if (event.defaultPrevented) return;\n\n setFocused(false);\n\n if (!touched) {\n setTouched(true);\n }\n\n if (shouldValidate(\"blur\")) {\n const currentValue = event.target.value;\n const unmaskedValue = maskPattern\n ? getUnmaskedValue({\n value: currentValue,\n transform: maskPattern.transform,\n ...transformOpts,\n })\n : currentValue;\n onInputValidate(unmaskedValue);\n }\n },\n [\n onBlurProp,\n touched,\n shouldValidate,\n onInputValidate,\n maskPattern,\n transformOpts,\n ],\n );\n\n const onCompositionStart = React.useCallback(\n (event: React.CompositionEvent) => {\n onCompositionStartProp?.(event);\n if (event.defaultPrevented) return;\n\n setComposing(true);\n },\n [onCompositionStartProp],\n );\n\n const onCompositionEnd = React.useCallback(\n (event: React.CompositionEvent) => {\n onCompositionEndProp?.(event);\n if (event.defaultPrevented) return;\n\n setComposing(false);\n\n const inputElement = inputRef.current;\n if (!inputElement) return;\n if (!(inputElement instanceof HTMLInputElement)) return;\n const inputValue = inputElement.value;\n\n if (!maskPattern || withoutMask) {\n if (!isControlled) setInternalValue(inputValue);\n if (shouldValidate(\"change\")) onValidate?.(true, inputValue);\n onValueChangeProp?.(inputValue, inputValue);\n return;\n }\n\n const unmasked = getUnmaskedValue({\n value: inputValue,\n transform: maskPattern.transform,\n ...transformOpts,\n });\n const masked = applyMask({\n value: unmasked,\n pattern: maskPattern.pattern,\n ...transformOpts,\n mask,\n });\n\n if (!isControlled) setInternalValue(masked);\n if (shouldValidate(\"change\")) onInputValidate(unmasked);\n onValueChangeProp?.(masked, unmasked);\n },\n [\n onCompositionEndProp,\n maskPattern,\n withoutMask,\n isControlled,\n shouldValidate,\n onValidate,\n onValueChangeProp,\n transformOpts,\n mask,\n onInputValidate,\n ],\n );\n\n const onPaste = React.useCallback(\n (event: React.ClipboardEvent) => {\n onPasteProp?.(event);\n if (event.defaultPrevented) return;\n\n if (withoutMask || !maskPattern) return;\n\n if (mask === \"ipv4\") return;\n\n const target = event.target as InputElement;\n if (!(target instanceof HTMLInputElement)) return;\n\n const pastedData = event.clipboardData.getData(\"text\");\n if (!pastedData) return;\n\n event.preventDefault();\n\n const currentValue = target.value;\n const selectionStart = target.selectionStart ?? 0;\n const selectionEnd = target.selectionEnd ?? 0;\n\n const beforeSelection = currentValue.slice(0, selectionStart);\n const afterSelection = currentValue.slice(selectionEnd);\n const newInputValue = beforeSelection + pastedData + afterSelection;\n\n const unmasked = getUnmaskedValue({\n value: newInputValue,\n transform: maskPattern.transform,\n ...transformOpts,\n });\n const newMaskedValue = applyMask({\n value: unmasked,\n pattern: maskPattern.pattern,\n ...transformOpts,\n mask,\n });\n\n target.value = newMaskedValue;\n\n if (isCurrencyMask({ mask, pattern: maskPattern.pattern })) {\n const currencyAtEnd = isCurrencyAtEnd(transformOpts);\n const caret = currencyAtEnd\n ? newMaskedValue.search(/\\s*[^\\d\\s]+$/)\n : newMaskedValue.length;\n target.setSelectionRange(caret, caret);\n return;\n }\n\n if (maskPattern.pattern.includes(\"%\")) {\n target.setSelectionRange(\n newMaskedValue.length - 1,\n newMaskedValue.length - 1,\n );\n return;\n }\n\n let newCursorPosition = newMaskedValue.length;\n try {\n const unmaskedCount = unmasked.length;\n let position = 0;\n let count = 0;\n\n for (\n let i = 0;\n i < maskPattern.pattern.length && i < newMaskedValue.length;\n i++\n ) {\n if (maskPattern.pattern[i] === \"#\") {\n count++;\n if (count <= unmaskedCount) {\n position = i + 1;\n }\n }\n }\n newCursorPosition = position;\n } catch {\n // fallback to end\n }\n\n target.setSelectionRange(newCursorPosition, newCursorPosition);\n\n if (!isControlled) setInternalValue(newMaskedValue);\n if (shouldValidate(\"change\")) onInputValidate(unmasked);\n onValueChangeProp?.(newMaskedValue, unmasked);\n },\n [\n onPasteProp,\n withoutMask,\n maskPattern,\n mask,\n transformOpts,\n isControlled,\n shouldValidate,\n onInputValidate,\n onValueChangeProp,\n ],\n );\n\n const onKeyDown = React.useCallback(\n (event: React.KeyboardEvent) => {\n onKeyDownProp?.(event);\n if (event.defaultPrevented) return;\n\n if (withoutMask || !maskPattern) return;\n\n if (mask === \"ipv4\") return;\n\n if (event.key === \"Backspace\") {\n const target = event.target as InputElement;\n if (!(target instanceof HTMLInputElement)) return;\n const cursorPosition = target.selectionStart ?? 0;\n const selectionEnd = target.selectionEnd ?? 0;\n const currentValue = target.value;\n\n if (\n mask === \"currency\" ||\n mask === \"percentage\" ||\n maskPattern.pattern.includes(\"$\") ||\n maskPattern.pattern.includes(\"€\") ||\n maskPattern.pattern.includes(\"%\")\n ) {\n return;\n }\n\n if (cursorPosition !== selectionEnd) {\n return;\n }\n\n if (cursorPosition > 0) {\n const charBeforeCursor = currentValue[cursorPosition - 1];\n\n if (charBeforeCursor) {\n event.preventDefault();\n\n const unmaskedIndex = toUnmaskedIndex({\n masked: currentValue,\n pattern: maskPattern.pattern,\n caret: cursorPosition,\n });\n\n if (unmaskedIndex > 0) {\n const currentUnmasked = getUnmaskedValue({\n value: currentValue,\n transform: maskPattern.transform,\n ...transformOpts,\n });\n const nextUnmasked =\n currentUnmasked.slice(0, unmaskedIndex - 1) +\n currentUnmasked.slice(unmaskedIndex);\n const nextMasked = applyMask({\n value: nextUnmasked,\n pattern: maskPattern.pattern,\n ...transformOpts,\n mask,\n });\n\n target.value = nextMasked;\n const nextCaret = fromUnmaskedIndex({\n masked: nextMasked,\n pattern: maskPattern.pattern,\n unmaskedIndex: unmaskedIndex - 1,\n });\n\n target.setSelectionRange(nextCaret, nextCaret);\n\n onValueChangeProp?.(nextMasked, nextUnmasked);\n }\n return;\n }\n }\n }\n\n if (event.key === \"Delete\") {\n const target = event.target as InputElement;\n if (!(target instanceof HTMLInputElement)) return;\n const cursorPosition = target.selectionStart ?? 0;\n const selectionEnd = target.selectionEnd ?? 0;\n const currentValue = target.value;\n\n if (\n mask === \"currency\" ||\n mask === \"percentage\" ||\n maskPattern.pattern.includes(\"$\") ||\n maskPattern.pattern.includes(\"€\") ||\n maskPattern.pattern.includes(\"%\")\n ) {\n return;\n }\n\n if (cursorPosition !== selectionEnd) {\n return;\n }\n\n if (cursorPosition < currentValue.length) {\n const charAtCursor = currentValue[cursorPosition];\n\n if (charAtCursor) {\n event.preventDefault();\n\n const unmaskedIndex = toUnmaskedIndex({\n masked: currentValue,\n pattern: maskPattern.pattern,\n caret: cursorPosition,\n });\n\n const currentUnmasked = getUnmaskedValue({\n value: currentValue,\n transform: maskPattern.transform,\n ...transformOpts,\n });\n\n if (unmaskedIndex < currentUnmasked.length) {\n const nextUnmasked =\n currentUnmasked.slice(0, unmaskedIndex) +\n currentUnmasked.slice(unmaskedIndex + 1);\n const nextMasked = applyMask({\n value: nextUnmasked,\n pattern: maskPattern.pattern,\n ...transformOpts,\n mask,\n });\n\n target.value = nextMasked;\n const nextCaret = fromUnmaskedIndex({\n masked: nextMasked,\n pattern: maskPattern.pattern,\n unmaskedIndex: unmaskedIndex,\n });\n\n target.setSelectionRange(nextCaret, nextCaret);\n\n onValueChangeProp?.(nextMasked, nextUnmasked);\n }\n return;\n }\n }\n }\n },\n [\n maskPattern,\n onKeyDownProp,\n onValueChangeProp,\n transformOpts,\n mask,\n withoutMask,\n ],\n );\n\n return useRender({\n defaultTagName: \"input\",\n props: mergeProps<\"input\">(\n {\n \"aria-invalid\": invalid,\n className: cn(\n \"flex h-9 w-full min-w-0 rounded-md border border-input bg-transparent px-3 py-1 text-base shadow-xs outline-none transition-[color,box-shadow] selection:bg-primary selection:text-primary-foreground file:inline-flex file:h-7 file:border-0 file:bg-transparent file:font-medium file:text-foreground file:text-sm placeholder:text-muted-foreground disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm dark:bg-input/30\",\n \"focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50\",\n \"aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40\",\n className,\n ),\n placeholder: placeholderValue,\n ref: composedRef,\n value: displayValue,\n disabled,\n maxLength: calculatedMaxLength,\n readOnly,\n required,\n inputMode: calculatedInputMode,\n min,\n max,\n onFocus,\n onBlur,\n onKeyDown,\n onPaste,\n onChange: onValueChange,\n onCompositionStart,\n onCompositionEnd,\n },\n inputProps,\n ),\n render,\n state: {\n slot: \"mask-input\",\n disabled: disabled ? \"\" : undefined,\n invalid: invalid ? \"\" : undefined,\n readonly: readOnly ? \"\" : undefined,\n required: required ? \"\" : undefined,\n },\n });\n}\n\nexport {\n applyCurrencyMask,\n applyMask,\n applyPercentageMask,\n fromUnmaskedIndex,\n getUnmaskedValue,\n MASK_PATTERNS,\n MaskInput,\n type MaskInputProps,\n type MaskPattern,\n toUnmaskedIndex,\n};\n", "target": "" }, { "path": "lib/compose-refs.ts", "type": "registry:lib", "content": "/**\n * @see https://github.com/radix-ui/primitives/blob/main/packages/react/compose-refs/src/compose-refs.tsx\n */\n\nimport * as React from \"react\";\n\ntype PossibleRef = React.Ref | undefined;\n\n/**\n * Set a given ref to a given value\n * This utility takes care of different types of refs: callback refs and RefObject(s)\n */\nfunction setRef(ref: PossibleRef, value: T) {\n if (typeof ref === \"function\") {\n return ref(value);\n }\n\n if (ref !== null && ref !== undefined) {\n ref.current = value;\n }\n}\n\n/**\n * A utility to compose multiple refs together\n * Accepts callback refs and RefObject(s)\n */\nfunction composeRefs(...refs: PossibleRef[]): React.RefCallback {\n return (node) => {\n let hasCleanup = false;\n const cleanups = refs.map((ref) => {\n const cleanup = setRef(ref, node);\n if (!hasCleanup && typeof cleanup === \"function\") {\n hasCleanup = true;\n }\n return cleanup;\n });\n\n // React <19 will log an error to the console if a callback ref returns a\n // value. We don't use ref cleanups internally so this will only happen if a\n // user's ref callback returns a value, which we only expect if they are\n // using the cleanup functionality added in React 19.\n if (hasCleanup) {\n return () => {\n for (let i = 0; i < cleanups.length; i++) {\n const cleanup = cleanups[i];\n if (typeof cleanup === \"function\") {\n cleanup();\n } else {\n setRef(refs[i], null);\n }\n }\n };\n }\n };\n}\n\n/**\n * A custom hook that composes multiple refs\n * Accepts callback refs and RefObject(s)\n */\nfunction useComposedRefs(...refs: PossibleRef[]): React.RefCallback {\n // biome-ignore lint/correctness/useExhaustiveDependencies: we want to memoize by all values\n return React.useCallback(composeRefs(...refs), refs);\n}\n\nexport { composeRefs, useComposedRefs };\n", "target": "" } ], "dependencies": [ "@base-ui/react" ] }