Code Kata: Alpine.js Calendar Component
When learning a new JavaScript application framework, it's hard to get a sense of the patterns until you actually start building stuff. In that spirit, I wanted to try and build a small calendar component using Alpine.js. The concept of a calendar is complex enough to work the muscles; but, not so complex that it becomes overwhelming.
Run this demo in my JavaScript Demos project on GitHub.
View this code in my JavaScript Demos project on GitHub.
"Components" in Alpine.js are really "headless components". Meaning, the components provide the business logic; but, it's the responsibility of the calling context to provide the view rendering. As such, my calendar component needs to provide the data that represents the current month; and, it needs to expose public methods to mutate that state.
Let's step through the main parts of the calendar component to get a sense of how it operates. Starting with the constructor. The constructor is responsible for setting up the reactive data bindings that can be consumed in the view rendering:
document.addEventListener(
"alpine:init",
function setupAlpineBindings() {
Alpine.data( "calendar", CalendarController );
}
);
/**
* I control the calendar component.
*/
function CalendarController( initialYear, initialMonth ) {
var timestamp = new Date();
var year = ( initialYear ?? timestamp.getFullYear() );
var month = ( initialMonth ?? timestamp.getMonth() );
var monthName = CALENDAR_MONTHS[ month ];
// Entries contains the entries for this month only.
var entries = buildEntries( year, month );
// Grid contains the headers and the entries for the rendered month (which may extend
// into both the previous month and the next month in order to create a full grid).
var grid = buildGrid( entries );
return {
// Properties.
year: year,
month: month,
monthName: monthName,
entries: entries,
grid: grid,
// Methods.
gotoDate: gotoDate,
gotoNextMonth: gotoNextMonth,
gotoNow: gotoNow,
gotoPrevMonth: gotoPrevMonth,
gotoYear: gotoYear,
}
// ... truncated code ...
}
The calendar state represents a given year/month combination (ex. March 2024). The exposed methods allow the calendar state to be changed to represent a different year/month combination.
The exposed reactive state has two main properties (that are worth exploring): .entries
and .grid
. The entries
property represents the days within the current year/month. The grid
property represents an expanded set of data tailored for rendering a calendar user interface (UI). It extends the collection of entries to bleed into both the previous and next months (as necessary); and, breaks the entries up onto weeks for easy x-for
rendering.
Let's look at how the entries
are built as they represent the core data model. I've tried to expose all the data (such as .isToday
and .isWeekend
) that might be helpful when rendering the calendar data for the user:
function CalendarController() {
// ... truncated code ...
/**
* I build the entries for the given year/month.
*/
function buildEntries( year, month ) {
var daysInMonth = getDaysInMonth( year, month );
var entries = [];
for ( var i = 1 ; i <= daysInMonth ; i++ ) {
entries.push( buildEntry( year, month, i, CURRENT_MONTH ) );
}
return entries;
}
/**
* I build the entry for the given year/month/date.
*/
function buildEntry( year, month, date, isCurrentMonth ) {
var timestamp = new Date( year, month, date );
return {
id: timestamp.getTime(),
year: timestamp.getFullYear(),
month: timestamp.getMonth(),
monthName: CALENDAR_MONTHS[ timestamp.getMonth() ],
date: timestamp.getDate(),
day: timestamp.getDay(),
dayName: CALENDAR_WEEKDAYS[ timestamp.getDay() ],
isToday: getIsToday( timestamp ),
isCurrentMonth: isCurrentMonth,
isOtherMonth: ! isCurrentMonth,
isWeekday: getIsWeekday( timestamp.getDay() ),
isWeekend: getIsWeekend( timestamp.getDay() )
};
}
}
As you can see, each entry contains year
, month
, and date
properties as well as a number of helper properties that can be used in rendering.
The entries
only represent the current month. Which means, the first element within the array may represent a Monday or a Wednesday; or, any other day of the week. Of course, when we render a calendar UI, we almost always render it as a full grid. To keep things as easy as possible on the rendering side, the grid
property takes the entries
property and fleshes-it-out for view consumption:
function CalendarController() {
/**
* I build the grid based on the given entries.
*/
function buildGrid( entries ) {
var grid = {
headers: CALENDAR_WEEKDAYS.slice(),
headersAbbreviated: CALENDAR_WEEKDAYS_ABBREVIATED.slice(),
entries: entries.slice(),
weeks: []
};
var temp;
// Extend the grid entries into the PREVIOUS month if necessary.
while ( ! getIsFirstEntryOfWeek( temp = grid.entries.at( 0 ) ) ) {
grid.entries.unshift(
buildEntry( temp.year, temp.month, ( temp.date - 1 ), OTHER_MONTH )
);
}
// Extend the grid entries into the NEXT month if necessary.
while ( ! getIsLastEntryOfWeek( temp = grid.entries.at( -1 ) ) ) {
grid.entries.push(
buildEntry( temp.year, temp.month, ( temp.date + 1 ), OTHER_MONTH )
);
}
// Slice the full list of entries into weeks (for easier rendering).
for ( var i = 0 ; i < grid.entries.length ; i += 7 ) {
grid.weeks.push(
grid.entries.slice( i, ( i + 7 ) )
);
}
return grid;
}
}
As you can see, the grid
creates its own .slice()
of the given entries
. And then, extends it into the previous month and the next month as necessary. Essentially, it keeps unshift()
ing entries onto the head of the collection until the first entry falls on a Sunday. And, it keeps push()
ing entries onto the end of the collection until the last entry falls on a Saturday.
Aside: I know that not all calendars start on Sunday. But, this is just a fun code kata.
Now that we have the primary state for the calendar component, we need to expose a way for the UI to alter the state as desired. For this, I am exposing a number of goto
methods.
The main goto
method is gotoDate(target)
. This updates the calendar state to represent the parent month of the given target date. The rest of the goto
methods just turn around and invoke gotoDate()
with the desired date.
function CalendarController() {
/**
* I update the calendar to represent the month that contains the given date.
*/
function gotoDate( target ) {
this.year = target.getFullYear();
this.month = target.getMonth();
this.monthName = CALENDAR_MONTHS[ this.month ];
this.entries = buildEntries( this.year, this.month );
this.grid = buildGrid( this.entries );
}
/**
* I update the calendar to represent the next month.
*/
function gotoNextMonth() {
this.gotoDate( new Date( this.year, ( this.month + 1 ), 1 ) );
}
/**
* I update the calendar to represent the current month.
*/
function gotoNow() {
this.gotoDate( new Date() );
}
/**
* I update the calendar to represent the previous month.
*/
function gotoPrevMonth() {
this.gotoDate( new Date( this.year, ( this.month - 1 ), 1 ) );
}
/**
* I update the calendar to represent the given year (and optional month).
*/
function gotoYear( year, month ) {
this.gotoDate( new Date( year, ( month || 0 ), 1 ) );
}
}
I think this set of methods really showcases just how powerful the native Date
object is. I know that Date
gets a really bad reputation in JavaScript; but, I never understood why. Every time I work with Date
object, I'm blown away by how easy it makes it to navigate between dates using relative values.
Nothing illustrates this luxury as much as finding the number of days in a given month:
function CalendarController() {
/**
* I get the number of days in the given year/month.
*/
function getDaysInMonth( year, month ) {
// I freaking love the Date object - makes working with dates so easy!
var lastDayOfMonth = new Date( year, ( month + 1 ) , 0 );
return lastDayOfMonth.getDate();
}
}
When I invoke the Date
constructor here, I'm passing in the next month. However, I'm passing in 0
as the day. And, since days start at 1
, the Date
constructor knows to treat this as a subtraction. Meaning, it goes to the next month, then backs up one day, giving us the last day in the given month.
Freakin' amazing!
Anyway, now that we understand the type of data being exposed by this Alpine.js calendar component, it's time to render the UI. For this, I'm using a standard <table>
element. Each <tr>
in the <tbody>
represent one of the weeks in the grid.weeks
collection.
<!doctype html>
<html lang="en">
<head>
<link rel="stylesheet" type="text/css" href="./main.css" />
</head>
<body x-data="app">
<h1>
Code Kata: Alpine.js Calendar Component
</h1>
<template x-if="( ! selectedEntry )">
<p>
Please selected your date.
</p>
</template>
<template x-if="selectedEntry">
<p>
You selected
<strong>
<span x-text="selectedEntry.monthName"></span>
<span x-text="selectedEntry.date"></span>,
<span x-text="selectedEntry.year"></span>
</strong>
<template x-if="selectedEntry.isToday">
<span>( that's today! )</span>
</template>
<button @click="selectEntry( null )">
Clear
</button>
</p>
</template>
<!-- BEGIN: Calendar Component. -->
<table x-data="calendar" class="calendar">
<thead>
<tr>
<th colspan="7">
<div class="tools">
<button @click="gotoPrevMonth()">
← Prev
</button>
<span x-text="monthName"></span>
<span x-text="year"></span>
<button @click="gotoNextMonth()">
Next →
</button>
</div>
</th>
</tr>
<tr>
<template x-for="header in grid.headersAbbreviated" :key="header">
<th
scope="col"
x-text="header">
</th>
</template>
</tr>
</thead>
<tbody>
<template x-for="( week, i ) in grid.weeks" :key="i">
<tr>
<template x-for="entry in week" :key="entry.id">
<td>
<button
@click="selectEntry( entry )"
:class="{
current: entry.isCurrentMonth,
other: entry.isOtherMonth,
today: entry.isToday,
weekday: entry.isWeekday,
weekend: entry.isWeekend,
selected: ( selectedEntry?.id === entry.id )
}"
x-text="entry.date">
</button>
</td>
</template>
</tr>
</template>
</tbody>
</table>
<!-- END: Calendar Component. -->
<p class="jumper">
<strong>Jump to:</strong>
<button @click="resetCalendar">Now</button>
<button @click="jumpToYear( 2023 )">2023</button>
<button @click="jumpToYear( 2024 )">2024</button>
<button @click="jumpToYear( 2025 )">2025</button>
<button @click="jumpToYear( 2026 )">2026</button>
<button @click="jumpToYear( 2027 )">2027</button>
</p>
<script type="text/javascript" src="./alpine.calendar.js" defer></script>
<script type="text/javascript" src="../../vendor/alpine/3.13.5/alpine.3.13.5.min.js" defer></script>
<script type="text/javascript">
document.addEventListener(
"alpine:init",
function setupAlpineBindings() {
Alpine.data( "app", AppController );
}
);
/**
* I control the app component.
*/
function AppController() {
return {
// Properties.
selectedEntry: null,
// Methods.
jumpToYear: jumpToYear,
resetCalendar: resetCalendar,
selectEntry: selectEntry
};
// ---
// PUBLIC METHODS.
// ---
/**
* I jump the calendar to January 1st of the given year.
*/
function jumpToYear( year ) {
getCalendarController().gotoYear( year );
}
/**
* I reset the calendar state (bring user back to current month).
*/
function resetCalendar() {
getCalendarController().gotoNow();
}
/**
* I select the given entry (or clear the selection if null is provided).
*/
function selectEntry( entry ) {
// Special case: toggling off currently selected entry.
if ( entry && this.selectedEntry && ( this.selectedEntry.id === entry.id ) ) {
this.selectedEntry = null;
return;
}
this.selectedEntry = entry;
}
// ---
// PRIVATE METHODS.
// ---
/**
* I get the merged proxy data for the calendar element (which is, depending on
* how hard you squint), the same as the "controller".
*/
function getCalendarController() {
// Grab the controller instance data stack from the given element. This
// gives us access to the calendar's public methods.
return Alpine.$data( document.querySelector( ".calendar" ) );
}
}
</script>
</body>
</html>
Reading properties (like .gird
and .monthName
) out of the calendar component is rather straightforward since Alpine.js exposes these properties on the Document Object Model (DOM). However, calling back into the calendar component in order to change the state is a little more complicated.
In order to invoke methods like gotoYear()
and gotoNow()
, our root application controller has to get a reference to the state exposed by the calendar. For this, I'm using Alpine.$data(element)
. This method returns the merged proxies associated with the given element. That is, it returns the collection of reactive state objects visible to the given element. And, from there, we can invoke state method.
Aside: In this case, I'm using
.querySelector()
to locate the calendar component. I couldn't usex-ref
sincex-ref
stores references into the closest data stack; which, in this case, was the calendar itself. As such, I had to query the DOM outside of the refs system.
With that said, we have a working calendar that allows navigation and selection:
To be honest, I have no idea if this is how you build components in Alpine.js. I'm still very much learning the framework. But, this feels like it's moving in the right direction.
For completeness, here's the full JavaScript code. I'm very much aware that I'm polluting the global scope by defining var
values at the top level. But, this keeps it easier to read in the demos.
document.addEventListener(
"alpine:init",
function setupAlpineBindings() {
Alpine.data( "calendar", CalendarController );
}
);
var CALENDAR_WEEKDAYS = [
"Sunday",
"Monday",
"Tuesday",
"Wednesday",
"Thursday",
"Friday",
"Saturday"
];
var CALENDAR_WEEKDAYS_ABBREVIATED = [
"Sun",
"Mon",
"Tue",
"Wed",
"Thr",
"Fri",
"Sat"
];
var CALENDAR_MONTHS = [
"January",
"February",
"March",
"April",
"May",
"June",
"July",
"August",
"September",
"October",
"November",
"December"
];
var CURRENT_MONTH = true;
var OTHER_MONTH = false;
/**
* I control the calendar component.
*/
function CalendarController( initialYear, initialMonth ) {
var timestamp = new Date();
var year = ( initialYear ?? timestamp.getFullYear() );
var month = ( initialMonth ?? timestamp.getMonth() );
var monthName = CALENDAR_MONTHS[ month ];
// Entries contains the entries for this month only.
var entries = buildEntries( year, month );
// Grid contains the headers and the entries for the rendered month (which may extend
// into both the previous month and the next month in order to create a full grid).
var grid = buildGrid( entries );
return {
// Properties.
year: year,
month: month,
monthName: monthName,
entries: entries,
grid: grid,
// Methods.
gotoDate: gotoDate,
gotoNextMonth: gotoNextMonth,
gotoNow: gotoNow,
gotoPrevMonth: gotoPrevMonth,
gotoYear: gotoYear,
}
// ---
// PUBLIC METHODS.
// ---
/**
* I update the calendar to represent the month that contains the given date.
*/
function gotoDate( target ) {
this.year = target.getFullYear();
this.month = target.getMonth();
this.monthName = CALENDAR_MONTHS[ this.month ];
this.entries = buildEntries( this.year, this.month );
this.grid = buildGrid( this.entries );
}
/**
* I update the calendar to represent the next month.
*/
function gotoNextMonth() {
this.gotoDate( new Date( this.year, ( this.month + 1 ), 1 ) );
}
/**
* I update the calendar to represent the current month.
*/
function gotoNow() {
this.gotoDate( new Date() );
}
/**
* I update the calendar to represent the previous month.
*/
function gotoPrevMonth() {
this.gotoDate( new Date( this.year, ( this.month - 1 ), 1 ) );
}
/**
* I update the calendar to represent the given year (and optional month).
*/
function gotoYear( year, month ) {
this.gotoDate( new Date( year, ( month || 0 ), 1 ) );
}
// ---
// PRIVATE METHODS.
// ---
/**
* I build the entries for the given year/month.
*/
function buildEntries( year, month ) {
var daysInMonth = getDaysInMonth( year, month );
var entries = [];
for ( var i = 1 ; i <= daysInMonth ; i++ ) {
entries.push( buildEntry( year, month, i, CURRENT_MONTH ) );
}
return entries;
}
/**
* I build the entry for the given year/month/date.
*/
function buildEntry( year, month, date, isCurrentMonth ) {
var timestamp = new Date( year, month, date );
return {
id: timestamp.getTime(),
year: timestamp.getFullYear(),
month: timestamp.getMonth(),
monthName: CALENDAR_MONTHS[ timestamp.getMonth() ],
date: timestamp.getDate(),
day: timestamp.getDay(),
dayName: CALENDAR_WEEKDAYS[ timestamp.getDay() ],
isToday: getIsToday( timestamp ),
isCurrentMonth: isCurrentMonth,
isOtherMonth: ! isCurrentMonth,
isWeekday: getIsWeekday( timestamp.getDay() ),
isWeekend: getIsWeekend( timestamp.getDay() )
};
}
/**
* I guild the grid based on the given entries.
*/
function buildGrid( entries ) {
var grid = {
headers: CALENDAR_WEEKDAYS.slice(),
headersAbbreviated: CALENDAR_WEEKDAYS_ABBREVIATED.slice(),
entries: entries.slice(),
weeks: []
};
var temp;
// Extend the grid entries into the PREVIOUS month if necessary.
while ( ! getIsFirstEntryOfWeek( temp = grid.entries.at( 0 ) ) ) {
grid.entries.unshift(
buildEntry( temp.year, temp.month, ( temp.date - 1 ), OTHER_MONTH )
);
}
// Extend the grid entries into the NEXT month if necessary.
while ( ! getIsLastEntryOfWeek( temp = grid.entries.at( -1 ) ) ) {
grid.entries.push(
buildEntry( temp.year, temp.month, ( temp.date + 1 ), OTHER_MONTH )
);
}
// Slice the full list of entries into weeks (for easier rendering).
for ( var i = 0 ; i < grid.entries.length ; i += 7 ) {
grid.weeks.push(
grid.entries.slice( i, ( i + 7 ) )
);
}
return grid;
}
/**
* I get the number of days in the given year/month.
*/
function getDaysInMonth( year, month ) {
// I freaking love the Date object - makes working with dates so easy!
var lastDayOfMonth = new Date( year, ( month + 1 ) , 0 );
return lastDayOfMonth.getDate();
}
/**
* I determine if the given entry represents the first day of the week.
*/
function getIsFirstEntryOfWeek( entry ) {
return ( entry.day === 0 );
}
/**
* I determine if the given entry represents the last day of the week.
*/
function getIsLastEntryOfWeek( entry ) {
return ( entry.day === 6 );
}
/**
* I determine if the given date represents Today.
*/
function getIsToday( date ) {
var timestamp = new Date();
return (
( date.getFullYear() === timestamp.getFullYear() ) &&
( date.getMonth() === timestamp.getMonth() ) &&
( date.getDate() === timestamp.getDate() )
);
}
/**
* I determine if the given day is a weekday.
*/
function getIsWeekday( day ) {
return ! getIsWeekend( day );
}
/**
* I determine if the given day is a weekend.
*/
function getIsWeekend( day ) {
return ( ( day === 0 ) || ( day === 6 ) );
}
}
And, here's the full CSS:
html {
font-size: 140% ;
}
body,
button {
font-family: monospace ;
font-size: inherit ;
line-height: inherit ;
}
.calendar {
background-color: #ffffff ;
border: 1px solid #333333 ;
border-collapse: collapse ;
margin: 20px 0 ;
}
.calendar th {
border: 1px solid #333333 ;
padding: 10px 15px ;
}
.calendar td {
border: 1px solid #333333 ;
padding: 0 ;
}
.calendar tbody td button {
border: none ;
background-color: #bdebff ;
cursor: pointer ;
display: block ;
padding: 10px 15px ;
width: 100% ;
}
.calendar tbody td button.other {
background-color: #f8fdff ;
}
.calendar tbody td button.today {
background-color: #0095d1 ;
color: #ffffff ;
font-weight: bold ;
}
.calendar tbody td button.selected,
.calendar tbody td button:hover {
outline: 3px solid #009fff ;
outline-offset: -3px ;
}
.calendar .tools {
align-items: center ;
display: flex ;
justify-content: center ;
gap: 20px ;
}
.calendar .tools button {
background-color: transparent ;
border: 1px solid #0095d1 ;
border-radius: 3px ;
color: #0095d1 ;
cursor: pointer ;
padding: 5px 9px ;
}
.calendar .tools button:first-of-type {
margin-right: auto ;
}
.calendar .tools button:last-of-type {
margin-left: auto ;
}
.jumper {
align-items: center ;
display: flex ;
gap: 10px ;
}
.jumper button {
background-color: transparent ;
border: 1px solid #0095d1 ;
border-radius: 3px ;
color: #0095d1 ;
cursor: pointer ;
padding: 2px 5px ;
margin: 0 ;
}
You may be wondering why I'm using <table>
instead of CSS Grid. This is just a personal choice. To me, this type of data feels like "table data". Meaning, I'm not implementing a "layout" (that happens to be a grid), I'm rendering a dataset. As such, the <table>
element feels like it's the most semantically appropriate implementation.
Want to use code from this post? Check out the license.
Reader Comments
In my post, I mentioned that I couldn't use
x-ref
to get a reference to the calendar since the ref was being stored on the calendar itself and not on the app's$refs
. However, looking at the Alpine.js source code, theref
directive has a higher priority than thedata
attribute. As such, I believe that I should have been able to get a reference using thex-ref
. I must have been messing up something else.Ahhh, I think I see what's happening. The
x-ref
directive has a higher priority (thanx-data
). However, under the hood, it's attaching itself to theclosestRoot()
, which is looking for the closest element with thex-data
attribute. As such, it finds itself, even though thex-data
directive hasn't been executed yet. The very existence of the attribute is what is blocking the assignment to a parent scope.thank for support 🙏
Post A Comment — ❤️ I'd Love To Hear From You! ❤️
Post a Comment →