When it comes to UI for choosing a date, there isn't a 'one-size-fits-all' solution. For example, having a Calendar-type UI to select a Date of Birth is a bad UX; being able to see that it's a Monday is irrelevant. If you're selecting dates for flights however, that fact becomes a lot more important.
A recent use-case I was working on for one of our React Apps was to be able to select a date, or range of dates, within the last couple of years. I wanted to support selection for a single day, a week, a month and year. I ended up with @kaluza/react-datepicker, a DatePicker loosely based around Material UI. (I felt that none of the 1400+ matches for "datepicker" on npm were quite what I was looking for!) This post is going to run through some of the design challenges I faced and how those translated into code. Here's what I ended up with:
There's a lot to cover, so I wont go too deeply into the code, but it is all available to view; just follow the npm links.
Swiping between months
On a mobile device, I don't want my users having to use an arrow to navigate back and forth between months. However, I need to offer arrows for my desktop users. I also don't want the datepicker to jump between months - there should be a nice smooth scroll between the current and next/previous months.
To get this working, I actually render 3 calendars; firstly one for the previous month, then one for the current month, and then finally one for next month. With a little CSS trickery, we can fix the component's width to be wide enough to display just 1 of those calendars. For example, if we set the calendar width to be 400px, we'd actually render 1200px (+(32 * 2)px margin) and then set the containers
scrollLeft property to be 432, meaning we have the 'current month' calendar visible. Everything else is hidden by CSS
To handle swiping between the months, I've put an event listener onto the
onScroll property for the container div. That handler works out if the user is scrolling left or right and cancels it if they're going past the specified min/max dates. There's then a debounced method that fires once the user has stopped scrolling. It works out which way's been scrolled, if it's over the half way mark (between current and previous/next), and then either completes the scroll (
scrollLeft = 0 or
scrollLeft = 10000) or set's the
scrollLeft back to what it was before the user started scrolling (
432, continuing with our example above). Once the scroll has been completed, the "current month" calendar re-renders, and the
scrollLeft is set back to the original value to show the "current" calendar.
A month can have between 4 and 6 weeks in it, so this is something to consider. For example, this month/year - December 2018 - has 6 weeks in it. December 1st falls on a Saturday, and December 31st falls on a Monday. On the other end of the extreme, February 2010 had only 4 weeks - Feb 1st was a Monday, and Feb 28th was a Sunday.
It'd be a pretty terrible UX to move the day columns around, so we always want to have Monday - Sunday, which means we need to handle rendering dates before the 1st of the month, and also after the last day of the month.
To do this, I've got a method that does the following:
Work out which day the 1st of the month is.
For December 2018, this is a Saturday.
Create an array starting from the Monday prior to this day, working out the correct dates for that day.
Continuing with this month, this would be a start of 26th November.
Populate the array all the way up until the 31st of December, and ensure we finish on a Sunday.
This would be 6th January.
Our final Array looks like this.
I calculate the same thing for the previous and next months.
All I need to do now is loop through that, rendering a
<ul> per 7 days, and 7
<li>'s in each. I also add a couple of CSS classes on to the days depending on whether they're in the previous/next month, and also whether the date being rendered is the user-selected date or not - that's what creates the nice blue circle around 16th December in the images above. This bit is a little fiddly, becuase the date should be shown as "selected" in the other (previous/next) calendars, not just in the current calendar. I end up checking the following conditions...
const isSelected = isSelectedInCurrentMonth || isSelectedInCurrentMonthButIsNextMonth || isSelectedInCurrentMonthButIsPreviousMonth || isSelectedInPreviousMonth || isSelectedInPreviousMonthButIsNextMonth || isSelectedInPreviousMonthButIsPreviousMonth || isSelectedInNextMonth || isSelectedInNextMonthButIsNextMonth || isSelectedInNextMonthButIsPreviousMonth;
I'll refactor this at some point; just storing the day number and whether it's the previous/current/next month makes this part of the code overly complicated.
Compared to the previous bit we looked at, this is a relatively simple thing to handle. When one of the
<li>'s are clicked, I just need to set the current selected date to the day we've clicked. If the selected date is in the previous month -- for example, the 30th November -- then I just need to add or subtract 1 month against the month that's been rendered. I also need to consider if the current month is January or December, then the year needs increasing/decreasing as well.
I also wanted to have a very subtle piece of feedback for when a user presses the left/right navigation arrows. This instant feedback is just a simple confirmation that the user's clicked the arrows. It's the little bits of polish that make a big difference!
This is fairly simple to manage. There's a CSS animation using
@keyframes which just sets a
box-shadow property. When an arrow is clicked, a CSS class is applied to the element which has the
animation property set to the defined animation. We allow the animation to run once, and then remove the class we added after a
And the rest...
There's a ton of things the component does that we've not explored, but you can only read about DatePicker's for so long! For example, we have Min and Max dates that can be set on the component and it can be used as a Multiday, Month or Year picker. There are plans, eventually, to have this be a universal component. I need to have a look at internationalisation & localisation. There's a little problem right now where setting the multiday option to select more than 7 days breaks cross-months. There is all sorts of fun I went through around BST and GMT (hint: UTC is your friend!). And some of the heavy lifting is taken care of by
There are loads of datepickers already out there; adding another one to the mix just makes it a little harder to choose which one you want to use. If you do decide to go ahead and roll your own, that's great -- just be aware of some of the complexities discussed in this post!
If you choose to use the DatePicker we've been discussing, it's available right now on npm. As with everything that we open-source, PR's, feature suggestions and issues are all very welcome, just let us know on Github!