From ae5dd20b7d6ac7bf0093f660da2363fdffa0504f Mon Sep 17 00:00:00 2001 From: Radoslav Karaivanov Date: Thu, 23 Oct 2025 15:30:01 +0300 Subject: [PATCH 1/2] feat(mask): Transform unicode digits codepoint to ASCII numbers --- src/components/mask-input/mask-parser.ts | 62 ++++++++++++++++-------- 1 file changed, 42 insertions(+), 20 deletions(-) diff --git a/src/components/mask-input/mask-parser.ts b/src/components/mask-input/mask-parser.ts index 17d862a65..7fc6480a1 100644 --- a/src/components/mask-input/mask-parser.ts +++ b/src/components/mask-input/mask-parser.ts @@ -30,6 +30,47 @@ type MaskReplaceResult = { const MASK_FLAGS = new Set('aACL09#&?'); const MASK_REQUIRED_FLAGS = new Set('0#LA&'); +const DIGIT_ZERO_CODEPOINTS = [ + 0x0030, // ASCII + 0x0660, // Arabic-Indic + 0x06f0, // Extended Arabic-Indic + 0x0966, // Devanagari + 0x09e6, // Bengali + 0x0a66, // Gurmukhi + 0x0ae6, // Gujarati + 0x0b66, // Oriya + 0x0c66, // Telugu + 0x0ce6, // Kannada + 0x0d66, // Malayalam + 0x0e50, // Thai + 0x0ed0, // Lao + 0x0f20, // Tibetan + 0x1040, // Myanmar + 0x17e0, // Khmer + 0x1810, // Mongolian + 0xff10, // Full-width +] as const; + +function replaceUnicodeNumbers(text: string): string { + const matcher = /\p{Nd}/gu; + const ascii_zero = 0x0030; + + return text.replace(matcher, (digit) => { + let digitValue = 0; + const codePoint = digit.charCodeAt(0); + + for (const zeroCodePoint of DIGIT_ZERO_CODEPOINTS) { + if (codePoint >= zeroCodePoint && codePoint <= zeroCodePoint + 9) { + digitValue = zeroCodePoint; + break; + } + } + + digitValue = codePoint - digitValue; + return String.fromCharCode(ascii_zero + digitValue); + }); +} + const MASK_PATTERNS = new Map([ ['C', /(?!^$)/u], // Non-empty (any character that is not an empty string) ['&', /[^\p{Separator}]/u], // Any non-whitespace character (Unicode-aware) @@ -42,25 +83,6 @@ const MASK_PATTERNS = new Map([ ['#', /[\p{Number}\-+]/u], // Numeric and sign characters (+, -) ]); -function replaceIMENumbers(string: string): string { - return string.replace( - /[0123456789]/g, - (num) => - ({ - '1': '1', - '2': '2', - '3': '3', - '4': '4', - '5': '5', - '6': '6', - '7': '7', - '8': '8', - '9': '9', - '0': '0', - })[num] as string - ); -} - function validate(char: string, flag: string): boolean { return MASK_PATTERNS.get(flag)?.test(char) ?? false; } @@ -265,7 +287,7 @@ export class MaskParser { // Initialize the array for the masked string or get a fresh mask with prompts and/or literals const maskedChars = Array.from(maskString || this.apply('')); - const inputChars = Array.from(replaceIMENumbers(value)); + const inputChars = Array.from(replaceUnicodeNumbers(value)); const inputLength = inputChars.length; // Clear any non-literal positions from `start` to `endBoundary` From 2fce6a2dd91d788eab228463ef4ca2d4b35b4e0d Mon Sep 17 00:00:00 2001 From: Radoslav Karaivanov Date: Thu, 6 Nov 2025 19:43:42 +0200 Subject: [PATCH 2/2] refactor: support unicode digits in mask input parser - Added support for Unicode digits in the mask input parser. - Updated relevant tests to cover Unicode digit scenarios. - Parser edge cases handled for better robustness. - Added more comprehensive test cases for various mask patterns. --- src/components/mask-input/mask-input-base.ts | 2 +- src/components/mask-input/mask-parser.spec.ts | 511 ++++++++++++++++++ src/components/mask-input/mask-parser.ts | 106 ++-- 3 files changed, 575 insertions(+), 44 deletions(-) diff --git a/src/components/mask-input/mask-input-base.ts b/src/components/mask-input/mask-input-base.ts index d8bb22f2e..239c6d3bf 100644 --- a/src/components/mask-input/mask-input-base.ts +++ b/src/components/mask-input/mask-input-base.ts @@ -88,7 +88,7 @@ export abstract class IgcMaskInputBaseComponent extends IgcInputBaseComponent { if (isComposing) return; return this._updateInput('', { start: this._parser.getPreviousNonLiteralPosition( - this._inputSelection.start + this._inputSelection.start + 1 ), end, }); diff --git a/src/components/mask-input/mask-parser.spec.ts b/src/components/mask-input/mask-parser.spec.ts index b1debaeb2..ac20b2309 100644 --- a/src/components/mask-input/mask-parser.spec.ts +++ b/src/components/mask-input/mask-parser.spec.ts @@ -167,4 +167,515 @@ describe('Mask parser', () => { const result = parser.replace(parser.apply(), value, 0, value.length); expect(result.value).to.equal('1987__'); }); + + describe('Unicode digit normalization', () => { + it('converts Arabic-Indic digits (٠-٩)', () => { + parser.mask = '0000'; + const result = parser.replace(parser.apply(), '٠١٢٣', 0, 4); + expect(result.value).to.equal('0123'); + }); + + it('converts Extended Arabic-Indic digits (۰-۹)', () => { + parser.mask = '0000'; + const result = parser.replace(parser.apply(), '۰۱۲۳', 0, 4); + expect(result.value).to.equal('0123'); + }); + + it('converts Devanagari digits (०-९)', () => { + parser.mask = '0000'; + const result = parser.replace(parser.apply(), '०१२३', 0, 4); + expect(result.value).to.equal('0123'); + }); + + it('converts Bengali digits (০-৯)', () => { + parser.mask = '0000'; + const result = parser.replace(parser.apply(), '০১২৩', 0, 4); + expect(result.value).to.equal('0123'); + }); + + it('converts Gurmukhi digits (੦-੯)', () => { + parser.mask = '0000'; + const result = parser.replace(parser.apply(), '੦੧੨੩', 0, 4); + expect(result.value).to.equal('0123'); + }); + + it('converts Gujarati digits (૦-૯)', () => { + parser.mask = '0000'; + const result = parser.replace(parser.apply(), '૦૧૨૩', 0, 4); + expect(result.value).to.equal('0123'); + }); + + it('converts Oriya digits (୦-୯)', () => { + parser.mask = '0000'; + const result = parser.replace(parser.apply(), '୦୧୨୩', 0, 4); + expect(result.value).to.equal('0123'); + }); + + it('converts Telugu digits (౦-౯)', () => { + parser.mask = '0000'; + const result = parser.replace(parser.apply(), '౦౧౨౩', 0, 4); + expect(result.value).to.equal('0123'); + }); + + it('converts Kannada digits (೦-೯)', () => { + parser.mask = '0000'; + const result = parser.replace(parser.apply(), '೦೧೨೩', 0, 4); + expect(result.value).to.equal('0123'); + }); + + it('converts Malayalam digits (൦-൯)', () => { + parser.mask = '0000'; + const result = parser.replace(parser.apply(), '൦൧൨൩', 0, 4); + expect(result.value).to.equal('0123'); + }); + + it('converts Thai digits (๐-๙)', () => { + parser.mask = '0000'; + const result = parser.replace(parser.apply(), '๐๑๒๓', 0, 4); + expect(result.value).to.equal('0123'); + }); + + it('converts Lao digits (໐-໙)', () => { + parser.mask = '0000'; + const result = parser.replace(parser.apply(), '໐໑໒໓', 0, 4); + expect(result.value).to.equal('0123'); + }); + + it('converts Tibetan digits (༠-༩)', () => { + parser.mask = '0000'; + const result = parser.replace(parser.apply(), '༠༡༢༣', 0, 4); + expect(result.value).to.equal('0123'); + }); + + it('converts Myanmar digits (၀-၉)', () => { + parser.mask = '0000'; + const result = parser.replace(parser.apply(), '၀၁၂၃', 0, 4); + expect(result.value).to.equal('0123'); + }); + + it('converts Khmer digits (០-៩)', () => { + parser.mask = '0000'; + const result = parser.replace(parser.apply(), '០១២៣', 0, 4); + expect(result.value).to.equal('0123'); + }); + + it('converts Mongolian digits (᠐-᠙)', () => { + parser.mask = '0000'; + const result = parser.replace(parser.apply(), '᠐᠑᠒᠓', 0, 4); + expect(result.value).to.equal('0123'); + }); + + it('converts Full-width digits (0-9)', () => { + parser.mask = '0000'; + const result = parser.replace(parser.apply(), '0123', 0, 4); + expect(result.value).to.equal('0123'); + }); + + it('handles mixed Unicode and ASCII digits', () => { + parser.mask = '00000000'; + const result = parser.replace(parser.apply(), '12٣٤५६७8', 0, 8); + expect(result.value).to.equal('12345678'); + }); + + it('converts Unicode digits in phone mask', () => { + parser.mask = '(000) 000-0000'; + const result = parser.replace(parser.apply(), '५५५१२३४५६७', 0, 10); + expect(result.value).to.equal('(555) 123-4567'); + }); + + it('converts Unicode digits in date mask', () => { + parser.mask = '00/00/0000'; + const result = parser.replace(parser.apply(), '१२३१२०२४', 0, 8); + expect(result.value).to.equal('12/31/2024'); + }); + + it('handles Unicode digits with alphanumeric mask', () => { + parser.mask = 'AAA-000'; + const result = parser.replace(parser.apply(), 'ABC१२३', 0, 6); + expect(result.value).to.equal('ABC-123'); + }); + + it('converts all digits to ASCII (0-9)', () => { + parser.mask = '0000000000'; + const result = parser.replace(parser.apply(), '०१২३४५६७८९', 0, 10); + expect(result.value).to.equal('0123456789'); + }); + + it('preserves non-digit Unicode characters', () => { + parser.mask = 'LLLL-0000'; + const result = parser.replace(parser.apply(), 'Test५५५५', 0, 8); + expect(result.value).to.equal('Test-5555'); + }); + + it('handles empty input with Unicode conversion', () => { + parser.mask = '0000'; + const result = parser.replace(parser.apply(), '', 0, 0); + expect(result.value).to.equal('____'); + }); + + it('handles partial Unicode digit input', () => { + parser.mask = '000-000'; + const result = parser.replace(parser.apply(), '१२', 0, 2); + expect(result.value).to.equal('12_-___'); + }); + + it('converts Unicode digits with sign mask', () => { + parser.mask = '####'; + const result = parser.replace(parser.apply(), '+१२३', 0, 4); + expect(result.value).to.equal('+123'); + }); + + it('apply() normalizes Unicode digits', () => { + parser.mask = '0000'; + expect(parser.apply('०१२३')).to.equal('0123'); + }); + + it('apply() with mixed Unicode systems', () => { + parser.mask = '00000000'; + expect(parser.apply('१२৩৪५६७८')).to.equal('12345678'); + }); + }); + + describe('Edge cases and boundary conditions', () => { + it('empty mask string', () => { + parser.mask = ''; + // Empty mask falls back to default mask 'CCCCCCCCCC' + expect(parser.apply('test')).to.equal('test______'); + expect(parser.parse('')).to.equal(''); + }); + + it('mask with only literals', () => { + parser.mask = '---'; + expect(parser.apply('abc')).to.equal('---'); + expect(parser.parse('---')).to.equal(''); + }); + + it('mask with only escaped characters', () => { + parser.mask = '\\C\\C\\C'; + expect(parser.apply('test')).to.equal('CCC'); + expect(parser.literalPositions.size).to.equal(3); + }); + + it('prompt character conflicts with mask flag', () => { + parser.mask = 'CCCC'; + parser.prompt = 'C'; + // Should be ignored silently + expect(parser.prompt).to.equal('_'); + expect(parser.apply()).to.equal('____'); + }); + + it('prompt character set to mask flag 0', () => { + parser.mask = '0000'; + parser.prompt = '0'; + // Should be ignored + expect(parser.prompt).to.equal('_'); + }); + + it('very long input exceeding mask length', () => { + parser.mask = '000'; + expect(parser.apply('123456789')).to.equal('123'); + }); + + it('input shorter than mask length', () => { + parser.mask = '0000000'; + expect(parser.apply('123')).to.equal('123____'); + }); + + it('replace with start position beyond mask length', () => { + parser.mask = '000'; + const result = parser.replace(parser.apply(), '123', 10, 15); + expect(result.value).to.equal('___'); + }); + + it('replace with boundary positions', () => { + parser.mask = '0000'; + // Replace processes the entire value string regardless of start/end range + const result = parser.replace(parser.apply(), '12', 0, 2); + expect(result.value).to.equal('12__'); + }); + + it('replace with start equals end (cursor position)', () => { + parser.mask = '0000'; + const result = parser.replace('12__', '3', 2, 2); + expect(result.value).to.equal('123_'); + expect(result.end).to.equal(3); + }); + + it('replace entire masked string', () => { + parser.mask = '(000) 000-0000'; + const existing = parser.apply('5551234567'); + const result = parser.replace(existing, '9998887777', 0, existing.length); + expect(result.value).to.equal('(999) 888-7777'); + }); + + it('multiple consecutive literals', () => { + parser.mask = '000---000'; + expect(parser.apply('123456')).to.equal('123---456'); + }); + + it('escape character at end of mask', () => { + parser.mask = 'CCC\\'; + // Trailing backslash with nothing after it is treated as a literal backslash + expect(parser.apply('test')).to.equal('tes\\'); + }); + + it('double backslash should produce single backslash literal', () => { + parser.mask = 'C\\\\C'; + // First C is flag, second \ escapes nothing (not a flag), so it's literal, third C is flag + // Actually, \\ is not a valid escape sequence (\ doesn't escape \), so both are literals + expect(parser.apply('ab')).to.equal('a\\C'); + }); + + it('special characters in mask', () => { + parser.mask = 'CCC-@@@'; + expect(parser.apply('abc123')).to.equal('abc-@@@'); + }); + + it('unicode letters in various scripts', () => { + parser.mask = 'LLLLLLLL'; + expect(parser.apply('Привет世界')).to.equal('Привет世界'); + }); + + it('whitespace handling with question mark flag', () => { + parser.mask = '????'; + expect(parser.apply('A B ')).to.equal('A B '); + }); + + it('numeric with spaces using 9 flag', () => { + parser.mask = '9999'; + expect(parser.apply('1 2 ')).to.equal('1 2 '); + }); + + it('sign characters with # flag', () => { + parser.mask = '####'; + expect(parser.apply('+1-2')).to.equal('+1-2'); + }); + + it('parse with prompt character at beginning', () => { + parser.mask = '0000'; + expect(parser.parse('__12')).to.equal('12'); + }); + + it('parse with prompt character in middle', () => { + parser.mask = '00-00'; + expect(parser.parse('12-__')).to.equal('12'); + }); + + it('parse preserves valid input only', () => { + parser.mask = '(000) 000-0000'; + const masked = '(555) 123-____'; + expect(parser.parse(masked)).to.equal('555123'); + }); + + it('isValidString with all positions filled', () => { + parser.mask = '000-000'; + expect(parser.isValidString('123-456')).to.be.true; + }); + + it('isValidString with optional positions unfilled', () => { + parser.mask = '000-999'; + expect(parser.isValidString('123-___')).to.be.true; + }); + + it('isValidString with required positions unfilled', () => { + parser.mask = '000-000'; + expect(parser.isValidString('123-__5')).to.be.false; + }); + + it('isValidString with invalid characters', () => { + parser.mask = '0000'; + expect(parser.isValidString('12ab')).to.be.false; + }); + + it('getPreviousNonLiteralPosition at start', () => { + parser.mask = '(000)'; + expect(parser.getPreviousNonLiteralPosition(0)).to.equal(0); + }); + + it('getPreviousNonLiteralPosition skips literals', () => { + parser.mask = '000-000'; + expect(parser.getPreviousNonLiteralPosition(4)).to.equal(2); + }); + + it('getPreviousNonLiteralPosition on literal', () => { + parser.mask = '000-000'; + expect(parser.getPreviousNonLiteralPosition(3)).to.equal(2); + }); + + it('getNextNonLiteralPosition at end', () => { + parser.mask = '000'; + expect(parser.getNextNonLiteralPosition(3)).to.equal(3); + }); + + it('getNextNonLiteralPosition skips literals', () => { + parser.mask = '000-000'; + expect(parser.getNextNonLiteralPosition(3)).to.equal(4); + }); + + it('getNextNonLiteralPosition at start', () => { + parser.mask = '(000)'; + expect(parser.getNextNonLiteralPosition(0)).to.equal(1); + }); + + it('getNextNonLiteralPosition all literals after position', () => { + parser.mask = '000)))'; + expect(parser.getNextNonLiteralPosition(3)).to.equal(6); + }); + + it('literalPositions returns correct set', () => { + parser.mask = '(000)-000'; + const positions = parser.literalPositions; + expect(positions.has(0)).to.be.true; // ( + expect(positions.has(4)).to.be.true; // ) + expect(positions.has(5)).to.be.true; // - + expect(positions.has(1)).to.be.false; + expect(positions.size).to.equal(3); + }); + + it('escapedMask removes escape sequences', () => { + parser.mask = 'CCC\\C-\\0\\0\\0'; + expect(parser.escapedMask).to.equal('CCCC-000'); + }); + + it('emptyMask getter returns properly formatted mask', () => { + parser.mask = '(000) 000-0000'; + expect(parser.emptyMask).to.equal('(___) ___-____'); + }); + + it('mask getter returns original format', () => { + const format = 'CCC\\C-000'; + parser.mask = format; + expect(parser.mask).to.equal(format); + }); + + it('changing mask updates literals and positions', () => { + parser.mask = '000'; + expect(parser.literalPositions.size).to.equal(0); + + parser.mask = '(000)'; + expect(parser.literalPositions.size).to.equal(2); + }); + + it('constructor with custom options', () => { + const customParser = new MaskParser({ + format: '####', + promptCharacter: '*', + }); + expect(customParser.apply()).to.equal('****'); + expect(customParser.mask).to.equal('####'); + expect(customParser.prompt).to.equal('*'); + }); + + it('constructor with partial options uses defaults', () => { + const customParser = new MaskParser({ format: '000' }); + expect(customParser.apply()).to.equal('___'); + expect(customParser.prompt).to.equal('_'); + }); + + it('C flag accepts any character including special chars', () => { + parser.mask = 'CCCC'; + expect(parser.apply('!@#$')).to.equal('!@#$'); + }); + + it('& flag rejects separators', () => { + parser.mask = '&&&&'; + // The apply method doesn't skip invalid chars, it just doesn't place them + // So 'a b ' processes as: a(valid) -> a, space(invalid) -> skip but advance, b(valid) -> b + expect(parser.apply('a b ')).to.equal('a_b_'); + }); + + it('A flag accepts letters and numbers but not spaces', () => { + parser.mask = 'AAAA'; + // apply() advances input index even for invalid chars, so space is skipped + expect(parser.apply('A1 B')).to.equal('A1_B'); + }); + + it('a flag accepts letters, numbers and spaces', () => { + parser.mask = 'aaaa'; + expect(parser.apply('A1 B')).to.equal('A1 B'); + }); + + it('L flag accepts only letters', () => { + parser.mask = 'LLLL'; + expect(parser.apply('AB12')).to.equal('AB__'); + }); + + it('0 flag accepts only numbers', () => { + parser.mask = '0000'; + // apply() method advances through input even when chars are invalid + expect(parser.apply('1a2b')).to.equal('1_2_'); + }); + + it('replace with input containing prompt character', () => { + parser.mask = '0000'; + parser.prompt = '_'; + const result = parser.replace('12__', '3_4', 2, 4); + // Prompt char should be skipped, only 3 and 4 are valid + expect(result.value).to.equal('1234'); + }); + + it('replace preserves literals when clearing range', () => { + parser.mask = '(000)-000'; + const existing = '(123)-456'; + // Clearing from position 1 to 8 clears non-literals but position 9 is outside the cleared range + const result = parser.replace(existing, '', 1, 6); + expect(result.value).to.equal('(___)-456'); + }); + + it('apply with null/undefined uses empty string', () => { + parser.mask = '000'; + expect(parser.apply()).to.equal('___'); + }); + + it('parse with empty string', () => { + parser.mask = '000'; + expect(parser.parse('')).to.equal(''); + }); + + it('parse with string shorter than mask', () => { + parser.mask = '000-000'; + expect(parser.parse('12')).to.equal('12'); + }); + + it('complex real-world credit card mask', () => { + parser.mask = '0000 0000 0000 0000'; + const result = parser.apply('1234567890123456'); + expect(result).to.equal('1234 5678 9012 3456'); + expect(parser.parse(result)).to.equal('1234567890123456'); + expect(parser.isValidString(result)).to.be.true; + }); + + it('complex real-world SSN mask', () => { + parser.mask = '000-00-0000'; + const result = parser.apply('123456789'); + expect(result).to.equal('123-45-6789'); + expect(parser.parse(result)).to.equal('123456789'); + }); + + it('complex real-world date mask with slashes', () => { + parser.mask = '00/00/0000'; + const result = parser.apply('12312024'); + expect(result).to.equal('12/31/2024'); + }); + + it('sequential replace operations maintain state', () => { + parser.mask = '0000'; + let result = parser.replace(parser.apply(), '1', 0, 0); + expect(result.value).to.equal('1___'); + + result = parser.replace(result.value, '2', result.end, result.end); + expect(result.value).to.equal('12__'); + + result = parser.replace(result.value, '3', result.end, result.end); + expect(result.value).to.equal('123_'); + }); + + it('replace with selection in middle updates correctly', () => { + parser.mask = '0000-0000'; + // Replacing positions 2-4 with 'XX' (invalid) clears those positions + // But doesn't affect positions beyond the cleared range + const result = parser.replace('1234-5678', 'XX', 2, 4); + expect(result.value).to.equal('12__-5678'); + }); + }); }); diff --git a/src/components/mask-input/mask-parser.ts b/src/components/mask-input/mask-parser.ts index 7fc6480a1..5424485f5 100644 --- a/src/components/mask-input/mask-parser.ts +++ b/src/components/mask-input/mask-parser.ts @@ -8,13 +8,19 @@ interface MaskOptions { * Use `'\'` to escape a flag character if it should be treated as a literal. * @default 'CCCCCCCCCC' */ - format: string; + format?: string; /** * The character used to prompt for input in unfilled mask positions. * Must be a single character. * @default '_' */ + promptCharacter?: string; +} + +/** Internal options with all required fields */ +interface MaskOptionsInternal { + format: string; promptCharacter: string; } @@ -30,8 +36,9 @@ type MaskReplaceResult = { const MASK_FLAGS = new Set('aACL09#&?'); const MASK_REQUIRED_FLAGS = new Set('0#LA&'); +const ASCII_ZERO = 0x0030; const DIGIT_ZERO_CODEPOINTS = [ - 0x0030, // ASCII + ASCII_ZERO, // ASCII 0x0660, // Arabic-Indic 0x06f0, // Extended Arabic-Indic 0x0966, // Devanagari @@ -51,36 +58,37 @@ const DIGIT_ZERO_CODEPOINTS = [ 0xff10, // Full-width ] as const; +/** + * Precomputed map of Unicode digit codepoints to their ASCII equivalents. + * This eliminates the need for iteration during conversion. + */ +const UNICODE_DIGIT_TO_ASCII = new Map( + DIGIT_ZERO_CODEPOINTS.flatMap((zeroCodePoint) => + Array.from({ length: 10 }, (_, i) => [ + zeroCodePoint + i, + String.fromCharCode(ASCII_ZERO + i), + ]) + ) +); + function replaceUnicodeNumbers(text: string): string { const matcher = /\p{Nd}/gu; - const ascii_zero = 0x0030; return text.replace(matcher, (digit) => { - let digitValue = 0; - const codePoint = digit.charCodeAt(0); - - for (const zeroCodePoint of DIGIT_ZERO_CODEPOINTS) { - if (codePoint >= zeroCodePoint && codePoint <= zeroCodePoint + 9) { - digitValue = zeroCodePoint; - break; - } - } - - digitValue = codePoint - digitValue; - return String.fromCharCode(ascii_zero + digitValue); + return UNICODE_DIGIT_TO_ASCII.get(digit.charCodeAt(0)) ?? digit; }); } -const MASK_PATTERNS = new Map([ - ['C', /(?!^$)/u], // Non-empty (any character that is not an empty string) - ['&', /[^\p{Separator}]/u], // Any non-whitespace character (Unicode-aware) - ['a', /[\p{Letter}\p{Number}\p{Separator}]/u], // Alphanumeric and whitespace (Unicode-aware) +const MASK_PATTERNS = new Map([ + ['C', /./u], // Any single character + ['&', /[^\p{Separator}]/u], // Any non-separator character (excludes spaces, line/paragraph separators) + ['a', /[\p{Letter}\p{Number}\p{Separator}]/u], // Alphanumeric and separator characters (Unicode-aware) ['A', /[\p{Letter}\p{Number}]/u], // Alphanumeric (Unicode-aware) - ['?', /[\p{Letter}\p{Separator}]/u], // Alpha and whitespace (Unicode-aware) + ['?', /[\p{Letter}\p{Separator}]/u], // Alphabetic and separator characters (Unicode-aware) ['L', /\p{Letter}/u], // Alphabetic (Unicode-aware) - ['0', /\p{Number}/u], // Numeric (0-9) (Unicode-aware) - ['9', /[\p{Number}\p{Separator}]/u], // Numeric and whitespace (Unicode-aware) - ['#', /[\p{Number}\-+]/u], // Numeric and sign characters (+, -) + ['0', /\p{Number}/u], // Numeric (Unicode-aware, converted to ASCII 0-9 during processing) + ['9', /[\p{Number}\p{Separator}]/u], // Numeric and separator characters (Unicode-aware) + ['#', /[\p{Number}+-]/u], // Numeric and sign characters (+, -) ]); function validate(char: string, flag: string): boolean { @@ -88,7 +96,7 @@ function validate(char: string, flag: string): boolean { } /** Default mask parser options */ -const MaskDefaultOptions: MaskOptions = { +const MaskDefaultOptions: MaskOptionsInternal = { format: 'CCCCCCCCCC', promptCharacter: '_', }; @@ -98,7 +106,7 @@ const MaskDefaultOptions: MaskOptions = { * It handles mask definitions, literals, character validation, and cursor positioning. */ export class MaskParser { - protected readonly _options: MaskOptions; + protected readonly _options: MaskOptionsInternal; /** Stores literal characters and their original positions in the mask (e.g., '(', ')', '-'). */ protected readonly _literals = new Map(); @@ -109,6 +117,9 @@ export class MaskParser { /** The mask format after processing escape characters */ protected _escapedMask = ''; + /** Cached array of required non-literal positions for validation */ + protected _requiredPositions: number[] = []; + /** * Returns a set of the all the literal positions in the mask. * These positions are fixed characters that are not part of the input. @@ -158,11 +169,17 @@ export class MaskParser { /** * Sets the prompt character. Only the first character of the provided string is used. + * @remarks The prompt character cannot be a mask flag character. */ public set prompt(value: string) { - this._options.promptCharacter = value - ? value.substring(0, 1) - : this._options.promptCharacter; + const char = value ? value.substring(0, 1) : this._options.promptCharacter; + + // Silently ignore if prompt character conflicts with mask flags + if (MASK_FLAGS.has(char)) { + return; + } + + this._options.promptCharacter = char; } constructor(options?: MaskOptions) { @@ -205,16 +222,17 @@ export class MaskParser { this._escapedMask = escapedMaskChars.join(''); this._literalPositions = new Set(this._literals.keys()); + this._requiredPositions = this._computeRequiredPositions(); } /** - * Gets an array of positions in the escaped mask that correspond to + * Computes an array of positions in the escaped mask that correspond to * required input flags (e.g., '0', 'L') and are not literal characters. * * These positions must be filled for the masked string to be valid. */ - protected _getRequiredNonLiteralPositions(): number[] { - const literalPositions = this.literalPositions; + protected _computeRequiredPositions(): number[] { + const literalPositions = this._literalPositions; const escapedMask = this._escapedMask; const length = escapedMask.length; const result: number[] = []; @@ -234,18 +252,18 @@ export class MaskParser { * Useful for backward navigation (e.g., backspace). * * @remarks - * If no non-literal is found before `start`, return `start`. + * If no non-literal is found before `start`, return 0. */ public getPreviousNonLiteralPosition(start: number): number { - const literalPositions = this.literalPositions; + const literalPositions = this._literalPositions; - for (let i = start; i > 0; i--) { + for (let i = start - 1; i >= 0; i--) { if (!literalPositions.has(i)) { return i; } } - return start; + return 0; } /** @@ -253,19 +271,19 @@ export class MaskParser { * Useful for forward navigation (e.g., arrow keys, delete key or initial cursor placement). * * @remarks - * If no non-literal is found after `start`, return `start`. + * If no non-literal is found after `start`, return the mask length. */ public getNextNonLiteralPosition(start: number): number { - const literalPositions = this.literalPositions; + const literalPositions = this._literalPositions; const length = this._escapedMask.length; - for (let i = start; i < length; i++) { + for (let i = Math.max(0, start); i < length; i++) { if (!literalPositions.has(i)) { return i; } } - return start; + return length; } /** @@ -360,7 +378,7 @@ export class MaskParser { public isValidString(input = ''): boolean { const prompt = this.prompt; - return this._getRequiredNonLiteralPositions().every((position) => { + return this._requiredPositions.every((position) => { const char = input.charAt(position); return ( validate(char, this._escapedMask.charAt(position)) && char !== prompt @@ -378,7 +396,6 @@ export class MaskParser { const prompt = this.prompt; const escapedMask = this._escapedMask; const length = escapedMask.length; - const inputLength = input.length; // Initialize the result array with prompt characters const result = new Array(escapedMask.length).fill(prompt); @@ -392,6 +409,9 @@ export class MaskParser { return result.join(''); } + // Normalize Unicode digits to ASCII + const normalizedInput = replaceUnicodeNumbers(input); + const inputLength = normalizedInput.length; let inputIndex = 0; // Iterate through the mask placing input characters skipping literals and invalid ones @@ -404,8 +424,8 @@ export class MaskParser { continue; } - if (validate(input.charAt(inputIndex), escapedMask.charAt(i))) { - result[i] = input.charAt(inputIndex); + if (validate(normalizedInput.charAt(inputIndex), escapedMask.charAt(i))) { + result[i] = normalizedInput.charAt(inputIndex); } inputIndex++;