DateGallery datepicker tutorial
1. Introduction
We are going to enhance a custom datepicker form element web-component. The web-component we currently have shows an input element which checks if the user entered date is valid, but it is missing one critical feature: a way to select dates visually. This is where the DateGallery comes in.
That we are using vanilla JavaScript which requires us to write our own DOM manipulation inside of a subscriber. When using a reactive framework such as Angular or React, this is not necessary, as these frameworks handle synchronizing with the DOM for us. See Usage with Frameworks for more information.
To get started with the tutorial open and fork this StackBlitz. By using the sandbox you do not have to worry to much about setting up an entire development environment.
2. Files overview
In the sandbox you will see a couple of files, most of them setup the dev environment, but these are the relevant files containing the code:
- index.html contains the HTML for the datepicker, shows a birthday form. It also includes "uiloos" from the UNPKG cdn.
- datepicker.js this file contains an implementation of a web-component that acts as a native HTML form element.
- datepicker.css it contains all styles for the datepicker.
- form.js the logic for the birthday form.
- formatters.js contains various Date formatters.
- utils.js contains a utility for parsing dates.
3. Goals
We want our datepicker to do the following things:
- We want the datepicker to open when clicking a button.
- We want to be able to navigate to the previous and next months.
- It should be possible to only select a single date.
- Have some form of keyboard navigation, to navigate between months and select dates.
- A button so we can quickly go to the current date, otherwise known as a "today" button.
- We want the datepicker to respect the "min" and "max" dates that the web-component accepts.
4. Before we start
First thing you want to do is look a the existing code a bit, to see how you can create a custom form element using web-components.
The power to create actual custom form elements that work nicely in HTML form elements is very exciting!
I recommend reading the following resources first:
- This article on web.dev called: Defining a form-associated custom element is a treasure trove of information on how to create a custom form element.
- For a more technical view read the specs: custom elements on the whatwg website.
If you are new to web-component, do not worry. The code we are going to write in this tutorial has very little to do with them.
Second thing I want you to do is actually use the "uiloos-datepicker" as it is know, play with it a little, enter some dates, see if it gives you error messages when you provide the wrong format, enter a date deep in the past as your birthday etc etc.
Now that you know how the code behind the custom element works, and you understand what it does, we can begin.
5. Opening the dialog
In the file called "datepicker.js" file you will see a method called connectedCallback, in here you will find that the innerHTML is set to give the inputs their appearance.
You will also find an empty dialog element, this is where we want to render our datepicker in. If you are not familiar with the dialog element: it allows you to create modals windows. The benefit of using the dialog element is that you get all sorts of accessibility benefits straight from the browser itself.
If you want to read up on the dialog element see: dialog on MDN for more information.
A modal is good place to show a datepicker in, so that is what we are going to do, but we need a button to open the dialog with.
Add the following button in the "datepicker.js" file, as the last child of the div.datepicker-input-wrapper:
<button
aria-label="Open calendar"
class="calendar-button"
type="button"
>
📅
</button>
To see changes you will have to press the "Refresh" button in the fake browser window.
The effect is that we now have button, lets add a click event listener, as the bottom of the onConnected callback:
// Get the calendar button
this.buttonEl = this.querySelector("button");
this.buttonEl.onclick = () => {
this.showPicker();
};
Now lets define the showPicker method. Add it just above the disconnectedCallback method:
// Form controls that show "pickers" should
// implement a showPicker method.
showPicker() {
// Open the dialog when clicked
this.dialogEl.showModal();
}
The showPicker method should open the picker up, just like a regular input that has a picker. This way we stay true to the browser. For more information see showPicker on MDN.
Clicking the calendar button should now open up the dialog element, which is completely empty for now.
Note: you can close the dialog by pressing ESC and you get that for free. Unfortunately however a dialog does not close when clicking on the backdrop. I had to implement this manually, look for: dialogEl.onclose to see how this is pulled off.
6. Rendering dates
Now that the dialog can be opened, lets add the DateGallery. Add the following code at the bottom of the showPicker method:
// Get a reference to the Datepicker instance, for use
// in the dateGallery subscriber.
const datepicker = this;
this.dateGallery = new window.uiloosDateGallery.DateGallery(
{
mode: "month",
},
(dateGallery) => {
datepicker.dialogEl.innerHTML = `Hello world!`;
},
);
This will instantiate the DateGallery in the "month" mode. The second argument is the subscriber function, which will get called whenever the DateGallery changes.
Whenever a change occurs we are simply going to re-create the dialog again, this "nuke" the HTML to re-render approach is performant for small chunks of HTML.
Lets change the "Hello world!" to something that resembles as datepicker a little more:
<div class="datepicker-dialog-content">
<div class="topbar">
<button aria-label="previous" type="button">‹</button>
<span class="title"></span>
<button aria-label="next" type="button">›</button>
</div>
<ul class="daygrid">
<li><abbr title="Sunday">S</abbr></li>
<li><abbr title="Monday">M</abbr></li>
<li><abbr title="Tuesday">T</abbr></li>
<li><abbr title="Wednesday">W</abbr></li>
<li><abbr title="Thursday">T</abbr></li>
<li><abbr title="Friday">F</abbr></li>
<li><abbr title="Saturday">S</abbr></li>
</ul>
<ul class="dates daygrid"></ul>
<div class="bottombar">
<button type="button">Cancel</button>
<button type="button">Today</button>
</div>
</div>
The ul.dates element is what is going to hold the dates. Lets add them, add the following code inside of the subscriber:
const datesEl = datepicker.dialogEl.querySelector(".dates");
dateGallery.firstFrame.dates.forEach((dateObj) => {
const dayEl = document.createElement("li");
dayEl.innerHTML = `
<button
aria-label="Select ${dateFormatter.format(dateObj.date)}"
type="button"
>
<time datetime="${dateObj.date.toISOString()}">
${dateObj.date.getDate()}
</time>
</button>
`;
// + 1 since CSS grids start at 1 and not 0.
dayEl.style.gridColumn = dateObj.date.getDay() + 1;
datesEl.appendChild(dayEl);
});
Lets try to understand what we have done, by understanding the DateGallery a little better:
A DateGallery shows a number of frames, a frame (represented by the class DateGalleryFrame) is an array containing DateGalleryDate's, which in turn represents a "date".
The idea is that based on the mode of the DateGallery the frames contain different amount of dates. When the mode is "week" a frame contains 7 days, when the mode is year a frame contain 365 days.
Since our mode is "month" one months worth of dates are contained in a frame. Since we have not configured the amount of frames, the DateGallery will default to having a single frame.
We use the firstFrame shortcut to access the first frame instead of using frames[0]. This makes the code a little easier to follow.
Now we just loop over each date in the firstFrame.dates and render each date as a li. Each date is a button so they can be selected, and has a time for screen readers.
The ul.dates is a CSS Grid with seven columns, one for each day of the week. So whenever we render a date we must place it into the correct gridColumn. Remember: getDay() returns the day of the week, not the day of the month!
The last thing I want to do is set the title, so we can see which month and year are shown. Add the following code just below the setting of the dialogs innerHTML:
const titleElement = datepicker.dialogEl.querySelector(
".title"
);
titleElement.textContent = monthYearFormatter.format(
dateGallery.firstFrame.anchorDate,
);
The monthYearFormatter is a Intl.DateTimeFormat instance: which is the browsers native way to format human readable dates.
The anchorDate can be seen as the first date within the frame, ideal to be used for purposes such as adding a title.
Now "refresh" the fake browser and take a look at our beautiful datepicker.
7. Navigation
Our datepicker is still static, and stuck on the current month, lets make the buttons work, add the following underneath in the subscriber:
const [prevButtonEl, nextButtonEl] = Array.from(
datepicker.dialogEl.querySelectorAll(".topbar button"),
);
prevButtonEl.onclick = () => {
dateGallery.previous();
};
nextButtonEl.onclick = () => {
dateGallery.next();
};
const [cancelButtonEl, todayButtonEl] = Array.from(
datepicker.dialogEl.querySelectorAll(".bottombar button"),
);
cancelButtonEl.onclick = () => {
this.dialogEl.close();
};
todayButtonEl.onclick = () => {
this.dateGallery.today();
};
Now you can move to the next and previous frames by clicking the chevrons / arrows, and go to todays date by clicking "Today".
All these actions simply call the appropriate method of the DateGallery instance. Letting the DateGallery do all the heavy lifting.
8. Selection
A datepicker without the ability to select dates is not a datepicker, lets remedy that, just before datesEl.appendChild(dayEl); add:
dayEl.onclick = () => {
const date = dateFormatter.format(dateObj.date);
this._changeDate(date);
this.dialogEl.close();
};
Whenever the day button is clicked, the date get selected, and the dialog element is closed.
One annoying thing is that the datepicker does not open up at the selected date. To see it for yourself: select birthday two months in the past and open the datepicker again.
To fix this we need to initialize the DateGallery with an initial date and selected dates, change the creation / instantiation of the DateGallery to:
// Default to the current day
let initialDate = new Date();
// but if there is a value
if (this.value) {
const date = parseAsDate(this.value);
// and it is valid
if (isValid(date)) {
// select that date as the initial date
initialDate = date;
}
}
this.dateGallery = new window.uiloosDateGallery.DateGallery(
{
mode: "month",
initialDate,
selectedDates: [initialDate],
},
The datepicker opens up at the correct date but does not visually show which date is selected. Lets show the selected date in blue, add this before the dayEl.onclick:
// Get the button and apply styles
// based on the dateObj's state.
const buttonEl = dayEl.firstElementChild;
if (dateObj.isSelected) {
buttonEl.classList.add("selected");
}
The nice thing about a DateGalleryDate is that it knows all sorts of things about the date, whether or not it has events, whether or not it represents today, and what we have been using: isSelected for whether or not the date is selected.
All this information you can use to set your CSS classes. For example you could set a CSS class .today whenever dateObj.isToday is true.
Do A quick refresh and the selected date should now be blue.
9. Min and max
The "uiloos-datepicker" supports a min and max date, this way you cannot set your birthday to a date in the future. Our DateGallery should also respect this.
Change the constructor of the DateGallery once again:
this.dateGallery = new window.uiloosDateGallery.DateGallery(
{
mode: "month",
initialDate,
selectedDates: [initialDate],
maxSelectionLimit: 1,
canSelect(dateObj) {
if (datepicker.min && dateObj.date < datepicker.min) {
return false;
} else if (datepicker.max && dateObj.date > datepicker.max) {
return false;
}
return true;
},
},
By providing a canSelect function we can control which dates can and cannot be selected.
Now all we have to do is disable day buttons which cannot be selected, add this below the dateObj.isSelected if-statement:
if (!dateObj.canBeSelected) {
buttonEl.disabled = true;
}
By disabling the button the "onclick" event handler will no longer get called. Also note that disabled dates are greyed due to a CSS rule.
10. Keyboard navigation
It would be nice if we could select dates using the keyboard, so lets add that, add the following code at the bottom of the showPicker method:
this.dialogEl.onkeydown = (event) => {
if (
["ArrowLeft", "ArrowRight", "ArrowUp", "ArrowDown"].includes(event.key)
) {
// Copy date as not to mutate the selected date.
const date = new Date(this.dateGallery.selectedDates.at(0));
// Mutate the date based on the arrow keys
if (event.key === "ArrowLeft") {
date.setDate(date.getDate() - 1);
} else if (event.key === "ArrowRight") {
date.setDate(date.getDate() + 1);
} else if (event.key === "ArrowUp") {
date.setDate(date.getDate() - 7);
} else if (event.key === "ArrowDown") {
date.setDate(date.getDate() + 7);
}
// Select the date so it highlights in blue.
this.dateGallery.selectDate(date);
// Change the initialDate (changes the frames) so the user
// can navigate to other months.
this.dateGallery.changeConfig({ initialDate: date });
} else if (event.key === "Enter") {
// When enter is pressed close the dialog and set
// the value to the selected date.
// We do not want the dialog to open again.
event.preventDefault();
const date = dateFormatter.format(this.dateGallery.selectedDates[0]);
this._changeDate(date);
this.dialogEl.close();
}
};
What the above code does is listen to the arrow keys, and move the selected date based on which arrow is pressed. You should try it out.
When calling setDate on a Date object you can change the date. The funny thing about Date is that it rolls over to the next year or month automatically.
For example: if you set a date to the first of the month, and do date.setDate(date.getDate() - 1); the date is now at the last day of the previous month.
One important thing to know about Date objects is that they are mutable! If you call setDate you actually change that date. This can lead to bugs if you accidentally share Date objects.
That is why it is important to always clone / copy dates whenever you mutate a Date coming from the DateGallery.
11. Cleanup
When a web-component is disconnected from the DOM the disconnectedCallback is called. To cleanup after the DateGallery you need to add these two lines:
// Also clean up the dateGallery and button
this.dateGallery = null;
this.buttonEl = null;
We should also clear the DateGallery whenever we close the model, find the dialogEl.onclose and add the following:
// Clear the dateGallery whenever the modal is closed as
// it is no longer needed.
this.dateGallery = null;
14. What you have learned
- That the subscriber receives all events that take place on the DateGallery, and that in the subscriber you must sync the DOM with what occurred.
- That a DateGallery has one or multiple DateGalleryFrame's, which in turn has one or more DateGalleryDate's.
- That firstFrame is a shortcut to the first frame in the frames array.
- That you can move between frames using the next() and previous() methods.
- That you can select dates via selectDate().
15. Further reading
- Read through the API of the DateGallery, DateGalleryFrame, DateGalleryEvent, and DateGalleryDate.
- You can also create calendars using the DateGallery which shows events. Check out the tutorial to learn how to create a week calendar.
- When using vanilla JavaScript you must handle DOM manipulation yourself, contrast this with the examples that use a framework.
16. Full code
For reference here is the complete code for this tutorial.
A more fully fledged example can be found here at the reference implementation of a datepicker in vanilla JS. It also shows you how to create a date range picker, and how to create a calendar.