惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

L
LINUX DO - 最新话题
I
InfoQ
V
V2EX
博客园 - 叶小钗
博客园 - 三生石上(FineUI控件)
爱范儿
爱范儿
T
Tailwind CSS Blog
大猫的无限游戏
大猫的无限游戏
IT之家
IT之家
奇客Solidot–传递最新科技情报
奇客Solidot–传递最新科技情报
阮一峰的网络日志
阮一峰的网络日志
博客园 - 聂微东
月光博客
月光博客
Hugging Face - Blog
Hugging Face - Blog
小众软件
小众软件
Last Week in AI
Last Week in AI
量子位
钛媒体:引领未来商业与生活新知
钛媒体:引领未来商业与生活新知
G
Google Developers Blog
MongoDB | Blog
MongoDB | Blog
博客园 - 司徒正美
腾讯CDC
罗磊的独立博客
有赞技术团队
有赞技术团队
Jina AI
Jina AI
T
The Exploit Database - CXSecurity.com
C
CERT Recently Published Vulnerability Notes
freeCodeCamp Programming Tutorials: Python, JavaScript, Git & More
Threat Intelligence Blog | Flashpoint
Threat Intelligence Blog | Flashpoint
V
Visual Studio Blog
H
Help Net Security
AWS News Blog
AWS News Blog
C
Cybersecurity and Infrastructure Security Agency CISA
Recorded Future
Recorded Future
GbyAI
GbyAI
Recent Commits to openclaw:main
Recent Commits to openclaw:main
Attack and Defense Labs
Attack and Defense Labs
Webroot Blog
Webroot Blog
S
SegmentFault 最新的问题
Know Your Adversary
Know Your Adversary
The GitHub Blog
The GitHub Blog
I
Intezer
博客园 - Franky
云风的 BLOG
云风的 BLOG
N
News and Events Feed by Topic
P
Proofpoint News Feed
D
DataBreaches.Net
Google DeepMind News
Google DeepMind News
cs.CL updates on arXiv.org
cs.CL updates on arXiv.org
G
GRAHAM CLULEY

ValeriaVG

Agentic Development Survival Guide Simplicity Police The sunset of Arrogance as a Service How do I delegate when I can do it faster myself? Plug & Play Modular Architecture for Scalable and Maintainable Apps ES Modules & Import Maps: Back to the Future Me & React: 5 Years in 15 Minutes 10 Reasons NOT to Use Go for Your Next Project How Build a Web App in 11 Minutes and Fall in Love With Sveltekit Master Git in 7 Minutes Crc 32 Checksum in Wasm and Raw Js Tutorial and Benchmark Master Binary in Five Minutes Five Pro Tips to Master Promises in Js How to Use Custom Files as Modules in Nodejs How to Do Magic With Numbers 5 Ways to Use Redis in Your Next Project
Implementing custom Calendar component
Valeria Viana Gusmao · 2024-10-20 · via ValeriaVG

HTML comes with a lot of ready to use and flexible elements, but date selector has a handful of limitations and the need to write your own calendar / date input emerges sooner rather than later. In this tutorial I'll walk you through implementing a calendar view and show how you can extend its functionality to fit your booking widget or dashboard filter.

Here's how the final result might look like:

May, 2026
SunMonTueWedThuFriSat

Building view

Building a calendar might sound challenging at first: you could wonder what function to use to get days in a month or how would you handle date range selection; and thus I suggest to start with something easier and less uncertain: a static representation of one specific month.

For example something like this:

See the Pen Calendar View by Valeria (@valeriavg-the-flexboxer) on CodePen.

I used semantic markup: table for the days table, th for the table headers and button for the calendar days that can be selected. This should reduce potential accessibility issues to a minimum since screen readers are pretty good at reading standard html and browsers already have all required keyboard navigation handlers assigned.

If you're, like me, residing in a country that counts weekdays from Monday, I still suggest starting with a Sunday-first calendar and change the order later on to match how JavaScript Date does that.

I encourage you to spend some time styling the calendar till it matches your vision before we move on to implement a function that would be able to create such view for any month of your choice.

Generating calendar matrix

If we are to strip the markup from above to the bare bones we would end up with the following data structure:

[ [null, null, 1, 2, 3, 4, 5], [6, 7, 8, 9, 10, 11, 12], [13, 14, 15, 16, 17, 18, 19], [20, 21, 22, 23, 24, 25, 26], [27, 28, 29, 30, 31, null, null] ]

We have a table with seven rows, a.k.a. matrix, where each row represents a tr in a table and each cell represents a td within that row. Implementing JSX component from such structure would be trivial:

export default function CalendarView({ month, year, days, }: { month: string; year: number; days: Array<Array<number | null>>; }) { return ( <section> <header> {month}, {year} </header> <table> <thead> <tr> <th>Sun</th> <th>Mon</th> <th>Tue</th> <th>Wed</th> <th>Thu</th> <th>Fri</th> <th>Sat</th> </tr> </thead> <tbody> {days.map((row) => ( <tr> {row.map((day) => ( <td>{Boolean(day) && <button>{day}</button>}</td> ))} </tr> ))} </tbody> </table> </section> ); }

Now we "just" need to generate said matrix for every month of every year 😅.

Note: I highly recommend creating a separate Calendar component that would hold the state and logic and would call CalendarView with the proper props to avoid over complicating the latter.

If you're up for a challenge, feel free to use these test cases to try and write a function that would generate a proper matrix so that all the tests would pass (including the commented out test checking all the calendars in the range of 1970 and 2100):

Note: function createCalendar, similarly to Date itself, accepts integer month in a range from 0 to 11, therefore March, for example, would be 4 and not 5 due to that.

Here is a hint if you need one

const lastDay = new Date(year, month + 1, 0);

And the whole solution if you prefer:

createCalendar function

export default function createCalendar({ month, year, }: { month: number; year: number; }) { const days: Array<Array<number | null>> = []; const firstDay = new Date(year, month, 1); days.push(new Array(firstDay.getDay()).fill(null)); const lastDay = new Date(year, month + 1, 0); for (let i = 1; i <= lastDay.getDate(); i++) { if (days[days.length - 1].length === 7) days.push([]); days[days.length - 1].push(i); } while (days[days.length - 1].length < 7) { days[days.length - 1].push(null); } return days; }

Day states

Now with an actual calendar let's try and highlight today's date and selected date.

How to check if a day is today

const today = new Date(); const isToday = (day: number) => today.getFullYear() === year && today.getMonth() === month && today.getDate() === day;

Then you just need to define a proper class for such case and assign it to the day button:

<button className={cx("day", isToday(day) && "today")}>{day}</button>;

Function cx I use comes from @emotion/css package I use, but you can write it yourself:

export const cx = (...classNames: Array<string | null | undefined | false>) => classNames.filter(Boolean).join(" ");

Let's do the same for the selected dates. For now add e.g. selectedDays property to the CalendarView and add a function to check is a certain day is selected:

// selectedDays?: Array<{day: number, month: number, year: number}> const isSelected = (day: number) => !!selectedDays?.find( (d) => d.year === year && d.month === month && d.day === day, );

And apply desired styles:

<button className={cx("day", isToday(day) && "today", isSelected(day) && "selected")} > {day} </button>;

Note: as you can see I'm avoiding using Date for storing calendar dates. This is intentional as I want to avoid issues due to time zones, since we're only interested in date and not specific time, but JavaScript is lacking a built in type for that.

Handling data selection

Finally we've got to the most important part of functionality: actually being able to select dates!

Let's start with defining the interface for our component. Similarly to design driven development where we ensure that code reflects UX, it's easier to write a function if we know what we want to pass as an input there.

I think I'd like to be able to pass any previously selected date into the Calendar and have a callback function to notify when user has made it's selection, something like that:

export default function Calendar({ onSelect, selectedDate, }: { onSelect: (date: string) => void; selectedDate?: string; }) { /* ... */ }

Depending on your needs, you should adjust the definition, for example if you'd like to handle both selecting one date and interval you could write the following code:

export default function Calendar({ onSelect, selectedDates, }: { onSelect: (...dates: string[]) => void; selectedDates?: [string] | [string, string]; });

Or you could create a component for selecting two dates and a component for selecting one date, since you can reuse CalendarView for either of the cases.

We've previously added selectedDays to CalendarView, so we only need to map dates to {day, month, year} format:

const date = new Date(selectedDate); const selectedDays = [{ year: date.getFullYear(), month: date.getMonth(), day: date.getDate(), }];

Handling the selection itself is even easier. Let's add onDayClick prop in the CalendarView for it:

export default function CalendarView({ month, year, days, onDayClick = () => { // <-- providing default "no operation" handler /*noop*/ // <-- this comment there is to appease my linter that catches unintentional empty functions }, selectedDays, }: { month: number; year: number; days: Array<Array<number | null>>; onDayClick?: (day: Day) => void; // <--- optional, because we defined a default value selectedDays?: Day[]; });

And finally, add it to day button.onClick:

<td> {!!day && ( <button className={cx( styles.day, isToday(day) && styles.today, isSelected(day) && styles.selected, )} onClick={() => onDayClick({ day, month, year })} > <span>{day}</span> </button> )} </td>;

You've probably heard about controlled and uncontrolled inputs in the context of React components and we can make a similar architectural decision for our Calendar component: would it have it's own state or would it be driven by parent component?

Given that we already have CalendarView that is fully controlled by it's props, I makes more sense to let Calendar handle logic and state.

For a single date selection we can utilize useState hook:

const [selectedDay, setSelectedDay] = useState< { day: number; month: number; year: number } >(defaultValue);

Then we pas setSelectedDay to CalendarView.onDayClick and done ✅

But when it comes to selecting two dates, the logic is much more complicated:

  • User clicks on the first day
  • User hovers over another day
  • User clicks on the second day

Calendar in this case could only transition between certain "states", e.g. if user hasn't selected the first day they yet can neither select the second one nor highlight date they hover. This model is usually referred as a finite-state machine and React (as well as Preact and other renditions) have a hook called useReducer for that case.

Calendar reducer

Reducer is a simple function that accepts two parameters: previous state and action object and returns next state:

type CalendarState = { month: number; year: number; selectedDays?: [Day] | [Day, Day]; hoverDay?: Day; isDirty: boolean; }; type CalendarAction = | { type: "select_day"; day: Day; } | { type: "hover_day"; day: Day; }; const calendarReducer = ( prevState: CalendarState, action: CalendarAction, ): CalendarState => { return prevState; };

Unlike a hook, a reducer doesn't need to be defined within a component and can be moved into it's own file and be unit tested just like any other function.

Here are some ideas how you could write tests for it:

describe("calendarReducer", () => { it.todo("should only allow to select first date when no dates are selected"); it.todo("should only allow to hover on a day when first day is selected"); it.todo("should only clear hover on day selection"); it.todo("should only allow to select second date when first one is selected"); });

Here's how I'd handle date selection:

const calendarReducer = ( prevState: CalendarState, action: CalendarAction, ): CalendarState => { if (action.type === "select_day") { if (prevState.selectedDays?.length === 1) { return { ...prevState, hoverDay: undefined, selectedDays: [prevState.selectedDays[0], action.day].sort( (a, b) => new Date(a.year, a.month, a.day).getTime() - new Date(b.year, b.month, b.day).getTime(), ) as [Day, Day], isDirty: true, }; } return { ...prevState, hoverDay: undefined, selectedDays: [action.day] as [Day], isDirty: true, }; } return prevState; };

Note: Do not modify prevState. You should either pass it as is to indicate that no changes were made or create a new object to trigger re-render of your component.

With that in place we can add the following to the Calendar component:

const [state, dispatch] = useReducer( calendarReducer, createDefaultState(selectedDates), ); const days = createCalendar({ month: state.month, year: state.year }); const onDayClick = (day: Day) => { dispatch({ type: "select_day", day }); };

That should handle the selection of multiple dates as well as lay out the foundation for switching between the months and years. The only missing part is calling onSelect when the selection is made.

We can't really incorporate it into a reducer, since it's outside of Calendar, but useEffect hook would come in handy.

Here's how mine looks like

useEffect(() => { if (state.isDirty && state.selectedDays?.length === 2) { if ( state.selectedDays[0].day === state.selectedDays[1].day && state.selectedDays[0].month === state.selectedDays[1].month && state.selectedDays[0].year === state.selectedDays[1].year ) { return onSelect( new Date( state.selectedDays[0].year, state.selectedDays[0].month, state.selectedDays[0].day, 12, ) .toISOString() .split("T") .shift()!, ); } onSelect( ...(state.selectedDays.map( (d) => new Date(d.year, d.month, d.day, 12) .toISOString() .split("T") .shift()!, ) as [string, string]), ); } }, [state.isDirty, state.selectedDays]);

And that's pretty much a fully functional calendar that works with keyboard navigation (try Tab,Shift + Tab and Enter), mobile touches and mouse out of the box!

Let's add some finishing touches to user experience.

Displaying hovered state and date intervals

Take a look at this example from Material Design library: Date picker with two dates selected and a pale background indicating dates that belong to this interval

Every day in the selected interval is highlighted and "connects" selected dates with an uninterrupted line.

To achieve this effect we need to go back to CalendarView and add a span element to the day button and move the selection state there:

<td> {!!day && ( <button className={cx( "day", )} onClick={() => onDayClick({ day, month, year })} > <span className={cx( "day", isToday(day) && "today", isSelected(day) && "selected", )} > {day} </span> </button> )} </td>;

This way we can use button container to add a background when day is covered, alter edge days borders and have the selected and today date highlighted same as before.

You'll probably need to adjust the "day" style and make sure that span takes all the space in the button to fix this:

Small selected text inside the button

It can be done by setting display to flex (as span is inline by default) and setting align-items and justify-content value to center

Let's define a style for the "hovered" day or a date in the selected interval, for example:

.hovered { background-color: pink; border-radius: 0; }

And a function to determine if the day is within the interval:

const isBetweenSelected = (day: number) => { if (selectedDays?.length !== 2) return false; const interval = selectedDays.map((d) => new Date(d.year, d.month, d.day)); const date = new Date(year, month, day); return ( interval[0].getTime() < date.getTime() && interval[1].getTime() > date.getTime() ); };

Note: this is possible because we're sorting state on every date selection therefore we can trust that days are always ordered in ascending order (older first).

But when applied to button class name the effect is not exactly as he hoped for: Interval with pink buttons

To change that let's remove the gap between table cells:

table { border-spacing: 0; border-collapse: collapse; }

Add add these properties to the table:

<table cellpadding={0} cellspacing={0}>

Additionally, you might need to increase the size of cells or reduce the text size in the table header as "Wed" was pushing all the cells in the column to be a bit wider than all others:

.day { width: 2.5rem; height: 2.5rem; min-width: 2.5rem; /* makes flex to be less flexible */ min-height: 2.5rem; /* and keep the size intact */ }

Fixed selected interval

You could notice that there's a bit of a gap between selected date and the interval, to fix that we could add same background to the selected days and apply borders for the left and right side, e.g.:

.is-first-selected { border-top-left-radius: 0.5rem; border-bottom-left-radius: 0.5rem; } .is-last-selected { border-top-right-radius: 0.5rem; border-bottom-right-radius: 0.5rem; }

And apply it to the button:

<button className={cx( "day", isBetweenSelected(day) && "hovered", isFirstSelected(day) && "isFirstSelected", isLastSelected(day) && "isLastSelected", )} onClick={() => onDayClick({ day, month, year })} > <span className={cx( "day", isToday(day) && "today", isSelected(day) && "selected", )} > {day} </span> </button>;

Note: You might also want to change isBetweenSelected to include first & last date or add same background to the last & first selected classes.

isBetweenSelected, isFirstSelected and isLastSelected functions

const isBetweenSelected = (day: number) => { if (selectedDays?.length !== 2) return false; const interval = selectedDays.map((d) => new Date(d.year, d.month, d.day)); const date = new Date(year, month, day); return ( interval[0].getTime() <= date.getTime() && interval[1].getTime() >= date.getTime() ); }; const isFirstSelected = (day: number) => { if (!selectedDays?.length) return false; const d = selectedDays[0]; return d.year === year && d.month === month && d.day === day; }; const isLastSelected = (day: number) => { if (selectedDays?.length !== 2) return false; const d = selectedDays[1]; return d.year === year && d.month === month && d.day === day; };

Implementing hover functionality should be quite straightforward by using the same approach we used with selectedDays and I'll leave it to you to figure out, but if you are interested in the exact code that fuels example, feel free to check it on GitHub.

Challenge: Remember how I said you could come back and implement a calendar that starts from Monday later on? Now is the time to try and tackle this challenge! Good luck!