Custom Controls
In Angular’s signal-based forms, a custom control is any component that implements the FormValueControl or FormCheckboxControl interface. Once implemented, the form’s FieldTree can bind to the component using the [Field] directive, just like it would with any native UI control.
In the same way as with native UI controls, the [field] directive creates a two-way binding between the custom control’s value and the form field’s value, and it synchronizes the field state (disabled, touched, required, etc.) with the custom control’s state.
Example Custom Control - Slider
In the following example we can see how easy is to implement a Custom Control and to integrate it in our form. We start by creating a simple slider component:
@Component({
selector: 'slider',
template: `
<label for="disabled-range" class="input-label">{{ title() }}</label>
<input
id="disabled-range"
type="range"
[value]="value()"
(input)="value.set($event.target.valueAsNumber)"
class="input-slider"
/>
`,
host: {
'[class.disabled]': 'disabled()',
},
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class Slider implements FormValueControl<number> {
readonly title = input.required<string>();
readonly value = model(0);
readonly disabled = input(false);
}The FormValueControl interface requires a value property and it's the only mandatory property. Although the contract supports additional properties such as errors, disabled, required, etc, only value must be implemented. The value property should be a ModelSignal. In this way, the [Field] directive can keep in sync the value of the Custom Control with the value of the bound FieldTree.
Similarly, for the
FormCheckboxControlcontract, the only required property ischecked.
The FormValueControl and the FormCheckboxControl contracts include also a number of other optional set of properties that you can implement (you can find the full list of these proporties in the following table). The only thing you have to do is to add the corresponding property in your custom control. All of them as you can see are input signals (except touched which is model signal). The Field directive will update automatically the value of all these input/model signals for you.
| Property | Type |
|---|---|
| errors | InputSignal<ValidationError[]> |
| disabled | InputSignal |
| disabledReasons | InputSignal<DisabledReason[]> |
| readonly | InputSignal |
| hidden | InputSignal |
| invalid | InputSignal |
| pending | InputSignal |
| touched | ModelSignal |
| dirty | InputSignal |
| name | InputSignal |
| required | InputSignal |
| min | InputSignal<number | undefined> |
| minLength | InputSignal<number | undefined> |
| max | InputSignal<number | undefined> |
| maxLength | InputSignal<number | undefined> |
| pattern | InputSignal<RegExp[]> |
Demo
In this demo, you can see how the custom Slider control integrates with our form. In addition to the value property, we've also implemented a disabled property to demonstrate synchronization of other control states as well.
Form Inspector
Field Tree
(root) valid: true | invalid: false | pending: false
value: {
"disableForm": false,
"range": 0
}disabledReasons: (0)
[]errors: (0)
[]errorSummary: (0)
[]meta: {
"pattern": [],
"required": false
}
Children
disableForm valid: true | invalid: false | pending: false
value: falsedisabledReasons: (0)
[]errors: (0)
[]errorSummary: (0)
[]meta: {
"pattern": [],
"required": false
}
Children
— none —
range valid: true | invalid: false | pending: false
value: 0disabledReasons: (0)
[]errors: (0)
[]errorSummary: (0)
[]meta: {
"pattern": [],
"required": false
}
Children
— none —
import { ChangeDetectionStrategy, Component, signal } from '@angular/core';
import { disabled, Field, form, schema } from '@angular/forms/signals';
import { Slider } from './slider';
import { FormInspectorComponent } from '../../../ui/form-inspector.ts/form-inspector';
import { DemoLayout } from '../../../ui/demo-layout/demo-layout';
interface MyDataModel {
disableForm: boolean;
range: number;
}
@Component({
selector: 'custom-control-demo',
templateUrl: './custom-control-demo.html',
imports: [Field, Slider, FormInspectorComponent, DemoLayout],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class CustomControlDemo {
protected readonly myForm = form(
signal<MyDataModel>({
disableForm: false,
range: 0,
}),
schema<MyDataModel>((schemaPath) => {
disabled(schemaPath.range, ({ valueOf }) => {
return valueOf(schemaPath.disableForm) ? 'Range cannot be changed' : false;
});
}),
);
}