Using an Angular Directive to Extend a Third-Party Component
I added mode-toggling to a PrimeNG Calendar! š¤
The Challenge
I recently had a requirement to add a datepicker to a page that would allow the user to select a single date or a date range.
In our application we already used the PrimeNG Calendar component, which has a selectionMode
property for configuring the datepicker to accept a single date, multiple dates, or a date range.
Unfortunately, though, the component does not provide a built-in way to toggle between modes.
It was time to get creative! š¤
The Goal
The Calendar component looks like this:
I thought the footer button bar would be a perfect place to put the mode toggle. So my goal became this:
The Approach
Since I would need to manipulate DOM, a Directive seemed like the right tool for the job.
This is the template I started with:
The next decision: how and where to define the custom Date and Date Range buttons that I would be inserting into the Calendar?
The Options
Two options occurred to me:
- Define them as HTML in the template and have the Directive reference them from there
- Create them programmatically within the Directive class using Renderer
With Option #1, it would be cleaner and easier to build the markup for the buttons (in my opinion, anywayāwhen working with markup, I generally prefer working within an .html file).
But with Option #2, the Directive would be more self-contained and you could just attach it to p-calendar
instances in multiple templates throughout your app without needing to add the extra button markup in each template to support it.
First Cut: Option #1
For my first cut, I went with Option #1, and thatās what this article will step through.
I chose that option because, in my situation, we had our own custom Angular wrapper component around p-calendar
anyway, and that custom component is what gets instantiated throughout the app, not p-calendar
, so I could just define the button markup in that custom componentās template and never have to do it again.
I did, however, also implement Option #2 afterward, just for my own edification, to see how it compared. Links to both implementations (as well as a third using a Component and CDK Portal that occurred to me later!) are at the end of the article.
The Implementation
Hereās what the button markup looks like defined in the custom wrapper componentās template right alongside p-calendar
:
On line 4, I attached the Directive, calendarModeToggle
, to p-calendar
and passed a reference to the buttonsā wrapper element, modeToggle
, into it.
I wrapped the buttons in adiv
that includes a style
tag for the necessary styling, keeping the markup and styling together in one place.
I placed that wrapper div
inside an HTML template
tag so it would be accessible to the Directive via a template reference variable but not rendered on the page.
Finally, I attached template reference variable #modeToggle
to the wrapper div
so I could pass the reference into the Directive.
The Directive Setup
Hereās what the Directive class looks like, taking the wrapper div
as an Input:
In case youāre not familiar with this patternā¦if your Directive uses an attribute selector and you create an @Input()
for the Directive with the same name as the selector (in this case, calendarModeToggle
), you can attach the Directive to an element and pass something into it in one convenient step, like we do on p-calendar
:
[calendarModeToggle]="modeToggle"
Inserting the Custom Buttons
Now that the Directive had a hold of div.toggle-wrapper
, the next step was to have it insert those buttons into the Calendarās button bar.
Hereās where DOM manipulation came into play!
This is what the Calendar componentās buttonbar looks like:
To insert my toggle buttons between the Today and Clear buttons, I turned to Angularās Renderer2.
Listening for Datepicker Show
Since the Calendarās datepicker isnāt displayed all the timeāonly when the control is selectedāthe Directive needed to hook into the Calendarās onShow
event and insert the custom buttons every time the datepicker was shown.
To do that, we inject the Calendar instance into the Directiveās constructor and subscribe to its onShow
event:
When the datepicker is shown, we call the Directiveās addToggleButtonToButtonBar()
method, which looks like this:
Notice that the method first checks to see if this.buttons
already exists.
Thatās because, even though the datepicker part of the Calendar component is inserted or removed from the DOM as the Calendar is clicked and dates are selected, the Calendar component itself remains, and therefore so does our Directiveāthe Directive is not re-instantiated every time the datepicker is displayed.
Because of that, we only need to define the buttons once, but we need to re-insert them into the button bar every time the datepicker is shown.
Empowering the Buttons
Next I needed to wire up the button clicks so they would change the Calendar selection mode.
For that, I used Render2ās listen
method:
The listen
method returns an āunlistenā function for disposing of the handler. I store those āunlistenā functions in an array called stopListening
so I can invoke them when the Directive is destroyed so as not to cause a memory leak. š¼
The click listeners call Directive method setMode
, passing in the selected mode and a reference to the clicked button. The setMode
method looks like this:
We explicitly set the Calendarās selectionMode
to the selected mode, then add a CSS class to the selected button so we can highlight it.
The optional third param, clearSelection
, is set to true
for the button clicks so that, when we change modes, we clear any current date selection. (Youāll see why thatās an optional flag defaulting to false
shortly.)
Tricky Considerations
There were a couple tricky scenarios that popped up during the implementation:
- Changing modes triggered a user-unfriendly jump to the current month if you had navigated to a different month or year, requiring you to re-navigate back to where you were
- Clicking the Today button when in Date Range mode didnāt work correctly because itās a single date
Unfriendly Jump to Current Month
When changing modes, the Directive triggers a clearing of the current date selection. That clearing, in turn, causes the Calendar component to trigger a jump to the current month, which is not desirable. So I needed to fix that.
The Directiveās clearDateSelection
method, called upon mode change, looks like this:
It simply uses the Calendar componentās writeValue
method, passing it null
to clear any selected value.
That was the simple part!
The tricky part came when I noticed that, if I had changed to a different month and then changed modes, I would automatically be taken back to the current month. Not a very friendly user experience.
So I had to dig into the Calendarās source code to figure out how to prevent this.
Undoing Jump to Current Month
It turned out there was no way to prevent it without modifying the source code, but there was a way to immediately undo it using the Calendarās createMonths
method, which sets the current displayed month/year.
So now, before clearing the selected date, I store the currently selected month and year in local vars. I get those from the Calendarās currentMonth
and currentYear
properties.
Then, after clearing, I see if either of those Calendar properties changed. If either one did, I invoke the Calendarās createMonths
method, restoring the previously selected month and year without impacting the user at all. šŖ š¤
Clicking the Today Button
The final tricky thing that needed to be handled was a click on the Today button.
If the Today button was clicked with the Calendar in Date Range mode, we would need to change to Date mode.
Conveniently, the Calendar component exposes an onTodayClick
event that we can subscribe to. I added that in ngOnInit
:
And hereās what the handleTodayClick
method looks like:
When the Calendar is in ārangeā mode, its value
property is an array rather than a single value. The first and last selected dates become the first and second elements in the array.
When the Today button is clicked in ārangeā mode, todayās date becomes the first element in the array.
So on line 6, we reassign the Calendarās value
property, setting it equal to that date and therefore changing it from an array to a single Date value. This is the state we would be in if the Calendar had already been in āsingleā mode.
We then hide the overlay/datepicker, which triggers a fade-out animation.
Then, after a small delay (see why below), we set the mode to āsingleā using the Directiveās setMode
method, but without the optional third clearSelection
argument because we just set the Calendarās value
property and we donāt want to clear it.
Finally, we call the Calendarās writeValue
method, passing it the value
property we just set, which is todayās date.
Why the Delay?
I added that small delay purely for aesthetic reasons.
It worked fine without the delay but, upon clicking Today, you would immediately see the selection highlight jump from Date Range to Date, which felt a little strange and jarring to me. By adding the small delay to account for the overlay fade animation time, that highlight jump becomes very subtle or even unnoticeable.
The Final Product
Hereās how it turned out:
To prevent the toggle buttons from shifting horizontally when the selection is changed and the selected button is bolded, I had to play with their letter-spacing
.
In the styles I set the letter-spacing
for the non-bolded and bolded buttons so that their total widths would be the same:
Without that, it would have looked like this, which is a bit janky and unprofessional:
Links
Here are links for the three different implementations of this enhancement.
1. Directive with buttons defined in the template
2. Directive with buttons defined programmatically
3. Component using CDK Portal to portal the buttons into Calendar
The repo for the CDK Portal implementation is an actual runnable Angular app, while the other two are just places to store and share the code.
Final Thoughts and a Caveat
I realize that some of this enhancement is dependent upon internal implementation details of a third party component that could change in the future with only a minor or patch version bump.
In my case, though, this was for an essentially in-house tool with dependency versions strictly controlled and upgrades fully tested. So I was not concerned about PrimeNG possibly changing their internal implementation and breaking this without our noticing and having an opportunity to fix it before it reached production.
Keep that in mind, though, before using an approach like this in your own projects!
Thanks for reading! šŗ