Customizing DatePicker components

Check out the updated guide in the replies below!

Hello everyone :wave: ,

I wanted to share a way to customize the DatePicker component, so it can be a “MonthPicker” or even a “YearPicker”. (I think) This is not supported natively by UI Bakery, at least yet, so this could be useful in some cases.

The results look like this:

MonthPicker
YearPicker

Q: Why need a Month-/YearPicker and not just an input?

Answer

You might say that it’s not really necessary to customize the DatePicker component and just let the user input something like Feb 2025 into a Text Input component. And depending on your specific needs, that can be the case.
I wanted to customize the DatePicker component because of several reasons. To name some of them:

  • The most important for me: a nice UI
  • Built-in date validation with error messages
  • Built-in timezone conversion
  • and some more

Generally, I just think that it makes sense that a DatePicker component should be responsible for date inputs, be it a full date or just month and/or year.

How to customize the component

To customize the component, I made use of the Mutation Observer API to observe when the overlay of a DatePicker has been opened.

const handleOverlayChange = () => {
  const dayPicker = document.querySelector("nb-calendar-day-picker");
  if (!dayPicker) return;
  const calendarDayLabels = [...dayPicker.querySelectorAll("nb-calendar-day-cell div")];
  const firstOfMonth = calendarDayLabels.find( label => label.textContent.trim() === "1" );
  firstOfMonth.parentElement.click();
}

const overlayContainer = document.querySelector(".cdk-overlay-container");

const config = { childList: true, subtree: true };

const observer = new MutationObserver(handleOverlayChange);
observer.observe(overlayContainer, config);

return;

If you have some JavaScript experience, you’ll quickly understand what’s going on here. For people who are not very experienced with coding, you can check out this breakdown of the code:

Breakdown of MonthPicker script
  1. The first thing we are doing, is defining the function handleOverlayChange. We’ll get into what it does below, for now just know that this is the function which the MutationObserver executes when something has changed on the page (or more correctly said, on the specified target)

  2. Below the function, we are selecting the cdk-overlay-container element on the page. I’m assuming (assuming because I’m not a UI Bakery developer!) that this element manages all the overlays. Since the nice User Interface (UI) that opens when clicking inside a DatePicker input is an overlay, we need a reference to that component.

    const overlayContainer = document.querySelector(".cdk-overlay-container");
    
  3. Next, we are defining a variable called config, which holds an object which defines the configuration we pass to the MutationObserver. It tells the observer what kind of changes we actually want to observe, among other things. For more details, refer to the MDN entry for the observe() function of the MutationObserver.

    const config = { childList: true, subtree: true };
    
  4. In the last two lines, we are defining the observer variable, which receives a new MutationObserver object. We pass the function handleOverlayChange we defined in the beginning that should be called when something changes. Then with the observe() function of the Observer we tell it what target to observe (target here is overlayContainer) and the configuration (here config).

    const observer = new MutationObserver(handleOverlayChange);
    observer.observe(overlayContainer, config);
    
  5. Now the MutationObserver observes the overlayContainer element and because we passed childList and subtree in the config, it will execute handleOverlayChange every time elements are added, removed or changed in the target.

  6. When the handleOverlayChange function is executed, the first thing it does is trying to find a nb-calendar-day-picker element. If the element was not found, meaning either a different overlay was opened or this one was closed, we simply return from the function.

    const dayPicker = document.querySelector("nb-calendar-day-picker");
    if (!dayPicker) return;
    
  7. If an element was found, we go to the next line. There, we are defining a new variable calendarDayLabels, which receives all div elements that are direct children of a nb-calendar-day-cell element. Fundamentally, these nb-calendar-day-cells are all the days visible in the calendar, and the div inside them wrap the string with the number of each day.

    const calendarDayLabels = [...dayPicker.querySelectorAll("nb-calendar-day-cell div")];
    

    If you don’t know what [...someVariable] does, take a look at the spread operator

  8. Finally, in the last two lines of the function, we are searching for the first div which has "1" as textContent. This will get us the first day of the month which the user selected. Then we are finishing off, by programmatically clicking the parent of the div, the nb-calendar-day-cell, which then sets the date for the input.

    const firstOfMonth = calendarDayLabels.find( label => label.textContent.trim() === "1" );
    firstOfMonth.parentElement.click();
    

Essentially, what it does, is automatically clicking the first day of the selected month.

YearPicker

To make this work as a “YearPicker”, we need to build on-top of the current
“MonthPicker” snippet because it still needs to select a day after the month.
The only thing that has to be changed is the handleOverlayChange function. These changes and additions are very few and rather simple. Here is the new function

const handleOverlayChange = () => {
  if (document.querySelector('[id=cdk-overlay-3]')) return;
  
  const monthPicker = document.querySelector('nb-calendar-month-picker');
  const dayPicker = document.querySelector('nb-calendar-day-picker');
  
  if (monthPicker) {
    const calendarMonthLabels = [...monthPicker.querySelectorAll('nb-calendar-month-cell div')];
    const firstMonth = calendarMonthLabels.find( label => label.textContent.trim().toLowerCase() === 'jan');
    firstMonth.parentElement.click();
  } else if(dayPicker) {
    const calendarDayLabels = [...dayPicker.querySelectorAll('nb-calendar-day-cell div')];
    const firstOfMonth = calendarDayLabels.find((label) => label.textContent.trim() === '1');
    firstOfMonth.parentElement.click();
  }
};

As you can see, we’re doing the exact same process for the month as for the day. The only other difference is that the ifs, which check if monthPicker and dayPicker are truthy values, are not returning immediately, but rather just skipping their section.

Styling

Brief visibility of day- and monthselector

If you have implemented and tested out one or both of the snippets, you probably have noticed that it still shows the day and month selection for a brief moment before it clicks automatically.

We can very simply hide this by adding some styling under the Custom Code tab at the bottom left
image

(Technically), All we need to add is this small piece of code

<style>
    .cdk-overlay-pane:has(nb-calendar-day-picker), .cdk-overlay-pane:has(nb-calendar-month-picker) {
      	opacity: 0;
    }
</style>

Of course, if you are just using the MonthPicker, remove the second CSS selector .cdk-overlay-pane:has(nb-calendar-month-picker).

View switch

You might have also noticed that the DatePicker component has this “view switcher” on the top, which switches between year and day selection


It’s actually not really a problem, as it only closes the overlay. But if you want to have it out of the way add this to the style (either display or opacity, both are not needed)

<style>
    // other things
    nb-calendar-view-mode {
        // either
        display: none !important;
        // or
        opacity: 0;
    }
</style>

Value display

Of course, to have the value display in the text field according to how you want it, simply change the Date Format setting of the component
image
Personally, I like to customize it in the JS format (see image above; JS button, top-right corner). That way you truly can display it the way you wan to

E.g. for a MonthPicker (will display like Feb 2017)
image

Q: What if I have multiple DatePicker components on a page, do they all have to be either Date-, Month- or YearPicker?

Answer

No, they don’t.

It was a challenge understanding how to differentiate the overlays and identify which overlay is linked with which DatePicker component because on the client-side they don’t have much that identifies them. But I found a way!

Basically, anytime the overlay manager has to display something for the first time, an id will be assigned to its overlay container. The ID looks like cdk-overlay-n where n would be a number starting from 1.
But, it’s not as simple so that you can now just go wild, e.g., thinking cdk-overlay-1 is the first DatePicker component on your page etc. That is because not only DatePicker has overlays, and not always will users open the DatePicker components in the same order. Yes, the cdk-overlay-n ID will be assigned on displaying, so it’s first come, first served.

Conveniently, this allows us to simply open all the DatePicker overlays, in the order we want to when the page has loaded. This way, we can be (at least 99%) sure that the cdk-overlay-n ID corresponds to a DatePicker we want to.

There are for sure other ways to do it, but I did it like this:

const allOverlayContainer = document.querySelector(".cdk-overlay-container");
allOverlayContainer.classList.add("hidden");
document.querySelectorAll("nb-datepicker").forEach( el => {
	const input = el.previousElementSibling;
  if (!input) return;
  input.click()
});
setTimeout( () => {
	document.body.click();
  allOverlayContainer.classList.remove("hidden");
}, 150)
return;

This snippet essentially looks for all nb-datepicker components, clicks on the input of each component so the overlay displays and at the end, after some delay, it clicks on the body, so all overlays close again. Additionally, it adds and removes the hidden class to the main overlay container. The hidden class can also be added to Custom Code and looks like this:

.hidden {
  	display: none !important;
  }

You can also combine this with the CSS, of the nb-calendar-view-mode element, from before:

.hidden, nb-calendar-view-mode {
  	display: none !important;
  }

Now you can selectively use either DatePicker, MonthPicker or YearPicker.

Here are two examples
  • (Blacklist) Excluding a DatePicker component from being a MonthPicker

    First, exclude it from the action that automatically chooses the date

    const handleOverlayChange = () => {
        // If overlay with <ID> was found on page, return
        if (document.querySelector("[id=cdk-overlay-<ID>]")) return;
        ...
    }
    

    Then, exclude it from the CSS class, so the date selection is visible

        /* Here again, replace <ID> with the ID of your choice */
        .cdk-overlay-pane:not([id=cdk-overlay-<ID>]):has(nb-calendar-day-picker) {
          	opacity: 0;
        }
    
  • (Whitelist) Selectively choose a DatePicker to become a MonthPicker

    Essentially, you add very similar lines like in the example above, just reversed

    const handleOverlayChange = () => {
        // if not this specific overlay with <ID> was found on page, return
        if (!document.querySelector("[id=cdk-overlay-<ID>]")) return;
        // For multiple just add more selectors!
        if (!document.querySelector("[id=cdk-overlay-<ID>], [id=cdk-overlay-<anotherID>]")) return;
        ...
    }
    

    And for the CSS class, you replace the .cdk-overlay-pane:not() with a simple :is()

    :is([id=cdk-overlay-<ID>]):has(nb-calendar-day-picker) {
      	opacity: 0;
    }
    

Final notes

  • It’s crucial to set the Start View setting of the DatePicker component to a value according to how you’ve set the customization up.

    For example, the MonthPicker must have either Year or Month as Start View or else the day selection is displayed, and it clicks on the first day immediately.

    Technically, with all the information here, you could also set up a DateComponent to have a fixed year and/or months. Then the user must choose a day from that range (use Min & Max settings). Here you want the Start View to be Day.

  • I have yet to test if there are any issues with multiple pages having different customized DatePicker and therefore possibly interfering with one another.

3 Likes

Hi @Max!

Wow, our whole team is SO IMPRESSED!! Thanks a lot for this! I’m wondering sometimes where do you find the time to do your job and be active in our community? :sweat_smile:
We’ll add the components, but your workaround will definitely be helpful for our users.

Thanks again! Appreciate your support :heart_eyes:

1 Like

Haha, thanks a lot!

Figuring everything out and setting it up is part of my current task, so it goes hand in hand. Actually writing this topic was way more work :joy:, so I had to do it after work.

Looking forward to the native implementation! Maybe whole separate components are not even necessary, just some more settings to the DatePicker :slight_smile:

Appreciate UI Bakery!

2 Likes

UPDATED GUIDE

Q: Why the update?

Answer

There are several reasons for why I needed to update this guide. Originally, I planned to just explain the differences between the first and this guide, but that ended up being way too complicated, so here is a whole new one (yay :confetti_ball:)
The main issues of the first guide are:

  • The Observer continued observing even after you navigate to a different page. If that page has DatePicker components that were not intended to be customized or are customized by an action on that page, they would still be affected by the handler of the first Observer as well

  • The way we identify which overlay corresponds to which DatePicker.

    With how it was implemented in the first guide, it relied on programmatically opening all overlays once, so we can “predict” what IDs are assigned to them. In an app with less than 3 pages, this works fine (but still unnecessarily complicated). If there are 3 or more pages present, we can’t predict which page the user will navigate to next. Therefore, the hardcoded whitelists/blacklists/CSS will not work.

  • The logic was too scattered throughout the different pages.

    If you want to have a second, third etc. customized DatePicker you have to do almost everything again for each customization. This is not just annoyingly tedious, but just in general not good logic. If a word for UI Bakery similar to “pythonic”, something like “uibakerish” (lol), would exist, then this would not be very “uibakerish”

  • Writing the CSS selectors is not only complicated due to the IDs of the overlays, but it’s more code than needed for what is accomplished.

Apart from changing these things, the quality of life in the updated guide is generally enhanced.


I encourage everyone to use this guide instead of the first. It’s easier to set up, maintain and expand on.

This updated guide will explain the whole set up. The first guide can be ignored.


The main logic explained

The calendar widgets, that let you pick a date, are added to and removed from the DOM when you click inside the input of a DatePicker component.

Utilizing the MutationObserver Web API, we can observe a given element and execute function if that element, or one of its children (all children, not only direct ones), have changed or have been removed/added.

The executed function would receive records of the changes as a parameter, but we aren’t concerned about that. What matters to us is that we know when the observed element has had changes, and that we can react upon it.

The function we tell the Observer to execute once a change has been detected, automatically selects either a date, month, year, or any combination of the three. It truly depends on what exactly you want; a MonthPicker that only lets you select year and month; a ...Picker that only lets you choose the day of a fixed month and year.


Step 1 - Setting up the main logic

Because we want to be able to easily customize DatePicker components on any page and have fast access to the logic for making changes, the first thing we need to do is to create an action in the App scope
image
Choose JavaScript Code for the step type

In the code editor, we start by defining the function which the Observer will execute when DOM changes are detected. For now, I’ll make a “MonthSelector”, that lets you pick a year and month.

const handleOverlayChange = () => {
  /* 1.*/ const dayPicker = document.querySelector("nb-calendar-day-picker");
  /* 2.*/ if (!dayPicker) return;

  /* 3.*/ const calendarDayLabels = [...dayPicker.querySelectorAll("nb-calendar-day-cell:not(.bounding-month) div")];
  /* 4.*/ const firstOfMonth = calendarDayLabels.find( label => label.textContent.trim() === "1" );
  /* 5.*/ firstOfMonth.parentElement.click();
}
  1. Check if the calendar day picker exists
  2. If it doesn’t, stop executing the function
  3. If it does, get all the day labels from the calendar.
  4. Pick one of the days, I chose the first day of the month
  5. Select that day

We can now pass this function to the MutationObserver constructor and get an observer

const observer = new MutationObserver(handleOverlayChange);

Now all that’s left is to define what element the observer should watch and what kind of changes it should detect

// get the overlay container
const overlayContainer = document.querySelector(".cdk-overlay-container");

// define the config so it detects changes on the `DOM` structure
const config = { childList: true, subtree: true };

Check out fix for overlayContainer

And finally start observin’

observer.observe(overlayContainer, config);

One last things we need to do, before we can try it out, is to set the Start view setting in DatePickers settings to Year

If you execute the action, all the DatePicker components on the page are now a MonthPicker and will automatically select the first day in the day picker view after you selected a month.

For reference, here is how the whole code looks up to now

const handleOverlayChange = () => {
  const dayPicker = document.querySelector("nb-calendar-day-picker");
  if (!dayPicker) return;
  const calendarDayLabels = [...dayPicker.querySelectorAll("nb-calendar-day-cell:not(.bounding-month) div")];
  const firstOfMonth = calendarDayLabels.find( label => label.textContent.trim() === "1" );
  firstOfMonth.parentElement.click();
}

const observer = new MutationObserver(handleOverlayChange);

const overlayContainer = document.querySelector(".cdk-overlay-container");
const config = { childList: true, subtree: true };

observer.observe(overlayContainer, config);

return;

Step 2 - Hide the pickers

But every so often, until the action has selected a day, the day picker view is briefly visible.
picker_issue
We can prevent this easily using CSS. For that, we just need to define a basic CSS class under the tab Custom Code in the bottom left

<style>
    .hidden {
        display: none !important;
    }
</style>

And add it to the pickers classList in the function that the observer executes

const handleOverlayChangeHome = () => {
  ...
  if (!dayPicker) return;
  dayPicker.classList.add("hidden"); // add hidden class to picker
  ...
}

Step 3 - Cleanup cycle

The main logic works, and the picker isn’t visible anymore, great!

But there is a problem: When we navigate to a different page, all the DatePicker components there are also manipulated by our function (that is executed by the observer). That by itself is not the issue, we can use the function on that page, but it doesn’t happen intentionally.

If a page is observed and manipulated by a observer, it should happen because we decided it should.

Therefore, we should be able to decide which function is used for which page, if even any. So every time we navigate to a different page, we need to tell the observer of the previous page to stop observing. We can do by that returning the observer from the action and every time the action (re)runs, we check if it has returned an observer in the last execution. If so, then disconnect() it.

// Begin with disconnecting the last observer
const lastObserver = {{actions.manageObserver.data}}
if (lastObserver) {
	lastObserver.disconnect();
}

...

// return observer at the end
return observer;

Now, if you set an App Trigger for this action that runs On Page Load, the observer should properly disconnect and a new one will be created. This allows us to define more function which customize the behavior of the DatePicker and apply them on the pages we want to.


Step 4 - Applying different customizations to each pages

There are 3 things we need to use a differently customized DatePicker in a different eac page.

The first is quite obviously a different page, I’ll name mine events.

Then, we create a new function that can be passed to the observer. The following function customizes the DatePicker, so that only a year can be selected. We define it just below our handleOverlayChange function, which we are going to rename to handleOverlayChangeHome

// renamed: handleOverlayChange -> handleOverlayChangeHome
const handleOverlayChangeHome = () => {
    ...
}

//  new function for 'events' page
const handleOverlayChangeEvents = () => {
  const monthPicker = document.querySelector('nb-calendar-month-picker');
  const dayPicker = document.querySelector('nb-calendar-day-picker');

  if (monthPicker) {
    monthPicker.classList.add('hidden');
    const calendarMonthLabels = [...monthPicker.querySelectorAll('nb-calendar-month-cell div')];
    const firstMonth = calendarMonthLabels.find((label) => label.textContent.trim().toLowerCase() === 'dec');
    firstMonth.parentElement.click();
  } else if (dayPicker) {
    dayPicker.classList.add("hidden");
    const calendarDayLabels = [...dayPicker.querySelectorAll('nb-calendar-day-cell:not(.bounding-month) div')];
    const firstOfMonth = calendarDayLabels.find((label) => label.textContent.trim() === '24');
    firstOfMonth.parentElement.click();
  }
}

You can see that we are doing the same thing for the month as we do for the day. This can also be applied the same way for the year.

The third thing we need, is a simple switch statement to determine the function that should be used. We can use the {{ activeRoute.name }} as the determining factor. If no handler fits the page we’re on, then we don’t need to initialize an observer and can just return

// add before initializing the MutationObserver
let fn= null;
switch ( {{ activeRoute.name.toLowerCase() }} ) {
  case 'home':
    fn= handleOverlayChangeHome;
    break;
  case 'events':
    fn= handleOverlayChangeEvents;
    break;
}

if (!fn) return;

Finally, we now need to initalize the observer with the handler variable

...
if (!fn) return;
const observer = new MutationObserver(fn);

The DatePicker components on the home page will still be the same, letting you select a year and a month, but the ones on the events page will only let you choose the year.


Step 5 - Applying different customizations to each `DatePicker`

We can even go a step further, and customize each DatePicker on every page. To achieve that, we need to know to which calendar widget belongs to which DatePicker. We can find that out by remembering which DatePickers input element was last active.

// add above all the functions

let lastActiveInput = null;

const detectActiveInput = () => {
  lastActiveInput = document.querySelector('ub-date-edit:has(input.cdk-focused)') || lastActiveInput;
};

Then change the parameter we pass to the MutationObserver constructor

const observer = new MutationObserver(() => {
  detectActiveInput();
  fn();
});

Now, every time the Observer detects changes in the overlay container, he will first execute the detectActiveInput function, so that when the function that manipulates the DatePicker components gets executed, the lastActiveInput variable can tell us which component we are planning to manipulate.

For example, I have 3 DatePicker components on my events page. I named them DayPicker, MonthPicker and YearPicker. I also removed the default Value and Label from each of the components settings and set the Start view for each one to Year

image

In the handleOverlayChangeEvents function, we can use the classList property of the lastActiveInput element to check which DatePicker should be affected by what.

Currently, the function automatically selects the date and month for all DayPickers. To get the MonthPicker working, he needs to be excluded from entering the if (monthPicker) {...} block

const handleOverlayChangeEvents = () => {
    ...
    if (monthPicker) {
        if (lastActiveInput && lastActiveInput.classList.contains('MonthPicker')) return;
    }
    ...
}

With this, the MonthPicker lets you select the year and also the month.
You can probably already guess what we need to do, so the DayPicker lets you select year, month, and a day. He need to be excluded from the if (monthPicker) {...} and the if(datePicker) {...} block

const handleOverlayChangeEvents = () => {
    ...
    if (monthPicker) {
        if (lastActiveInput && (
            lastActiveInput.classList.contains('MonthPicker') ||
            lastActiveInput.classList.contains('DatePicker')
        )) return;
    }
    if (datePicker) {
        if (lastActiveInput && (
            lastActiveInput.classList.contains('DatePicker')
        )) return;
    }
    ...
}

Doing that, all 3 components should now work like we wanted them to.
What we are doing here, is essentially creating a “Blacklist”. A list which, in this case, excludes certain DatePickers from getting manipulated by certain blocks in our function. And your choices of how to customize each DatePicker on the page aren’t limited to that. You could also create “Whitelists”

if (monthPicker) {
    if (lastActiveInput &&
     // "!" makes the difference
        !lastActiveInput.classList.contains("YearPicker")
    ) return;
}

or exclude certain DatePickers entirely from running the functions

const handleOverlayChangeEvents = () => {
    if (lastActiveInput &&
        lastActiveInput.classList.contains("DayPicker")
    ) return;
    ...
}

or make the automatically selected values different for each DatePicker

let autoDay = null;
if (!component) autoDay = "1";
else {
    const classArray = [...component.classList];
    if (classArray.includes("MonthPicker")) autoDay = "17"
    else if (classArray.includes("YearPicker")) autoDay = "28"
}
const calendarDayLabels = [...dayPicker.querySelectorAll('nb-calendar-day-cell:not(.bounding-month) div')];
const firstOfMonth = calendarDayLabels.find(
    (label) => label.textContent.trim() === autoDay;
);

There is something that already appeared in step 1, but wasn’t explained yet. That’s because it’s mainly important for the snippet right above. I’m talking about the :not(.bounding-month) CSS pseudo class we are using in the dayPicker.querySelectorAll() selector. If we look at the calendar widgets date view

you can see here that the last 2 days of the previous month and the first 2 days of the next month are also selectable.
Let’s say, for example, we didn’t add the :not(.bounding-month) pseudo-class to the selector and this DatePicker would be a customized one that automatically selects the date, which you set to be the 30th. If you now try to select May 2018, you’ll actually end up with April 2018. That’s why in that case, the :not(.bounding-month) pseudo selector is significant, it excludes the days from the previous and next month.

I also thought about using the opposite pseudo-class :is, but I can’t recommend doing so. It won’t work consistently because the bounding-month days are not the same for each month over the years.


Optional Step 6 - Categories

Up to now, we have always used the DatePickers name to set some “rules”, like

if (lastActiveInput && lastActiveInput.classList.contains('MonthPicker')) return;

But we can create categories instead, well, kind of.

Instead of naming your component DayPicker, you can name it, for example, Birthday, and set DayPicker as a CSS class in the Styling options of the component setting at the very bottom.

For the action this doesn’t change anything and this way we can give this class to multiple DatePicker components.


Fix for the overlayContainer

[FIX] overlayContainer

The issue, specifically with the div.cdk-overlay-container element, is that for some reason, on a fresh app load, I can see and query the element manually in the DevTools console, but in the action it returns null.
So we need to somehow make it actually render (probably that?) by clicking on the first input element of any DatePicker component we can find. Then quickly click on the document.body, so the calendar widget closes again. If there aren’t any DatePicker found, we can return because then there isn’t anything to do anyway.

let overlayContainer = document.querySelector('.cdk-overlay-container');
if (!overlayContainer) {
  const firstInput = document.querySelector("ub-date-edit input");
  if (!firstInput ) return;
  else firstInput .click();
  await new Promise(resolve => setTimeout(resolve, 100));
  document.body.click();
  overlayContainer = document.querySelector('.cdk-overlay-container');
}

Normally, I would not use nor recommend using an await with a promise that sets a timeout which then resolves the promise after a set amount of time, aka. sleep() because it’s evil, in cases like this.
But ! ! !
Interestingly enough, using 100 milliseconds for the timeout is consistently the perfect amount of time, so the calendar isn’t visible once, and we now can query the overlayContainer.

2 Likes