From 28259939fac306e02d12d2ec0826f11a72be4176 Mon Sep 17 00:00:00 2001 From: Isaac Stevens Date: Tue, 13 Oct 2026 20:18:10 -0700 Subject: [PATCH 1/6] fix(phone-number): reject an area code starting 0 or 1 in the mask and validator --- .../phone-number/phone-number.component.html | 4 +- .../phone-number.component.spec.ts | 98 ++++++++++++++++--- .../phone-number/phone-number.component.ts | 20 +++- 3 files changed, 106 insertions(+), 16 deletions(-) diff --git a/src/lib/components/phone-number/phone-number.component.html b/src/lib/components/phone-number/phone-number.component.html index a1619c4..3c94063 100644 --- a/src/lib/components/phone-number/phone-number.component.html +++ b/src/lib/components/phone-number/phone-number.component.html @@ -10,6 +10,7 @@ (blur)="onBlur($event)" [placeholder]="placeholder" [mask]="mask" + [patterns]="patterns" [prefix]="allowInternational ? '+1 ' : ''" [required]="required" [disabled]="disabled" /> @@ -20,7 +21,8 @@ [displayError]=" (controlDir && !disabled && - (controlDir.dirty || controlDir.touched) && !!controlDir.errors) ?? + (controlDir.dirty || controlDir.touched) && + !!controlDir.errors) ?? false ">
diff --git a/src/lib/components/phone-number/phone-number.component.spec.ts b/src/lib/components/phone-number/phone-number.component.spec.ts index 5188b28..32f61c0 100644 --- a/src/lib/components/phone-number/phone-number.component.spec.ts +++ b/src/lib/components/phone-number/phone-number.component.spec.ts @@ -251,6 +251,23 @@ describe('Phone-Number.Component self validation', () => { fixture.detectChanges(); } + // One input event per character, caret at the end, as a keyboard produces. + async function typeKeys(text: string) { + const inputEl: HTMLInputElement = + fixture.nativeElement.querySelector('input'); + inputEl.focus(); + for (const ch of text) { + inputEl.value += ch; + inputEl.setSelectionRange(inputEl.value.length, inputEl.value.length); + inputEl.dispatchEvent(new Event('input')); + await fixture.whenStable(); + fixture.detectChanges(); + } + inputEl.dispatchEvent(new Event('blur')); + await fixture.whenStable(); + fixture.detectChanges(); + } + function renderedError(): string | null { const el = fixture.nativeElement.querySelector('common-error-container'); return el ? el.textContent.trim() : null; @@ -397,21 +414,68 @@ describe('Phone-Number.Component self validation', () => { expect(ngForm.valid).toBe(true); }); - ['0', '000'].forEach((zeros) => { - it(`rejects the partial number ${zeros} with a numeric model`, async () => { - await setup(true, (host) => (host.phone = 2501234567)); - // Observed: without this extra settle, the 0 and 000 tests pass on a - // validator that treats 0 as empty. - await fixture.whenStable(); - fixture.detectChanges(); + it('rejects a numeric 0 model as a partial number, not as empty', async () => { + // Observed: with the mask displayed, a numeric 0 model leaves the control + // without an error, so this case runs unmasked. + await setup(true, (host) => { + host.displayMask = false; + host.phone = 0; + }); + await fixture.whenStable(); + fixture.detectChanges(); + fixture.nativeElement + .querySelector('input') + .dispatchEvent(new Event('blur')); + fixture.detectChanges(); - await typeDigits(zeros); + expect(ngForm.controls['phone'].errors).toEqual({ + incompleteValue: true, + }); + expect(ngForm.valid).toBe(false); + expect(renderedError()).toBe('Phone Number does not appear to be valid.'); + }); - expect(ngForm.controls['phone'].errors).toEqual({ - incompleteValue: true, + describe('when typed key by key', () => { + it('keeps the first digit after +1 as the area code start', async () => { + await setup(); + await typeKeys('+1 (234) 567-8978'); + + const inputEl: HTMLInputElement = + fixture.nativeElement.querySelector('input'); + expect(inputEl.value).toBe('+1 (234) 567-8978'); + expect(ngForm.controls['phone'].value).toBe('2345678978'); + expect(ngForm.controls['phone'].errors).toBeNull(); + }); + + ['1', '0'].forEach((lead) => { + it(`ignores a leading ${lead} before the area code`, async () => { + await setup(); + await typeKeys(`${lead}2345678978`); + + const inputEl: HTMLInputElement = + fixture.nativeElement.querySelector('input'); + expect(inputEl.value).toBe('+1 (234) 567-8978'); + expect(ngForm.controls['phone'].value).toBe('2345678978'); + expect(ngForm.controls['phone'].errors).toBeNull(); + }); + }); + }); + + describe('when the value is set without typing', () => { + ['1234567897', '0234567890'].forEach((value) => { + it(`rejects ${value} because the area code starts with ${value[0]}`, async () => { + await setup(); + const control = ngForm.controls['phone']; + control.setValue(value); + control.markAsTouched(); + fixture.detectChanges(); + + expect(control.errors).toEqual({ incompleteValue: true }); + expect(ngForm.valid).toBe(false); + expect(renderedError()).toBe( + 'Phone Number does not appear to be valid.' + ); }); - expect(ngForm.valid).toBe(false); - expect(renderedError()).toBe('Phone Number does not appear to be valid.'); }); }); @@ -428,6 +492,16 @@ describe('Phone-Number.Component self validation', () => { expect(ngForm.valid).toBe(true); }); + it('rejects ten digits starting with 1', async () => { + await setup(true, unmasked); + await typeDigits('1234567890'); + + expect(ngForm.controls['phone'].errors).toEqual({ + incompleteValue: true, + }); + expect(ngForm.valid).toBe(false); + }); + it('rejects a value with no digits', async () => { await setup(true, unmasked); await typeDigits('abcdefghij'); diff --git a/src/lib/components/phone-number/phone-number.component.ts b/src/lib/components/phone-number/phone-number.component.ts index c515044..6601cc7 100644 --- a/src/lib/components/phone-number/phone-number.component.ts +++ b/src/lib/components/phone-number/phone-number.component.ts @@ -16,7 +16,7 @@ import { RequiredMsg, } from '../../models/error-message.interface'; import { AbstractFormControl } from '../../models/abstract-form-control'; -import { NgxMaskDirective, provideNgxMask } from 'ngx-mask'; +import { initialConfig, NgxMaskDirective, provideNgxMask } from 'ngx-mask'; import { CommonModule } from '@angular/common'; import { ErrorContainerComponent } from '../error-container/error-container.component'; @@ -50,12 +50,21 @@ export class PhoneNumberComponent implements OnInit { static PhoneNumberRegEx = '^[2-9]{1}\\d{2}[\\-]?\\d{3}[\\-]?\\d{4}$'; + + // ngx-mask replaces its whole pattern table when [patterns] is bound, so the + // stock tokens are kept and 'N' (a digit 2-9) is added for the default mask. + // A custom [mask] that does not use 'N' behaves exactly as before. + readonly patterns = { + ...initialConfig.patterns, + N: { pattern: /[2-9]/ }, + }; + @Input() displayMask: boolean = true; @Input() required: boolean = false; @Input() label: string = 'Mobile'; @Input() allowInternational: boolean = true; - @Input() mask = '(000) 000-0000'; + @Input() mask = '(N00) 000-0000'; // TODO: Remove once all project using library remove placeholders -- Temporary solution @Input() enablePlaceHolder: boolean = true; @@ -146,6 +155,11 @@ export class PhoneNumberComponent } // String() because ngx-mask can emit a number for a numeric model. const digits = String(value).replace(/\D/g, ''); - return digits.length === phoneLength ? null : { incompleteValue: true }; + // The mask only guards typing; a programmatic value or a custom mask can + // still reach here, so enforce 10 digits starting 2-9 as well. + return digits.length === phoneLength && + new RegExp(PhoneNumberComponent.PhoneNumberRegEx).test(digits) + ? null + : { incompleteValue: true }; } } From 2dc7a74be3113e681c4e8a7635665ac868681fe4 Mon Sep 17 00:00:00 2001 From: Isaac Stevens Date: Tue, 13 Oct 2026 20:18:16 -0700 Subject: [PATCH 2/6] chore(release): 2.5.4 --- CHANGELOG.md | 43 ++++++++++++++++++++++++++++++++ package-lock.json | 4 +-- package.json | 2 +- projects/common-lib/package.json | 2 +- 4 files changed, 47 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c3d2d20..f036e88 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,46 @@ +## 2.5.4 (2026-10-13) + +Fixes `common-phone-number` storing the wrong number when a user types the +country code. The field already shows a `+1 ` prefix, but the mask accepted any +digit in the first position, so typing `+1 (234) 567-8978` stored `1234567897`: +the typed 1 became the area code and the last digit was dropped. The validator +only counted digits, so the control stayed valid and the bad number reached the +app. In MSP DEAM this failed submission with +`should match pattern "^[2-9]([0-9]{9})$"`. The mask and prefix have behaved +this way since 1.0.0; no release flagged the shifted number as invalid. + +### Breaking + +- `common-phone-number`: a value whose first digit is 0 or 1 is now invalid + (`incompleteValue`, "