So far in the series, we have learned the basics of Angular Reactive forms and created some neat logic to construct and display dynamic forms. But our work is still not done yet. Whether we just want to make our controls look good and enhance them with some markup, or whether we need a more complex control than a simple textarea, input or checkbox, we'll either need to use a component library such as Angular Material Components or get familiar with the ControlValueAccessor
interface.
Angular Material, by the way, uses ControlValueAccessor
in its components and I recommend looking into the source code if you want to learn some advanced use cases (I have borrowed a lot of their ideas in the past). In this post, however, we will build a basic custom control from scratch.
A common requirement for a component that cannot be satisfied by using standard HTML markup I came across in many projects is having a searchable combobox. So let's build one. We will start by creating a new Angular component and we can do that with a handy ng cli command:
ng generate component form-fields/combobox
Then we'll implement displaying data passed in the form of our FormField
class we have defined earlier in a list and allowing for filtering and selecting the options:
// combobox.component.ts
import { Component, ElementRef, Input, ViewChild } from '@angular/core';
import { FormField } from '../../forms.model';
@Component({
selector: 'app-combobox',
templateUrl: './combobox.component.html',
styleUrls: ['./combobox.component.scss'],
})
export class ComboboxComponent {
private filteredOptions?: (string | number)[];
// a simple way to generate a "unique" id for each component
// in production, you should rather use a library like uuid
public id = String(Date.now() + Math.random());
@ViewChild('input')
public input?: ElementRef<HTMLInputElement>;
public selectedOption = '';
public listboxOpen = false;
@Input()
public formFieldConfig!: FormField<string | number>;
public get options(): (string | number)[] {
return this.filteredOptions || this.formFieldConfig.options || [];
}
public get label(): string {
return this.formFieldConfig.label;
}
public toggleListbox(): void {
this.listboxOpen = !this.listboxOpen;
if (this.listboxOpen) {
this.input?.nativeElement.focus();
}
}
public closeListbox(event: FocusEvent): void {
// timeout is needed to prevent the list box from closing when clicking on an option
setTimeout(() => {
this.listboxOpen = false;
}, 150);
}
public filterOptions(filter: string): void {
this.filteredOptions = this.formFieldConfig.options?.filter((option) => {
return option.toString().toLowerCase().includes(filter.toLowerCase());
});
}
public selectOption(option: string | number): void {
this.selectedOption = option.toString();
this.listboxOpen = false;
}
}
<!-- combobox.component.html -->
<label for="combobox-input-{{ id }}">{{ label }}</label>
<div class="combobox">
<div class="group">
<input
#input
type="text"
role="combobox"
(focus)="listboxOpen = true"
(blur)="closeListbox($event)"
(input)="filterOptions(input.value)"
[value]="selectedOption"
id="combobox-input-{{ id }}"
/>
<button type="button" tabindex="-1" (click)="toggleListbox()">
▼
</button>
</div>
<ul *ngIf="listboxOpen" role="listbox">
<li
*ngFor="let option of options"
role="option"
class="option-for-{{ id }}"
(click)="selectOption(option)"
>
{{ option }}
</li>
</ul>
</div>
Note: For the sake of brevity, we will not be implementing keyboard navigation and aria labels. I strongly suggest referring to W3C WAI patterns to get guidelines on the markup and behavior of an accessible combo box.
While our component now looks and behaves like a combo box, it's not a form control yet and is not connected with the Angular forms API. That's where the aforementioned ControlValueAccessor
comes into play along with the NG_VALUE_ACCESSOR
provider. Let's import them first, update the @Component
decorator to provide the value accessor, and declare that our component is going to implement the interface:
import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms';
@Component({
selector: 'app-combobox',
templateUrl: './combobox.component.html',
styleUrls: ['./combobox.component.scss'],
providers: [
{
// provide the value accessor
provide: NG_VALUE_ACCESSOR,
// for our combobox component
useExisting: ComboboxComponent,
// and we don't want to override previously provided value accessors
// we want to provide an additional one under the same "NG_VALUE_ACCESSOR" token instead
multi: true,
},
],
})
export class ComboboxComponent implements ControlValueAccessor {
Now, the component should complain about a few missing methods that we need to satisfy the ControlValueAccessor
interface:
- A
writeValue
method that is called whenever the form control value is updated from the forms API (e.g. withpatchValue()
). - A
registerOnChange
method, which registers a callback function for when the value is changed from the UI. - A
registerOnTouched
method that registers a callback function that marks the control when it's been interacted with by the user (typically called in ablur
handler). - An optional
setDisabledState
method that is called when we change the form controldisabled
state-
Our (pretty standard) implementation will look like the following:
private onChanged!: Function;
private onTouched!: Function;
public disabled = false;
// This will write the value to the view if the form control is updated from outside.
public writeValue(value: any) {
this.value = value;
}
// Register a callback function that is called when the control's value changes in the UI.
public registerOnChange(onChanged: Function) {
this.onChanged = onChanged;
}
// Register a callback function that is called by the forms API on initialization to update the form model on blur.
public registerOnTouched(onTouched: Function) {
this.onTouched = onTouched;
}
public setDisabledState(isDisabled: boolean): void {
this.disabled = isDisabled;
}
public setDisabledState(isDisabled: boolean): void {
this.disabled = isDisabled;
}
We don't have to update the template a lot, but we can add [disabled]="disabled"
attribute on our button and input to disable the interactive UI elements if the provided form control was disabled. The rest of the work can be done in the component's TypeScript code. We'll call this.onTouched()
in our closeListbox
method, and create a value
setter that updates our internal value and also notifies the model about the value change:
public set value(val: string | number) {
this.selectedOption = val.toString();
this.onChanged && this.onChanged(this.selectedOption);
this.onTouched && this.onTouched();
}
You can check out the full implementation on StackBlitz.
Conclusion
In this series, we've explored the powerful features of Angular reactive forms, including creating and managing dynamic typed forms. We also demonstrated how to use the ControlValueAccessor interface to create custom form controls, such as a searchable combo box. This knowledge will enable you to design complex and dynamic forms in your Angular applications.
While the examples provided here are basic, they serve as a solid foundation for building more advanced form controls and implementing validation, accessibility, and other features that are essential for a seamless user experience. By mastering Angular reactive forms and custom form controls, you'll be able to create versatile and maintainable forms in your web applications. If you want to further explore the topic and prefer a form of a video, you can check out an episode of JavaScript Marathon by my amazing colleague Chris.
Happy coding!