Component Design : Content Child, Host Listener & Host Binding with a practical example!
Designing reusable UI components and keeping the code as DRY as possible is not a trivial task. Maintaining good practices in component design is essential for a reliable code base that can scale. In this article, we are going to have a quick look at how can we can grab component references to listen to generic events, manage bindings and how to respond to output events that occur on the host element.
This article is not specifically about component communication. It focuses only on events happening within the component and its host and how to manage them.
First thing first
Here is a very trivial example of a component and the use of @ContentChild decorator. Elements which are used between the opening and closing tags of the host element of a given component are called Content Children.
Side note: The children element located inside of a component template are called View Children. We are not going to discuss ViewChildren in this article. Let's focus only on Content Children for now.
Here is a quick example of our my-custom-input component.
The component template looks very straight forward too:
and finally the consumer of this component. Suppose this component is being used thrice by the consuming application. Here is how it looks:
In my-custom-input component, we would like to get hold of/reference injected <input> within <my-custom-input>. To do this, we will update our component to use @ContentChild directive. Here is a updated version:
Let's list down all the interesting stuff we can point to in the above code snipppet:
- We added ContentChild & AfterContentInit to the import statement at the top.ContentChild decorator( & directive) will help us reference injected component via ng-content and AfterContentInit allows us to listen to the ngAfterContentInit life cycle hooks.If ng-content is used in component template, any content injected by consumer of your component will only be available in the ngAfterContentInit life cycle hook.
On line 18 and 19, we initialized a variable "injectedInput". Decorated this variable with the @ContentChild decorator passing a selector argument of 'input'. This selector string 'input' should match the template reference name (More on template reference in a bit). Angular, automagically, after rendering the component template looks for anything injected with the template reference name 'input' to be injected in place of ng-content and sets DOM reference to this variable. In other words, ContentChild configures content query here.
Other than @ContentChild, Angular also provides @ContentChildren. As the name suggests, @ContentChildren is used to configure content query for multiple injected elements. Since in our simple example, the consumer is only injecting 1 input so we don't need @ContentChildren. @ContentChildren returns an array of refernces (QueryList). We won't be discussing @ContentChildren in this article.
- Lasltly, our component implements AfterContentInit which forces it to give definition for the ngAfterContentInit life cycle hook. This is the life cycle hook where we can be sure that our @ContentChild is already populated. Content queries are set before the ngAfterContentInit callback is called. In ngAfterContentInit, we are just consoling out the reference variable 'injectedInput'.
- 'injectInput' variable is of type HTMLInputElement. This type is implicitly added by TypeScript compiler for referencing generic HTML Input Elements. This definition is using by TypeScript compiler
Here is a running example (which actually isn't working as expected):
If you quickly look into the console, you will see something like:
Doesn't seem to work. To make this work, we will need to put template reference to the content we are trying to query. Here is how the consuming application template looks like after adding tempalate references ( #input ) for each injected input. It does not have to be called '#input'. You can call it whatever prefixed with a hash tag. So if you would call it #whatever in the tempalte, you need to match the selector string passed to @ContentChild so it will looks something like @ContentChild('whatever')
Here is a running example with the console snapshot to follow:
This solution seems to work but is it worth it ? We need to pass in template reference for every input we inject to my-custom-input everytime. This is one way of getting referene to the injected DOM in our component. Its easy to forget to put template references in your template and then it is not very convinient to debug. Angular won't throw an error but will just put a 'undefined' in your content child variable. This can be frustrating. The question is, can we do better? Can we avoid passing a special selector to @ContentChild everytime which needs a template reference to be passed?
Before we get into a improved solution, let me present a problem statement here. What if we want to implement a 'focus' behavior on our custom input component? If you look at the last runnig example on Plnkr, the focus seems to work on the internal input element injected into our component but what if we don't want to show focus glow on that input instead we want to show the focus glow outside of our custom input. Let's look at our problem statement in a picture of what we want and what we don't:
Technically, what actually needs to happen is we want to detect the focus on the internal input element but don't want the focus styling on it. As soon as the internal input element has the focus, we want to apply focus styling on the host my-custom-input element.
As a matter of fact, we may need similar functionality in other components as well. We may need to reference input elements inside of other custom components as well other than my-custom-input. How about extracting out this functionality into a separate Angular directive. We can then apply that directive to any component that needs reference to input injected inside of it. This directive will also serve as a nice abstraction layer. It is not a direct refernce to the DOM element. It is reference to a directive that abstract out the underlying DOM implementation. Let's try this option!
For this, we want to get rid of all template references from our consuming app and the HTML looks as simple as before without any #input template reference:
Let's write a new directive to provide abstraction. I am naming it 'InputReference'. You can name it whateever. Here is what the directive looks like at this time:
We have not done any real implementations yet. The only interesting thing is the directive's 'selector' for now. This directive selects every 'input' within the 'my-custom-input' host. This will work for both input present directly in 'my-custom-input' template or input inject via ng-content.
Next up, we are going to add our @ContentChild variable to be of type 'InputReference' instead of type HTMLInputElement and also remove the 'input' selector we were using before. Here is how our my-custom-input looks like after changes:
A quick check of the console reveals:
Here is a working application at this point:
It's time to enhance our directive. We can use this directive to listen to the host - that is, the DOM element the directive is attached to. In our case, this directive is attached to 'my-cusotm-input input' i.e any input inside of the 'my-custom-input' host. Using host listener is a very effective way in which directives extend the component or element's behavior. We will inherit HostListener from @angular/core and attach a listener to host events. Let's see how the changes look like in the directive:
We wrote 2 methods onFocus() and onBlur() and attached @HostListener decorator to both. We then passed special event selectors to these decorators, 'focus' and 'blur'. This translates to "Whenever a focus or blur event is fired on the host element, run these handlers.". Obviously, you do not have to name these handlers onFocus and onBlur, you can name them anything as long as you are attaching HostListener decorators to them and passing them correct event selectors. Since our directives apply to native input elements injected inside of the my-custom-element, the host for this directive will be the input element.
If you remember our actual problem statement, we wanted to detect focus on the internal input and want to apply focus styling on the host my-custom-input element. Since we are now able to detect internal input's focus event, lets add a state variable which can help us apply styles to the host. Lets finish off simulating the focus functionality of our custom input component.
We need some adjustement in styling, we want to apply a border to the host element so that it acts like a native input box and also get rid of the border from native input element. Here is a updated version of our styles:
At this time, the pieces we are interested in are border:none and outline:none we applied to native input element and the border: 1px solid #d3d3d3; we applied to :host. :host in this example refers to the my-custom-input tag in HTML.
If you want to learn about :host and /deep/ modifier in detail, please refer to the article on Component Style Isolation & ng-content in Angular 2+
One more thing we will have to add in order to have focus styling applied to host is to apply CSS state class We want to apply a special '.input-focus' class to the host whenever our 'focus' variable is set to true in the directive. Again, you can name this class anything. Let's look at the updated styles to check how we can apply CSS state classes to ':host' selector
The styling rules applied to :host(.input-focus) is trying to simulate the natural focus blue color border with shadow. In order to apply this class, we need to use HostBinding decorator at my-custom-input component level itself and not on the directive level. We need to have a getter property which helps us determine if the internal input element has focus or not. In the @HostBinding argument, we will need to define a class name that needs to be applied if the getter returns true.
Additionaly, we can also attach a guard to check if the consumer of our component has properly initialized the component or not. In this dummy example, by "proper initialization" i mean if the consumer has injected a 'input' to the my-custom-input or not. If not, console log a error message or throw a error to tell the user to inject the required 'input' element. ngAfterContentInit life cycle hooks seems to be the right choice for this check:
We can now see that our focus class is applied on the host element as soon as we have focus on internal input
Everything seems to work as expected and we have achieved our desired behavior of simulating focus on our host my-custom-input with the help of HostListener, HostBinding and ngAfterContentInit in Angular 2+. Here is a working example:
Feel free to leave a comment, question, suggestion and corrections. Until next time, Happy learning!