Search code examples
angularangular-reactive-formscontrolvalueaccessor

Reusable FormControl component implementing ControlValueAccessor


I am attempting to create a reusable custom-input component that does not have the formControlName hard coded anywhere in the custom-input component (I have found some examples where the formControlName is hardcoded in the custom component). I want to be able to supply only the formControlName to the component, so that the same component could be used for multiple FormControls within one FormGroup. Here is a Stackblitz, and the relevant code is below:

/// CustomInputComponent ///
import { Component, forwardRef, Input, ViewChild, ElementRef } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule, ControlValueAccessor, NG_VALUE_ACCESSOR, NG_VALIDATORS } from '@angular/forms';

@Component({
  standalone: true,
  selector: 'custom-input',
  template: `  <input
                  class="input"
                  type="text"
                  [disabled]="disabled"
                  (input)="updateValue($event)"
                  [value]="value"
                />`,
  styles: [],
  imports: [CommonModule, FormsModule],
  providers: [
    {
      provide: NG_VALUE_ACCESSOR,
      multi: true,
      useExisting: forwardRef(() => CustomInputComponent),
    },
    {
      provide: NG_VALIDATORS,
      multi: true,
      useExisting: CustomInputComponent,
    },
  ],
})
export class CustomInputComponent implements ControlValueAccessor {  
  @ViewChild('input', { static: true }) input!: ElementRef<HTMLInputElement>;

  @Input() ctrlConfigData!: any;
  @Input() ctrlName!: string;

  @Input() value = '';
  disabled: boolean = false;

  onChanged: any = () => {};
  onTouched: any = () => {};

  writeValue(value: string): void {  // Update component state
    console.log('writeValue() called with:', value);
    this.value = value;
  }

  registerOnChange(fn: any): void { // Register the callback for value changes
    this.onChanged = fn;
  }

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

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

  updateValue(event: Event): void {
    const newValue = (event.target as HTMLInputElement).value;
    console.log('newValue: ' + newValue);
    this.value = newValue;
    this.onChanged(newValue);
    this.onTouched();
  }

}

Here is the parent component, which creates the FormGroup and instantiates the custom component:

/// ContainerComponent ///
import { Component, OnInit } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormGroup, FormControl } from '@angular/forms';
import { CustomInputComponent } from '../custom-input/custom-input.component';


@Component({
  standalone: true,
  selector: 'container',
  template: `
      <custom-input 
        formControlName="ctrlA" 
      </custom-input>
`,
  styles: [],
  imports: [CommonModule, CustomInputComponent],
})
export class ContainerComponent implements OnInit{
  fg: FormGroup = new FormGroup({});
  
  ngOnInit() {
    console.log('ContainerComponent instantiated');
  
    this.fg.addControl('ctrlA', 
    new FormControl({ disabled: false, value: 'ctrlA!!' }));
    this.fg.addControl('ctrlB', 
    new FormControl({ disabled: false, value: 'ctrlB' }));
  }


}

Thanks.


Solution

  • You have to import ReactiveFormsModule to the component, to use the formGroup directive.

    @Component({
      standalone: true,
      selector: 'container',
      templateUrl: './container.component.html',
      styleUrls: ['./container.component.scss'],
      imports: [CommonModule, CustomInputComponent, ReactiveFormsModule],
    })
    

    Then make sure you wrap the custom inputs in a formGroup directive.

    <form [formGroup]="fg">
      <custom-input
        formControlName="ctrlA"
        [ctrlConfigData]="{ label: 'CTRL A' }"
        [ctrlName]="'ctrlA'"
      >
      </custom-input>
      <custom-input
        formControlName="ctrlB"
        [ctrlConfigData]="{ label: 'CTRL B' }"
        [ctrlName]="'ctrlB'"
      ></custom-input>
    </form>
    

    Finally, I removed the validators providers property, since it's only needed when you provide custom validators.

    providers: [
        {
          provide: NG_VALUE_ACCESSOR,
          multi: true,
          useExisting: forwardRef(() => CustomInputComponent),
        },
        // {
        //   provide: NG_VALIDATORS,
        //   multi: true,
        //   useExisting: CustomInputComponent,
        // },
      ],
    })
    

    FULL CODE:

    Custom Input:

    import {
      Component,
      forwardRef,
      Input,
      ViewChild,
      ElementRef,
    } from '@angular/core';
    import { CommonModule } from '@angular/common';
    import {
      FormsModule,
      ControlValueAccessor,
      NG_VALUE_ACCESSOR,
      NG_VALIDATORS,
    } from '@angular/forms';
    
    @Component({
      standalone: true,
      selector: 'custom-input',
      template: `  <input
                      class="input"
                      type="text"
                      (focus)="onTouched()"
                      [disabled]="disabled"
                      (input)="updateValue($event)"
                      [value]="value"
                    />`,
      // templateUrl: './custom-input.component.html',
      styles: [],
      imports: [CommonModule, FormsModule],
      providers: [
        {
          provide: NG_VALUE_ACCESSOR,
          multi: true,
          useExisting: forwardRef(() => CustomInputComponent),
        },
        // {
        //   provide: NG_VALIDATORS,
        //   multi: true,
        //   useExisting: CustomInputComponent,
        // },
      ],
    })
    export class CustomInputComponent implements ControlValueAccessor {
      @ViewChild('input', { static: true }) input!: ElementRef<HTMLInputElement>;
    
      @Input() ctrlConfigData!: any;
      @Input() ctrlName!: string;
    
      @Input() value = '';
      disabled: boolean = false;
    
      onChanged: any = () => {};
      onTouched: any = () => {};
    
      writeValue(value: string): void {
        // Update component state
        console.log('writeValue() called with:', value);
        this.value = value;
      }
    
      registerOnChange(fn: any): void {
        // Register the callback for value changes
        this.onChanged = fn;
      }
    
      registerOnTouched(fn: any): void {
        this.onTouched = fn;
      }
    
      setDisabledState(isDisabled: boolean): void {
        this.disabled = isDisabled;
      }
    
      updateValue(event: Event): void {
        const newValue = (event.target as HTMLInputElement).value;
        console.log('newValue: ' + newValue);
        this.value = newValue;
        this.onChanged(newValue);
        this.onTouched();
      }
    }
    

    Parent HTML:

    <form [formGroup]="fg">
      <custom-input
        formControlName="ctrlA"
        [ctrlConfigData]="{ label: 'CTRL A' }"
        [ctrlName]="'ctrlA'"
      >
      </custom-input>
      <custom-input
        formControlName="ctrlB"
        [ctrlConfigData]="{ label: 'CTRL B' }"
        [ctrlName]="'ctrlB'"
      ></custom-input>
    </form>
    <!-- reset button -->
    <div style="margin: 4rem 0 0 0"><button>Reset Form</button></div>
    
    <!-- monitoring values -->
    <div style="padding-top:1rem;">
      <table border="1">
        <tr>
          <th style="padding: 10px">FormControl (ctrlA)</th>
          <th style="padding: 10px">FormControl (ctrlB)</th>
        </tr>
        <tr>
          <td style="padding: 10px">
            touched : {{ fg.get('ctrlA')?.touched }} <br />
            dirty : {{ fg.get('ctrlA')?.dirty }} <br />
            valid : {{ fg.get('ctrlA')?.valid }} <br />
            Value : {{ fg.get('ctrlA')?.value }} <br />
            Required : {{ fg.get('ctrlA')?.hasError('required') }}
          </td>
          <td style="padding: 10px">
            touched : {{ fg.get('ctrlB')?.touched }} <br />
            dirty : {{ fg.get('ctrlB')?.dirty }} <br />
            valid : {{ fg.get('ctrlB')?.valid }} <br />
            Value : {{ fg.get('ctrlB')?.value }} <br />
            Required : {{ fg.get('ctrlB')?.hasError('required') }}
          </td>
        </tr>
      </table>
    </div>
    

    Parent TS:

    import { Component, OnInit } from '@angular/core';
    import { CommonModule } from '@angular/common';
    import { FormGroup, FormControl, ReactiveFormsModule } from '@angular/forms';
    import { CustomInputComponent } from '../custom-input/custom-input.component';
    
    @Component({
      standalone: true,
      selector: 'container',
      templateUrl: './container.component.html',
      styleUrls: ['./container.component.scss'],
      imports: [CommonModule, CustomInputComponent, ReactiveFormsModule],
    })
    export class ContainerComponent implements OnInit {
      fg: FormGroup = new FormGroup({});
    
      ngOnInit() {
        console.log('ContainerComponent instantiated');
    
        this.fg.addControl(
          'ctrlA',
          new FormControl({ disabled: false, value: 'ctrlA!!' })
        );
        this.fg.addControl(
          'ctrlB',
          new FormControl({ disabled: false, value: 'ctrlB' })
        );
      }
    
      resestForm() {
        this.fg?.get('ctrlA')?.setValue('ctrlA reset!');
        this.fg?.get('ctrlB')?.setValue('ctrlB reset!');
      }
    }
    

    Stackblitz Demo