Custom Controls

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 FormCheckboxControl contract, the only required property is checked.

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.

PropertyType
errorsInputSignal<ValidationError[]>
disabledInputSignal
disabledReasonsInputSignal<DisabledReason[]>
readonlyInputSignal
hiddenInputSignal
invalidInputSignal
pendingInputSignal
touchedModelSignal | InputSignal | OutputRef
dirtyInputSignal
nameInputSignal
requiredInputSignal
minInputSignal<number | undefined>
minLengthInputSignal<number | undefined>
maxInputSignal<number | undefined>
maxLengthInputSignal<number | undefined>
patternInputSignal<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
}
touched: false
dirty: false
hidden: false
readonly: false
disabled: false
disabledReasons: (0)
[]
errors: (0)
[]
errorSummary: (0)
[]
meta: {
  "pattern": [],
  "required": false
}
            

Children

disableForm valid: true | invalid: false | pending: false
value: false
touched: false
dirty: false
hidden: false
readonly: false
disabled: false
disabledReasons: (0)
[]
errors: (0)
[]
errorSummary: (0)
[]
meta: {
  "pattern": [],
  "required": false
}
            

Children

— none —

range valid: true | invalid: false | pending: false
value: 0
touched: false
dirty: false
hidden: false
readonly: false
disabled: false
disabledReasons: (0)
[]
errors: (0)
[]
errorSummary: (0)
[]
meta: {
  "pattern": [],
  "required": false
}
            

Children

— none —

TypeScript
HTML
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;
      });
    }),
  );
}