UNPKG

@altricade/react-mask-field

Version:

A modern, flexible and accessible input mask component for React

561 lines (546 loc) 24.7 kB
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