angularangular-reactive-formsangular-custom-validatorsangular-controlvalueaccessor

Why aren't the validation errors in this custom Angular control visible from the parent component's form?


I'm working on my first custom control in Angular (16), for a Reactive Form, that contains a slider and an input. If the slider is true, then the input should have a value. If it is not, then the input shouldn't have a value.

Custom component HTML:

<div class="benefit-field">
    <div class="slide-toggle">
        <mat-slide-toggle [checked]="slideEnabled" (change)="updateEnabled($event)">Enabled?</mat-slide-toggle>
    </div>
    <div [ngClass]="{'disabled': !slideEnabled}">
        <mat-form-field>
            <mat-label>{{ typeName }}</mat-label>
            <input type="text" matInput [value]="valueId" [disabled]="!slideEnabled" (change)="onValueChanged($event.target)" />
        </mat-form-field>
    </div>
</div>

Custom component TypeScript:

import { Component, forwardRef, Input } from '@angular/core';
import { AbstractControl, ControlValueAccessor, NG_VALIDATORS, NG_VALUE_ACCESSOR, ValidationErrors, Validator } from '@angular/forms';
import { MatSlideToggleChange } from '@angular/material/slide-toggle';

@Component({
  selector: 'app-optional-benefit-field',
  templateUrl: './optional-benefit-field.component.html',
  styleUrls: ['./optional-benefit-field.component.scss'],
  providers: [
    {
      provide: NG_VALUE_ACCESSOR,
      multi: true,
      useExisting: OptionalBenefitFieldComponent
    },
    {
      provide: NG_VALIDATORS,
      multi: true,
      useExisting: forwardRef(() => OptionalBenefitFieldComponent)
    },
  ]
})
export class OptionalBenefitFieldComponent implements ControlValueAccessor, Validator {
  @Input() typeId: number;
  @Input() typeName: number;

  valueId: number;
  slideEnabled = false;

  onChange = (_valueId) => { /* nothing needs to be done here, let it trickle up */ };
  onTouched = () => { /* nothing needs to be done here, let it trickle up */ };
  touched = false;
  disabled = false;

  writeValue(valueId: number): void {
    if (valueId && valueId > 0) {
      this.slideEnabled = true;
    }
    this.valueId = valueId;
  }

  registerOnChange(onChange: any): void {
    this.onChange = onChange;
  }

  registerOnTouched(onTouched: any): void {
    this.onTouched = onTouched;
  }

  markAsTouched(): void {
    if (!this.touched) {
      this.onTouched();
      this.touched = true;
    }
  }

  setDisabledState(isDisabled: boolean): void {
    this.disabled = isDisabled;
  }

  updateEnabled(event: MatSlideToggleChange): void {
    this.markAsTouched();
    this.slideEnabled = event.checked;
    if (!this.slideEnabled) {
      this.onChange(null);
    } else {
      this.onChange(this.valueId);
    }
  }

  onValueChanged(input: HTMLInputElement): void {
    if (input.value && (+input.value) > 0) {
      this.onChange(+input.value);
    } else {
      this.onChange(null);
    }
  }

  validate(control: AbstractControl): ValidationErrors | null {
    return (!this.slideEnabled || (this.slideEnabled && control.value))
      ? null
      : { required: true };
  }
}

Parent component HTML:

<app-optional-benefit-field
    [typeId]="..."
    [typeName]="..."
    formControlName="valueId"
    ></app-optional-benefit-field>

When I put the custom control into an invalid state, I can see that validate is returning a { required: true }.

If I look at the parent form, however, .value has the current control's values but .errors is null.

What am I missing in my custom control or parent component for it to properly send/receive the validation error?


Solution

  • The validation of the control are visible, but when you check the error object of the form, it's not updated/ propagated up by the control, it looks like a minor bug in the angular release (version ^19.0.0-next.10), proof.

    Stackblitz Demo

    If you want you can raise a github issue. The solution is to lower your angular version to the stable release like 18.

    Anyway, the code needs to following changes.

    When you use the form field module, it must have the NgControl, this can be provided by either ngModel or formControl, I am using formControl.

    <mat-form-field>
        <mat-label>{{ typeName }}</mat-label>
        <input type="number" matInput [formControl]="valueId" (input)="onValueChanged($event)"/>
    </mat-form-field>
    

    Do not use the [disabled] attribute when working with reactive forms, so I changed it to be disabled programmatically.

      updateEnabled(event: MatSlideToggleChange): void {
        this.markAsTouched();
        this.slideEnabled = event.checked;
        if (!this.slideEnabled) {
          this.onChange(null);
          this.valueId.disable();
        } else {
          this.onChange(this.valueId.value);
          this.valueId.enable();
        }
        this.valueId.updateValueAndValidity();
      }
    

    I changed the input to be number, since you have written some checks for only numbers.

    Apart from this your code works great.

    Full Code:

    import { Component } from '@angular/core';
    import { MatSlideToggleModule } from '@angular/material/slide-toggle';
    import { bootstrapApplication } from '@angular/platform-browser';
    import { forwardRef, Input } from '@angular/core';
    import { CommonModule } from '@angular/common';
    import {
      AbstractControl,
      ControlValueAccessor,
      FormControl,
      FormGroup,
      FormsModule,
      NG_VALIDATORS,
      NG_VALUE_ACCESSOR,
      ReactiveFormsModule,
      ValidationErrors,
      Validator,
      Validators,
    } from '@angular/forms';
    import { MatSlideToggleChange } from '@angular/material/slide-toggle';
    import { MatFormFieldModule } from '@angular/material/form-field';
    import { MatInputModule } from '@angular/material/input';
    
    @Component({
      selector: 'app-optional-benefit-field',
      imports: [
        MatFormFieldModule,
        MatSlideToggleModule,
        CommonModule,
        MatInputModule,
        FormsModule,
        ReactiveFormsModule,
      ],
      standalone: true,
      template: `
      <div class="benefit-field">
          <div class="slide-toggle">
              <mat-slide-toggle [checked]="slideEnabled" (change)="updateEnabled($event)">Enabled?</mat-slide-toggle>
          </div>
          <div [ngClass]="{'disabled': !slideEnabled}">
              <mat-form-field>
                  <mat-label>{{ typeName }}</mat-label>
                  <input type="number" matInput [formControl]="valueId" (input)="onValueChanged($event)"/>
              </mat-form-field>
          </div>
      </div>
      `,
      providers: [
        {
          provide: NG_VALUE_ACCESSOR,
          multi: true,
          useExisting: OptionalBenefitFieldComponent,
        },
        {
          provide: NG_VALIDATORS,
          multi: true,
          useExisting: forwardRef(() => OptionalBenefitFieldComponent),
        },
      ],
    })
    export class OptionalBenefitFieldComponent
      implements ControlValueAccessor, Validator
    {
      @Input() typeId!: number;
      @Input() typeName!: number;
    
      valueId: FormControl<number | null> = new FormControl<number | null>({
        value: null,
        disabled: true,
      });
      slideEnabled = false;
    
      onChange = (_valueId: any) => {
        /* nothing needs to be done here, let it trickle up */
      };
      onTouched = () => {
        /* nothing needs to be done here, let it trickle up */
      };
      touched = false;
      disabled = false;
    
      writeValue(valueId: number): void {
        if (valueId && valueId > 0) {
          this.slideEnabled = true;
        }
        this.valueId.patchValue(valueId);
      }
    
      registerOnChange(onChange: any): void {
        this.onChange = onChange;
      }
    
      registerOnTouched(onTouched: any): void {
        this.onTouched = onTouched;
      }
    
      markAsTouched(): void {
        if (!this.touched) {
          this.onTouched();
          this.touched = true;
        }
      }
    
      setDisabledState(isDisabled: boolean): void {
        this.disabled = isDisabled;
      }
    
      updateEnabled(event: MatSlideToggleChange): void {
        this.markAsTouched();
        this.slideEnabled = event.checked;
        if (!this.slideEnabled) {
          this.onChange(null);
          this.valueId.disable();
        } else {
          this.onChange(this.valueId.value);
          this.valueId.enable();
        }
        this.valueId.updateValueAndValidity();
      }
    
      onValueChanged(event: any): void {
        const value = event?.target?.value;
        if (value && +value > 0) {
          this.onChange(+value);
        } else {
          this.onChange(null);
        }
      }
    
      validate(control: AbstractControl): ValidationErrors | null {
        return !this.slideEnabled || (this.slideEnabled && control.value)
          ? null
          : { required: true };
      }
    }
    
    /**
     * @title Basic slide-toggles
     */
    @Component({
      selector: 'slide-toggle-overview-example',
      template: `
      <form [formGroup]="form">
        <app-optional-benefit-field
          [typeId]="1234"
          [typeName]="1234"
          formControlName="valueId"
          ></app-optional-benefit-field>
      </form>
      <hr/>
      {{form.errors | json}}
      <hr/>
      {{form.controls.valueId.errors | json}}
      <hr/>
      {{form.value | json}}
      `,
      standalone: true,
      imports: [OptionalBenefitFieldComponent, ReactiveFormsModule, CommonModule],
    })
    export class SlideToggleOverviewExample {
      form = new FormGroup({
        valueId: new FormControl(null),
      });
    }
    

    Stackblitz Demo