18
Adding a Component to Angular Forms WITHOUT Modifying it
It's common to find ourselves using an existing component from a third-party lib or even one that's been in the project for a long time.
In those situations, we would like to preserve the original implementation of the component without adding some unnecessary complexity to itself.
How did angular make an input element compatible with their form system? They couldn't modify the implementation of the standard.
Let's take a look into the Angular code... This is an extract of the code used for making an input checkbox compatible with Angular forms:
@Directive({
selector:
'input[type=checkbox][formControlName],input[type=checkbox][formControl],input[type=checkbox][ngModel]',
host: {'(change)': 'onChange($event.target.checked)', '(blur)': 'onTouched()'},
providers: [CHECKBOX_VALUE_ACCESSOR]
})
export class CheckboxControlValueAccessor extends BuiltInControlValueAccessor implements
ControlValueAccessor {
/**
* Sets the "checked" property on the input element.
* @nodoc
*/
writeValue(value: any): void {
this.setProperty('checked', value);
}
}
Do you see that? They are using directives to make it possible. A brilliant way of using directives.
Let's explore the solution with a simplified version of the component used in the previous article.
Component Code:
import { Component, EventEmitter } from '@angular/core';
export enum Mood {
Red = 'red',
Green = 'green',
}
@Component({
selector: 'app-custom-component-and-directive',
templateUrl: './custom-component-and-directive.component.html',
styleUrls: ['./custom-component-and-directive.component.scss'],
})
export class CustomComponentAndDirectiveComponent {
/* Reference to the Enum to be used in the template */
readonly moodRef = Mood;
disable: boolean = false;
selected: Mood = Mood.Green;
/* Simulating an standard output of a component */
onChange: EventEmitter<Mood> = new EventEmitter();
updateState(selectedItem: Mood): void {
this.selected = selectedItem; // Updating internal state
this.onChange.emit(this.selected); // 'publish' the new state
}
}
Template code:
<p>How do you feel?</p>
<ng-container *ngIf="!disable; else disabledTemplate">
<button
[ngClass]="{
custom__button__red: true,
'custom__button--selected': selected === moodRef.Red
}"
(click)="updateState(moodRef.Red)"
>
Red
</button>
<button
[ngClass]="{
custom__button__green: true,
'custom__button--selected': selected === moodRef.Green
}"
(click)="updateState(moodRef.Green)"
>
Green
</button>
</ng-container>
<ng-template #disabledTemplate>
<p>I'm disabled</p>
</ng-template>
SCSS:
.custom__button {
&__red {
background-color: red;
}
&__green {
background-color: green;
}
&--selected {
margin: 1em;
border: solid 5px black;
}
}
To add that functionality while keeping the origin behavior, we will build this directive on top of it and ship it with the component module.
As you can see, a lot of boilerplate needs to be added, but we are just doing three things:
- Defining the scope of the directive (selector)
- Accessing the component's output and input
- Implementing Control Value Accessor
import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms';
import { Directive, OnDestroy, forwardRef } from '@angular/core';
import { Subject, filter, takeUntil } from 'rxjs';
import { CustomComponentAndDirectiveComponent } from './custom-component-and-directive.component';
@Directive({
// Indicates the component that the directive is used on
selector: 'app-custom-component-and-directive',
providers: [
// This part is very important to register the class as a ControlValueAccessor one
{
provide: NG_VALUE_ACCESSOR,
// This reference the class that implements Control Value Accessor
useExisting: forwardRef(() => CustomComponentDirective),
multi: true,
},
],
})
export class CustomComponentDirective
implements ControlValueAccessor, OnDestroy
{
private readonly destroyed$ = new Subject<void>();
/**
* @param element Reference to the component instance
*/
constructor(private readonly element: CustomComponentAndDirectiveComponent) {
this.listenComponentChanges();
}
ngOnDestroy(): void {
this.destroyed$.next();
this.destroyed$.complete();
}
/**
* Subscribes to the component output and updates the internal state
*/
private listenComponentChanges(): void {
if (!this.element) {
return;
}
/**
* Event emitter is an Observable that emits events.
*
* Take a look on the definition:
* - export declare interface EventEmitter<T> extends Subject<T> { }
* */
this.element.onChange
.pipe(
filter(() => this.onChange !== null), // check that we have the correct ref to the callback
takeUntil(this.destroyed$)
)
.subscribe((value) => {
this.onChange(value);
});
}
/***********************************************************************
* Control Value Accessor Implementation
***********************************************************************/
private onChange: any;
private onTouch: any;
// Invoked by angular - update internal state
writeValue(obj: any): void {
this.element.selected = obj; // Updating component internal state
}
// Invoked by angular - callback function for changes
registerOnChange(fn: any): void {
this.onChange = fn;
}
// Invoked by angular - callback function for touch events
registerOnTouched(fn: any): void {
this.onTouch = fn;
}
// Invoked by angular - update disabled state
setDisabledState?(isDisabled: boolean): void {
this.element.disable = isDisabled; // Updating component status
}
}
The component is compatible with the directives: formControlName and formControl.
<form [formGroup]="formGroup">
<app-custom-component-and-directive
[formControlName]="controlsRef.Mood"
></app-custom-component-and-directive>
</form>
The component is also compatible with ngModel property:
<form>
<app-custom-component-and-directive
[disabled]="disabled"
[(ngModel)]="selectedMood"
[ngModelOptions]="{ standalone: true }"
></app-custom-component-and-directive>
</form>
The detailed implementation is in one of my Github repos:
18