How to create a custom form control in Angular
Published on
15 min read
Angular has two forms modules, Forms and ReactiveForms that come with a series of built-in directives that make it very simple to bind standard HTML elements such as inputs, checkboxes and text areas etc. to a form group.
Occasionally these standard HTML elements may not be suitable for your needs and you might therefore want to create custom form controls, such as dropdowns, toggle buttons, sliders, or many other types of commonly used custom form components.
In this tutorial I will show you how to create a custom form control that can be used in the same way using the same directives (ngModel
, formControl
, formControlName
) as we do for the standard built in form components, and work with built-in and custom validators.
How do Angular form controls work?
In Angular there are several built-in form controls that target native HTML input elements, such as inputs, text areas, checkboxes and radio buttons.
Here is a very simple example form with a few of the plain HTML form fields:
Here we’ve added a few standard form controls with the formControlName
property applied to them. This is how the HTML elements get bound to the form.
Whenever the user interacts with the form input controls, the form value and validity states will be automatically re-calculated; this is done using a ControlValueAccessor.
What is the ControlValueAccessor?
In Angular, there is a special ControlValueAccessor directive that is applied to all of the native HTML elements, it is responsible for tracking the value of the control and communicating it back to the parent form.
Here is a simplified example of the built-in Angular number input value accessor:
If you look closely at the selector you will notice that this control value accessor targets numeric input elements only, but only if the ngModel
, formControl
or formControlName
properties are bound on it.
For other types of form controls such as a text input, checkbox or select control, they have their own implementation of a control value accessor.
Here are a few of them:
- DefaultValueAccessor - used by text inputs
- CheckboxControlValueAccessor - used by checkbox inputs
- SelectControlValueAccessor - used by select dropdowns
The built-in directives provided by the Angular forms module only handles the standard HTML form controls. Therefore, to build our own custom form control, we must implement our own custom control value accessor.
Implementing a ControlValueAccessor
Let’s take a look at the control value accessor interface to understand what we will need to implement in our custom forms component.
- writeValue: This method is called by the Forms module(s) to write a value into the form control
- registerOnChange: When a form value changes due to user input, we need to report the value back to the parent form. This is done by executing a callback, that was initially registered with the control using the registerOnChange method
- registerOnTouched: When the user first interacts with the form control, the control is considered to have the status touched, which is useful for styling. In order to report to the parent form that the control was touched, we need to use a callback registered using the registerOnTouched method
- setDisabledState: Form controls can be enabled and disabled using the Forms API. This state can be transmitted to the form control via the setDisabledState method
Since Angular v15, it is worth noting that the setDisabledState method is always called when a ControlValueAccessor
is attached. Move information can be found here.
What are we going to build?
Very often I find myself having to capture a date, whether this is a date of birth or and address move in date where we want the user to enter the day, month and year into separate boxes.
Let’s assume that the use case for this component is that the date stored is a ISO 8601 formatted string and that our component will need to split this up into the three input boxes when the value is updated by the forms API (outside of our component) and should build the ISO formatted string up from the three input boxes whenever one of them has changed. We will also assume that the user has entered something invalid, meaning the resulting ISO formatted string isn’t value, we should update the value to be null
instead.
Let’s take a look at what we will be building throughout this tutorial:
Specifically we will be building this custom form control component seen within that demo:
Just for reference, I am using Tailwind CSS for the styling in the demo application. which uses utility classes as you can see in the template HTML.
Building the component
To get started, let’s first build a very simple component with a single input field.
date-field.component.ts:
date-field.component.html:
Let me explain what is going on in this code and how it was implemented.
Implementing the writeValue method
As mentioned previously, the writeValue
method is called by the Angular forms module whenever the parent wants to update a value in the child control.
In this case, we take the value and assign it to a protected value
property:
Implementing the registerOnChange method
We now know that a parent form can update the value in a child control using writeValue
, but what about the other way around when the value is updated within out component through the user changing the input field value?
When a user interacts with our component and changes the input field value, we need to communicate this new value back to the parent form (otherwise they will be out of sync and ultimately the parent form will have the wrong value).
This child control can easily notify the parent of new value changes by calling a callback function that is registered with the child control. This callback function is registered with the child control by using the registerOnChange
method:
When the parent form registers a callback function to be notified of changes, we assign this to a private onChangeCallback
property that we can use later on whenever we want to communicate the new values back to the parent form. You will also notice that the onChangeCallback
property is initially initialized with an empty function; a function with an empty body.
We have bound the onDateValueChanged
method to the ngModelChange
output event emitter, which will be fired every time the model value changes as a result of the user changing the value of the input field. Within the onDateValueChange
method we are then using the onChangeCallback
callback function to notify the parent form of the new value.
Implementing the registerOnTouched method
Whenever a form is initialised, every form control (including the form group) is seen to be in a untouched status, and the ng-untouched
CSS class is applied to the form group and each of it’s individual child controls.
When a child control gets “touched” by a user, meaning that the user has attempted to interact with it at least once, then the whole form is considered touched as well and therefore the ng-touched
CSS class is applied to the entire form.
These touched and untouched CSS classes are important as they allow you to styles errors appropriately within your form, and so our custom control needs to support this also.
The control value accessor provides a registerOnTouched
callback registration method for registering a callback function to be called whenever the control is “touched” by the user.
As before, we assign the touched callback function to a protected onTouchedCallback
property for use later on.
In our case the component is deemed as “touched” whenever the user has focused and left the input field (whether it the value was changed). This is why the touched callback function property is marked as protected rather than private as we use it from within the component template:
To ensure that we only only call the onTouchedCallback
once, we use the markAsTouched
method to execute the callback and set a touched
property to true, this is checked before executing the callback function.
Implementing the setDisabledState method
The forms API provides the ability to enable/disable form controls, which can be done to the parent form or any of it’s child controls, by calling the setDisabledState
method. We keep the disabled status in a protected property that we can then use to disable the input field, when the status is disabled.
Dependency injection
We must register the custom form control as a known value accessor within the dependency injection system. We do this by registering the implementation of the ControlValueAccessor
as seen here within the component decorator:
Without doing this, our custom control will not function correctly.
Validating the ISO formatted string
Now that we’ve got a working component that allows the user to input a date, albeit in a horribly unfriendly format, let’s add some validation to ensure that what the user has entered is valid. If the entered value is a valid ISO 8601 formatted date string, then we will accept it and update the parent form with this new value. However, if the value entered is not valid, then we will instead notify the parent form that the value has changed to null
.
For validating the date, we are going to use two methods parseISO
and isValid
from date-fns the most comprehensive, yet simple and consistent, toolset for manipulating JavaScript dates in a browser or Node JS and is tree shakeable.
Let’s add a new method to validate ISO date strings, using the aforementioned date-fns methods:
Now that we can validate a ISO date string, we should use this within the onDateValueChange
and writeValue
methods to guard against invalid values.
Within onDateValueChange
we will use a value of null
when the value is not valid and within writeValue
, which is where the value was updated outside of this control, we will set the value
property to an empty string when it is not valid.
Now if the user enters an invalid ISO date string the control will update the parent form with a value of null
, and if an invalid value is set by the parent form for this control, it will disregard it and clear the input field.
Capture day, month and year separately
As I mentioned earlier, we want this control to capture the day, month and year for a date separately; this is a much friendlier experience than asking them to enter in a valid ISO date string.
So, let’s update the template HTML to include three input fields opposed to just one:
We now have three input fields, each with their own placeholder, maxlength and unique ID/name. Each of these are bound to a different property now, there is no single value
property.
Let’s remove the value
property, add in three new ones for day, month and year and then update both the writeValue
and onDateValueChange
methods to use them:
Here you can see that we now build up a ISO date string in the onDateValueChange
method and validate it as we did before. Since this control is only to capture day, month and year, we are disregarding the time part of the ISO string.
As day and month is meant to be in a double digit format for an ISO string, we ensure that when the entered value is a single digit, we pad it with a leading 0.
To do this, we’ve used a bit of code like this for day and month, within both methods:
It will only add a leading 0 for a single digit number, double digit numbers will remain unaffected; like 12 months for example.
Now, whenever the value is updated by the parent form we split the incoming value into day, month and year and therefore the value of each input field updated. When the user changes on of the input fields we build up and validate a ISO date string, which when valid is notified to the parent form, otherwise we tell the parent form it’s null (because it was invalid).
At this point we now have a fully functional custom forms control that can be used in reactive forms (with formControlName
) or template driven forms (with ngModel
).
Going further
One of the problems, that you may have noticed, is that we can only really use this custom forms control once without violating accessibly due to the IDs and names set on the input fields.
So what can we do about this? One way that I like to deal with this is to take the ID the user has given our custom form control where it is being used and then concatenate the input field id/name on the end.
Firstly, add a input property at the top of the component:
Then, add a new getId
method that takes in a id and then returns that id with a prefix populate by the components given id (from the input property).
Lastly, lets update the input fields to use this new getId
method to ensure that they are given an unique id/name. For example, for day we update the id and name like this:
Excellent, the input fields now have unique IDs/names, meaning we can use this form control multiple times on a page. Or can we?
Let’s look at accessibility next and ensure that our component is accessible therefore doesn’t violate accessibility (a11y) rules.
Accessibility
If we were to use a tool like WAVE (web accessibility evaluation tool) to analyze the page where we have used our custom control, we would see the following accessibility error for the day, month and year input fields.
Missing form label - A form control does not have a corresponding label.
This main reason this matter is because if a form control does not have a properly associated text label, the function or purpose of that form control may not be presented to screen reader users.
To fix this, let’s use the aria-labelledby
attribute to tie the label text above each input to their input. For example, we would do this to correct the problem for the day input:
Here we’ve given the label text div element a unique ID using the getId
method we created earlier and then added a aria-labelledby
attribute to the input field that uses the same ID to associate them.
If we were to re-analyze the page again using WAVE, the accessibility error(s) above will now be gone.
Conclusion
That’s it, if you’ve reached this far then you should have successfully built a custom forms control component for Angular.
There is a lot more that you can do to improve the example control that we’ve built here and probably a lot more you can do regarding accessibility; it’s one of those areas that is hard to get perfectly right first time.
Don’t forget that you can preview a demonstration application for the above component on Stackblitz where you can see the component in it’s final state and being consumed properly.
Share on social media platforms.