In this blog post, I want to show a use case that demonstrates the power of Angular Directives coupled with Angularโs Dependency Injection system.
Use-case
I am working on a library called Angular Three, which is a THREE.js integration for Angular. Here is a simple 3D Scene and the code for that scene:
A spinning cube with mouse interactions
As seen in the GIF, you can see that I can interact with the cube in a couple of ways:
- Hovering changes its colors.
- Clicking changes its scale.
There is no feedback to tell the users that the cube is actionable (no cursor: pointer
). A cube is a 3D object drawn inside an HTMLCanvasElement
, so there is no DOM for this cube, so there is no cursor: pointer
.
We are going to fix that in this blog post.
Naive fix
The naive approach would be to change the style.cursor
on the document.body
, and we can do this because we can listen to (pointerover)
and (pointerout)
events on the cube. Letโs change our code to implement this:
Everything should be the same as before. Letโs actually start with the fix
Cursor changes to โpointerโ on hover
Hurray ๐! We now have feedback for users that our cube is actionable.
What if we add the cursor: pointer
fix to different 3D objects that might not be in the same Component? That would be quite a chore to do.
- We would need to listen to two events
(pointerover)
and(pointerout)
even if the object might not need to have โhoverโ interactions. - We would need to inject
DOCUMENT
to change thebody.style
.
Letโs re-assert what we need and think of a different approach ๐ค
- We need to listen to
(pointerover)
and(pointerout)
events on 3D ObjectsThis is only applicable to Angular Three, but the thought process for different projects/use-cases is the same.
- We need to change the style of a global object (
document.body
) - We need to do this for any 3D objects
- We want to be able to declaratively add/remove this functionality (
cursor:pointer
)
With all of these points listed out, an Angular Directive, specifically an Attribute Directive, sounds like what we need to create.
Check Attribute Directive to learn more about it.
Into the Directive lane
In Angular, you can attach Attribute Directives on any elements on the DOM, and if the Directiveโs selector matches, Angular will instantiate the Directive with the appropriate context.
What is this โcontextโ thing?
A context is an environment that the Directive is created with. In other words, what is available for this Directive to access via Angularโs Dependency Injection based on where it is attached.
Hereโs how SomeDirective
might be used/attached:
HTML Elements
When a Directive is attached on an HTMLElement
, the Directive will have access to:
ElementRef<TElement>
: whereTElement
is the actual type of the element. Eg:<button>
isHTMLButtonElement
,<div>
isHTMLDivElement
.ElementRef
is the reference to the DOM element that is rendered, like what you would see in the Element Dev Tool.
Component
When a Directive is attached on a Component, the Directive will have access to:
ElementRef<HTMLElement>
: same as aboveTComponent
: whereTComponent
is the type of the Component. The Directive has access to the instance of the Component that Angular creates.
ng-template
When a Directive is attached on ng-template
, the Directive will have access to:
ElementRef<Comment>
: When you renderng-template
, Angular will put a comment:<!-- container -->
in its place. This comment will be available for the Directive viaElementRef<Comment>
TemplateRef<any>
: TheTemplateRef
instance.
ViewContainerRef
is always available to Attribute Directive
Inheritance
In addition to the above, the Directive also has access to the Hierarchical Injector Tree, where itโs attached. Letโs look at the following examples:
- Other directives
SomeDirective
has access to the NgModel
instance and anything that NgModel
might have inherited (the underlying NgControl
), so you can have a Directive that might do some additional Form logic.
- Componentโs Providers
SomeDirective
has access to anything that is provided in SomeComponent
โs providers
SomeDirective
also has access to SomeComponent
โs ancestorsโ Injector
In this case, anything that is available in SomeParent
will also be made available to SomeDirective
via Dependency Injection.
- Root/Platform Providers:
ChangeDetectorRef
,NgZone
,ApplicationRef
,@Inject(DOCUMENT)
, etc.
Everything listed here might not be exhaustive, as these are the things that I am aware of. You can quickly check what is available in any specific building block of Angular by injecting the
Injector
and checking it in the console.
Checking Injector
in the console
The overlooked feature: Directiveโs Selector
One powerful feature of Angular Directives is specifying the selector
like CSS Selectors. Letโs bring back our SomeDirective
Letโs also assume that SomeDirective
will ALWAYS need to be there whenever SomeComponent
is used. In other words, SomeDirective
handles some certain logic for SomeComponent
that is extracted out of the Component to achieve Separation of Concern. Having to do the following is tedious
Instead, we can change SomeDirective
โs selector
to some-component
so that it will get instantiated in the same manner as SomeComponent
This is extremely powerful if you know how to take advantage of it, especially youโre building reusable components and want to get rid of some nuances for your consumers. You can control how your Directives are instantiated:
- Relaxing: make the selectors more relaxed. Like what we did with
SomeDirective
andSomeComponent
- Constraining: make the selectors more constrained. For example,
[some]
might be too broad and might be misused; we can constrain it to justsome-component
by changing the selector tosome-component[some]
- Condition: Directivesโ selectors are like CSS Selectors, you can apply some conditions to them. For example,
[some][foo]:not([bar]),[some][bar]:not([foo]),[some]:not([foo]):not(bar)
. This says:SomeDirective
can be instantiated by itself, with[foo]
input, or with[bar]
input but never[foo]
and[bar]
inputs at the same time.
Now that we have all that information, we can continue on our proper fix to the problem stated at the beginning of this blog post.
Proper fix to the cursor problem
Start by creating a Directive (you can use the Angular CLI if you want to)
This is how we would want to use CursorPointerDirective
The job of CursorPointerDirective
is to listen to the pointersโ events and update document.body
style. Letโs fill the directive up
That is all the prep work we need for CursorPointerDirective
. Now, the listening part:
Thatโs it! Now we can use cursorPointer
on ngt-mesh
and see the result:
Conclusion
With some knowledge about Directives, we made a reusable cursorPointer
directive that other 3D objects can use on any 3D Objects for showing cursor: pointer
. I hope you learn something from this blog post. Have fun and good luck ๐.