Signal Forms went stable in Angular 22. They bring FormValueControl, a contract where a custom control hands the form a value and a list of errors. A toggle or a stepper meets it in a few lines. The real test is a control whose input text and model value are two different things.

A phone field is such a control. The user types 4155550132. The input shows (415) 555-0132 while the model stores +14155550132. Between the two sit parsing, formatting, and validation per country. The contract asks the control to hand over the E.164 value and a typed error when the number is incomplete. This is the first hurdle.

Angular Material adds a second hurdle, a contract of its own. A custom component inside <mat-form-field> has to implement MatFormFieldControl, whichever forms API it binds with. The guide for it runs 529 lines, with a phone input as the worked example.

This post builds the field step by step, from a bare <input> to Angular Material. The Material step ends with zero lines of MatFormFieldControl.

Starting point

The field comes from @telixon/angular. One command installs it:

ng add @telixon/angular 

The schematic adds the package, the flags stylesheet, and provideTelixon with the engine preload. The package is a kit. This post uses the field and the picker from it. TelixonPhoneField is a directive for your own <input>. TelixonRegionPicker adds the flag and a searchable list of countries and links to the field through [for]. Both render on the server and restyle through one class selector each.

Step one, a bare input on a Signal Form

Start with your own markup, a <div>, the picker, and an <input>:

import { ChangeDetectionStrategy, Component, computed, signal } from '@angular/core'; import { FormField, form, required } from '@angular/forms/signals'; import { TelixonPhoneField, TelixonRegionPicker } from '@telixon/angular'; @Component({ selector: 'app-contact', imports: [FormField, TelixonPhoneField, 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="telixonPhoneField" [telixonPhoneField]="{ mode: 'international', defaultRegion: 'US', display: { callingCodeInInput: false } }" [formField]="contactForm.phone" [placeholder]="phone.state()?.placeholder ?? ''" /> </div> <p>{{ model().phone ?? 'null' }}</p> <p>{{ error() }}</p> `, }) export class Contact { readonly model = signal<{ phone: string | null }>({ phone: null }); readonly contactForm = form(this.model, (schema) => { required(schema.phone, { message: 'Phone number is required.' }); }); readonly error = computed(() => this.contactForm.phone().errors()[0]?.message ?? 'none'); } 

Three attributes carry the whole thing. [telixonPhoneField] turns the input into the phone field, here in international mode with the calling code shown on the picker. [formField] binds it to the form, the same binding a native input gets. [for] on the picker links it to the field through the #phone reference.

The input stays a plain <input>. Its look is your CSS. The quick start uses this much:

.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; } 

Type 4155550132. The input shows 415-555-0132 and the model shows +14155550132. Type 416 and the flag turns Canadian on its own, because Canada shares the calling code and 416 belongs to Toronto. Nothing in the component knows about Toronto.

Step two, what the form sees

The model holds E.164 while the number is valid and null at any other time. The submit handler never parses anything. The rules you write stay in the schema, required here. An invalid number adds one entry to errors():

contactForm.phone().errors(); // [{ kind: 'telixonPhone', message: 'This number is too short.', fault: { kind: 'TOO_SHORT', minLength: 10 } }] 

message is ready to show. fault carries the reason, such as TOO_SHORT with the minimum length, for your own wording or a translation through the errorMessage input. The error marks the field invalid and blocks submission like a schema rule. While the number is invalid the model is null, which trips the schema's required as well. Angular lists the parse error first, which is why the template reads errors()[0] and shows the field's message. An empty field reports nothing from the field itself and leaves the message to required.

disabled, readonly, and required from the schema land on the input element. Add readonly(schema.phone) and the input locks.

Step three, the same field inside Angular Material

This is where MatFormFieldControl usually comes in. Here is the whole change:

@Component({ selector: 'app-contact', imports: [FormField, MatFormFieldModule, MatInputModule, TelixonPhoneField, TelixonRegionPicker], changeDetection: ChangeDetectionStrategy.OnPush, template: ` <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="telixonPhoneField" [telixonPhoneField]="{ mode: 'national', defaultRegion: 'US' }" [formField]="contactForm.phone" [placeholder]="phone.state()?.placeholder ?? ''" /> <mat-error>{{ contactForm.phone().errors()[0]?.message }}</mat-error> </mat-form-field> `, }) export class Contact { readonly model = signal<{ phone: string | null }>({ phone: null }); readonly contactForm = form(this.model, (schema) => { required(schema.phone, { message: 'Phone number is required.' }); }); } 

matInput sits on the same <input> as the directive. This is the whole trick. Material reads the field's state through Signal Forms, which gives the floating label, the outline notch, aria-invalid, and mat-error with no @if around it. matTextPrefix puts the picker on the text line. anchor=".mdc-text-field" lines the list up with the box Material draws. The mode is national this time, with the calling code implied by the flag.

The input itself needs no styles here, because matInput draws it. The one snag is the picker. It renders inside the form field where component styles do not reach. Its one rule goes into 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; } 

Then the same run as before. 4155550132 shows as (415) 555-0132. Pick the United Kingdom and type 02071838750. The input shows 020 7183 8750 while the model holds +442071838750. Leave the field after 020 71 and Material shows "This number is too short." The second contract never comes up.

What to take away

  • Signal Forms let a directive be the control. FormValueControl is a contract, a value plus errors. A directive on a native <input> fulfills it.
  • A ControlValueAccessor bound through [formField] works through a compatibility path and loses its typed errors on the way. For Signal Forms, use a FormValueControl. Telixon ships both, TelixonPhoneInput for reactive forms and TelixonPhoneField for Signal Forms, with the same options.
  • The validation comes from an engine compiled from Google libphonenumber's metadata and verified against it in CI. Whether 020 7183 875 is complete is a question for the numbering plan. The engine answers it the way Google's library does.

Run it

If the field formats or validates a number differently from Google libphonenumber, open an issue with the number. A wrong answer is a bug.