diff --git a/projects/demo-integrations/src/tests/kit/time/time-basic.cy.ts b/projects/demo-integrations/src/tests/kit/time/time-basic.cy.ts index 4743108ac..2a93028b3 100644 --- a/projects/demo-integrations/src/tests/kit/time/time-basic.cy.ts +++ b/projects/demo-integrations/src/tests/kit/time/time-basic.cy.ts @@ -248,7 +248,7 @@ describe('Time', () => { .should('have.prop', 'selectionEnd', '20:0'.length); }); - it('|23|:59 => Delete => 00:|59', BROWSER_SUPPORTS_REAL_EVENTS, () => { + it('|23|:59 => Delete => 00|:59', BROWSER_SUPPORTS_REAL_EVENTS, () => { cy.get('@input') .type('2359') .realPress([ diff --git a/projects/kit/src/lib/masks/date-time/date-time-mask.ts b/projects/kit/src/lib/masks/date-time/date-time-mask.ts index f0db055aa..9539e0b25 100644 --- a/projects/kit/src/lib/masks/date-time/date-time-mask.ts +++ b/projects/kit/src/lib/masks/date-time/date-time-mask.ts @@ -8,6 +8,7 @@ import { createDateSegmentsZeroPaddingPostprocessor, createFirstDateEndSeparatorPreprocessor, createFullWidthToHalfWidthPreprocessor, + createInvalidTimeSegmentInsertionPreprocessor, createZeroPlaceholdersPreprocessor, normalizeDatePreprocessor, } from '../../processors'; @@ -63,6 +64,17 @@ export function maskitoDateTimeOptionsGenerator({ dateSegmentsSeparator: dateSeparator, dateTimeSeparator, }), + createInvalidTimeSegmentInsertionPreprocessor({ + timeMode, + parseValue: (x) => { + const [dateString, timeString] = parseDateTimeString(x, { + dateModeTemplate, + dateTimeSeparator, + }); + + return {timeString, restValue: dateString + dateTimeSeparator}; + }, + }), createValidDateTimePreprocessor({ dateModeTemplate, dateSegmentsSeparator: dateSeparator, diff --git a/projects/kit/src/lib/masks/date-time/preprocessors/valid-date-time-preprocessor.ts b/projects/kit/src/lib/masks/date-time/preprocessors/valid-date-time-preprocessor.ts index b6aa7109f..43efa8185 100644 --- a/projects/kit/src/lib/masks/date-time/preprocessors/valid-date-time-preprocessor.ts +++ b/projects/kit/src/lib/masks/date-time/preprocessors/valid-date-time-preprocessor.ts @@ -1,9 +1,9 @@ import type {MaskitoPreprocessor} from '@maskito/core'; -import {DEFAULT_TIME_SEGMENT_MAX_VALUES, TIME_FIXED_CHARACTERS} from '../../../constants'; +import {TIME_FIXED_CHARACTERS} from '../../../constants'; import type {MaskitoTimeMode} from '../../../types'; import {escapeRegExp, validateDateString} from '../../../utils'; -import {padStartTimeSegments, validateTimeString} from '../../../utils/time'; +import {enrichTimeSegmentsWithZeroes} from '../../../utils/time'; import {parseDateTimeString} from '../utils'; export function createValidDateTimePreprocessor({ @@ -66,25 +66,16 @@ export function createValidDateTimePreprocessor({ validatedValue += validatedDateString; - const paddedMaxValues = padStartTimeSegments(DEFAULT_TIME_SEGMENT_MAX_VALUES); + const updatedTimeState = enrichTimeSegmentsWithZeroes( + {value: timeString, selection: [from, to]}, + {mode: timeMode}, + ); - const {validatedTimeString, updatedTimeSelection} = validateTimeString({ - timeString, - paddedMaxValues, - offset: validatedValue.length + dateTimeSeparator.length, - selection: [from, to], - timeMode, - }); - - if (timeString && !validatedTimeString) { - return {elementState, data: ''}; // prevent changes - } - - to = updatedTimeSelection[1]; + to = updatedTimeState.selection[1]; validatedValue += hasDateTimeSeparator - ? dateTimeSeparator + validatedTimeString - : validatedTimeString; + ? dateTimeSeparator + updatedTimeState.value + : updatedTimeState.value; const newData = validatedValue.slice(from, to); diff --git a/projects/kit/src/lib/masks/time/processors/index.ts b/projects/kit/src/lib/masks/time/processors/index.ts deleted file mode 100644 index 7be61663a..000000000 --- a/projects/kit/src/lib/masks/time/processors/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './max-validation-preprocessor'; diff --git a/projects/kit/src/lib/masks/time/processors/max-validation-preprocessor.ts b/projects/kit/src/lib/masks/time/processors/max-validation-preprocessor.ts deleted file mode 100644 index ac9f4f8e3..000000000 --- a/projects/kit/src/lib/masks/time/processors/max-validation-preprocessor.ts +++ /dev/null @@ -1,74 +0,0 @@ -import type {MaskitoPreprocessor} from '@maskito/core'; - -import {TIME_FIXED_CHARACTERS} from '../../../constants'; -import type {MaskitoTimeMode, MaskitoTimeSegments} from '../../../types'; -import {escapeRegExp} from '../../../utils'; -import {padStartTimeSegments, validateTimeString} from '../../../utils/time'; - -export function createMaxValidationPreprocessor( - timeSegmentMaxValues: MaskitoTimeSegments, - timeMode: MaskitoTimeMode, -): MaskitoPreprocessor { - const paddedMaxValues = padStartTimeSegments(timeSegmentMaxValues); - const invalidCharsRegExp = new RegExp( - `[^\\d${TIME_FIXED_CHARACTERS.map(escapeRegExp).join('')}]+`, - ); - - return ({elementState, data}, actionType) => { - if (actionType === 'deleteBackward' || actionType === 'deleteForward') { - return {elementState, data}; - } - - const {value, selection} = elementState; - - if (actionType === 'validation') { - const {validatedTimeString, updatedTimeSelection} = validateTimeString({ - timeString: value, - paddedMaxValues, - offset: 0, - selection, - timeMode, - }); - - return { - elementState: { - value: validatedTimeString, - selection: updatedTimeSelection, - }, - data, - }; - } - - const newCharacters = data.replace(invalidCharsRegExp, ''); - const [from, rawTo] = selection; - let to = rawTo + newCharacters.length; // to be conformed with `overwriteMode: replace` - const newPossibleValue = value.slice(0, from) + newCharacters + value.slice(to); - - const {validatedTimeString, updatedTimeSelection} = validateTimeString({ - timeString: newPossibleValue, - paddedMaxValues, - offset: 0, - selection: [from, to], - timeMode, - }); - - if (newPossibleValue && !validatedTimeString) { - return {elementState, data: ''}; // prevent changes - } - - to = updatedTimeSelection[1]; - - const newData = validatedTimeString.slice(from, to); - - return { - elementState: { - selection, - value: - validatedTimeString.slice(0, from) + - '0'.repeat(newData.length) + - validatedTimeString.slice(to), - }, - data: newData, - }; - }; -} diff --git a/projects/kit/src/lib/masks/time/processors/tests/max-validation-preprocessor.spec.ts b/projects/kit/src/lib/masks/time/processors/tests/max-validation-preprocessor.spec.ts deleted file mode 100644 index ab668ef55..000000000 --- a/projects/kit/src/lib/masks/time/processors/tests/max-validation-preprocessor.spec.ts +++ /dev/null @@ -1,63 +0,0 @@ -import {describe, expect, it} from '@jest/globals'; - -import {DEFAULT_TIME_SEGMENT_MAX_VALUES} from '../../../../constants'; -import {createMaxValidationPreprocessor} from '../max-validation-preprocessor'; - -describe('createMaxValidationPreprocessor', () => { - const processor = createMaxValidationPreprocessor( - DEFAULT_TIME_SEGMENT_MAX_VALUES, - 'HH:MM:SS', - ); - - describe('Paste from clipboard', () => { - const process = (data: string): string => - processor( - { - elementState: { - value: '', - selection: [0, 0], - }, - data, - }, - 'insert', - ).data ?? ''; - - it('all time segments valid', () => { - expect(process('17:43:00')).toBe('17:43:00'); - }); - - it('contains invalid time segment for hours', () => { - expect(process('30:30:30')).toBe(''); - }); - - it('invalid time segment for minutes', () => { - expect(process('23:70:30')).toBe(''); - }); - }); - - describe('Dropping text inside with a pointer / browser autofill', () => { - const process = (value: string): string => - processor( - { - elementState: { - value, - selection: [0, value.length], - }, - data: '', - }, - 'validation', - ).elementState.value; - - it('all time segments valid', () => { - expect(process('17:43:00')).toBe('17:43:00'); - }); - - it('contains invalid time segment for hours', () => { - expect(process('30:30:30')).toBe(''); - }); - - it('invalid time segment for minutes', () => { - expect(process('23:70:30')).toBe(''); - }); - }); -}); diff --git a/projects/kit/src/lib/masks/time/time-mask.ts b/projects/kit/src/lib/masks/time/time-mask.ts index 63ed751e5..841579727 100644 --- a/projects/kit/src/lib/masks/time/time-mask.ts +++ b/projects/kit/src/lib/masks/time/time-mask.ts @@ -1,14 +1,14 @@ import type {MaskitoOptions} from '@maskito/core'; -import {MASKITO_DEFAULT_OPTIONS} from '@maskito/core'; import {DEFAULT_TIME_SEGMENT_MAX_VALUES, TIME_FIXED_CHARACTERS} from '../../constants'; import {createTimeSegmentsSteppingPlugin} from '../../plugins'; import { createColonConvertPreprocessor, createFullWidthToHalfWidthPreprocessor, + createInvalidTimeSegmentInsertionPreprocessor, createZeroPlaceholdersPreprocessor, } from '../../processors'; -import {createMaxValidationPreprocessor} from './processors'; +import {enrichTimeSegmentsWithZeroes} from '../../utils/time'; import type {MaskitoTimeParams} from './time-options'; export function maskitoTimeOptionsGenerator({ @@ -22,7 +22,6 @@ export function maskitoTimeOptionsGenerator({ }; return { - ...MASKITO_DEFAULT_OPTIONS, mask: Array.from(mode).map((char) => TIME_FIXED_CHARACTERS.includes(char) ? char : /\d/, ), @@ -30,7 +29,17 @@ export function maskitoTimeOptionsGenerator({ createFullWidthToHalfWidthPreprocessor(), createColonConvertPreprocessor(), createZeroPlaceholdersPreprocessor(), - createMaxValidationPreprocessor(enrichedTimeSegmentMaxValues, mode), + createInvalidTimeSegmentInsertionPreprocessor({ + timeMode: mode, + timeSegmentMaxValues: enrichedTimeSegmentMaxValues, + }), + ], + postprocessors: [ + (elementState) => + enrichTimeSegmentsWithZeroes(elementState, { + mode, + timeSegmentMaxValues: enrichedTimeSegmentMaxValues, + }), ], plugins: [ createTimeSegmentsSteppingPlugin({ diff --git a/projects/kit/src/lib/processors/index.ts b/projects/kit/src/lib/processors/index.ts index 1c88274e6..f40f4c169 100644 --- a/projects/kit/src/lib/processors/index.ts +++ b/projects/kit/src/lib/processors/index.ts @@ -2,6 +2,7 @@ export {createColonConvertPreprocessor} from './colon-convert-preprocessor'; export {createDateSegmentsZeroPaddingPostprocessor} from './date-segments-zero-padding-postprocessor'; export {createFirstDateEndSeparatorPreprocessor} from './first-date-end-separator-preprocessor'; export {createFullWidthToHalfWidthPreprocessor} from './fullwidth-to-halfwidth-preprocessor'; +export {createInvalidTimeSegmentInsertionPreprocessor} from './invalid-time-segment-insertion-preprocessor'; export {createMinMaxDatePostprocessor} from './min-max-date-postprocessor'; export {normalizeDatePreprocessor} from './normalize-date-preprocessor'; export {maskitoPostfixPostprocessorGenerator} from './postfix-postprocessor'; diff --git a/projects/kit/src/lib/processors/invalid-time-segment-insertion-preprocessor.ts b/projects/kit/src/lib/processors/invalid-time-segment-insertion-preprocessor.ts new file mode 100644 index 000000000..a9a656e41 --- /dev/null +++ b/projects/kit/src/lib/processors/invalid-time-segment-insertion-preprocessor.ts @@ -0,0 +1,69 @@ +import type {MaskitoPreprocessor} from '@maskito/core'; +import type {MaskitoTimeMode, MaskitoTimeSegments} from '@maskito/kit'; + +import { + DEFAULT_TIME_SEGMENT_MAX_VALUES, + TIME_FIXED_CHARACTERS, + TIME_SEGMENT_VALUE_LENGTHS, +} from '../constants'; +import {escapeRegExp} from '../utils'; +import {padStartTimeSegments, parseTimeString} from '../utils/time'; + +/** + * 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 + */ +export function createInvalidTimeSegmentInsertionPreprocessor({ + timeMode, + timeSegmentMaxValues = DEFAULT_TIME_SEGMENT_MAX_VALUES, + parseValue = (x) => ({timeString: x}), +}: { + timeMode: MaskitoTimeMode; + timeSegmentMaxValues?: MaskitoTimeSegments; + parseValue?: (value: string) => {timeString: string; restValue?: string}; +}): MaskitoPreprocessor { + const paddedMaxValues = padStartTimeSegments(timeSegmentMaxValues); + const invalidCharsRegExp = new RegExp( + `[^\\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), + ) as Array<[keyof MaskitoTimeSegments, string]>; + + let offset = restValue.length; + + for (const [segmentName, segmentValue] of timeSegments) { + const maxSegmentValue = paddedMaxValues[segmentName]; + const lastSegmentDigitIndex = + offset + TIME_SEGMENT_VALUE_LENGTHS[segmentName]; + + if ( + lastSegmentDigitIndex >= from && + lastSegmentDigitIndex <= to && + Number(segmentValue) > Number(maxSegmentValue) + ) { + return {elementState, data: ''}; // prevent insertion + } + + offset += + segmentValue.length + + // any time segment separator + 1; + } + + return {elementState, data}; + }; +} diff --git a/projects/kit/src/lib/utils/time/enrich-time-segments-with-zeroes.ts b/projects/kit/src/lib/utils/time/enrich-time-segments-with-zeroes.ts new file mode 100644 index 000000000..c3013bfd8 --- /dev/null +++ b/projects/kit/src/lib/utils/time/enrich-time-segments-with-zeroes.ts @@ -0,0 +1,76 @@ +import {DEFAULT_TIME_SEGMENT_MAX_VALUES, TIME_FIXED_CHARACTERS} from '../../constants'; +import type {MaskitoTimeMode, MaskitoTimeSegments} from '../../types'; +import {escapeRegExp} from '../escape-reg-exp'; +import {padWithZeroesUntilValid} from '../pad-with-zeroes-until-valid'; +import {padStartTimeSegments} from './pad-start-time-segments'; +import {parseTimeString} from './parse-time-string'; +import {toTimeString} from './to-time-string'; + +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 + */ +export function enrichTimeSegmentsWithZeroes( + {value, selection}: {value: string; selection: readonly [number, number]}, + { + mode, + timeSegmentMaxValues = DEFAULT_TIME_SEGMENT_MAX_VALUES, + }: { + mode: MaskitoTimeMode; + timeSegmentMaxValues?: MaskitoTimeSegments; + }, +): {value: string; selection: readonly [number, number]} { + const [from, to] = selection; + const parsedTime = parseTimeString(value, mode); + const possibleTimeSegments = Object.entries(parsedTime) as Array< + [keyof MaskitoTimeSegments, string] + >; + + const paddedMaxValues = padStartTimeSegments(timeSegmentMaxValues); + const validatedTimeSegments: Partial = {}; + 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 [trailingSegmentSeparator = ''] = + value.match(TRAILING_TIME_SEGMENT_SEPARATOR_REG) || []; + const validatedTimeString = + toTimeString(validatedTimeSegments) + trailingSegmentSeparator; + const addedDateSegmentSeparators = Math.max( + validatedTimeString.length - value.length, + 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], + }; +} diff --git a/projects/kit/src/lib/utils/time/index.ts b/projects/kit/src/lib/utils/time/index.ts index 17fe9bcec..1a00cd250 100644 --- a/projects/kit/src/lib/utils/time/index.ts +++ b/projects/kit/src/lib/utils/time/index.ts @@ -1,5 +1,5 @@ +export * from './enrich-time-segments-with-zeroes'; export * from './pad-end-time-segments'; export * from './pad-start-time-segments'; export * from './parse-time-string'; export * from './to-time-string'; -export * from './validate-time-string'; diff --git a/projects/kit/src/lib/utils/time/tests/enrich-time-segments-with-zeroes.spec.ts b/projects/kit/src/lib/utils/time/tests/enrich-time-segments-with-zeroes.spec.ts new file mode 100644 index 000000000..a4d46abb8 --- /dev/null +++ b/projects/kit/src/lib/utils/time/tests/enrich-time-segments-with-zeroes.spec.ts @@ -0,0 +1,29 @@ +import {describe, expect, it} from '@jest/globals'; + +import {enrichTimeSegmentsWithZeroes} from '../enrich-time-segments-with-zeroes'; + +describe('enrichTimeSegmentsWithZeroes', () => { + const fn = (value: string): string => + enrichTimeSegmentsWithZeroes( + {value, selection: [0, 0]}, + { + mode: 'HH:MM:SS', + }, + ).value; + + it('all time segments valid', () => { + expect(fn('17:43:00')).toBe('17:43:00'); + }); + + it('contains invalid time segment for hours', () => { + expect(fn('30:30:30')).toBe('03:30:30'); + }); + + it('invalid time segment for minutes', () => { + expect(fn('23:70:30')).toBe('23:07:30'); + }); + + it('invalid time segment for seconds', () => { + expect(fn('23:07:90')).toBe('23:07:09'); + }); +}); diff --git a/projects/kit/src/lib/utils/time/validate-time-string.ts b/projects/kit/src/lib/utils/time/validate-time-string.ts deleted file mode 100644 index b1d2de973..000000000 --- a/projects/kit/src/lib/utils/time/validate-time-string.ts +++ /dev/null @@ -1,80 +0,0 @@ -import {TIME_FIXED_CHARACTERS, TIME_SEGMENT_VALUE_LENGTHS} from '../../constants'; -import type {MaskitoTimeMode, MaskitoTimeSegments} from '../../types'; -import {escapeRegExp} from '../escape-reg-exp'; -import {padWithZeroesUntilValid} from '../pad-with-zeroes-until-valid'; -import {parseTimeString} from './parse-time-string'; -import {toTimeString} from './to-time-string'; - -const TRAILING_TIME_SEGMENT_SEPARATOR_REG = new RegExp( - `[${TIME_FIXED_CHARACTERS.map(escapeRegExp).join('')}]$`, -); - -export function validateTimeString({ - timeString, - paddedMaxValues, - offset, - selection: [from, to], - timeMode, -}: { - timeString: string; - paddedMaxValues: MaskitoTimeSegments; - offset: number; - selection: readonly [number, number]; - timeMode: MaskitoTimeMode; -}): {validatedTimeString: string; updatedTimeSelection: [number, number]} { - const parsedTime = parseTimeString(timeString, timeMode); - - const possibleTimeSegments = Object.entries(parsedTime) as Array< - [keyof MaskitoTimeSegments, string] - >; - - const validatedTimeSegments: Partial = {}; - - let paddedZeroes = 0; - - for (const [segmentName, segmentValue] of possibleTimeSegments) { - const validatedTime = toTimeString(validatedTimeSegments); - const maxSegmentValue = paddedMaxValues[segmentName]; - - const fantomSeparator = validatedTime.length && 1; - - const lastSegmentDigitIndex = - offset + - validatedTime.length + - fantomSeparator + - TIME_SEGMENT_VALUE_LENGTHS[segmentName]; - const isLastSegmentDigitAdded = - lastSegmentDigitIndex >= from && lastSegmentDigitIndex <= to; - - if (isLastSegmentDigitAdded && Number(segmentValue) > Number(maxSegmentValue)) { - // 2|0:00 => Type 9 => 2|0:00 - return {validatedTimeString: '', updatedTimeSelection: [from, to]}; // prevent changes - } - - const {validatedSegmentValue, prefixedZeroesCount} = padWithZeroesUntilValid( - segmentValue, - `${maxSegmentValue}`, - ); - - paddedZeroes += prefixedZeroesCount; - - validatedTimeSegments[segmentName] = validatedSegmentValue; - } - - const [trailingSegmentSeparator = ''] = - timeString.match(TRAILING_TIME_SEGMENT_SEPARATOR_REG) || []; - const validatedTimeString = - toTimeString(validatedTimeSegments) + trailingSegmentSeparator; - const addedDateSegmentSeparators = Math.max( - validatedTimeString.length - timeString.length, - 0, - ); - - return { - validatedTimeString, - updatedTimeSelection: [ - from + paddedZeroes + addedDateSegmentSeparators, - to + paddedZeroes + addedDateSegmentSeparators, - ], - }; -}