Let’s Make a Vue-Powered Monthly Calendar
Have you ever seen a calendar on a webpage and thought, how the heck did they did that? For something like that, it might be natural to reach for a plugin, or even an embedded Google Calendar, but it’s actually a lot more straightforward to make one than you might think. Especially when we use the component-driven power of Vue.
I’ve set up a demo over at CodeSandbox so you can see what we’re aiming for, but it’s always a good idea to spell out what we’re trying to do:
- Create a month view grid that displays the days of the current month
- Display dates from the previous and next months to so the grid is always full
- Indicate the current date
- Show the name of the currently selected month
- Navigate to the previous and next month
- Allow the user to navigate back to the current month with a single click
Oh, and we’ll build this as a single page application that fetches calendar dates from Day.js, a super light utility library.
Step 1: Start with the basic markup
We’re going to jump straight into templates. If you’re new to Vue, Sarah’s introduction series is a nice place to start. It’s also worth noting that I’ll be linking to the Vue 2 docs throughout this post. Vue 3 is currently in beta and the docs for it are subject to change.
Let’s start with creating a basic template for our calendar. We can outline our markup as three layers where we have:
- A section for the calendar header. This will show components with the currently selected month and the elements responsible for paginating between months.
- A section for the calendar grid header. A table header that holds a list containing the days of the week, starting with Monday.
- The calendar grid. You know, each day in the current month, represented as a square in the grid.
Let’s write this up in a file called CalendarMonth.vue
. This will be our main component.
<!-- CalendarMonth.vue -->
<template>
<!-- Parent container for the calendar month -->
<div class="calendar-month">
<!-- The calendar header -->
<div class="calendar-month-header"
<!-- Month name -->
<CalendarDateIndicator />
<!-- Pagination -->
<CalendarDateSelector />
</div>
<!-- Calendar grid header -->
<CalendarWeekdays />
<!-- Calendar grid -->
<ol class="days-grid">
<CalendarMonthDayItem />
</ol>
</div>
</template>
Now that we have some markup to work with, let’s go one step further and create required components.
Step 2: Header components
In our header we have two components:
CalendarDateIndicator
shows the currently selected month.CalendarDateSelector
is responsible for paginating between months.
Let’s start with CalendarDateIndicator
. This component will accept a selectedDate
property which is a Day.js object that will format the current date properly and show it to the user.
<!-- CalendarDateIndicator.vue -->
<template>
<div class="calendar-date-indicator">{{ selectedMonth }}</div>
</template>
<script>
export default {
props: {
selectedDate: {
type: Object,
required: true
}
},
computed: {
selectedMonth() {
return this.selectedDate.format("MMMM YYYY");
}
}
};
</script>
That was easy. Let’s go and create the pagination component that lets us navigate between months. It will contain three elements responsible for selecting the previous, current and next month. We’ll add an event listener on those that fires the appropriate method when the element is clicked.
<!-- CalendarDateSelector.vue -->
<template>
<div class="calendar-date-selector">
<span @click="selectPrevious">﹤</span>
<span @click="selectCurrent">Today</span>
<span @click="selectNext">﹥</span>
</div>
</template>
Then, in the script section, we will set up two props that the component will accept:
currentDate
allows us to come back to current month when the “Today” button is clicked.selectedDate
tells us what month is currently selected.
We will also define methods responsible for calculating the new selected date based on the currently selected date using the subtract
and add
methods from Day.js. Each method will also $emit
an event to the parent component with the newly selected month. This allows us to keep the value of selected date in one place — which will be our CalendarMonth.vue
component — and pass it down to all child components (i.e. header, calendar grid).
// CalendarDateSelector.vue
<script>
import dayjs from "dayjs";
export default {
name: "CalendarDateSelector",
props: {
currentDate: {
type: String,
required: true
},
selectedDate: {
type: Object,
required: true
}
},
methods: {
selectPrevious() {
let newSelectedDate = dayjs(this.selectedDate).subtract(1, "month");
this.$emit("dateSelected", newSelectedDate);
},
selectCurrent() {
let newSelectedDate = dayjs(this.currentDate);
this.$emit("dateSelected", newSelectedDate);
},
selectNext() {
let newSelectedDate = dayjs(this.selectedDate).add(1, "month");
this.$emit("dateSelected", newSelectedDate);
}
}
};
</script>
Now, let’s go back to the CalendarMonth.vue
component and use our newly created components.
To use them we first need to import and register the components, also we need to create the values that will be passed as props to those components:
today
?properly formats today’s date and is used as a value for the “Today” pagination button.selectedDate
?is the ?currently selected date (set to today’s date by default).
The last thing we need to do before we can render the components is create a method that’s responsible for changing the value of selectedDate
. This method will be fired when the event from the pagination component is received.
// CalendarMonth.vue
<script>
import dayjs from "dayjs";
import CalendarDateIndicator from "./CalendarDateIndicator";
import CalendarDateSelector from "./CalendarDateSelector";
export default {
components: {
CalendarDateIndicator,
CalendarDateSelector
},
data() {
return {
selectedDate: dayjs(),
today: dayjs().format("YYYY-MM-DD")
};
},
methods: {
selectDate(newSelectedDate) {
this.selectedDate = newSelectedDate;
}
}
};
</script>
Now we have everything we need to render our calendar header:
<!-- CalendarMonth.vue -->
<template>
<div class="calendar-month">
<div class="calendar-month-header">
<CalendarDateIndicator
:selected-date="selectedDate"
class="calendar-month-header-selected-month"
/>
<CalendarDateSelector
:current-date="today"
:selected-date="selectedDate"
@dateSelected="selectDate"
/>
</div>
</div>
</template>
This is a good spot to stop and see what we have so far. Our calendar header is doing everything we want, so let’s move forward and create components for our calendar grid.
Step 3: Calendar grid components
Here, again, we have two components:
CalendarWeekdays
shows the names of the weekdays.CalendarMonthDayItem
represents a single day in the calendar.
The CalendarWeekdays
component contains a list that iterates through the weekday labels (using the v-for
directive) and renders that label for each weekday. In the script section, we need to define our weekdays and create a computed
property to make it available in the template and cache the result to prevent us from having to re-calculate it in the future.
// CalendarWeekdays.vue
<template>
<ol class="day-of-week">
<li
v-for="weekday in weekdays"
:key="weekday"
>
{{ weekday }}
</li>
</ol>
</template>
<script>
const WEEKDAYS = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"];
export default {
name: 'CalendarWeekdays',
computed: {
weekdays() {
return WEEKDAYS
}
}
}
</script>
Next is CalendarMonthDayItem
. It’s a list item that receives a day
property that is an object, and a boolean prop, isToday
, that allows us to style the list item to indicate that it’s the current date. We also have one computed
property that formats the received day object to our desired date format (D
, or the numeric day of the month).
// CalendarMonthDayItem.vue
<template>
<li
class="calendar-day"
:class="{
'calendar-day--not-current': !isCurrentMonth,
'calendar-day--today': isToday
}"
>
<span>{{ label }}</span>
</li>
</template>
<script>
import dayjs from "dayjs";
export default {
name: "CalendarMonthDayItem",
props: {
day: {
type: Object,
required: true
},
isCurrentMonth: {
type: Boolean,
default: false
},
isToday: {
type: Boolean,
default: false
}
},
computed: {
label() {
return dayjs(this.day.date).format("D");
}
}
};
</script>
OK, now that we have these two components, let’s see how we can add them to our CalendarMonth
component.
We first need to import and register them. We also need to create a computed
property that will return an array of objects representing our days. Each day contains a date
property and isCurrentMonth
property.
// CalendarMonth.vue
<script>
import dayjs from "dayjs";
import CalendarMonthDayItem from "./CalendarMonthDayItem";
import CalendarWeekdays from "./CalendarWeekdays";
export default {
name: "CalendarMonth",
components: {
// ...
CalendarMonthDayItem,
CalendarWeekdays
},
computed: {
days() {
return [
{ date: "2020-06-29", isCurrentMonth: false },
{ date: "2020-06-30", isCurrentMonth: false },
{ date: "2020-07-01", isCurrentMonth: true },
{ date: "2020-07-02", isCurrentMonth: true },
// ...
{ date: "2020-07-31", isCurrentMonth: true },
{ date: "2020-08-01", isCurrentMonth: false },
{ date: "2020-08-02", isCurrentMonth: false }
];
}
}
};
</script>
Then, in the template, we can render our components. Again, we use the v-for
directive to render the required number of day elements.
<!-- CalendarMonth.vue -->
<template>
<div class="calendar-month">
<div class="calendar-month-header">
// ...
</div>
<CalendarWeekdays/>
<ol class="days-grid">
<CalendarMonthDayItem
v-for="day in days"
:key="day.date"
:day="day"
:is-today="day.date === today"
/>
</ol>
</div>
</template>
OK, things are starting to look good now. Have a look at where we are. It looks nice but, as you probably noticed, the template only contains static data at the moment. The month is hardcoded as July and the day numbers are hardcoded as well. We will change that by calculating what date should be shown on a specific month. Let’s dive into the code!
Step 4: Setting up current month calendar
Let’s think how we can calculate the date that should be shown on a specific month. That’s where Day.js really comes into play. It provides all the data we need to properly place dates on the correct days of the week for a given month using real calendar data. It allows us to get and set anything from the start date of a month to all the date formatting options we need to display the data.
We will:
- Get the current month
- Calculate where the days should be placed (weekdays)
- Calculate the days for displaying dates from the previous and next months
- Put all of the days together in a single array
We already have Day.js imported in our CalendarMonth
component. We’re also going to lean on a couple of Day.js plugins for help. WeekDay helps us set the first day of the week. Some prefer Sunday as the first day of the week. Other prefer Monday. Heck, in some cases, it makes sense to start with Friday. We’re going to start with Monday.
The WeekOfYear plugin returns the numeric value for the current week out of all weeks in the year. There are 52 weeks in a year, so we’d say that the week starting January 1 is the the first week of the year, and so on.
Here’s what we put into CalendarMonth.vue
to put all of that to use:
// CalendarMonth.vue
<script>
import dayjs from "dayjs";
import weekday from "dayjs/plugin/weekday";
import weekOfYear from "dayjs/plugin/weekOfYear";
// ...
dayjs.extend(weekday);
dayjs.extend(weekOfYear);
// ...
That was pretty straightforward but now the real fun starts as we will now play with the calendar grid. Let’s stop for a second a think what we really need to do to get that right.
First, we want the date numbers to fall in the correct weekday columns. For example, July 1, 2020, is on a Wednesday. That’s where the date numbering should start.
If the first of the month falls on Wednesday, then that means we’ll have empty grid items for Monday and Tuesday in the first week. The last day of the month is July 31, which falls on a Friday. That means Saturday and Sunday will be empty in the last week of the grid. We want to fill those with the trailing and leading dates of the previous and next months, respectively, so that the calendar grid is always full.
Adding dates for the current month
To add the days of the current month to the grid, we need to know how many days exist in the current month. We can get that using the daysInMonth
method provided by Day.js. Let’s create a computed
property for that.
// CalendarMonth.vue
computed: {
// ...
numberOfDaysInMonth() {
return dayjs(this.selectedDate).daysInMonth();
}
}
When we know that, we create an empty array with a length that’s equal to number of days in the current month. Then we map()
that array and create a day object for each one. The object we create has an arbitrary structure, so you can add other properties if you need them.
In this example, though, we need a date
property that will be used to check if a particular date is the current day. We’ll also return a isCurrentMonth
value that checks whether the date is in the current month or outside of it. If it is outside the current month, we will style those so folks know they are outside the range of the current month.
// CalendarMonth.vue
computed: {
// ...
currentMonthDays() {
return [...Array(this.numberOfDaysInMonth)].map((day, index) => {
return {
date: dayjs(`${this.year}-${this.month}-${index + 1}`).format("YYYY-MM-DD")
isCurrentMonth: true
};
});
},
}
Adding dates from the previous month
To get dates from the previous month to display in the current month, we need to check what the weekday of the first day is in selected month. That’s where we can use the WeekDay plugin for Day.js. Let’s create a helper method for that.
// CalendarMonth.vue
methods: {
// ...
getWeekday(date) {
return dayjs(date).weekday();
},
}
Then, based on that, we need to check which day was the last Monday in the previous month. We need that value to know how many days from the previous month should be visible in the current month view. We can get that by subtracting the weekday value from the first day of the current month. For example, if first day of the month is Wednesday, we need to subtract three days to get last Monday of the previous month. Having that value allows us to create an array of day objects starting from the last Monday of the previous month through the end of that month.
// CalendarMonth.vue
computed: {
// ...
previousMonthDays() {
const firstDayOfTheMonthWeekday = this.getWeekday(this.currentMonthDays[0].date);
const previousMonth = dayjs(`${this.year}-${this.month}-01`).subtract(1, "month");
const previousMonthLastMondayDayOfMonth = dayjs(this.currentMonthDays[0].date).subtract(firstDayOfTheMonthWeekday - 1, "day").date();
// Cover first day of the month being sunday
(firstDayOfTheMonthWeekday === 0)
const visibleNumberOfDaysFromPreviousMonth = firstDayOfTheMonthWeekday ? firstDayOfTheMonthWeekday - 1 : 6;
return [...Array(visibleNumberOfDaysFromPreviousMonth)].map((day, index) = {
return {
date: dayjs(`${previousMonth.year()}-${previousMonth.month() + 1}-${previousMonthLastMondayDayOfMonth + index}`).format("YYYY-MM-DD"),
isCurrentMonth: false
};
});
}
}
Adding dates from the next month
Now, let’s do the reverse and calculate which days we need from the next month to fill in the grid for the current month. Fortunately, we can use the same helper we just created for the previous month calculation. The difference is that we will calculate how many days from the next month should be visible by subtracting that weekday numeric value from seven.
So, for example, if the last day of the month is Saturday, we need to subtract one day from seven to construct an array of dates needed from next month (Sunday).
// CalendarMonth.vue
computed: {
// ...
nextMonthDays() {
const lastDayOfTheMonthWeekday = this.getWeekday(`${this.year}-${this.month}-${this.currentMonthDays.length}`);
const nextMonth = dayjs(`${this.year}-${this.month}-01`).add(1, "month");
const visibleNumberOfDaysFromNextMonth = lastDayOfTheMonthWeekday ? 7 - lastDayOfTheMonthWeekday : lastDayOfTheMonthWeekday;
return [...Array(visibleNumberOfDaysFromNextMonth)].map((day, index) => {
return {
date: dayjs(`${nextMonth.year()}-${nextMonth.month() + 1}-${index + 1}`).format("YYYY-MM-DD"),
isCurrentMonth: false
};
});
}
}
OK, we know how to create all days we need, so let’s use them and merge all days into a single array of all the days we want to show in the current month, including filler dates from the previous and next months.
// CalendarMonth.vue
computed: {
// ...
days() {
return [
...this.previousMonthDays,
...this.currentMonthDays,
...this.nextMonthDays
];
},
}
Voilà, there we have it! Check out the final demo to see everything put together.
The post Let’s Make a Vue-Powered Monthly Calendar appeared first on CSS-Tricks.
You can support CSS-Tricks by being an MVP Supporter.