Skip to main content
Ben Nadel at InVision In Real Life (IRL) 2018 (Hollywood, CA) with: Jeremy Mount
Ben Nadel at InVision In Real Life (IRL) 2018 (Hollywood, CA) with: Jeremy Mount

Code Kata: Alpine.js Calendar Component

By
Published in Comments (3)

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()">
						&larr; Prev
					</button>

					<span x-text="monthName"></span>
					<span x-text="year"></span>

					<button @click="gotoNextMonth()">
						Next &rarr;
					</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 use x-ref since x-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:

Clicking around a calendar component and selected a date.

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

15,848 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, the ref directive has a higher priority than the data attribute. As such, I believe that I should have been able to get a reference using the x-ref. I must have been messing up something else.

15,848 Comments

Ahhh, I think I see what's happening. The x-ref directive has a higher priority (than x-data). However, under the hood, it's attaching itself to the closestRoot(), which is looking for the closest element with the x-data attribute. As such, it finds itself, even though the x-data directive hasn't been executed yet. The very existence of the attribute is what is blocking the assignment to a parent scope.

Post A Comment — I'd Love To Hear From You!

Post a Comment

I believe in love. I believe in compassion. I believe in human rights. I believe that we can afford to give more of these gifts to the world around us because it costs us nothing to be decent and kind and understanding. And, I want you to know that when you land on this site, you are accepted for who you are, no matter how you identify, what truths you live, or whatever kind of goofy shit makes you feel alive! Rock on with your bad self!
Ben Nadel