Signal Forms went stable in Angular 22. They bring FormValueControl, a contract through which custom controls hand their value and their errors to the form. Toggles and steppers meet it in a few lines, because the value they show is the value they store. The real test comes when those two drift apart, when an input shows one thing and its model holds another.
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.
The second hurdle appears as soon as the field moves into Angular Material, which has 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.
Both come down to one idea. The control is a directive on a native <input>. The form gets its value and errors from the directive, while Material keeps its own matInput on the same element. The rest of 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, which one command installs:
ng add @telixon/angular
The schematic adds the package, the flags stylesheet, and provideTelixon with the engine preload. The package is a kit. Two of its parts matter here. TelixonPhoneField is a directive for your own <input>. TelixonRegionPicker adds the flag and a searchable list of countries next to it 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> and its look is up to 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 and the input shows 415-555-0132 while 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, which means 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.
The schema's disabled, readonly, and required land on the input element as well. Add readonly(schema.phone) and the input locks.
Step three, the same field inside Angular Material
This is the step 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, which is the one idea from the start of this post. 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.
FormValueControlis 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 aFormValueControl. Telixon ships both,TelixonPhoneInputfor reactive forms andTelixonPhoneFieldfor 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 875is 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.
Top comments (0)