"use client" import * as React from "react"; import { cn } from "../../../lib/utils"; // ============================================================================= // Types // ============================================================================= export type MaskToken = | "0" // digit | "a" // letter | "*" // alphanumeric | string; // literal export interface MaskDefinition { /** Character that represents this token in the mask pattern */ token: MaskToken; /** Regex to validate the character at this position */ pattern: RegExp; /** Whether this token is optional */ optional?: boolean; /** Transform function for the character */ transform?: (char: string) => string; } export interface MaskInputProps extends Omit, "value" | "defaultValue" | "onChange" | "onBeforeInput"> { onBeforeInput?: (event: React.FormEvent & { data?: string }) => void; /** Mask pattern, e.g. "(000) 000-0000" or "00/00/0000" */ mask: string; /** Custom mask definitions. Defaults to digit-only. */ definitions?: Record; /** Controlled value (raw, unmasked) */ value?: string; /** Default uncontrolled value (raw, unmasked) */ defaultValue?: string; /** Callback when raw value changes */ onChange?: (value: string) => void; /** Character used for empty mask positions */ maskChar?: string; /** Whether to always show the mask */ alwaysShowMask?: boolean; /** Whether to clean the value on blur if incomplete */ cleanOnBlur?: boolean; } // ============================================================================= // Default Definitions // ============================================================================= const defaultDefinitions: Record = { "0": { token: "0", pattern: /\d/ }, "a": { token: "a", pattern: /[a-zA-Z]/, transform: (c) => c.toLowerCase() }, "A": { token: "A", pattern: /[a-zA-Z]/, transform: (c) => c.toUpperCase() }, "*": { token: "*", pattern: /[a-zA-Z0-9]/ }, }; // ============================================================================= // Utilities // ============================================================================= function parseMask( mask: string, definitions: Record ): Array<{ type: "literal"; char: string } | { type: "token"; def: MaskDefinition }> { const result: ReturnType = []; for (const char of mask) { const def = definitions[char]; if (def) { result.push({ type: "token", def }); } else { result.push({ type: "literal", char }); } } return result; } function applyMask( rawValue: string, maskParts: ReturnType, maskChar: string ): { masked: string; raw: string; complete: boolean } { let rawIndex = 0; let masked = ""; let raw = ""; let complete = true; for (const part of maskParts) { if (part.type === "literal") { masked += part.char; continue; } if (rawIndex < rawValue.length) { const char = rawValue[rawIndex]; if (part.def.pattern.test(char)) { const transformed = part.def.transform ? part.def.transform(char) : char; masked += transformed; raw += transformed; rawIndex++; } else { // Invalid char for this position — skip it rawIndex++; // Re-process this mask position with next raw char const nextChar = rawValue[rawIndex]; if (nextChar && part.def.pattern.test(nextChar)) { const transformed = part.def.transform ? part.def.transform(nextChar) : nextChar; masked += transformed; raw += transformed; rawIndex++; } else { masked += maskChar; complete = false; } } } else { masked += maskChar; complete = false; } } return { masked, raw, complete }; } function extractRaw(value: string, maskParts: ReturnType): string { let raw = ""; let valueIndex = 0; for (const part of maskParts) { if (valueIndex >= value.length) break; if (part.type === "literal") { if (value[valueIndex] === part.char) { valueIndex++; } continue; } const char = value[valueIndex]; if (part.def.pattern.test(char)) { raw += part.def.transform ? part.def.transform(char) : char; } valueIndex++; } return raw; } function getNextTokenIndex( maskParts: ReturnType, currentIndex: number, direction: 1 | -1 ): number { let index = currentIndex + direction; while (index >= 0 && index < maskParts.length) { if (maskParts[index].type === "token") return index; index += direction; } return direction === 1 ? maskParts.length : -1; } // ============================================================================= // Component // ============================================================================= const MaskInput = React.forwardRef( ( { mask, definitions = defaultDefinitions, value: controlledValue, defaultValue = "", onChange, maskChar = "_", alwaysShowMask = false, cleanOnBlur = false, className, onFocus, onBlur, onKeyDown, onBeforeInput, ...props }, ref ) => { const maskParts = React.useMemo(() => parseMask(mask, definitions), [mask, definitions]); const isControlled = controlledValue !== undefined; const [internalRaw, setInternalRaw] = React.useState(defaultValue); const rawValue = isControlled ? controlledValue : internalRaw; const { masked: maskedValue } = React.useMemo( () => applyMask(rawValue, maskParts, maskChar), [rawValue, maskParts, maskChar] ); const inputRef = React.useRef(null); const composedRef = React.useMemo(() => { return (node: HTMLInputElement | null) => { inputRef.current = node; if (typeof ref === "function") { ref(node); } else if (ref) { (ref as React.MutableRefObject).current = node; } }; }, [ref]); const [isFocused, setIsFocused] = React.useState(false); const displayValue = alwaysShowMask || isFocused ? maskedValue : rawValue || ""; // Controlled inputs reset caret to end on every value commit, so all // caret moves (focus + every keystroke that rewrites the value) defer // through this ref and run from useLayoutEffect after the render. const pendingCaretRef = React.useRef(null); const updateValue = React.useCallback( (newRaw: string) => { if (!isControlled) { setInternalRaw(newRaw); } onChange?.(newRaw); }, [isControlled, onChange] ); const handleBeforeInput = React.useCallback( (event: React.FormEvent & { data?: string }) => { onBeforeInput?.(event); if (event.defaultPrevented) return; const input = event.currentTarget; const data = event.data; if (!data) return; const start = input.selectionStart ?? 0; const end = input.selectionEnd ?? 0; // Determine which mask position we're at let maskPos = 0; let charCount = 0; for (let i = 0; i < maskParts.length && charCount < start; i++) { if (maskParts[i].type === "token") { charCount++; } maskPos = i + 1; } // Find next token position const nextToken = getNextTokenIndex(maskParts, maskPos - 1, 1); if (nextToken === -1 || nextToken >= maskParts.length) { event.preventDefault(); return; } const part = maskParts[nextToken]; if (part.type !== "token") { event.preventDefault(); return; } if (!part.def.pattern.test(data)) { event.preventDefault(); return; } }, [maskParts, onBeforeInput] ); const handleKeyDown = React.useCallback( (event: React.KeyboardEvent) => { onKeyDown?.(event); if (event.defaultPrevented) return; const input = event.currentTarget; const start = input.selectionStart ?? 0; const end = input.selectionEnd ?? 0; if (event.key === "Backspace") { event.preventDefault(); if (start !== end) { const before = input.value.slice(0, start); const after = input.value.slice(end); const newRaw = extractRaw(before + after, maskParts); let pos = start; while (pos > 0 && maskParts[pos - 1]?.type === "literal") pos--; pendingCaretRef.current = pos; updateValue(newRaw); return; } if (start === 0) return; let pos = start - 1; while (pos >= 0 && maskParts[pos]?.type === "literal") pos--; if (pos < 0) return; const currentRaw = extractRaw(input.value, maskParts); let rawIndex = 0; for (let i = 0; i < pos; i++) { if (maskParts[i].type === "token") rawIndex++; } const newRaw = currentRaw.slice(0, rawIndex) + currentRaw.slice(rawIndex + 1); pendingCaretRef.current = pos; updateValue(newRaw); return; } if (event.key === "Delete") { event.preventDefault(); if (start !== end) { const before = input.value.slice(0, start); const after = input.value.slice(end); const newRaw = extractRaw(before + after, maskParts); pendingCaretRef.current = start; updateValue(newRaw); return; } const currentRaw = extractRaw(input.value, maskParts); let rawIndex = 0; for (let i = 0; i < start; i++) { if (maskParts[i].type === "token") rawIndex++; } if (rawIndex >= currentRaw.length) return; const newRaw = currentRaw.slice(0, rawIndex) + currentRaw.slice(rawIndex + 1); pendingCaretRef.current = start; updateValue(newRaw); return; } if (event.key === "ArrowLeft" || event.key === "ArrowRight") { // Let default happen but skip over literals requestAnimationFrame(() => { const newStart = input.selectionStart ?? 0; const direction = event.key === "ArrowLeft" ? -1 : 1; let pos = newStart; if (direction === -1) { pos--; while (pos >= 0 && maskParts[pos]?.type === "literal") pos--; if (pos >= 0) { input.setSelectionRange(pos + 1, pos + 1); } } else { while (pos < maskParts.length && maskParts[pos]?.type === "literal") pos++; input.setSelectionRange(pos, pos); } }); return; } if (event.key.length === 1 && !event.ctrlKey && !event.metaKey && !event.altKey) { event.preventDefault(); const char = event.key; const nextToken = getNextTokenIndex(maskParts, start - 1, 1); if (nextToken === -1 || nextToken >= maskParts.length) return; const part = maskParts[nextToken]; if (part.type !== "token") return; if (!part.def.pattern.test(char)) return; const currentRaw = extractRaw(input.value, maskParts); let rawIndex = 0; for (let i = 0; i < nextToken; i++) { if (maskParts[i].type === "token") rawIndex++; } const transformed = part.def.transform ? part.def.transform(char) : char; const newRaw = currentRaw.slice(0, rawIndex) + transformed + currentRaw.slice(rawIndex + 1); let nextPos = nextToken + 1; while (nextPos < maskParts.length && maskParts[nextPos]?.type === "literal") nextPos++; pendingCaretRef.current = nextPos; updateValue(newRaw); } }, [maskParts, maskChar, onKeyDown, updateValue] ); const computeNextEmptyCaret = React.useCallback(() => { const filledCount = rawValue.length; let tokenSeen = 0; for (let i = 0; i < maskParts.length; i++) { if (maskParts[i].type === "token") { if (tokenSeen === filledCount) return i; tokenSeen++; } } return maskParts.length; }, [maskParts, rawValue]); const handleFocus = React.useCallback( (event: React.FocusEvent) => { setIsFocused(true); onFocus?.(event); // setIsFocused triggers re-render to masked display. The caret // must be placed AFTER that render committed, otherwise the // browser defaults to end-of-string. useLayoutEffect below does // the placement; we just record where to go. pendingCaretRef.current = computeNextEmptyCaret(); }, [onFocus, computeNextEmptyCaret] ); React.useLayoutEffect(() => { if (pendingCaretRef.current == null) return; const input = inputRef.current; if (!input) return; const pos = pendingCaretRef.current; pendingCaretRef.current = null; input.setSelectionRange(pos, pos); }); const handleBlur = React.useCallback( (event: React.FocusEvent) => { setIsFocused(false); onBlur?.(event); if (cleanOnBlur) { const { complete, raw } = applyMask(rawValue, maskParts, maskChar); if (!complete) { updateValue(""); } } }, [cleanOnBlur, rawValue, maskParts, maskChar, onBlur, updateValue] ); const handleChange = React.useCallback( (event: React.ChangeEvent) => { // Extract raw from whatever the user managed to input const newRaw = extractRaw(event.target.value, maskParts); updateValue(newRaw); }, [maskParts, updateValue] ); return ( ); } ); MaskInput.displayName = "MaskInput"; export { MaskInput };