Mike Dalrymple Phone Numbers with Angular Material Design

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:

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-fields. 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.