UNPKG

@maskito/kit

Version:

The optional framework-agnostic Maskito's package with ready-to-use masks

1,262 lines (1,196 loc) 101 kB
import { maskitoUpdateElement, MASKITO_DEFAULT_OPTIONS, maskitoTransform } from '@maskito/core'; /** * Clamps a value between two inclusive limits */ function clamp(value, minimum, maximum) { const minClamped = max(minimum ?? value, value); return min(maximum ?? minClamped, minClamped); } function min(x, ...values) { return values.reduce((a, b) => (a < b ? a : b), x); } function max(x, ...values) { return values.reduce((a, b) => (a > b ? a : b), x); } function countDigits(str) { return str.replaceAll(/\W/g, '').length; } const DATE_SEGMENTS_MAX_VALUES = { day: 31, month: 12, year: 9999, }; const DEFAULT_DECIMAL_PSEUDO_SEPARATORS = ['.', ',', 'б', 'ю']; const DEFAULT_MIN_DATE = new Date('0001-01-01T00:00'); const DEFAULT_MAX_DATE = new Date('9999-12-31T23:59:59.999'); /** * {@link https://unicode-table.com/en/00A0/ Non-breaking space}. */ const CHAR_NO_BREAK_SPACE = '\u00A0'; /** * {@link https://symbl.cc/en/200B/ Zero width space}. */ const CHAR_ZERO_WIDTH_SPACE = '\u200B'; /** * {@link https://unicode-table.com/en/2013/ EN dash} * is used to indicate a range of numbers or a span of time. * @example 2006–2022 */ const CHAR_EN_DASH = '\u2013'; /** * {@link https://unicode-table.com/en/2014/ EM dash} * is used to mark a break in a sentence. * @example Taiga UI — powerful set of open source components for Angular * ___ * Don't confuse with {@link CHAR_EN_DASH} or {@link CHAR_HYPHEN}! */ const CHAR_EM_DASH = '\u2014'; /** * {@link https://unicode-table.com/en/002D/ Hyphen (minus sign)} * is used to combine words. * @example well-behaved * ___ * Don't confuse with {@link CHAR_EN_DASH} or {@link CHAR_EM_DASH}! */ const CHAR_HYPHEN = '\u002D'; /** * {@link https://unicode-table.com/en/2212/ Minus} * is used as math operator symbol or before negative digits. * --- * Can be used as `&minus;`. Don't confuse with {@link CHAR_HYPHEN} */ const CHAR_MINUS = '\u2212'; /** * {@link https://symbl.cc/en/30FC/ Katakana-Hiragana Prolonged Sound Mark} * is used as prolonged sounds in Japanese. */ const CHAR_JP_HYPHEN = '\u30FC'; /** * {@link https://symbl.cc/en/003A/ Colon} * is a punctuation mark that connects parts of a text logically. * --- * is also used as separator in time. */ const CHAR_COLON = '\u003A'; /** * {@link https://symbl.cc/en/FF1A/ Full-width colon} * is a full-width punctuation mark used to separate parts of a text commonly in Japanese. */ const CHAR_JP_COLON = '\uFF1A'; const DEFAULT_PSEUDO_MINUSES = [ CHAR_HYPHEN, CHAR_EN_DASH, CHAR_EM_DASH, CHAR_JP_HYPHEN, CHAR_MINUS, ]; const DEFAULT_TIME_SEGMENT_MAX_VALUES = { hours: 23, minutes: 59, seconds: 59, milliseconds: 999, }; const DEFAULT_TIME_SEGMENT_MIN_VALUES = { hours: 0, minutes: 0, seconds: 0, milliseconds: 0, }; const ANY_MERIDIEM_CHARACTER_RE = new RegExp(`[${CHAR_NO_BREAK_SPACE}APM]+$`, 'g'); const ALL_MERIDIEM_CHARACTERS_RE = new RegExp(`${CHAR_NO_BREAK_SPACE}[AP]M$`, 'g'); const TIME_FIXED_CHARACTERS = [':', '.']; const TIME_SEGMENT_VALUE_LENGTHS = { hours: 2, minutes: 2, seconds: 2, milliseconds: 3, }; const POSSIBLE_DATE_RANGE_SEPARATOR = [ CHAR_HYPHEN, CHAR_EN_DASH, CHAR_EM_DASH, CHAR_MINUS, CHAR_JP_HYPHEN, ]; const MIN_DAY = 1; const MONTHS_IN_YEAR = 12; const MonthNumber = { January: 0, February: 1, April: 3, June: 5, September: 8, November: 10, December: 11, }; function appendDate(date, { day = 0, month = 0, year = 0 } = {}) { if (day === 0 && month === 0 && year === 0) { return date; } const initialYear = date.getFullYear(); const initialMonth = date.getMonth(); const initialDate = date.getDate(); const totalMonths = (initialYear + year) * MONTHS_IN_YEAR + initialMonth + month; let years = Math.floor(totalMonths / MONTHS_IN_YEAR); let months = totalMonths % MONTHS_IN_YEAR; const monthDaysCount = getMonthDaysCount(months, isLeapYear(years)); const currentMonthDaysCount = getMonthDaysCount(initialMonth, isLeapYear(years)); let days = day; if (initialDate >= monthDaysCount) { days += initialDate - (currentMonthDaysCount - monthDaysCount); } else if (currentMonthDaysCount < monthDaysCount && initialDate === currentMonthDaysCount) { days += initialDate + (monthDaysCount - currentMonthDaysCount); } else { days += initialDate; } while (days > getMonthDaysCount(months, isLeapYear(years))) { days -= getMonthDaysCount(months, isLeapYear(years)); if (months === MonthNumber.December) { years++; months = MonthNumber.January; } else { months++; } } while (days < MIN_DAY) { if (months === MonthNumber.January) { years--; months = MonthNumber.December; } else { months--; } days += getMonthDaysCount(months, isLeapYear(years)); } days = day < 0 || month < 0 || year < 0 ? days + 1 // add one day when moving days, or months, or years backward : days - 1; // "from"-day is included in the range return new Date(years, months, days); } function getMonthDaysCount(month, isLeapYear) { switch (month) { case MonthNumber.April: case MonthNumber.June: case MonthNumber.November: case MonthNumber.September: return 30; case MonthNumber.February: return isLeapYear ? 29 : 28; default: return 31; } } function isLeapYear(year) { return year % 400 === 0 || (year % 4 === 0 && year % 100 !== 0); } const getDateSegmentValueLength = (dateString) => ({ day: dateString.match(/d/g)?.length ?? 0, month: dateString.match(/m/g)?.length ?? 0, year: dateString.match(/y/g)?.length ?? 0, }); function dateToSegments(date) { return { day: String(date.getDate()).padStart(2, '0'), month: String(date.getMonth() + 1).padStart(2, '0'), year: String(date.getFullYear()).padStart(4, '0'), hours: String(date.getHours()).padStart(2, '0'), minutes: String(date.getMinutes()).padStart(2, '0'), seconds: String(date.getSeconds()).padStart(2, '0'), milliseconds: String(date.getMilliseconds()).padStart(3, '0'), }; } const ALL_POSSIBLE_SEGMENTS = [ 'day', 'month', 'year', ]; function getDateSegmentsOrder(template) { return [...ALL_POSSIBLE_SEGMENTS].sort((a, b) => template.indexOf(a[0]) > template.indexOf(b[0]) ? 1 : -1); } function getFirstCompleteDate(dateString, dateModeTemplate) { const digitsInDate = countDigits(dateModeTemplate); const [completeDate = ''] = new RegExp(String.raw `(\D*\d){${digitsInDate}}`).exec(dateString) || []; return completeDate; } function isDateStringComplete(dateString, dateModeTemplate) { if (dateString.length < dateModeTemplate.length) { return false; } return dateString.split(/\D/).every((segment) => !/^0+$/.exec(segment)); } function parseDateRangeString(dateRange, dateModeTemplate, rangeSeparator) { const digitsInDate = countDigits(dateModeTemplate); return (dateRange .replace(rangeSeparator, '') .match(new RegExp(String.raw `(\D*\d[^\d\s]*){1,${digitsInDate}}`, 'g')) || []); } function parseDateString(dateString, fullMode) { const cleanMode = fullMode.replaceAll(/[^dmy]/g, ''); const onlyDigitsDate = dateString.replaceAll(/\D+/g, ''); const dateSegments = { day: onlyDigitsDate.slice(cleanMode.indexOf('d'), cleanMode.lastIndexOf('d') + 1), month: onlyDigitsDate.slice(cleanMode.indexOf('m'), cleanMode.lastIndexOf('m') + 1), year: onlyDigitsDate.slice(cleanMode.indexOf('y'), cleanMode.lastIndexOf('y') + 1), }; return Object.fromEntries(Object.entries(dateSegments) .filter(([_, value]) => Boolean(value)) .sort(([a], [b]) => fullMode.toLowerCase().indexOf(a.slice(0, 1)) > fullMode.toLowerCase().indexOf(b.slice(0, 1)) ? 1 : -1)); } function segmentsToDate(parsedDate, parsedTime) { const year = parsedDate.year?.length === 2 ? `20${parsedDate.year}` : parsedDate.year; const date = new Date(Number(year ?? '0'), Number(parsedDate.month ?? '1') - 1, Number(parsedDate.day ?? '1'), Number(parsedTime?.hours ?? '0'), Number(parsedTime?.minutes ?? '0'), Number(parsedTime?.seconds ?? '0'), Number(parsedTime?.milliseconds ?? '0')); // needed for years less than 1900 date.setFullYear(Number(year ?? '0')); return date; } const DATE_TIME_SEPARATOR = ', '; function toDateString({ day, month, year, hours, minutes, seconds, milliseconds, }, { dateMode, dateTimeSeparator = DATE_TIME_SEPARATOR, timeMode, }) { const yearLength = dateMode.match(/y/g)?.length ?? 0; const fullMode = dateMode + (timeMode ? dateTimeSeparator + timeMode : ''); return fullMode .replaceAll(/d+/g, day ?? '') .replaceAll(/m+/g, month ?? '') .replaceAll(/y+/g, year?.slice(-yearLength) ?? '') .replaceAll(/H+/g, hours ?? '') .replaceAll('MSS', milliseconds ?? '') .replaceAll(/M+/g, minutes ?? '') .replaceAll(/S+/g, seconds ?? '') .replaceAll(/^\D+/g, '') .replaceAll(/\D+$/g, ''); } function validateDateString({ dateString, dateModeTemplate, dateSegmentsSeparator, offset, selection: [from, to], }) { const parsedDate = parseDateString(dateString, dateModeTemplate); const dateSegments = Object.entries(parsedDate); const segmentsOrder = getDateSegmentsOrder(dateModeTemplate); const validatedDateSegments = {}; for (let i = 0; i < dateSegments.length; i++) { const [segmentName, segmentValue] = dateSegments[i]; const validatedDate = toDateString(validatedDateSegments, { dateMode: dateModeTemplate, }); const maxSegmentValue = DATE_SEGMENTS_MAX_VALUES[segmentName]; const fantomSeparator = validatedDate.length && dateSegmentsSeparator.length; const lastSegmentDigitIndex = offset + validatedDate.length + fantomSeparator + getDateSegmentValueLength(dateModeTemplate)[segmentName]; const isLastSegmentDigitAdded = lastSegmentDigitIndex >= from && lastSegmentDigitIndex === to; if (isLastSegmentDigitAdded && Number(segmentValue) > Number(maxSegmentValue)) { const nextSegment = segmentsOrder[segmentsOrder.indexOf(segmentName) + 1]; if (!nextSegment || nextSegment === 'year') { // 31.1|0.2010 => Type 9 => 31.1|0.2010 return { validatedDateString: '', updatedSelection: [from, to] }; // prevent changes } validatedDateSegments[segmentName] = `0${segmentValue.slice(0, -1)}`; dateSegments[i + 1] = [ nextSegment, segmentValue.slice(-1) + (dateSegments[i + 1]?.[1] ?? '').slice(1), ]; continue; } if (isLastSegmentDigitAdded && Number(segmentValue) < 1) { // 31.0|1.2010 => Type 0 => 31.0|1.2010 return { validatedDateString: '', updatedSelection: [from, to] }; // prevent changes } validatedDateSegments[segmentName] = segmentValue; } const validatedDateString = toDateString(validatedDateSegments, { dateMode: dateModeTemplate, }); const addedDateSegmentSeparators = validatedDateString.length - dateString.length; return { validatedDateString, updatedSelection: [ from + addedDateSegmentSeparators, to + addedDateSegmentSeparators, ], }; } function identity(x) { return x; } // eslint-disable-next-line @typescript-eslint/no-empty-function function noop() { } /** * Copy-pasted solution from lodash * @see https://lodash.com/docs/4.17.15#escapeRegExp */ const reRegExpChar = /[\\^$.*+?()[\]{}|]/g; const reHasRegExpChar = new RegExp(reRegExpChar.source); function escapeRegExp(str) { return str && reHasRegExpChar.test(str) ? str.replaceAll(reRegExpChar, String.raw `\$&`) : str; } function findCommonBeginningSubstr(a, b) { let res = ''; for (let i = 0; i < a.length; i++) { if (a[i] !== b[i]) { return res; } res += a[i]; } return res; } function isEmpty(entity) { return !entity || (typeof entity === 'object' && Object.keys(entity).length === 0); } const ALL_ZEROES_RE = /^0+$/; function padWithZeroesUntilValid(segmentValue, paddedMaxValue, prefixedZeroesCount = 0) { const paddedSegmentValue = segmentValue.padEnd(paddedMaxValue.length, '0'); if (Number(paddedSegmentValue) <= Number(paddedMaxValue)) { return { validatedSegmentValue: segmentValue, prefixedZeroesCount }; } if (paddedSegmentValue.endsWith('0')) { // 00:|00 => Type 9 => 00:09| return padWithZeroesUntilValid(`0${segmentValue.slice(0, paddedMaxValue.length - 1)}`, paddedMaxValue, prefixedZeroesCount + 1); } const valueWithoutLastChar = segmentValue.slice(0, paddedMaxValue.length - 1); if (ALL_ZEROES_RE.exec(valueWithoutLastChar)) { return { validatedSegmentValue: '', prefixedZeroesCount }; } // |19:00 => Type 2 => 2|0:00 return padWithZeroesUntilValid(`${valueWithoutLastChar}0`, paddedMaxValue, prefixedZeroesCount); } /** * Replace fullwidth colon with half width colon * @param fullWidthColon full width colon * @returns processed half width colon */ function toHalfWidthColon(fullWidthColon) { return fullWidthColon.replaceAll(new RegExp(CHAR_JP_COLON, 'g'), CHAR_COLON); } /** * Replace fullwidth numbers with half width number * @param fullWidthNumber full width number * @returns processed half width number */ function toHalfWidthNumber(fullWidthNumber) { return fullWidthNumber.replaceAll(/[0-9]/g, (s) => String.fromCharCode(s.charCodeAt(0) - 0xfee0)); } /** * Convert full width colon (:) to half width one (:) */ function createColonConvertPreprocessor() { return ({ elementState, data }) => { const { value, selection } = elementState; return { elementState: { selection, value: toHalfWidthColon(value), }, data: toHalfWidthColon(data), }; }; } function createDateSegmentsZeroPaddingPostprocessor({ dateModeTemplate, dateSegmentSeparator, splitFn, uniteFn, }) { return ({ value, selection }) => { const [from, to] = selection; const { dateStrings, restPart = '' } = splitFn(value); const validatedDateStrings = []; let caretShift = 0; dateStrings.forEach((dateString) => { const parsedDate = parseDateString(dateString, dateModeTemplate); const dateSegments = Object.entries(parsedDate); const validatedDateSegments = dateSegments.reduce((acc, [segmentName, segmentValue]) => { const { validatedSegmentValue, prefixedZeroesCount } = padWithZeroesUntilValid(segmentValue, `${DATE_SEGMENTS_MAX_VALUES[segmentName]}`); caretShift += prefixedZeroesCount; return { ...acc, [segmentName]: validatedSegmentValue }; }, {}); validatedDateStrings.push(toDateString(validatedDateSegments, { dateMode: dateModeTemplate })); }); const validatedValue = uniteFn(validatedDateStrings, value) + (dateStrings[dateStrings.length - 1]?.endsWith(dateSegmentSeparator) ? dateSegmentSeparator : '') + restPart; if (caretShift && validatedValue.slice(to + caretShift, to + caretShift + dateSegmentSeparator.length) === dateSegmentSeparator) { /** * If `caretShift` > 0, it means that time segment was padded with zero. * It is only possible if any character insertion happens. * If caret is before `dateSegmentSeparator` => it should be moved after `dateSegmentSeparator`. */ caretShift += dateSegmentSeparator.length; } return { selection: [from + caretShift, to + caretShift], value: validatedValue, }; }; } /** * It replaces pseudo range separators with valid one. * @example '01.01.2000_11.11.2000' -> '01.01.2000 - 01.01.2000' * @example '01.01.2000_23:59' -> '01.01.2000, 23:59' */ function createFirstDateEndSeparatorPreprocessor({ dateModeTemplate, firstDateEndSeparator, dateSegmentSeparator, pseudoFirstDateEndSeparators, }) { return ({ elementState, data }) => { const { value, selection } = elementState; const [from, to] = selection; const firstCompleteDate = getFirstCompleteDate(value, dateModeTemplate); const pseudoSeparators = pseudoFirstDateEndSeparators.filter((x) => !firstDateEndSeparator.includes(x) && x !== dateSegmentSeparator); const pseudoSeparatorsRE = new RegExp(`[${pseudoSeparators.join('')}]`, 'gi'); const newValue = firstCompleteDate && value.length > firstCompleteDate.length ? firstCompleteDate + value .slice(firstCompleteDate.length) .replace(/^\D*/, firstDateEndSeparator) : value; const caretShift = newValue.length - value.length; return { elementState: { selection: [from + caretShift, to + caretShift], value: newValue, }, data: data.replace(pseudoSeparatorsRE, firstDateEndSeparator), }; }; } /** * Convert full width numbers like 1, 2 to half width numbers 1, 2 */ function createFullWidthToHalfWidthPreprocessor() { return ({ elementState, data }) => { const { value, selection } = elementState; return { elementState: { selection, value: toHalfWidthNumber(value), }, data: toHalfWidthNumber(data), }; }; } function createTimeMaskExpression(mode) { return Array.from(mode.replace(' AA', '')) .map((char) => (TIME_FIXED_CHARACTERS.includes(char) ? char : /\d/)) .concat(mode.includes('AA') ? [CHAR_NO_BREAK_SPACE, /[AP]/i, /M/i] : []); } function padTimeSegments(timeSegments, pad) { return Object.fromEntries(Object.entries(timeSegments).map(([segmentName, segmentValue]) => [ segmentName, pad(String(segmentValue), TIME_SEGMENT_VALUE_LENGTHS[segmentName]), ])); } function padStartTimeSegments(timeSegments) { return padTimeSegments(timeSegments, (value, length) => value.padStart(length, '0')); } const SEGMENT_FULL_NAME = { HH: 'hours', MM: 'minutes', SS: 'seconds', MSS: 'milliseconds', }; /** * @param timeString can be with/without fixed characters */ function parseTimeString(timeString, timeMode) { const onlyDigits = timeString.replaceAll(/\D+/g, ''); let offset = 0; return Object.fromEntries(timeMode .split(/\W/) .filter((segmentAbbr) => SEGMENT_FULL_NAME[segmentAbbr]) .map((segmentAbbr) => { const segmentValue = onlyDigits.slice(offset, offset + segmentAbbr.length); offset += segmentAbbr.length; return [SEGMENT_FULL_NAME[segmentAbbr], segmentValue]; })); } const LEADING_NON_DIGITS = /^\D*/; const TRAILING_NON_DIGITS = /\D*$/; function toTimeString({ hours = '', minutes = '', seconds = '', milliseconds = '', }) { return `${hours}:${minutes}:${seconds}.${milliseconds}` .replace(LEADING_NON_DIGITS, '') .replace(TRAILING_NON_DIGITS, ''); } const TRAILING_TIME_SEGMENT_SEPARATOR_REG = new RegExp(`[${TIME_FIXED_CHARACTERS.map(escapeRegExp).join('')}]$`); /** * Pads invalid time segment with zero to make it valid. * @example 00:|00 => Type 9 (too much for the first digit of minutes) => 00:09| * @example |19:00 => Type 2 (29 - invalid value for hours) => 2|0:00 */ function enrichTimeSegmentsWithZeroes({ value, selection }, { mode, timeSegmentMaxValues = DEFAULT_TIME_SEGMENT_MAX_VALUES, }) { const [from, to] = selection; const parsedTime = parseTimeString(value, mode); const possibleTimeSegments = Object.entries(parsedTime); const paddedMaxValues = padStartTimeSegments(timeSegmentMaxValues); const validatedTimeSegments = {}; let paddedZeroes = 0; for (const [segmentName, segmentValue] of possibleTimeSegments) { const maxSegmentValue = paddedMaxValues[segmentName]; const { validatedSegmentValue, prefixedZeroesCount } = padWithZeroesUntilValid(segmentValue, String(maxSegmentValue)); paddedZeroes += prefixedZeroesCount; validatedTimeSegments[segmentName] = validatedSegmentValue; } const [leadingNonDigitCharacters = ''] = value.match(/^\D+(?=\d)/g) || []; // prefix const [trailingNonDigitCharacters = ''] = value.match(/\D+$/g) || []; // trailing segment separators / meridiem characters / postfix const validatedTimeString = leadingNonDigitCharacters + toTimeString(validatedTimeSegments) + trailingNonDigitCharacters; const addedDateSegmentSeparators = Math.max(validatedTimeString.length - value.length - paddedZeroes, 0); let newFrom = from + paddedZeroes + addedDateSegmentSeparators; let newTo = to + paddedZeroes + addedDateSegmentSeparators; if (newFrom === newTo && paddedZeroes && // if next character after cursor is time segment separator validatedTimeString.slice(0, newTo + 1).match(TRAILING_TIME_SEGMENT_SEPARATOR_REG)) { newFrom++; newTo++; } return { value: validatedTimeString, selection: [newFrom, newTo], }; } function padEndTimeSegments(timeSegments) { return padTimeSegments(timeSegments, (value, length) => value.padEnd(length, '0')); } /** * Prevent insertion if any time segment will become invalid * (and even zero padding won't help with it). * @example 2|0:00 => Type 9 => 2|0:00 */ function createInvalidTimeSegmentInsertionPreprocessor({ timeMode, timeSegmentMinValues = DEFAULT_TIME_SEGMENT_MIN_VALUES, timeSegmentMaxValues = DEFAULT_TIME_SEGMENT_MAX_VALUES, parseValue = (x) => ({ timeString: x }), }) { const invalidCharsRegExp = new RegExp(String.raw `[^\d${TIME_FIXED_CHARACTERS.map(escapeRegExp).join('')}]+`); return ({ elementState, data }, actionType) => { if (actionType !== 'insert') { return { elementState, data }; } const { value, selection } = elementState; const [from, rawTo] = selection; const newCharacters = data.replace(invalidCharsRegExp, ''); const to = rawTo + newCharacters.length; // to be conformed with `overwriteMode: replace` const newPossibleValue = value.slice(0, from) + newCharacters + value.slice(to); const { timeString, restValue = '' } = parseValue(newPossibleValue); const timeSegments = Object.entries(parseTimeString(timeString, timeMode)); let offset = restValue.length; for (const [segmentName, stringifiedSegmentValue] of timeSegments) { const minSegmentValue = timeSegmentMinValues[segmentName]; const maxSegmentValue = timeSegmentMaxValues[segmentName]; const segmentValue = Number(stringifiedSegmentValue); const lastSegmentDigitIndex = offset + TIME_SEGMENT_VALUE_LENGTHS[segmentName]; if (lastSegmentDigitIndex >= from && lastSegmentDigitIndex <= to && segmentValue !== clamp(segmentValue, minSegmentValue, maxSegmentValue)) { return { elementState, data: '' }; // prevent insertion } offset += stringifiedSegmentValue.length + // any time segment separator 1; } return { elementState, data }; }; } function createMeridiemPreprocessor(timeMode) { if (!timeMode.includes('AA')) { return identity; } const mainMeridiemCharRE = /^[AP]$/gi; return ({ elementState, data }) => { const { value, selection } = elementState; const newValue = value.toUpperCase(); const newData = data.toUpperCase(); if (newValue.match(ALL_MERIDIEM_CHARACTERS_RE) && newData.match(mainMeridiemCharRE)) { return { elementState: { value: newValue.replaceAll(ALL_MERIDIEM_CHARACTERS_RE, ''), selection, }, data: `${newData}M`, }; } return { elementState: { selection, value: newValue }, data: newData }; }; } function createMeridiemPostprocessor(timeMode) { if (!timeMode.includes('AA')) { return identity; } return ({ value, selection }, initialElementState) => { if (!value.match(ANY_MERIDIEM_CHARACTER_RE) || value.match(ALL_MERIDIEM_CHARACTERS_RE)) { return { value, selection }; } const [from, to] = selection; // any meridiem character was deleted if (initialElementState.value.match(ALL_MERIDIEM_CHARACTERS_RE)) { const newValue = value.replace(ANY_MERIDIEM_CHARACTER_RE, ''); return { value: newValue, selection: [ Math.min(from, newValue.length), Math.min(to, newValue.length), ], }; } const fullMeridiem = `${CHAR_NO_BREAK_SPACE}${value.includes('P') ? 'P' : 'A'}M`; const newValue = value.replace(ANY_MERIDIEM_CHARACTER_RE, (x) => x !== CHAR_NO_BREAK_SPACE ? fullMeridiem : x); return { value: newValue, selection: to >= newValue.indexOf(fullMeridiem) ? [newValue.length, newValue.length] : selection, }; }; } function raiseSegmentValueToMin(segments, fullMode) { const segmentsLength = getDateSegmentValueLength(fullMode); return Object.fromEntries(Object.entries(segments).map(([key, value]) => { const segmentLength = segmentsLength[key]; return [ key, value.length === segmentLength && /^0+$/.exec(value) ? '1'.padStart(segmentLength, '0') : value, ]; })); } const LEAP_YEAR = '1972'; function createMinMaxDatePostprocessor({ dateModeTemplate, min = DEFAULT_MIN_DATE, max = DEFAULT_MAX_DATE, rangeSeparator = '', dateSegmentSeparator = '.', }) { return ({ value, selection }) => { const endsWithRangeSeparator = rangeSeparator && value.endsWith(rangeSeparator); const dateStrings = parseDateRangeString(value, dateModeTemplate, rangeSeparator); let validatedValue = ''; for (const dateString of dateStrings) { validatedValue += validatedValue ? rangeSeparator : ''; const parsedDate = parseDateString(dateString, dateModeTemplate); if (!isDateStringComplete(dateString, dateModeTemplate)) { const fixedDate = raiseSegmentValueToMin(parsedDate, dateModeTemplate); const fixedValue = toDateString(fixedDate, { dateMode: dateModeTemplate }); const tail = dateString.endsWith(dateSegmentSeparator) ? dateSegmentSeparator : ''; validatedValue += fixedValue + tail; continue; } const date = segmentsToDate({ year: LEAP_YEAR, ...parsedDate }); const clampedDate = clamp(date, min, max); validatedValue += toDateString(dateToSegments(clampedDate), { dateMode: dateModeTemplate, }); } return { selection, value: validatedValue + (endsWithRangeSeparator ? rangeSeparator : ''), }; }; } function normalizeDatePreprocessor({ dateModeTemplate, dateSegmentsSeparator, rangeSeparator = '', dateTimeSeparator = DATE_TIME_SEPARATOR, }) { return ({ elementState, data }) => { const templateSegments = dateModeTemplate.split(dateSegmentsSeparator); const includesTime = data.includes(dateTimeSeparator); const dateSegments = data .slice(0, includesTime ? data.indexOf(dateTimeSeparator) : Infinity) .split(/\D/) .filter(Boolean); if (!dateSegments.length || dateSegments.length % templateSegments.length !== 0) { return { elementState, data }; } const dates = dateSegments.reduce((dates, segment, index) => { const template = templateSegments[index % templateSegments.length] ?? ''; const dateIndex = Math.trunc(index / templateSegments.length); const isLastDateSegment = index % templateSegments.length === templateSegments.length - 1; if (!dates[dateIndex]) { dates[dateIndex] = ''; } dates[dateIndex] += isLastDateSegment ? segment : `${segment.padStart(template.length, '0')}${dateSegmentsSeparator}`; return dates; }, []); return { elementState, data: includesTime ? `${dates[0]}${data.slice(data.indexOf(dateTimeSeparator))}` : dates.join(rangeSeparator), }; }; } function maskitoPostfixPostprocessorGenerator(postfix) { const completedPostfixRE = new RegExp(`${escapeRegExp(postfix)}$`); const incompletePostfixRE = new RegExp(postfix && `(${postfix .split('') .map(escapeRegExp) // eslint-disable-next-line .reduce((acc, _, i, arr) => `${acc}|${arr.slice(0, i + 1).join('')}`)})$`); return postfix ? ({ value, selection }, initialElementState) => { if (!value && !initialElementState.value.endsWith(postfix)) { // cases when developer wants input to be empty (programmatically) return { value, selection }; } if (!value.match(incompletePostfixRE) && !initialElementState.value.endsWith(postfix)) { return { selection, value: value + postfix }; } const initialValueBeforePostfix = initialElementState.value.replace(completedPostfixRE, ''); const postfixWasModified = initialElementState.selection[1] > initialValueBeforePostfix.length; const alreadyExistedValueBeforePostfix = findCommonBeginningSubstr(initialValueBeforePostfix, value); return { selection, value: Array.from(postfix) .reverse() .reduce((newValue, char, index) => { const i = newValue.length - 1 - index; const isInitiallyMirroredChar = alreadyExistedValueBeforePostfix[i] === char && postfixWasModified; return newValue[i] !== char || isInitiallyMirroredChar ? newValue.slice(0, i + 1) + char + newValue.slice(i + 1) : newValue; }, value), }; } : identity; } function maskitoPrefixPostprocessorGenerator(prefix) { return prefix ? ({ value, selection }, initialElementState) => { if (value.startsWith(prefix) || // already valid (!value && !initialElementState.value.startsWith(prefix)) // cases when developer wants input to be empty ) { return { value, selection }; } const [from, to] = selection; const prefixedValue = Array.from(prefix).reduce((modifiedValue, char, i) => modifiedValue[i] === char ? modifiedValue : modifiedValue.slice(0, i) + char + modifiedValue.slice(i), value); const addedCharsCount = prefixedValue.length - value.length; return { selection: [from + addedCharsCount, to + addedCharsCount], value: prefixedValue, }; } : identity; } function createValidDatePreprocessor({ dateModeTemplate, dateSegmentsSeparator, rangeSeparator = '', }) { return ({ elementState, data }) => { const { value, selection } = elementState; if (data === dateSegmentsSeparator) { return { elementState, data: selection[0] === value.length ? data : '', }; } if (!data.replaceAll(/\D/g, '')) { return { elementState, data }; } const newCharacters = data.replaceAll(new RegExp(String.raw `[^\d${escapeRegExp(dateSegmentsSeparator)}${rangeSeparator}]`, 'g'), ''); const [from, rawTo] = selection; let to = rawTo + data.length; const newPossibleValue = value.slice(0, from) + newCharacters + value.slice(to); const dateStrings = parseDateRangeString(newPossibleValue, dateModeTemplate, rangeSeparator); let validatedValue = ''; const hasRangeSeparator = Boolean(rangeSeparator) && newPossibleValue.includes(rangeSeparator); for (const dateString of dateStrings) { const { validatedDateString, updatedSelection } = validateDateString({ dateString, dateModeTemplate, dateSegmentsSeparator, offset: validatedValue.length, selection: [from, to], }); if (dateString && !validatedDateString) { return { elementState, data: '' }; // prevent changes } to = updatedSelection[1]; validatedValue += hasRangeSeparator && !validatedValue ? validatedDateString + rangeSeparator : validatedDateString; } const newData = validatedValue.slice(from, to); return { elementState: { selection, value: validatedValue.slice(0, from) + newData .split(dateSegmentsSeparator) .map((segment) => '0'.repeat(segment.length)) .join(dateSegmentsSeparator) + validatedValue.slice(to), }, data: newData, }; }; } function maskitoEventHandler(name, handler, eventListenerOptions) { return (element, maskitoOptions) => { const listener = () => handler(element, maskitoOptions); element.addEventListener(name, listener, eventListenerOptions); return () => element.removeEventListener(name, listener, eventListenerOptions); }; } function maskitoAddOnFocusPlugin(value) { return maskitoEventHandler('focus', (element) => { if (!element.value) { maskitoUpdateElement(element, value); } }); } function maskitoSelectionChangeHandler(handler) { return (element, options) => { const document = element.ownerDocument; let isPointerDown = 0; const onPointerDown = () => isPointerDown++; const onPointerUp = () => { isPointerDown = Math.max(--isPointerDown, 0); }; const listener = () => { if (!element.matches(':focus')) { return; } if (isPointerDown) { return document.addEventListener('mouseup', listener, { once: true, passive: true, }); } handler(element, options); }; document.addEventListener('selectionchange', listener, { passive: true }); // Safari does not fire `selectionchange` on focus after programmatic update of textfield value element.addEventListener('focus', listener, { passive: true }); element.addEventListener('mousedown', onPointerDown, { passive: true }); document.addEventListener('mouseup', onPointerUp, { passive: true }); return () => { document.removeEventListener('selectionchange', listener); element.removeEventListener('focus', listener); element.removeEventListener('mousedown', onPointerDown); document.removeEventListener('mouseup', onPointerUp); }; }; } function maskitoCaretGuard(guard) { return maskitoSelectionChangeHandler((element) => { const start = element.selectionStart ?? 0; const end = element.selectionEnd ?? 0; const [fromLimit, toLimit] = guard(element.value, [start, end]); if (fromLimit > start || toLimit < end) { element.setSelectionRange(clamp(start, fromLimit, toLimit), clamp(end, fromLimit, toLimit)); } }); } const maskitoRejectEvent = (element) => { const listener = () => { const value = element.value; element.addEventListener('beforeinput', (event) => { if (event.defaultPrevented && value === element.value) { element.dispatchEvent(new CustomEvent('maskitoReject', { bubbles: true })); } }, { once: true }); }; element.addEventListener('beforeinput', listener, true); return () => element.removeEventListener('beforeinput', listener, true); }; function maskitoRemoveOnBlurPlugin(value) { return maskitoEventHandler('blur', (element) => { if (element.value === value) { maskitoUpdateElement(element, ''); } }); } function createMeridiemSteppingPlugin(meridiemStartIndex) { if (meridiemStartIndex < 0) { return noop; } return (element) => { const listener = (event) => { const caretIndex = Number(element.selectionStart); const value = element.value.toUpperCase(); if ((event.key !== 'ArrowUp' && event.key !== 'ArrowDown') || caretIndex < meridiemStartIndex) { return; } event.preventDefault(); // eslint-disable-next-line no-nested-ternary const meridiemMainCharacter = value.includes('A') ? 'P' : value.includes('P') || event.key === 'ArrowUp' ? 'A' : 'P'; const newMeridiem = `${CHAR_NO_BREAK_SPACE}${meridiemMainCharacter}M`; maskitoUpdateElement(element, { value: value.length === meridiemStartIndex ? value + newMeridiem : value.replace(ANY_MERIDIEM_CHARACTER_RE, newMeridiem), selection: [caretIndex, caretIndex], }); }; element.addEventListener('keydown', listener); return () => element.removeEventListener('keydown', listener); }; } function createTimeSegmentsSteppingPlugin({ step, fullMode, timeSegmentMinValues, timeSegmentMaxValues, }) { const segmentsIndexes = createTimeSegmentsIndexes(fullMode); return step <= 0 ? noop : (element) => { const listener = (event) => { if (event.key !== 'ArrowUp' && event.key !== 'ArrowDown') { return; } event.preventDefault(); const selectionStart = element.selectionStart ?? 0; const activeSegment = getActiveSegment({ segmentsIndexes, selectionStart, }); if (!activeSegment) { return; } const updatedValue = updateSegmentValue({ selection: segmentsIndexes.get(activeSegment), value: element.value, toAdd: event.key === 'ArrowUp' ? step : -step, min: timeSegmentMinValues[activeSegment], max: timeSegmentMaxValues[activeSegment], }); maskitoUpdateElement(element, { value: updatedValue, selection: [selectionStart, selectionStart], }); }; element.addEventListener('keydown', listener); return () => element.removeEventListener('keydown', listener); }; } function createTimeSegmentsIndexes(fullMode) { return new Map([ ['hours', getSegmentRange(fullMode, 'HH')], ['milliseconds', getSegmentRange(fullMode, 'MSS')], ['minutes', getSegmentRange(fullMode, 'MM')], ['seconds', getSegmentRange(fullMode, 'SS')], ]); } function getSegmentRange(mode, segment) { const index = mode.indexOf(segment); return index === -1 ? [-1, -1] : [index, index + segment.length]; } function getActiveSegment({ segmentsIndexes, selectionStart, }) { for (const [segmentName, segmentRange] of segmentsIndexes.entries()) { const [from, to] = segmentRange; if (from <= selectionStart && selectionStart <= to) { return segmentName; } } return null; } function updateSegmentValue({ selection, value, toAdd, min, max, }) { const [from, to] = selection; const segmentValue = Number(value.slice(from, to).padEnd(to - from, '0')); const newSegmentValue = mod(segmentValue + toAdd, min, max + 1); return (value.slice(0, from) + String(newSegmentValue).padStart(to - from, '0') + value.slice(to, value.length)); } function mod(value, min, max) { const range = max - min; return ((((value - min) % range) + range) % range) + min; } function maskitoWithPlaceholder(placeholder, focusedOnly = false) { let lastClearValue = ''; let action = 'validation'; const removePlaceholder = (value) => { for (let i = value.length - 1; i >= lastClearValue.length; i--) { if (value[i] !== placeholder[i]) { return value.slice(0, i + 1); } } return value.slice(0, lastClearValue.length); }; const plugins = [maskitoCaretGuard((value) => [0, removePlaceholder(value).length])]; let focused = false; if (focusedOnly) { const focus = maskitoEventHandler('focus', (element) => { focused = true; maskitoUpdateElement(element, element.value + placeholder.slice(element.value.length)); }, { capture: true }); const blur = maskitoEventHandler('blur', (element) => { focused = false; maskitoUpdateElement(element, removePlaceholder(element.value)); }, { capture: true }); plugins.push(focus, blur); } return { plugins, removePlaceholder, preprocessors: [ ({ elementState, data }, actionType) => { action = actionType; const { value, selection } = elementState; return { elementState: { selection, value: removePlaceholder(value), }, data, }; }, ], postprocessors: [ ({ value, selection }, initialElementState) => { lastClearValue = value; const justPlaceholderRemoval = value + placeholder.slice(value.length, initialElementState.value.length) === initialElementState.value; if (action === 'validation' && justPlaceholderRemoval) { /** * If `value` still equals to `initialElementState.value`, * then it means that value is patched programmatically (from Maskito's plugin or externally). * In this case, we don't want to mutate value and automatically add/remove placeholder. * ___ * For example, developer wants to remove manually placeholder (+ do something else with value) on blur. * Without this condition, placeholder will be unexpectedly added again. */ return { selection, value: initialElementState.value }; } const newValue = focused || !focusedOnly ? value + placeholder.slice(value.length) : value; if (newValue === initialElementState.value && action === 'deleteBackward') { const [caretIndex] = initialElementState.selection; return { value: newValue, selection: [caretIndex, caretIndex], }; } return { value: newValue, selection }; }, ], }; } function createZeroPlaceholdersPreprocessor(postfix = '') { const isLastChar = (value, [_, to]) => to >= value.length - postfix.length; return ({ elementState }, actionType) => { const { value, selection } = elementState; if (!value || isLastChar(value, selection)) { return { elementState }; } const [from, to] = selection; const zeroes = value.slice(from, to).replaceAll(/\d/g, '0'); const newValue = value.slice(0, from) + zeroes + value.slice(to); if (!zeroes.replaceAll(/\D/g, '')) { return { elementState }; } if (actionType === 'validation' || (actionType === 'insert' && from === to)) { return { elementState: { selection, value: newValue } }; } return { elementState: { selection: actionType === 'deleteBackward' || actionType === 'insert' ? [from, from] : [to, to], value: newValue, }, }; }; } function maskitoDateOptionsGenerator({ mode, separator = '.', max, min, }) { const dateModeTemplate = mode.split('/').join(separator); return { ...MASKITO_DEFAULT_OPTIONS, mask: Array.from(dateModeTemplate).map((char) => separator.includes(char) ? char : /\d/), overwriteMode: 'replace', preprocessors: [ createFullWidthToHalfWidthPreprocessor(), createZeroPlaceholdersPreprocessor(), normalizeDatePreprocessor({ dateModeTemplate, dateSegmentsSeparator: separator, }), createValidDatePreprocessor({ dateModeTemplate, dateSegmentsSeparator: separator, }), ], postprocessors: [ createDateSegmentsZeroPaddingPostprocessor({ dateModeTemplate, dateSegmentSeparator: separator, splitFn: (value) => ({ dateStrings: [value] }), uniteFn: ([dateString = '']) => dateString, }), createMinMaxDatePostprocessor({ min, max, dateModeTemplate, dateSegmentSeparator: separator, }), ], }; } function maskitoParseDate(value, { mode, min = DEFAULT_MIN_DATE, max = DEFAULT_MAX_DATE }) { const digitsPattern = mode.replaceAll(/[^dmy]/g, ''); const digits = value.replaceAll(/\D+/g, ''); if (digits.length !== digitsPattern.length) { return null; } const dateSegments = parseDateString(value, mode); const parsedDate = segmentsToDate(dateSegments); return mode.includes('y') ? clamp(parsedDate, min, max) : parsedDate; } const formatter = Intl.DateTimeFormat('en-US', { month: '2-digit', day: '2-digit', year: 'numeric', }); function toDateSegments(date) { return formatter .formatToParts(date) .reduce((acc, part) => ({ ...acc, [part.type]: part.value }), {}); } function maskitoStringifyDate(date, { mode, separator = '.', min = DEFAULT_MIN_DATE, max = DEFAULT_MAX_DATE, }) { const validatedDate = clamp(date, min, max); const { year, ...segments } = toDateSegments(validatedDate); return toDateString({ ...segments, year: year.padStart(mode.match(/y/g)?.length ?? 0, '0') }, { dateMode: mode.replaceAll('/', separator) }); } function createMinMaxRangeLengthPostprocessor({ dateModeTemplate, rangeSeparator, minLength, maxLength, max = DEFAULT_MAX_DATE, }) { if (isEmpty(minLength) && isEmpty(maxLength)) { return identity; } return ({ value, selection }) => { const dateStrings = parseDateRangeString(value, dateModeTemplate, rangeSeparator); if (dateStrings.length !== 2 || dateStrings.some((date) => !isDateStringComplete(date, dateModeTemplate))) { return { value, selection }; } const [fromDate, toDate] = dateStrings.map((dateString) => segmentsToDate(parseDateString(dateString, dateModeTemplate))); if (!fromDate || !toDate) { return { value, selection }; } const minDistantToDate = appendDate(fromDate, minLength); const maxDistantToDate = !isEmpty(maxLength) ? appendDate(fromDate, maxLength) : max; const minLengthClampedToDate = clamp(toDate, minDistantToDate, max); const minMaxLengthClampedToDate = minLengthClampedToDate > maxDistantToDate ? maxDistantToDate : minLengthClampedToDate; return { selection, value: dateStrings[0] + rangeSeparator + toDateString(dateToSegments(minMaxLengthClampedToDate), { dateMode: dateModeTemplate, }), }; }; } function createSwapDatesPostprocessor({ dateModeTemplate, rangeSeparator, }) { return ({ value, selection }) => { const dateStrings = parseDateRangeString(value, dateModeTemplate, rangeSeparator); const isDateRangeComplete = dateStrings.length === 2 && dateStrings.every((date) => isDateStringComplete(date, dateModeTemplate