react-text-mask-modern
Version:
A modern, React 19+ compatible input masking component using hooks and TypeScript.
486 lines (479 loc) • 16.8 kB
JavaScript
// 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