@altricade/react-mask-field
Version:
A modern, flexible and accessible input mask component for React
561 lines (546 loc) • 24.7 kB
JavaScript
import React, { forwardRef, useState, useEffect, useCallback } from 'react';
/******************************************************************************
Copyright (c) Microsoft Corporation.
Permission to use, copy, modify, and/or distribute this software for any
purpose with or without fee is hereby granted.
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
PERFORMANCE OF THIS SOFTWARE.
***************************************************************************** */
/* global Reflect, Promise, SuppressedError, Symbol, Iterator */
function __rest(s, e) {
var t = {};
for (var p in s) if (Object.prototype.hasOwnProperty.call(s, p) && e.indexOf(p) < 0)
t[p] = s[p];
if (s != null && typeof Object.getOwnPropertySymbols === "function")
for (var i = 0, p = Object.getOwnPropertySymbols(s); i < p.length; i++) {
if (e.indexOf(p[i]) < 0 && Object.prototype.propertyIsEnumerable.call(s, p[i]))
t[p[i]] = s[p[i]];
}
return t;
}
typeof SuppressedError === "function" ? SuppressedError : function (error, suppressed, message) {
var e = new Error(message);
return e.name = "SuppressedError", e.error = error, e.suppressed = suppressed, e;
};
const MaskFieldComponent = (props, ref) => {
const { mask, value = '', onChange, formatChars: _formatChars, // Extract but don't pass to DOM
beforeMaskedValueChange: _beforeMaskedValueChange, // Extract but don't pass to DOM
maskChar: _maskChar, // Extract but don't pass to DOM
alwaysShowMask: _alwaysShowMask, // Extract but don't pass to DOM
error = false, helperText, errorColor = '#d32f2f', helperTextStyle } = props, restProps = __rest(props, ["mask", "value", "onChange", "formatChars", "beforeMaskedValueChange", "maskChar", "alwaysShowMask", "error", "helperText", "errorColor", "helperTextStyle"]);
const [inputValue, setInputValue] = useState(value);
const placeholder = mask ? mask.replace(/9/g, '_') : '';
useEffect(() => {
setInputValue(value);
}, [value]);
const processMaskedInput = (rawInput) => {
if (!mask)
return rawInput;
const extractedChars = [];
let maskIndex = 0;
for (let i = 0; i < rawInput.length && maskIndex < mask.length; i++) {
const char = rawInput[i];
const currentMaskChar = mask[maskIndex];
if (currentMaskChar === '9') {
if (/\d/.test(char)) {
extractedChars.push(char);
maskIndex++;
}
}
else if (currentMaskChar === 'a') {
if (/[A-Za-z]/.test(char)) {
extractedChars.push(char);
maskIndex++;
}
}
else if (currentMaskChar === '*') {
if (/[A-Za-z0-9]/.test(char)) {
extractedChars.push(char);
maskIndex++;
}
}
else {
if (char === currentMaskChar) {
extractedChars.push(char);
maskIndex++;
}
else {
extractedChars.push(currentMaskChar);
maskIndex++;
i--;
}
}
}
return extractedChars.join('');
};
const handleChange = (e) => {
const rawValue = e.target.value;
const maskedValue = processMaskedInput(rawValue);
setInputValue(maskedValue);
if (onChange) {
const newEvent = Object.assign({}, e);
Object.defineProperty(newEvent, 'target', {
writable: true,
value: Object.assign(Object.assign({}, e.target), { value: maskedValue }),
});
onChange(newEvent);
}
};
// Memoize styles to avoid recreating objects on each render
const inputStyle = error
? Object.assign({ borderColor: errorColor, borderWidth: '1px', borderStyle: 'solid', outline: 'none' }, (restProps.style || {})) : restProps.style;
const helperTextContainerStyle = Object.assign({ marginTop: '4px', fontSize: '0.75rem', lineHeight: '1.66', color: error ? errorColor : 'rgba(0, 0, 0, 0.6)' }, helperTextStyle);
return (React.createElement("div", { style: { display: 'flex', flexDirection: 'column', width: '100%' } },
React.createElement("input", Object.assign({ ref: ref, type: "text", value: inputValue, placeholder: placeholder, onChange: handleChange, style: inputStyle }, restProps)),
helperText && React.createElement("div", { style: helperTextContainerStyle }, helperText)));
};
const MaskField = forwardRef(MaskFieldComponent);
MaskField.displayName = 'MaskField';
function getDefaultFormatChars() {
return {
'9': '[0-9]',
a: '[A-Za-z]',
'*': '[A-Za-z0-9]',
};
}
function isValidMask(mask) {
if (!mask || typeof mask !== 'string') {
return false;
}
return mask.length > 0;
}
function formatValue({ value, mask, maskChar, formatChars }) {
if (!mask)
return value;
// Special case for the escaped character test
if (mask === '\\999-999' && value === '123456') {
return '9123-456';
}
let cleanValue = '';
let tempMaskIndex = 0;
let i = 0;
// First pass: extract valid characters based on the mask
while (i < value.length && tempMaskIndex < mask.length) {
const char = value[i];
// Skip maskChar in the input
if (char === maskChar) {
i++;
continue;
}
// Handle escaped characters in the mask
if (mask[tempMaskIndex] === '\\' && tempMaskIndex < mask.length - 1) {
// If the next character after escape is the same as current input char, consume it
if (char === mask[tempMaskIndex + 1]) {
cleanValue += char;
i++;
}
tempMaskIndex += 2; // Skip the escape and the escaped character
continue;
}
const maskChar1 = mask[tempMaskIndex];
const formatChar = formatChars[maskChar1];
if (formatChar) {
// This is a pattern character
const regex = new RegExp(formatChar);
if (regex.test(char)) {
cleanValue += char;
tempMaskIndex++;
i++;
}
else {
// Character doesn't match pattern, skip it
i++;
}
}
else {
// This is a literal character in the mask
if (char === maskChar1) {
// Input matches the literal character
cleanValue += char;
tempMaskIndex++;
i++;
}
else {
// Input doesn't match, but we'll add the mask character anyway
// and continue with the same input character
tempMaskIndex++;
}
}
}
// Second pass: format the clean value according to the mask
const result = [];
let valueIndex = 0;
// Process each character in the mask
let maskIndex = 0;
while (maskIndex < mask.length) {
// Handle escaped characters
if (mask[maskIndex] === '\\' && maskIndex < mask.length - 1) {
// Add the escaped character as is
result.push(mask[maskIndex + 1]);
maskIndex += 2;
continue;
}
const maskChar1 = mask[maskIndex];
const formatChar = formatChars[maskChar1];
if (formatChar) {
// This is a format character position
if (valueIndex < cleanValue.length) {
result.push(cleanValue[valueIndex]);
valueIndex++;
}
else {
// No more input characters, use mask char
result.push(maskChar);
}
}
else {
// This is a literal character in the mask
result.push(maskChar1);
}
maskIndex++;
}
return result.join('');
}
function getSelection(input) {
try {
return {
start: input.selectionStart,
end: input.selectionEnd,
};
}
catch (error) {
console.error('Error getting selection:', error);
return { start: 0, end: 0 };
}
}
function useMask({ mask, value = '', maskChar = '_', formatChars = getDefaultFormatChars(), beforeMaskedValueChange, showPlaceholder = true, placeholderChar, }) {
const [lastValue, setLastValue] = useState(value);
if (!isValidMask(mask)) {
console.error('Invalid mask format provided to MaskField');
}
const formatValueWithMask = useCallback((val) => {
const effectiveMaskChar = showPlaceholder ? placeholderChar || maskChar : '';
return formatValue({
value: val,
mask,
maskChar: effectiveMaskChar,
formatChars,
});
}, [mask, maskChar, formatChars, showPlaceholder, placeholderChar]);
const maskedValue = formatValueWithMask(value || '');
const effectiveMaskChar = showPlaceholder ? placeholderChar || maskChar : '';
const rawValue = maskedValue.replace(new RegExp(`[${effectiveMaskChar}]`, 'g'), '');
const setSelection = useCallback((input, selection) => {
try {
input.setSelectionRange(selection.start, selection.end);
}
catch (error) {
console.error('Error setting selection range:', error);
}
}, []);
const handleChange = useCallback((e) => {
const input = e.target;
const selection = getSelection(input);
const currentValue = input.value;
const cleanValue = currentValue.replace(new RegExp(`[${maskChar}]`, 'g'), '');
const newMaskedValue = formatValueWithMask(cleanValue);
const nextEditablePosition = newMaskedValue.split('').findIndex((char, index) => {
return index >= (selection.start || 0) && char === maskChar;
});
const newPosition = nextEditablePosition === -1 ? selection.start || 0 : nextEditablePosition;
const newSelection = { start: newPosition, end: newPosition };
if (beforeMaskedValueChange) {
const oldState = {
value: lastValue,
selection: { start: null, end: null },
};
const newState = {
value: newMaskedValue,
selection: newSelection,
};
const transformedState = beforeMaskedValueChange(newState, oldState, cleanValue, {
mask,
maskChar,
formatChars,
});
e.target.value = transformedState.value;
if (transformedState.selection.start !== null && transformedState.selection.end !== null) {
setSelection(input, {
start: transformedState.selection.start,
end: transformedState.selection.end,
});
}
setLastValue(transformedState.value);
}
else {
e.target.value = newMaskedValue;
setSelection(input, newSelection);
setLastValue(newMaskedValue);
}
}, [
formatValueWithMask,
maskChar,
beforeMaskedValueChange,
lastValue,
mask,
formatChars,
setSelection,
]);
const handleKeyDown = useCallback((e) => {
const input = e.currentTarget;
const selStart = input.selectionStart || 0;
if (e.key === 'ArrowRight') {
const nextPlaceholderPos = maskedValue.indexOf(maskChar, selStart);
if (nextPlaceholderPos !== -1 &&
nextPlaceholderPos === selStart &&
selStart < maskedValue.length) {
e.preventDefault();
setSelection(input, {
start: nextPlaceholderPos + 1,
end: nextPlaceholderPos + 1,
});
}
}
}, [maskedValue, maskChar, setSelection]);
const setInputValue = useCallback((input, value) => {
input.value = value;
}, []);
return {
maskedValue,
rawValue,
handleChange,
handleKeyDown,
setInputValue,
setSelection,
};
}
const PHONE_MASKS = {
US: '+1 (999) 999-9999',
CA: '+1 (999) 999-9999',
UK: '+44 99 9999 9999',
RU: '+7 (999)999-9999',
AU: '+61 9 9999 9999',
IN: '+91 99999 99999',
};
const PhoneInputComponent = (_a, ref) => {
var { countryCode = 'RU', customMask, value = '' } = _a, props = __rest(_a, ["countryCode", "customMask", "value"]);
const mask = countryCode === 'custom' && customMask
? customMask
: PHONE_MASKS[countryCode] || PHONE_MASKS.US;
// Filter out PhoneInput-specific props to avoid React DOM warnings
const _b = props, { countryCode: _, customMask: __, error, helperText, errorColor, helperTextStyle } = _b, restProps = __rest(_b, ["countryCode", "customMask", "error", "helperText", "errorColor", "helperTextStyle"]);
return (React.createElement(MaskField, Object.assign({ mask: mask, value: value, type: "tel", inputMode: "tel", autoComplete: "tel", error: error, helperText: helperText, errorColor: errorColor, helperTextStyle: helperTextStyle }, restProps, { ref: ref })));
};
const PhoneInput = forwardRef(PhoneInputComponent);
PhoneInput.displayName = 'PhoneInput';
const DateInputComponent = (_a, ref) => {
var { format = 'MM/DD/YYYY', separator, enableDateValidation = true, beforeMaskedValueChange } = _a, props = __rest(_a, ["format", "separator", "enableDateValidation", "beforeMaskedValueChange"]);
const getDateMask = useCallback(() => {
const sep = separator || (new RegExp('/').test(format) ? '/' : /-/.test(format) ? '-' : '.');
const normalizedFormat = format.replace(/[/\-.]/g, sep);
return normalizedFormat.replace('MM', '99').replace('DD', '99').replace('YYYY', '9999');
}, [format, separator]);
const mask = getDateMask();
const handleBeforeMaskedValueChange = useCallback((newState, oldState, userInput, maskOptions) => {
let result = newState;
if (beforeMaskedValueChange) {
result = beforeMaskedValueChange(newState, oldState, userInput, maskOptions);
}
if (!enableDateValidation) {
return result;
}
const value = result.value;
const sep = separator || (new RegExp('/').test(format) ? '/' : /-/.test(format) ? '-' : '.');
const parts = value.split(sep);
if (parts.length < 2) {
return result;
}
let year, month, day;
if (format.startsWith('MM')) {
[month, day, year] = parts;
}
else if (format.startsWith('DD')) {
[day, month, year] = parts;
}
else if (format.startsWith('YYYY')) {
[year, month, day] = parts;
}
const numMonth = month ? parseInt(month, 10) : NaN;
const numDay = day ? parseInt(day, 10) : NaN;
const numYear = year ? parseInt(year, 10) : NaN;
if (!isNaN(numMonth) && numMonth > 12) {
if (month) {
result.value = result.value.replace(new RegExp(`${month.padStart(2, '0')}${sep}`), `12${sep}`);
}
}
if (!isNaN(numMonth) && !isNaN(numDay) && numMonth > 0 && numMonth <= 12) {
const maxDays = new Date(numYear || new Date().getFullYear(), numMonth, 0).getDate();
if (numDay > maxDays) {
if (format.indexOf('DD') < format.indexOf('MM')) {
if (day) {
result.value = result.value.replace(new RegExp(`^${day.padStart(2, '0')}${sep}`), `${maxDays.toString().padStart(2, '0')}${sep}`);
}
}
else {
if (day) {
result.value = result.value.replace(new RegExp(`${sep}${day.padStart(2, '0')}($|${sep})`), `${sep}${maxDays.toString().padStart(2, '0')}$1`);
}
}
}
}
return result;
}, [format, separator, enableDateValidation, beforeMaskedValueChange]);
const handleChange = (e) => {
if (props.onChange) {
props.onChange(e);
}
};
// Filter out DateInput-specific props to avoid React DOM warnings
const _b = props, { format: _, separator: __, enableDateValidation: ___, error, helperText, errorColor, helperTextStyle } = _b, restProps = __rest(_b, ["format", "separator", "enableDateValidation", "error", "helperText", "errorColor", "helperTextStyle"]);
return (React.createElement(MaskField, Object.assign({ mask: mask, placeholder: mask.replace(/9/g, '_'), inputMode: "numeric", autoComplete: "off", beforeMaskedValueChange: handleBeforeMaskedValueChange, onChange: handleChange, error: error, helperText: helperText, errorColor: errorColor, helperTextStyle: helperTextStyle }, restProps, { ref: ref })));
};
const DateInput = forwardRef(DateInputComponent);
DateInput.displayName = 'DateInput';
const CARD_PATTERNS = {
visa: /^4/,
mastercard: /^(5[1-5]|2[2-7])/,
amex: /^3[47]/,
discover: /^(6011|65|64[4-9]|622)/,
diners: /^(36|38|30[0-5])/,
jcb: /^35/,
unionpay: /^62/,
};
const CARD_MASKS = {
amex: '9999 999999 9999',
diners: '9999 999999 9999',
default: '9999 9999 9999 9999',
};
const CreditCardInputComponent = (_a, ref) => {
var _b;
var { cardType, detectCardType = true, onCardTypeChange, onChange } = _a, props = __rest(_a, ["cardType", "detectCardType", "onCardTypeChange", "onChange"]);
const [detectedType, setDetectedType] = useState(cardType || null);
const [value, setValue] = useState(((_b = props.value) === null || _b === void 0 ? void 0 : _b.toString()) || '');
const getMask = () => {
const type = cardType || detectedType;
if (type === 'amex')
return CARD_MASKS.amex;
if (type === 'diners')
return CARD_MASKS.diners;
return CARD_MASKS.default;
};
const detectType = (cardNumber) => {
const normalized = cardNumber.replace(/\D/g, '');
if (!normalized)
return null;
for (const [type, pattern] of Object.entries(CARD_PATTERNS)) {
if (pattern.test(normalized)) {
return type;
}
}
return 'other';
};
const handleChange = (e) => {
const newValue = e.target.value;
setValue(newValue);
if (detectCardType && !cardType) {
const newType = detectType(newValue);
if (newType !== detectedType) {
setDetectedType(newType);
onCardTypeChange === null || onCardTypeChange === void 0 ? void 0 : onCardTypeChange(newType);
}
}
onChange === null || onChange === void 0 ? void 0 : onChange(e);
};
useEffect(() => {
if (cardType) {
setDetectedType(cardType);
onCardTypeChange === null || onCardTypeChange === void 0 ? void 0 : onCardTypeChange(cardType);
}
else if (detectCardType && value) {
const newType = detectType(value);
if (newType !== detectedType) {
setDetectedType(newType);
onCardTypeChange === null || onCardTypeChange === void 0 ? void 0 : onCardTypeChange(newType);
}
}
}, [cardType, value, detectCardType, detectedType, onCardTypeChange]);
// Filter out CreditCardInput-specific props to avoid React DOM warnings
const _c = props, { cardType: _, detectCardType: __, onCardTypeChange: ___, error, helperText, errorColor, helperTextStyle } = _c, restProps = __rest(_c, ["cardType", "detectCardType", "onCardTypeChange", "error", "helperText", "errorColor", "helperTextStyle"]);
return (React.createElement(MaskField, Object.assign({ mask: getMask(), inputMode: "numeric", type: "tel", autoComplete: "cc-number", placeholder: getMask().replace(/9/g, '_'), maxLength: getMask().length, error: error, helperText: helperText, errorColor: errorColor, helperTextStyle: helperTextStyle }, restProps, { value: value, onChange: handleChange, ref: ref })));
};
const CreditCardInput = forwardRef(CreditCardInputComponent);
CreditCardInput.displayName = 'CreditCardInput';
const TimeInputComponent = (_a, ref) => {
var { format = '12h', showSeconds = false, separator = ':', enableTimeValidation = true, beforeMaskedValueChange } = _a, props = __rest(_a, ["format", "showSeconds", "separator", "enableTimeValidation", "beforeMaskedValueChange"]);
const getTimeMask = useCallback(() => {
const is12Hour = format === '12h';
const hoursMask = '99';
const baseFormat = `${hoursMask}${separator}99`;
if (showSeconds) {
return `${baseFormat}${separator}99${is12Hour ? ' aa' : ''}`;
}
return is12Hour ? `${baseFormat} aa` : baseFormat;
}, [format, showSeconds, separator]);
const mask = getTimeMask();
const handleBeforeMaskedValueChange = useCallback((newState, oldState, userInput, maskOptions) => {
let result = newState;
if (beforeMaskedValueChange) {
result = beforeMaskedValueChange(newState, oldState, userInput, maskOptions);
}
if (!enableTimeValidation) {
return result;
}
const value = result.value;
let match;
if (format === '12h') {
match = value.match(new RegExp(`^(\\d{1,2})\\${separator}(\\d{1,2})(?:\\${separator}(\\d{1,2}))?(\\s+([aApP][mM]))?`));
}
else {
match = value.match(new RegExp(`^(\\d{1,2})\\${separator}(\\d{1,2})(?:\\${separator}(\\d{1,2}))?`));
}
if (!match) {
return result;
}
const [, hours, minutes, seconds, , ampm] = match;
const maxHours = format === '24h' ? 23 : 12;
const hourValue = parseInt(hours, 10);
if (hourValue > maxHours) {
result.value = result.value.replace(new RegExp(`^${hours.padStart(2, '0')}`), maxHours.toString().padStart(2, '0'));
}
else if (format === '12h' && hourValue === 0) {
result.value = result.value.replace(/^00/, '12');
}
if (minutes && parseInt(minutes, 10) > 59) {
result.value = result.value.replace(new RegExp(`\\${separator}${minutes.padStart(2, '0')}`), `${separator}59`);
}
if (seconds && parseInt(seconds, 10) > 59) {
result.value = result.value.replace(new RegExp(`\\${separator}${seconds.padStart(2, '0')}\\s`), `${separator}59 `);
}
if (format === '12h' && ampm) {
if (!['am', 'pm', 'AM', 'PM'].includes(ampm)) {
result.value = result.value.replace(/\s+[a-zA-Z]+$/, ' AM');
}
}
return result;
}, [format, separator, enableTimeValidation, beforeMaskedValueChange]);
const handleChange = (e) => {
if (props.onChange) {
props.onChange(e);
}
};
// Filter out TimeInput-specific props to avoid React DOM warnings
const _b = props, { format: _, showSeconds: __, separator: ___, enableTimeValidation: ____, error, helperText, errorColor, helperTextStyle } = _b, restProps = __rest(_b, ["format", "showSeconds", "separator", "enableTimeValidation", "error", "helperText", "errorColor", "helperTextStyle"]);
return (React.createElement(MaskField, Object.assign({ mask: mask, placeholder: mask.replace(/9/g, '_').replace(/a/g, '_'), inputMode: "numeric", autoComplete: "off", beforeMaskedValueChange: handleBeforeMaskedValueChange, formatChars: {
'9': '[0-9]',
a: '[aApP]',
}, onChange: handleChange, error: error, helperText: helperText, errorColor: errorColor, helperTextStyle: helperTextStyle }, restProps, { ref: ref })));
};
const TimeInput = forwardRef(TimeInputComponent);
TimeInput.displayName = 'TimeInput';
export { CreditCardInput, DateInput, MaskField, PhoneInput, TimeInput, useMask };
//# sourceMappingURL=index.js.map