Cover photo by Alev Takil on Unsplash
It's quite common to have forms where the user can select many options from several available:
The most popular way to tackle this in Angular is by using a set of <input type="checkbox">
with a FormArray
. However, when the same functionality is required in several forms across the application, it's highly possible we start repeating lots of code, for both the logic and the markup.
In this post we will address this issue by building a component that has the following features:
MultiCheck: several options can be selected simultaneously
Reusable: the options can be presented with different visual implementations without re-writing the logic
Custom Field: tailored form field that works directly with Angular Forms
Once we are done, we could use the component to build forms that behave like this:
Table of Contents
Design
Step 1: Supporting a SimpleCheckOption Component
Step 2: Supporting Any Kind of Option Component
Step 3: Integration with Angular Forms
Final Words
Demo
Further Improvement
Code Repository Links
Design
Our component will be composed by two elements:
The field component, which keeps track of the selected options and provides the integration with AngularForms.
The option component, which represents a single check option and provides the visual implementation for it. The idea is that we have several of this kind.
Step 1: Supporting a SimpleCheckOption Component
We will start by supporting only a simple-check-option
by our multi-check-field
, but keeping in mind that we want the field to be used with any option component.
That being said, we'll use Content Projection to provide the desired options to the multi-check-field
, like this:
<multi-check-field>
<simple-check-option *ngFor="let option of options" [value]="option"
[label]="option.label">
</single-check-option>
</multi-check-field>
Note how Content Projection is used by passing the options inside the enclosing tags of the multi-check-field
.
Now, let's see the implementation of the simple-check-option
:
@Component({
selector: 'simple-check-option',
template: `
<label>
<input type="checkbox" [formControl]="control">
{{ label }}
</label>
`
})
export class SimpleCheckOptionComponent {
@Input() value: any;
@Input() label: string;
public control = new FormControl(false);
get valueChanges$(): Observable<boolean> {
return this.control.valueChanges;
}
}
The component has a standard <input type="checkbox">
with it's label. We also declare a FormControl
to manipulate the checkbox value and, additionally, we provide a valueChanges$
accessor so we can interact with the component with type safety from the outside.
The multi-check-field
component will use the ContentChildren
decorator to query the projected options:
@Component({
selector: 'multi-check-field',
template: `<ng-content></ng-content>`
})
export class MultiCheckFieldComponent implements AfterContentInit {
@ContentChildren(SimpleCheckOptionComponent)
options!: QueryList<SimpleCheckOptionComponent>;
ngAfterContentInit(): void {
// Content query ready
}
}
It's worth to be noted that the content query will first be ready to be used in the AfterContentInit
lifecycle, but not before. Additionally, see how we use the <ng-content>
tags in the component's template to render there the provided content (the options).
Now, let's see how we keep track of the selected options
private subscriptions = new Subscription();
private selectedValues: any[] = [];
ngAfterContentInit(): void {
this.options.forEach(option => {
this.subscriptions.add(
option.valueChanges$.subscribe(
(optionChecked) => {
if (optionChecked) {
this.add(option.value);
} else {
this.remove(option.value);
}
}
)
);
});
}
private add(value: any): void {
this.selectedValues.push(value);
}
private remove(value: any): void {
const idx = this.selectedValues.findIndex(v => v === value);
if (idx >= 0) {
this.selectedValues.splice(idx, 1);
}
}
We use the option's valueChanges$
accessor to subscribe to the event when an option is checked/unchecked. Depending on the optionChecked
boolean value, we then proceed to add or remove this option from our selectedValues
array.
At this point, our multi-check-field
is fully integrated with the simple-check-option
. But we should take advantage of Angular's Content Projection to be able to support any kind of component as a check-option. Let's see how.
Step 2: Supporting Any Kind of Option Component
Let's create a new option component that looks very different to the simple-check-option
but has the same functionality. We'll name it user-check-option
and it will represent... well, an user 😅.
The component logic is basically the same that we have in simple-check-option
, but the template has considerable differences:
@Component({
selector: 'user-check-option',
template: `
<label>
<input type="checkbox" [formControl]="control">
<div class="card">
<div class="avatar">
<img src="assets/images/{{ value.avatar }}">
<div class="span"></div>
</div>
<h1>{{ value.name }}</h1>
<h2>{{ value.location }}</h2>
</div>
</label>
`
})
export class UserCheckOptionComponent {
@Input() value: any;
public control = new FormControl(false);
get valueChanges$(): Observable<boolean> {
return this.control.valueChanges;
}
}
To support our new user-check-option
by the field component, we have to modify the ContentChildren
query, given that we are not targeting exclusively a SimpleCheckOption
anymore. This is the query we currently have:
@ContentChildren(SimpleCheckOptionComponent)
options!: QueryList<SimpleCheckOptionComponent>;
Unfortunately, we cannot use ContentChildren
to target two different kind of components, but we can use the power of Angular's Dependency Injection (DI) to overcome this situation.
Dependency Injection to the Rescue 👨🚒 👩🚒 🚒
One possible solution for this issue would be to use alias providers to create a common DI token to be employed by our option components.
abstract class MultiCheckOption { } // (1)
@Component({
selector: 'simple-check-option',
providers: [
{ // (2)
provide: MultiCheckOption,
useExisting: SimpleCheckOptionComponent,
}
]
})
export class SimpleCheckOptionComponent { ... }
@Component({
selector: 'user-check-option',
providers: [
{ // (3)
provide: MultiCheckOption,
useExisting: UserCheckOptionComponent
}
]
})
export class UserCheckOptionComponent { ... }
We start by creating a
MultiCheckOption
class to be used as DI token by our option components.We configure the injector at the component level of our
SimpleCheckOptionComponent
by using the providers metadata key. With this configuration, when Angular's DI ask our component's injector for an instance ofMultiCheckOption
, it would pass the existing instance of the component itself.We do the same for the
UserCheckOptionComponent
.
The ContentChildren
query could now be rewritten as:
@ContentChildren(MultiCheckOption)
options!: QueryList<MultiCheckOption>;
But we are not finished yet... at this point we lost access to the members and methods of the option components, since the MultiCheckOption
class is empty. We can fix this by using the class itself to hold what is common among the options and expose what necessary. After that, we take advantage of ES6 class inheritance to extend the option
components from MultiCheckOption
.
export abstract class MultiCheckOption {
abstract value: any;
public control = new FormControl(false);
get valueChanges$(): Observable<boolean> {
return this.control.valueChanges;
}
}
@Component(...)
export class SimpleCheckOptionComponent extends MultiCheckOption {
@Input() value: any;
@Input() label: string;
}
@Component(...)
export class UserCheckOptionComponent extends MultiCheckOption {
@Input() value: any;
}
And just like that, the multi-check-field
supports now any component that implements the MultiCheckOption
logic.
Step 3: Integration with Angular Forms
At this stage, you might try to use the multi-check-field
with Angular Forms
<multi-check-field formControlName="subjects">
...
</multi-check-field>
But then, you will get the following error:
No value accessor for form control with name: 'subjects'
The reason is, the AngularFormsModule
only knows how to deal with native form elements (like <input>
and <select>
). In order for our custom multi-check-field
to work with Angular Forms, we'll have to tell the framework how to communicate with it. (If this is the first time you hear about custom form fields in Angular, I would recommend you to check this post.
1. The NG_VALUE_ACCESSOR
Provider
We start by registering the component with the global NG_VALUE_ACCESSOR
provider:
import { Component, forwardRef } from '@angular/core';
import { NG_VALUE_ACCESSOR } from '@angular/forms';
@Component({
selector: 'multi-check-field',
providers: [
{
provide: NG_VALUE_ACCESSOR,
useExisting: forwardRef(() => MultiCheckFieldComponent),
multi: true
}
]
})
export class MultiCheckFieldComponent { ... }
2 . The ControlValueAccesor
Interface
Additionally, we need to implement the ControlValueAccesor
interface, which defines the following set of methods to keep the view (our component) and the model (the form control) in sync.
writeValue(obj: any): void;
registerOnChange(fn: any): void;
registerOnTouched(fn: any): void;
setDisabledState?(isDisabled: boolean): void;
writeValue(obj: any)
This function is executed by the framework to set the field value from the model to the view. For example, when performing any of the following actions.
multiCheckControl = new FormControl(TEST_INITIAL_VALUE);
multiCheckControl.setValue(TEST_VALUE);
multiCheckControl.patchValue(TEST_VALUE);
In our case, the obj
parameter should be an array containing the selected options values. We better name it values
for improved readability.
writeValue(values: any[]): void {
this.selectedValues = [];
values = values || [];
values.forEach(selectedValue => {
const selectedOption = this.options.find(v => v.value === selectedValue);
selectedOption.control.setValue(true);
});
}
Each item of the values
array is mapped to the corresponding option
, and then the checked value is reflected in it's view (in our example, this is done yet through another control).
Note that every time we call selectedOption.control.setValue()
, the corresponding valueChanges$
subscription declared in ngAfterContentInit
is called and the option's value gets added to the local selectedValues
array.
Let's see it working
@Component({
selector: 'app-root',
template: `
<multi-check-field [formControl]="multiCheckControl">
<simple-check-option *ngFor="let subject of subjects"
[value]="subject" [label]="subject.label">
</simple-check-option>
</multi-check-field>
<button (click)="setTestValue()">Set Test Value</button>
Control value: <pre>{{ multiCheckControl.value | json }}</pre>
`,
})
export class AppComponent {
public subjects = [
{ code: '001', label: 'Math' },
{ code: '002', label: 'Science' },
{ code: '003', label: 'History' },
];
public multiCheckControl = new FormControl();
setTestValue() {
const testValue = [this.subjects[0], this.subjects[1]];
this.multiCheckControl.setValue(testValue);
}
}
registerOnChange(fn: any)
Registers the function that needs to be called when the field value changes in the UI. When the provided function is called, it will update the value from the view to the model.
In our case, we have to update the model value every time an option is checked/unchecked.
export class MultiCheckFieldComponent implements ControlValueAccessor {
_onChange: (_: any) => void;
registerOnChange(fn: any): void {
this._onChange = fn;
}
private add(value: any): void {
this.selectedValues.push(value);
this._onChange(this.selectedValues);
}
private remove(value: any): void {
const idx = this.selectedValues.findIndex(v => v === value);
if (idx >= 0) {
this.selectedValues.splice(idx, 1);
this._onChange(this.selectedValues);
}
}
...
}
registerOnTouched(fn: any)
In the same way as the previous method, we need to register the function to be called when the field is touched, in order for the control to trigger validation and more.
We will leave the implementation of this method out of the scope of this tutorial.
setDisabledState?(isDisabled: boolean)
Last but no least, the setDisabledState
method. This function is called when the field is enable/disabled programmatically. For example, when the following actions are performed:
multiCheckControl = new FormControl({
value: TEST_INITIAL_VALUE,
disabled: true
});
multiCheckControl.disabled();
multiCheckControl.enabled();
This method will also be left out of the scope of the tutorial.
Final Words
We managed to create a component that provides a multi-check functionality but also offers:
Reducing of code duplication, given that all the logic is encapsulated within the component and doesn't need to be re-written for every form.
Simplicity, since the usage is pretty straightforward. Very similar to a native
<select>
with<option>
tags inside.Reusability, because the options can be styled as desired.
Compatibility, considering that it supports integration with Angular Forms.
Demo Time 🌋
Further Improvement
There is still a lot of room for improvement. I list here some ideas in case you want to code a bit. Don't hesitate to open a PR to integrate your solution to the repository:
Support a value passed on initialization (writeValue executed before ngAfterContentInit) ✅
Support changes in the projected options (when they are added or removed from DOM)
Support registerOnTouched and setDisableState methods
Write a minValuesLength and maxValuesLength validators
Support passing a template as an option instead of a component
Code Repository Links
The full source code can be found here
In this branch, you can find the implementation for some of the improvements suggested above
Top comments (2)
Amazing !
Very good article.