How to wrap an Angular directive library?
You've been asked to implement a new feature in the Angular application at work. As you sit at your desk and reach for the keyboard a thought pops into your mind: "I can't be the first one to have to implement something like this. I bet there's a library that does what I need".
Good for you. That's a good reflex to have in today's open-source world. Why reinvent the wheel when you can just borrow someone else's wheel? Chances are you are right; someone did have to solve the same problem you are trying to solve and was nice enough to share it with the world.
So a quick search on npmjs.com and you find exactly what you are looking for. The perfect Angular library which, through a few exported directives, does pretty much what you want.
Now, you realize that it might not be the best idea to start using those directives all over the app and would like to wrap that library so that your app doesn't become tightly coupled to it. But how?
When we are talking about wrapping a 3rd party library, we are usually talking about using composition to provide a new interface to our application, interface that will delegate work to the 3rd party library. That way, the 3rd party library does all the heavy lifting, but our app doesn't even know it exists, it just knows about the pretty wrapper we've made for it.
If you are familiar with design patterns, you'll probably end up using something that looks a lot like the Adapter, the Proxy, or the Façade pattern.
For our demonstration, we'll wrap the angular-resizable-element library. You can try it out, and see the code associated with this article, in the following Stackblitz.
Choose your API
angular-resizable-element is a cool little library that makes it possible to resize elements by dragging their edges. Let's take a quick look at how it works. According to its documentation, it provides two directives through it's exported module: ResizableDirective
and ResizeHandleDirective
.
Upon examination, we conclude that we don't really need to use ResizeHandleDirective
. It's purpose is to give finer grain control over each handle to the sides of the resizable element and we don't really care about that. So that leaves us with ResizableDirective
. Looking at the docs, we see that it takes in 9 inputs and emits 3 outputs.
As is often the case with libraries, they tend to offer a much wider API than you actually need. Do not feel like you have to mirror the 3rd party library with your wrapper. In fact, your wrapper's API should only provide what your app needs. No more, no less.
In our case, after a careful examination of our requirements, we determine that we don't need to provide the equivalent of the allowNegativeResizes
, mouseMoveThrottleMS
, resizeCursors
, resizeCursorPrecision
and resizeSnapGrid
inputs. Other than that, it would make sense for our wrapper to provide a similar interface to that of the 3rd party library, as it will cover our needs nicely.
Wrap it up
At the moment, our demo component uses the 3rd party library directly and the code looks like this:
<div class="text-center">
<h1>Drag and pull the edges of the rectangle</h1>
<div
class="rectangle"
[ngStyle]="style"
mwlResizable
[validateResize]="validate"
[enableGhostResize]="true"
(resizeEnd)="onResizeEnd($event)"
[resizeEdges]="{bottom: true, right: true, top: true, left: true}"
></div>
</div>
import { Component } from "@angular/core";
import { ResizeEvent } from "angular-resizable-element";
@Component({
selector: "my-app",
templateUrl: "./app.component.html",
styleUrls: ["./app.component.css"]
})
export class AppComponent {
public style: object = {};
validate(event: ResizeEvent): boolean {
const MIN_DIMENSIONS_PX: number = 50;
if (
event.rectangle.width &&
event.rectangle.height &&
(event.rectangle.width < MIN_DIMENSIONS_PX ||
event.rectangle.height < MIN_DIMENSIONS_PX)
) {
return false;
}
return true;
}
onResizeEnd(event: ResizeEvent): void {
this.style = {
position: "fixed",
left: `${event.rectangle.left}px`,
top: `${event.rectangle.top}px`,
width: `${event.rectangle.width}px`,
height: `${event.rectangle.height}px`
};
}
}
As you can see, we are using the mwlResizable
directive selector from the library in our template and its ResizeEvent
interface in the component. We need to use our wrapper instead. So let's do that.
Step one: inputs and outputs
As a first step I often find it useful to define the inputs and outputs of our wrapper. To begin we will create a new directive in a new file for our wrapper. Since we plan on providing a similar, yet simpler, interface than the one exposed by the library, we can use its source code as a base and simply copy the inputs and outputs that we plan to provide. After this step we end up with something like this:
@Directive({
selector: "[resizableWrapper]"
})
export class ResizableDirective implements OnInit, OnChanges, OnDestroy {
@Input() validateResize: (resizeEvent: ResizeEvent) => boolean;
@Input() resizeEdges: Edges = {};
@Input() enableGhostResize: boolean = false;
@Input() ghostElementPositioning: "fixed" | "absolute" = "fixed";
@Output() resizeStart = new EventEmitter<ResizeEvent>();
@Output() resizing = new EventEmitter<ResizeEvent>();
@Output() resizeEnd = new EventEmitter<ResizeEvent>();
}
You'll also want to make sure that you don't just reuse the library's interfaces and instead provide your own. For instance, in the above code we have the ResizeEvent
and Edges
interfaces. We made sure to define our own, in separate files.
Step two: constructor parameters
As we will be creating an instance of the library's directive whenever we create an instance of our wrapper, we will need to pass the appropriate dependencies. Here the 3rd party directive's constructor:
constructor(
@Inject(PLATFORM_ID) private platformId: any,
private renderer: Renderer2,
public elm: ElementRef,
private zone: NgZone
) {
this.pointerEventListeners = PointerEventListeners.getInstance(
renderer,
zone
);
}
So we'll need to pass in four dependencies. All four are part of the @angular/core
package and therefore should be easy for the DI system to resolve. Let's do that now.
This step is not particularly hard. All we need to do is add the library's directive to our wrapper's constructor and supply Angular's DI with a factory provider.
function resizableDirectiveFactory(
platformId: any,
renderer: Renderer2,
elm: ElementRef,
zone: NgZone
) {
return new ResizableDirective(platformId, renderer, elm, zone);
}
const resizableDirectiveProvider = {
provide: ResizableDirective,
useFactory: resizableDirectiveFactory,
deps: [PLATFORM_ID, Renderer2, ElementRef, NgZone]
};
@Directive({
selector: "[resizableWrapper]",
providers: [resizableDirectiveProvider]
})
export class ResizableWrapperDirective implements OnInit, OnChanges, OnDestroy {
constructor(private library: ResizableDirective) {}
}
Step three: Lifecycle hooks
One thing to keep in mind when wrapping a directive in Angular is that we need to account for the lifecycle hooks. They can be viewed as part of your wrapper's API. You'll probably want to have the same lifecycle hooks as the directive you are wrapping. Keeping that in mind, let's look at the three hooks we'll need to implement.
First ngOnInit
. The first thing we wanna do is hook up the outputs.
ngOnInit(): void {
this.library.resizeStart
.pipe(takeUntil(this.destroy$))
.subscribe(event => this.resizeStart.emit(event));
this.library.resizing
.pipe(takeUntil(this.destroy$))
.subscribe(event => this.resizing.emit(event));
this.library.resizeEnd
.pipe(takeUntil(this.destroy$))
.subscribe(event => this.resizeEnd.emit(event));
}
Keep in mind that this example is very simple because our event interfaces are a mirror image of the library's interfaces. Were it not the case, you would have to map them to your own interfaces before emitting them.
Ok, all that is left is to delegate to the library's own ngOnInit
function.
ngOnInit(): void {
...
this.library.ngOnInit();
}
As simple as that. Moving on to ngOnChanges
which gets called before ngOnInit
and every time one or more data-bound input properties change. So guess what we need to do in that function. That's right, assign our input properties... and delegate to the library's ngOnChanges
function.
ngOnChanges(changes: SimpleChanges): void {
if (changes.validateResize)
this.library.validateResize = this.validateResize;
if (changes.resizeEdges) this.library.resizeEdges = this.resizeEdges;
if (changes.enableGhostResize)
this.library.enableGhostResize = this.enableGhostResize;
if (changes.ghostElementPositioning)
this.library.ghostElementPositioning = this.ghostElementPositioning;
this.library.ngOnChanges(changes);
}
And finally, ngOnDestroy
ngOnDestroy(): void {
this.library.ngOnDestroy();
this.destroy$.next();
}
Step four: Declare your wrapper and use it
All that is left is to add our wrapper to our module and to use it in our template.
import { NgModule } from "@angular/core";
import { BrowserModule } from "@angular/platform-browser";
import { FormsModule } from "@angular/forms";
import { AppComponent } from "./app.component";
import { ResizableWrapperDirective } from "../lib/resizable-wrapper.directive";
@NgModule({
imports: [BrowserModule, FormsModule],
declarations: [AppComponent, ResizableWrapperDirective],
bootstrap: [AppComponent]
})
export class AppModule {}
As you can see, our module has no references to the 3rd party angular-resizable-element library. It only declares our wrapper directive. Our template and component also now only depend on our wrapper directive.
<div class="text-center">
<h1>Drag and pull the edges of the rectangle</h1>
<div
class="rectangle"
[ngStyle]="style"
resizableWrapper
[validateResize]="validate"
[enableGhostResize]="true"
(resizeEnd)="onResizeEnd($event)"
[resizeEdges]="{bottom: true, right: true, top: true, left: true}"
></div>
</div>
Conclusion
Wrapping 3rd party libraries is generally good practice but it can be a challenge to do so when dealing with Angular directives. Each library is different and will require a slightly different approach but the four steps laid out in this article should serve as a good foundation.