Phone Numbers with Angular Material Design
I recently struggled with creating a phone number entry field in an Angular application that uses Material Design components. This post describes how I solved the problem and provides code that might be useful for your project.
My basic requirements for phone number entry were:
- Phone numbers must format as they’re entered
- Data entry must be limited to digits
- International numbers must be accepted (for countries I support)
- Users must be able to paste formatted phone numbers in the field
- Pasted data must be properly reformatted and validated
- Valid phone numbers must be available in E.164 format
The Solution
The solution I came up with uses Reactive forms with a FormGroup containing a pair of related FormControls for the country and phone number, a custom ValidatorFn, and an Angular Material ErrorStateMatcher.
For validation and formatting, I use awesome-phonenumber, a simple to use, pre-compiled version of Google’s libphonenumber library that doesn’t bring any additional dependencies.
The Code
The code for this project is available on GitHub in the mousedownco/angular-material-phone repository. You can run it locally or try out the live demo.
Form Fields
Data entry is handled with a pair of form fields, a select menu for the country
and a text input for the phone number. The validity of these fields is
determined by their combined values, so I have them grouped together in
a phone
group that is part of a broader profileForm
group in the Component.
profileForm = this.fb.group({
phone: this.fb.group({
country: ['US'],
number: ['']
}, {validators: phoneValidator})
});
In the HTML template we expose these FormControls in our mat-form-field
s.
The mat-select
is populated with our country list using the ISO-3116-1 country
codes as the selection value. We listen for selectionChange
events and
reformat the phone number when the country is changed.
<mat-select formControlName="country" (selectionChange)="formatNumber()">
<mat-option *ngFor="let countryCode of countyCodes"
[value]="countryCode.code">
{{countryCode.country}}
</mat-option>
</mat-select>
In the <input>
element, we listen for input
events and reformat the new
value of the field. We also register our errorStateMatcher
so we can mark the
phone number as being valid or invalid based on the data in both the country
and number
fields.
Formatting
The formatNumber()
method creates a new PhoneNumber
object using the
selected country code and just the digits from the phone number field.
The PhoneNumber.getNumber()
method is used to retrieve the 'national'
formatted number. That formatted number is then used to replace the contents of
the phone number field. If getNumber()
doesn’t return a value, we just use the
digits to populate the field. This is necessary for the first one or two digits
entered.
/**
* Return a string containing only numeric values from the
* phone.number form field.
*/
get phoneNumberDigits(): string {
return this.phoneNumberControl.value.replace(/\D/g, '');
}
/**
* Return an {@see PhoneNumber} value created from the
* phoneNumberDigits and currently selected country code.
*/
get phoneNumber(): PhoneNumber {
return new PhoneNumber(this.phoneNumberDigits, this.phoneCountryControl.value);
}
/**
* Formats the phone number digits using the 'national' format
* from awesome-phonenumber.
*/
formatNumber() {
const natNum = this.phoneNumber.getNumber('national');
this.phoneNumberControl.setValue((natNum) ? natNum : this.phoneNumberDigits);
}
Replacing the phone number form control value with a formatted version of the number (or just the digits) prevents the user from entering anything other than numbers while still presenting a formatted number appropriate for the selected country. This formatting also handles cases where a user pastes a phone number of any format into the phone number field.
Validation
The phoneValidator
function validates the phone FormGroup
by using
the PhoneNumber.isValid()
method to check the validity of the current phone
number/country combination.
/**
* Validates a FormGroup containing `country` and `number` fields that
* are used to generate a {@see PhoneNumber}. Valid numbers are
* determined by the PhoneNumber.isValid() method.
*/
export const phoneValidator: ValidatorFn = (control: FormGroup): ValidationErrors | null => {
const country = control.get('country');
const num = control.get('number');
if (num?.value && country?.value && !(new PhoneNumber(num.value, country.value).isValid())) {
return {invalidPhone: true};
} else {
return null;
}
};
We use the PhoneErrorMatcher
to flag our phone number field as being in error
when the phone
FormGroup
is invalid. Specifically, the field will be marked
in error if it has a value, has been touched and the parent form group is not
valid.
/**
* {@see ErrorStateMatcher} used to update the error state of the
* phone number when the country or phone number changes.
*/
export class PhoneErrorMatcher implements ErrorStateMatcher {
isErrorState(control: FormControl | null, form: FormGroupDirective | NgForm | null): boolean {
return !!(control.value && control.touched && !control?.parent?.valid);
}
}
As an added bonus, PhoneNumber.getExample()
returns an example phone number
that can be used as a <mat-hint>
.
/**
* Generate a hint using the {@see PhoneNumber} getExample method
* with the currently selected country.
*/
get phoneHint():string {
return PhoneNumber.getExample(this.phoneCountryControl.value).getNumber('national');
}
Finally, when the form is valid, we can use the PhoneNumber.getNumber('e164')
method to get the properly formatted number to send to our backend.
/**
* Get the [E.164]{@link https://en.wikipedia.org/wiki/E.164} formatted
* phone number typically required by systems for making calls and
* sending text messages.
*/
get phoneE164(): string {
return this.phoneNumber.getNumber('e164');
}
If you haven’t done so already, go ahead and try the live demo.
The Trick
I tried coming at this problem from many angles. Most of my previous attempts
required keeping track of the exact contents of the phone number field and
determining if a Backspace
event was deleting a character inserted by
formatting or if it was deleting an actual phone number digit. This proved to be
quite a challenge and was requiring far too much code for my liking. I also
attempted to use the “As You Type” feature of the awesome-phonenumber
library
but
that led to unexpected results.
As I continued to work with awesome-phonenumber
I realized Google never
generates a formatted number that doesn’t end in a digit. With this simple
pattern, you can safely assume that any Backspace
event is removing a digit.
Once you know that, you can just strip the input field of everything except
numbers and then replace the contents with a formatted version of the new data.
Once I realized this, I could have stopped using the library and manually
formatted the numbers using a similar pattern but the library proved to be such
a value-add that I continued using it.
Final Thoughts
If you run the sample application, you’ll notice I don’t limit the number of digits that can be entered into the phone number field. This avoids the complexity of changing that limit based on the country selected (not all regions have 10-digit numbers as we do in North America).
More importantly, allowing extra digits can improve data quality. If the phone number was limited to the country’s maximum length, it’s quite likely that an erroneous digit would be entered early in the phone number without the user noticing. If the field were limited, the last number would be dropped while still appearing valid. With this implementation, an extra digit early in the phone number will be noticed and corrected by the user.
Updated 2021-12-08: This was originally posted in May 2020 when the project was using Angular 9. I’ve updated the repository to use the latest Angular 13 and included more documentation.