13 KiB
title, description, published, authors, tags, attached, license
| title | description | published | authors | tags | attached | license | |||
|---|---|---|---|---|---|---|---|---|---|
| Make Better Angular Form Components using ControlValueAccessor | You may have ran into elements or components that allow you to use formControl or ngModel. They make your life as a consumer much easier. Let's build one! | 2020-06-09T13:45:00.284Z |
|
|
cc-by-nc-sa-4 |
One of Angular's greatest strengths over its contemporaries like React or Vue is that it's a framework. What does this mean in the practical sense? Well, because you're providing the defaults for everything right out-of-the-box, you have a set of guard rails to follow when architecting new things. A set of baseline rules for things to follow, so to speak.
One such guard rail comes in the form of the @angular/forms package. If you've used Angular for long, you're doubtlessly familiar with the [(ngModel)] method of two-way data binding in the UI. Seemingly all native elements have support for this feature (so long as you have FormsModule imported in your module).
More than that, if you want more powerful functionality, such as disabling an entire form of fields, tracking a collection of fields in a form, and doing basic data validation, you can utilize Angular Reactive Forms' [formControl] and do all of that and more.
These features are hugely helpful when dealing with complex form logic throughout your application. Luckily for us, they're not just exclusive to native elements - we can implement this functionality into our own form!
Example
It's hard for us to talk about the potential advantages to a component without having actually taken a look at it. Let's start with this component, just for fun.
It'll allow you to type in data, have a header label (as opposed to a floating label, which is notoriously bad for A11Y), and even present a fun message when "Unicorns" is typed in.
Here's the code:
import { Component, Input } from "@angular/core";
@Component({
selector: "app-example-input",
template: `
<label class="inputContainer">
<span class="inputLabel">{{ placeholder }}</span>
<input
placeholder=""
class="inputInput"
[(ngModel)]="value"
/>
</label>
<p
class="hiddenMessage"
[class.hideTheMessage]="!isSecretValue"
aria-hidden="true"
>
You unlocked the secret unicorn rave!<span>🦄🦄🦄</span>
</p>
<!-- This is for screen-readers, since the animation doesn't work with the 'aria-live' toggle -->
<p aria-live="assertive" class="visually-hidden">
{{
isSecretValue
? "You discovered the secret unicorn rave! They're all having a party now that you summoned them by typing their name"
: ""
}}
</p>
`,
styleUrls: ["./example-input.component.css"]
})
export class ExampleInputComponent {
@Input() placeholder: string;
value: any = "";
get isSecretValue() {
return /unicorns/.exec(this.value.toLowerCase());
}
}
With only a bit of CSS, we have a visually appealing, A11Y friendly, and quirky input component. Look, it even wiggles the unicorns!
Now, this component is far from feature complete. There's no way to disable the input, there's no way to extract data out from the typed input, there's not a lot of functionality you'd typically expect to see from an input component. Let's change that.
ControlValueAccessor
Most of the expected form functionality will come as a complement of the ControlValueAccessor interface. Much like you implement ngOnInit by implementing class methods, you do the same with ControlValueAccessor to gain functionality for form components.
The methods you need to implement are the following:
writeValueregisterOnChangeregisterOnTouchedsetDisabledState
Let's go through these one-by-one and see how we can introduce change to our component to support each one.
Setup
In order to use these four methods, you'll first need to provide them somehow. To do this, we use a combination of the component's providers array, NG_VALUE_ACCESSOR, and forwardRef.
import { forwardRef } from '@angular/core';
import {ControlValueAccessor, NG_VALUE_ACCESSOR} from '@angular/forms';
/**
* Provider Expression that allows your component to register as a ControlValueAccessor. This
* allows it to support [(ngModel)] and ngControl.
*/
export const EXAMPLE_CONTROL_VALUE_ACCESSOR: any = {
/**
* Used to provide a `ControlValueAccessor` for form controls.
*/
provide: NG_VALUE_ACCESSOR,
/**
* Allows to refer to references which are not yet defined.
* This is because it's needed to `providers` in the component but references
* the component itself. Handles circular dependency issues
*/
useExisting: forwardRef(() => ExampleInputComponent),
multi: true
};
Once we have this example provide setup, we can now pass it to a component's providers array:
@Component({
selector: 'app-example-input',
templateUrl: './example-input.component.html',
styleUrls: ['./example-input.component.css'],
providers: [EXAMPLE_CONTROL_VALUE_ACCESSOR]
})
export class ExampleInputComponent implements ControlValueAccessor {
With this, we'll finally be able to use these methods to control our component.
If you're wondering why you don't need to do something like this with
ngOnInit, it's because that functionality is baked right into Angular. Angular always looks for anonInitfunction and tries to call it when the respective lifecycle method is run.implementsis just a type-safe way to ensure that you're explicitly wanting to call that method.
writeValue
writeValue is a method that acts exactly as you'd expect it to: It simply writes a value to your component's value. Because of this, it's suggested to have a setter and getter for value and a private internal value that's used as the real value:
private _value: any = null;
@Input()
get value(): any { return this._value; }
set value(newValue: any) {
if (this._value !== newValue) {
// Set this before proceeding to ensure no circular loop occurs with selection.
this._value = newValue;
}
}
Once this is done, the method is trivial to implement:
writeValue(value: any) {
this.value = value;
}
However, you may notice that your component doesn't properly re-render when you update your value from the parent component. Because you're updating your value outside of the typical pattern, change detection may have a difficult time running when you'd want it to. To solve for this, provide a ChangeDetectorRef in your constructor and manually check for updates in the writeValue method:
export class ExampleInputComponent implements ControlValueAccessor {
// ...
constructor(private _changeDetector: ChangeDetectorRef) { }
// ...
writeValue(value: any) {
this.value = value;
this._changeDetector.markForCheck();
}
Now, when we use a value like new FormValue('test') and pass it as [formControl] to our component, it will render the correct default value
setDisabledState
Implementing the disabled state check is extremely similar to implementing value writing. Simply add a setter, getter, and setDisabledState to your component and you should be good-to-go:
private _disabled: boolean = false;
@Input()
get disabled(): boolean { return this._disabled; }
set disabled(value) {
this._disabled = coerceBooleanProperty(value);
}
setDisabledState(isDisabled: boolean) {
this.disabled = isDisabled;
this._changeDetector.markForCheck();
}
Just as we did with value writing, we want to run a markForCheck to allow change detection to work as expected when the value is changed from a parent
It's worth mentioning that unlike the other three methods, this one is entirely optional for implementing a
ControlValueAccessor. This allows us to disable the component or keep it enabled, but is not required for usage with the other methods.ngModelandformControlwill work without this method implemented.
registerOnChange
While the previous methods have been implemented in a way that required usage of markForCheck, these last two methods are implemented in a bit of a different way. You only need look at the type of the methods on the interface to see as much:
registerOnChange(fn: (value: any) => void);
As you might be able to deduce from the method type, when registerOnChange is called, it passes you a function. You'll then want to store this function in your class instance and call it whenever the user changes data.
/** The method to be called in order to update ngModel */
_controlValueAccessorChangeFn: (value: any) => void = () => {};
registerOnChange(fn: (value: any) => void) {
this._controlValueAccessorChangeFn = fn;
}
While this code sample shows you how to store the function, but doesn't outline how to call it once stored. You'll want to make sure to call it with the updated value on every update. For example, if you are expecting an input to change, you'd want to add it to (change) output of the input:
<input
placeholder=""
[disabled]="disabled"
[(ngModel)]="value"
(change)="_controlValueAccessorChangeFn($event.target.value)"
/>
registerOnTouched
Likewise to how you store a function and call it to register changes, you do much of the same to register when a component has been "touched" or not. This tells your consumer when a component has had interaction or not.
onTouched: () => any = () => {};
registerOnTouched(fn: any) {
this.onTouched = fn;
}
You'll want to call this onTouched method any time that your user "touches" (or, interacts) with your component. In the case of an input, you'll likely want to place it on the (blur) output:
<input
placeholder=""
[disabled]="disabled"
[(ngModel)]="value"
(change)="onChange($event)"
(blur)="onTouched()"
/>
Consumption
Now that we've done that work, let's put it all together, apply the styling from before, and consume the component we've built!
We'll need to start by importing FormModule and ReactiveFormModule into your AppModule for ngModel and formControl support respectively.
import { NgModule } from '@angular/core';
import { BrowserModule } from '@angular/platform-browser';
import { FormsModule } from '@angular/forms';
import { ReactiveFormsModule } from '@angular/forms';
import { AppComponent } from './app.component';
import { ExampleInputComponent } from './example-input/example-input.component';
@NgModule({
imports: [ ReactiveFormsModule, FormsModule, BrowserModule ],
declarations: [ AppComponent, ExampleInputComponent ],
bootstrap: [ AppComponent ]
})
export class AppModule { }
Once you have support for them both, you can move onto adding a formControl item to your parent component:
import { Component, VERSION } from '@angular/core';
import {FormControl} from '@angular/forms';
@Component({
selector: 'my-app',
templateUrl: './app.component.html',
styleUrls: [ './app.component.css' ]
})
export class AppComponent {
control = new FormControl('');
modelValue = "";
}
Finally, you can pass these options to ngModel and formControl (or even formControlName) and inspect the value directly from the parent itself:
<h1>Form Control</h1>
<app-example-input placeholder="What's your favorite animal?" [formControl]="control"></app-example-input>
<p>The value of the input is: {{control.value}}</p>
<h1>ngModel</h1>
<app-example-input placeholder="What's your favorite animal?" [(ngModel)]="modelValue"></app-example-input>
<p>The value of the input is: {{modelValue}}</p>
If done properly, you should see something like this:
Form Control Classes
Angular CSS masters might point to classes that's applied to inputs when various state changes are made.
These classes include:
ng-pristineng-dirtyng-untouchedng-touchedThey reflect states so that you can update the visuals in CSS to reflect them. When using[(ngModel)], they won't appear, since nothing is tracking when a component ispristineordirty. However, when using[formControl]or[formControlName], these classes will appear and act accordingly, thanks to theregisterOnChangeandregisterOnTouchedfunctions.