DEV Community

Myroslav Martsin
Myroslav Martsin

Posted on

Phone input for Angular

A phone input has three jobs. It shows the number the way people write it in their country, checks it against the numbering plan of that country, and hands the form the one shape worth storing, the number in E.164. A mask covers the first job for a single country and leaves the other two to a regex, which is where most phone fields go wrong.

This post builds such an input on a reactive form, adds a region picker, and then moves the same input into Angular Material. Each step runs on StackBlitz, linked at the end.

Install

One command installs it:

ng add @telixon/angular
Enter fullscreen mode Exit fullscreen mode

The schematic adds the package, the flags stylesheet, and the engine preload. Of the parts inside, this post uses two. TelixonPhoneInput is a directive for your own <input>. TelixonRegionPicker shows the flag and a searchable list of countries next to it, linked to the input through [for]. Both render on the server and restyle through one class selector each.

The input on a reactive form

import { ChangeDetectionStrategy, Component } from '@angular/core';
import { FormControl, ReactiveFormsModule } from '@angular/forms';
import { TelixonPhoneInput, TelixonRegionPicker } from '@telixon/angular';

@Component({
  selector: 'app-contact',
  imports: [ReactiveFormsModule, TelixonPhoneInput, TelixonRegionPicker],
  changeDetection: ChangeDetectionStrategy.OnPush,
  template: `
    <div class="field">
      <telixon-region-picker [for]="phone" [prioritize]="['US', 'CA', 'GB']" />

      <input
        type="tel"
        autocomplete="tel"
        aria-label="Phone"
        #phone="telixonPhoneInput"
        [telixonPhoneInput]="{ mode: 'international', defaultRegion: 'US', display: { callingCodeInInput: false } }"
        [formControl]="control"
        [placeholder]="phone.state()?.placeholder ?? ''"
      />
    </div>

    <p>{{ control.value ?? 'null' }}</p>
    <p>{{ control.errors?.['telixonPhone']?.kind ?? 'none' }}</p>
  `,
})
export class Contact {
  readonly control = new FormControl<string | null>(null);
}
Enter fullscreen mode Exit fullscreen mode

Three attributes do the work. [telixonPhoneInput] turns the input into a phone field, here in international mode with the calling code shown on the picker. [formControl] binds it like any other input, while [for] on the picker ties it to this field through the #phone reference. The placeholder is an example number for the current country, 201-555-0123 for the United States.

The input itself stays a plain <input>, styled by your CSS. The quick start gets by with this:

.field {
  display: flex;
  gap: 0.5rem;
}

.field > input {
  flex: 1 1 10rem;
  min-width: 0;
  padding: 0.55rem 0.7rem;
  font: inherit;
  border: 1px solid color-mix(in srgb, currentColor 30%, transparent);
  border-radius: 0.375em;
  background: transparent;
  color: inherit;
}
Enter fullscreen mode Exit fullscreen mode

Type 4155550132 and the input shows 415-555-0132 while the control holds +14155550132. Type 416 instead and the flag turns Canadian on its own, because Canada shares the calling code and 416 belongs to Toronto. Paste +44 20 7183 8750 and the picker jumps to the United Kingdom, where the same field shows 20 7183 8750 and holds +442071838750.

What the form gets

The control holds E.164 while the number is valid and null the rest of the time, which spares everything downstream from parsing it again. An invalid number puts the reason under telixonPhone:

control.errors;
// { telixonPhone: { kind: 'TOO_SHORT', minLength: 10 }, required: true }
Enter fullscreen mode Exit fullscreen mode

The reason names the fault, TOO_SHORT here, together with the detail behind it, the minimum length in this case or the prefix a country expects in another. The wording of the message stays yours, which keeps translation in your hands. One consequence of the null value is worth knowing. While the number is incomplete, Validators.required fires as well. Your template therefore checks telixonPhone first. An empty input reports nothing of its own and leaves required to do its job.

ngModel works the same way:

<input name="phone" [telixonPhoneInput]="{ mode: 'international' }" [(ngModel)]="phone" #model="ngModel" />
Enter fullscreen mode Exit fullscreen mode

The same input inside Angular Material

<mat-form-field appearance="outline">
  <mat-label>Phone</mat-label>

  <telixon-region-picker matTextPrefix [for]="phone" anchor=".mdc-text-field" [prioritize]="['US', 'CA', 'GB']" />

  <input
    matInput
    type="tel"
    autocomplete="tel"
    #phone="telixonPhoneInput"
    [telixonPhoneInput]="{ mode: 'national', defaultRegion: 'US' }"
    [formControl]="control"
    [placeholder]="phone.state()?.placeholder ?? ''"
  />

  @if (control.hasError('telixonPhone')) {
    <mat-error>{{ message(control.getError('telixonPhone')) }}</mat-error>
  } @else if (control.hasError('required')) {
    <mat-error>Phone number is required.</mat-error>
  }
</mat-form-field>
Enter fullscreen mode Exit fullscreen mode

matInput goes on the same <input> as the directive, which is the whole trick. Material draws the label, the outline, and the error state from the control, with no MatFormFieldControl to write. matTextPrefix places the picker on the text line, while anchor=".mdc-text-field" lines its list up with the box Material draws. message is a small switch over the fault kind in your component. The picker renders inside the form field, out of reach of component styles, which is why its one rule lives in the global stylesheet:

.mat-mdc-form-field .tlx-region-picker__trigger {
  border: 0;
  padding: 0.5em 1rem 0.5em 0.5em;
  margin-inline-start: -0.5em;
  translate: 0 -1px;
}
Enter fullscreen mode Exit fullscreen mode

Signal Forms

On Angular 22 the same field binds to a Signal Form through [formField] as TelixonPhoneField, where the error arrives with a message and the fault. The walk-through is in Phone field on Angular Signal Forms.

Run it

The engine behind all of this is compiled from Google libphonenumber's metadata and checked against it in CI. If a number formats or validates differently from Google libphonenumber, open an issue with the number. A wrong answer is a bug.

Top comments (0)