UNPKG

react-text-mask-modern

Version:

A modern, React 19+ compatible input masking component using hooks and TypeScript.

486 lines (479 loc) 16.8 kB
// src/MaskedInput.tsx import { useEffect, useRef, useCallback, forwardRef } from "react"; // src/adjustCaretPosition.ts var defaultArray = []; var emptyString = ""; function adjustCaretPosition({ previousConformedValue = emptyString, previousPlaceholder = emptyString, currentCaretPosition = 0, conformedValue, rawValue, placeholderChar: placeholderChar2, placeholder, indexesOfPipedChars = defaultArray, caretTrapIndexes = defaultArray }) { if (currentCaretPosition === 0 || !rawValue.length) { return 0; } const rawValueLength = rawValue.length; const previousConformedValueLength = previousConformedValue.length; const placeholderLength = placeholder.length; const conformedValueLength = conformedValue.length; const editLength = rawValueLength - previousConformedValueLength; const isAddition = editLength > 0; const isFirstRawValue = previousConformedValueLength === 0; const isPartialMultiCharEdit = editLength > 1 && !isAddition && !isFirstRawValue; if (isPartialMultiCharEdit) { return currentCaretPosition; } const possiblyHasRejectedChar = isAddition && (previousConformedValue === conformedValue || conformedValue === placeholder); let startingSearchIndex = 0; let trackRightCharacter = false; let targetChar; if (possiblyHasRejectedChar) { startingSearchIndex = currentCaretPosition - editLength; } else { const normalizedConformedValue = conformedValue.toLowerCase(); const normalizedRawValue = rawValue.toLowerCase(); const leftHalfChars = normalizedRawValue.substring(0, currentCaretPosition).split(emptyString); const intersection = leftHalfChars.filter( (char) => normalizedConformedValue.indexOf(char) !== -1 ); targetChar = intersection[intersection.length - 1]; const previousLeftMaskChars = previousPlaceholder.substring(0, intersection.length).split(emptyString).filter((char) => char !== placeholderChar2).length; const leftMaskChars = placeholder.substring(0, intersection.length).split(emptyString).filter((char) => char !== placeholderChar2).length; const maskLengthChanged = leftMaskChars !== previousLeftMaskChars; const targetIsMaskMovingLeft = previousPlaceholder[intersection.length - 1] !== void 0 && placeholder[intersection.length - 2] !== void 0 && previousPlaceholder[intersection.length - 1] !== placeholderChar2 && previousPlaceholder[intersection.length - 1] !== placeholder[intersection.length - 1] && previousPlaceholder[intersection.length - 1] === placeholder[intersection.length - 2]; if (!isAddition && (maskLengthChanged || targetIsMaskMovingLeft) && previousLeftMaskChars > 0 && placeholder.indexOf(targetChar) > -1 && rawValue[currentCaretPosition] !== void 0) { trackRightCharacter = true; targetChar = rawValue[currentCaretPosition]; } const pipedChars = indexesOfPipedChars.map( (index) => normalizedConformedValue[index] ); const countTargetCharInPipedChars = pipedChars.filter( (c) => c === targetChar ).length; const countTargetCharInIntersection = intersection.filter( (c) => c === targetChar ).length; const countTargetCharInPlaceholder = placeholder.substring(0, placeholder.indexOf(placeholderChar2)).split(emptyString).filter( (char, index) => char === targetChar && rawValue[index] !== char ).length; const requiredNumberOfMatches = countTargetCharInPlaceholder + countTargetCharInIntersection + countTargetCharInPipedChars + (trackRightCharacter ? 1 : 0); let numberOfEncounteredMatches = 0; for (let i = 0; i < conformedValueLength; i++) { const conformedValueChar = normalizedConformedValue[i]; startingSearchIndex = i + 1; if (conformedValueChar === targetChar) { numberOfEncounteredMatches++; } if (numberOfEncounteredMatches >= requiredNumberOfMatches) { break; } } } if (isAddition) { let lastPlaceholderChar = startingSearchIndex; for (let i = startingSearchIndex; i <= placeholderLength; i++) { if (placeholder[i] === placeholderChar2) { lastPlaceholderChar = i; } if (placeholder[i] === placeholderChar2 || caretTrapIndexes.indexOf(i) !== -1 || i === placeholderLength) { return lastPlaceholderChar; } } } else { if (trackRightCharacter) { for (let i = startingSearchIndex - 1; i >= 0; i--) { if (conformedValue[i] === targetChar || caretTrapIndexes.indexOf(i) !== -1 || i === 0) { return i; } } } else { for (let i = startingSearchIndex; i >= 0; i--) { if (placeholder[i - 1] === placeholderChar2 || caretTrapIndexes.indexOf(i) !== -1 || i === 0) { return i; } } } } return currentCaretPosition; } // constants.ts var placeholderChar = "_"; // utils.ts function convertMaskToPlaceholder(mask = [], placeholderChar2 = placeholderChar) { if (!isArray(mask)) { throw new Error( "Text-mask: convertMaskToPlaceholder; The mask must be an array." ); } if (mask.includes(placeholderChar2)) { throw new Error( `Placeholder character must not be used inside the mask. Received placeholder: ${JSON.stringify(placeholderChar2)} Received mask: ${JSON.stringify(mask)}` ); } return mask.map((char) => char instanceof RegExp ? placeholderChar2 : char).join(""); } function isArray(value) { return Array.isArray(value); } function isString(value) { return typeof value === "string" || value instanceof String; } function isNumber(value) { return typeof value === "number" && !isNaN(value); } var strCaretTrap = "[]"; function processCaretTraps(mask) { const indexes = []; const resultMask = []; mask.forEach((item, index) => { if (item === strCaretTrap) { indexes.push(index); } else { resultMask.push(item); } }); return { maskWithoutCaretTraps: resultMask, indexes }; } // src/conformToMask.ts var emptyString2 = ""; function conformToMask(rawValue = emptyString2, mask, config = {}) { if (!isArray(mask)) { if (typeof mask === "function") { const result = mask(rawValue, config); if (result === false) return { conformedValue: rawValue, meta: { someCharsRejected: false } }; mask = processCaretTraps(result).maskWithoutCaretTraps; } else { throw new Error( "Text-mask: conformToMask; The mask must be an array or a function." ); } } const { guide = true, previousConformedValue = emptyString2, placeholderChar: placeholderChar2 = placeholderChar, placeholder = convertMaskToPlaceholder(mask, placeholderChar2), currentCaretPosition, keepCharPositions } = config; const suppressGuide = guide === false && previousConformedValue !== void 0; const rawValueLength = rawValue.length; const previousConformedValueLength = previousConformedValue.length; const placeholderLength = placeholder.length; const maskLength = mask.length; const editDistance = rawValueLength - previousConformedValueLength; const isAddition = editDistance > 0; const indexOfFirstChange = typeof currentCaretPosition === "number" ? currentCaretPosition + (isAddition ? -editDistance : 0) : 0; const indexOfLastChange = indexOfFirstChange + Math.abs(editDistance); if (keepCharPositions === true && !isAddition) { let compensatingPlaceholderChars = ""; for (let i = indexOfFirstChange; i < indexOfLastChange; i++) { if (placeholder[i] === placeholderChar2) { compensatingPlaceholderChars += placeholderChar2; } } rawValue = rawValue.slice(0, indexOfFirstChange) + compensatingPlaceholderChars + rawValue.slice(indexOfFirstChange); } const rawValueArr = rawValue.split("").map((char, i) => ({ char, isNew: i >= indexOfFirstChange && i < indexOfLastChange })); for (let i = rawValueLength - 1; i >= 0; i--) { const { char } = rawValueArr[i]; if (char !== placeholderChar2) { const shouldOffset = i >= indexOfFirstChange && previousConformedValueLength === maskLength; if (char === placeholder[shouldOffset ? i - editDistance : i]) { rawValueArr.splice(i, 1); } } } let conformedValue = ""; let someCharsRejected = false; placeholderLoop: for (let i = 0; i < placeholderLength; i++) { const charInPlaceholder = placeholder[i]; if (charInPlaceholder === placeholderChar2) { if (rawValueArr.length > 0) { while (rawValueArr.length > 0) { const { char: rawValueChar, isNew } = rawValueArr.shift(); if (rawValueChar === placeholderChar2 && suppressGuide !== true) { conformedValue += placeholderChar2; continue placeholderLoop; } else if (mask[i].test(rawValueChar)) { if (keepCharPositions !== true || isNew === false || previousConformedValue === emptyString2 || guide === false || !isAddition) { conformedValue += rawValueChar; } else { const rawValueArrLength = rawValueArr.length; let indexOfNextAvailablePlaceholderChar = null; for (let j = 0; j < rawValueArrLength; j++) { const charData = rawValueArr[j]; if (charData.char !== placeholderChar2 && charData.isNew === false) break; if (charData.char === placeholderChar2) { indexOfNextAvailablePlaceholderChar = j; break; } } if (indexOfNextAvailablePlaceholderChar !== null) { conformedValue += rawValueChar; rawValueArr.splice(indexOfNextAvailablePlaceholderChar, 1); } else { i--; } } continue placeholderLoop; } else { someCharsRejected = true; } } } if (!suppressGuide) { conformedValue += placeholder.slice(i); } break; } else { conformedValue += charInPlaceholder; } } if (suppressGuide && !isAddition) { let indexOfLastFilledPlaceholderChar = null; for (let i = 0; i < conformedValue.length; i++) { if (placeholder[i] === placeholderChar2) { indexOfLastFilledPlaceholderChar = i; } } conformedValue = indexOfLastFilledPlaceholderChar !== null ? conformedValue.slice(0, indexOfLastFilledPlaceholderChar + 1) : ""; } return { conformedValue, meta: { someCharsRejected } }; } // src/createTextMaskInputElement.ts var emptyString3 = ""; var strNone = "none"; var strObject = "object"; var isAndroid = typeof navigator !== "undefined" && /Android/i.test(navigator.userAgent); var defer = (cb) => { if (typeof requestAnimationFrame !== "undefined") { requestAnimationFrame(cb); } else { setTimeout(cb, 0); } }; function createTextMaskInputElement(config) { const state = { previousConformedValue: void 0, previousPlaceholder: void 0 }; return { state, update(rawValue, { inputElement, mask: providedMask, guide, pipe, placeholderChar: placeholderChar2 = placeholderChar, keepCharPositions = false, showMask = false } = config) { if (typeof rawValue === "undefined") { rawValue = inputElement?.value; } if (rawValue === state.previousConformedValue) return; if (typeof providedMask === strObject && providedMask.pipe !== void 0 && providedMask.mask !== void 0) { pipe = providedMask.pipe; providedMask = providedMask.mask; } let mask = providedMask; let placeholder; let caretTrapIndexes; if (Array.isArray(mask)) { placeholder = convertMaskToPlaceholder(mask, placeholderChar2); } else if (typeof mask === "function") { const evaluatedMask = mask(getSafeRawValue(rawValue), { currentCaretPosition: inputElement?.selectionEnd, previousConformedValue: state.previousConformedValue, placeholderChar: placeholderChar2 }); if (evaluatedMask === false) return; const { maskWithoutCaretTraps, indexes } = processCaretTraps(evaluatedMask); mask = maskWithoutCaretTraps; caretTrapIndexes = indexes; placeholder = convertMaskToPlaceholder(mask, placeholderChar2); } if (mask === false) return; const safeRawValue = getSafeRawValue(rawValue); const currentCaretPosition = inputElement?.selectionEnd ?? 0; const { previousConformedValue, previousPlaceholder } = state; const conformToMaskConfig = { previousConformedValue, guide, placeholderChar: placeholderChar2, pipe, placeholder, currentCaretPosition, keepCharPositions }; const { conformedValue } = conformToMask( safeRawValue, mask, conformToMaskConfig ); let finalConformedValue = conformedValue; let indexesOfPipedChars = []; if (typeof pipe === "function") { let pipeResult = pipe(conformedValue, { rawValue: safeRawValue, ...conformToMaskConfig }); if (pipeResult === false) { pipeResult = { value: previousConformedValue || "", rejected: true }; } else if (isString(pipeResult)) { pipeResult = { value: pipeResult }; } finalConformedValue = pipeResult.value; indexesOfPipedChars = pipeResult.indexesOfPipedChars || []; } const adjustedCaretPosition = adjustCaretPosition({ previousConformedValue, previousPlaceholder, conformedValue: finalConformedValue, placeholder: placeholder ?? "", rawValue: safeRawValue, currentCaretPosition, placeholderChar: placeholderChar2, indexesOfPipedChars, caretTrapIndexes: caretTrapIndexes || [] }); const inputValueShouldBeEmpty = finalConformedValue === placeholder && adjustedCaretPosition === 0; const emptyValue = showMask ? placeholder ?? "" : emptyString3; const inputElementValue = inputValueShouldBeEmpty ? emptyValue : finalConformedValue; state.previousConformedValue = inputElementValue; state.previousPlaceholder = placeholder; if (!inputElement || inputElement.value === inputElementValue) return; inputElement.value = inputElementValue; safeSetSelection(inputElement, adjustedCaretPosition || 0); } }; } function safeSetSelection(element, position) { if (document.activeElement === element) { const cb = () => element.setSelectionRange(position, position, strNone); if (isAndroid) { defer(cb); } else { element.setSelectionRange(position, position, strNone); } } } function getSafeRawValue(value) { if (isString(value)) return value; if (isNumber(value)) return String(value); if (value === void 0 || value === null) return emptyString3; throw new Error( `The 'value' provided to Text Mask needs to be a string or a number. Received: ${JSON.stringify( value )}` ); } // src/MaskedInput.tsx import { jsx } from "react/jsx-runtime"; var MaskedInput = forwardRef( ({ mask, guide, value, pipe, placeholderChar: placeholderChar2, keepCharPositions, showMask, onBlur, onChange, ...rest }, ref) => { const localRef = useRef(null); const inputRef = ref ?? localRef; const textMaskRef = useRef(null); const initTextMask = useCallback(() => { if (!inputRef.current) return; textMaskRef.current = createTextMaskInputElement({ inputElement: inputRef.current, mask, guide, pipe, placeholderChar: placeholderChar2, keepCharPositions, showMask }); textMaskRef.current.update(value); }, [ mask, guide, pipe, placeholderChar2, keepCharPositions, showMask, value ]); useEffect(() => { initTextMask(); }, [initTextMask]); const handleChange = (event) => { textMaskRef.current?.update(); onChange?.(event); }; const handleBlur = (event) => { onBlur?.(event); }; return /* @__PURE__ */ jsx( "input", { ref: (el) => { localRef.current = el; if (typeof ref === "function") { ref(el); } else if (ref) { ref.current = el; } }, onBlur: handleBlur, onChange: handleChange, value, ...rest } ); } ); export { MaskedInput }; //# sourceMappingURL=index.mjs.map