CLNDR2 is a straightforward framework-agnostic front-end calendar widget operating on HTML templates powered by the template rendering engine of your choice.
It was inspired by awesome CLNDR. If you intend to migrate from CLNDR to CLNDR2, check out the migration notes.
đź“„ Code Documentation: https://clndr2.snater.com/docs
🗓️ Demos: https://clndr2.snater.com/demos
- Basic Usage
- Dependencies
- Installation
- Templating
- Calendar Events
- Interaction and Render Events
- Configuring Pagination
- Switching the View
- Asynchronously loading Calendar Events
- Constraints & Date Pickers
- Internationalization
- Using with React
- Key Differences to CLNDR
CLNDR2 requires a template engine to be hooked up for rendering. Apart from a reference to an HTML element, the only requirement for CLNDR2 to render a calendar is a render
function to be provided. Basically, this function is supposed to be a wrapper for a template engine of your choice. A common choice for templating is EJS, which is also implemented in Underscore and lodash.
The following minimal example uses EJS:
import Clndr from 'clndr2';
import {render} from 'ejs';
const clndr = new Clndr(
document.getElementById('calendar'),
{render: data => render('... your template HTML string ...', data)}
);
Of course, you can also precompile your template, so compilation would not need to run on every render cycle of the calendar:
import Clndr from 'clndr2';
import {compile} from 'ejs';
const compiled = compile('... your template HTML string ...');
const clndr = new Clndr(
document.getElementById('calendar'),
{render: compiled}
);
date-fns is required for date calculations. Instead of baking it into the CLNDR2 build, it is installed as peer dependency, so you can use date-fns in your project without duplicating functionality imported from of date-fns. date-fns operates on native Date
objects. Hence, an advantage of using date-fns is not locking in to a specific date model or library. Additionally, by date-fns supporting tree-shaking, only the date-fns functions actually used will be included in your build, which results in a minimal footprint of CLNDR2 along with date-fns in your build.
CLNDR2 can be installed via NPM:
npm i clndr2
Apart from EJS/Underscore/lodash, CLNDR2 is supposed to work with any templating engine.
The basic concept is to provide a render
function:
const precompiledTemplate = myRenderingEngine.template('...my template HTML string...');
new Clndr(container, {
render: precompiledTemplate,
});
Here is a simple CLNDR2 month template for EJS, Underscore and lodash. It has got a controls section for navigating, and a grid section rendering the days of the month.
<script id="template" type="text/template">
<div class="clndr-controls">
<div class="clndr-previous-button" role="button">‹</div>
<div class="month"><%= format(date, 'MMMM') %></div>
<div class="clndr-next-button" role="button">›</div>
</div>
<div class="clndr-grid">
<div class="days-of-the-week">
<% daysOfTheWeek.forEach(day => { %>
<div class="header-day"><%= day %></div>
<% }) %>
</div>
<div class="days">
<% items.forEach(day => { %>
<div class="<%= day.classes %>"><%= day.date.getDate() %></div>
<% }) %>
</div>
</div>
</script>
The template may be compiled and passed to the render
option:
const template = ejs.compile(document.querySelector('#template').innerHTML);
new Clndr(container, {render: template});
In order to use Handlebars, a custom helper formatting dates may be defined:
import Clndr from 'clndr2';
import Handlebars from 'handlebars';
Handlebars.registerHelper(
'formatHelper',
(format: typeof formatFn, formatString: string, date?: Date) => {
return date ? format(date, formatString) : '';
}
);
const template = `
<div class="clndr-controls">
<div class="clndr-previous-button" role="button">‹</div>
<div class="month">{{formatHelper format 'MMMM' date}}</div>
<div class="clndr-next-button" role="button">›</div>
</div>
<div class="clndr-grid">
<div class="days-of-the-week">
{{~#each daysOfTheWeek~}}
<div class="header-day">{{this}}</div>
{{~/each~}}
</div>
<div class="days">
{{~#each items~}}
<div class="{{this.classes}}">
{{~formatHelper ../format 'd' this.date~}}
</div>
{{~/each~}}
</div>
</div>`;
new Clndr(container, {render: Handlebars.compile(handlebarsTemplate)});
For using Mustache templates, the template parameters can be extended for preparing the data to be filled into the template:
import Clndr from 'clndr2';
import Mustache from 'mustache';
const template = `
<div class="clndr-controls">
<div class="clndr-previous-button" role="button">‹</div>
<div class="month">{{heading}}</div>
<div class="clndr-next-button" role="button">›</div>
</div>
<div class="clndr-grid">
<div class="days-of-the-week">
{{#daysOfTheWeek}}
<div class="header-day">{{.}}</div>
{{/daysOfTheWeek}}
</div>
<div class="days">
{{#items}}
<div class="{{this.classes}}">{{day}}</div>
{{/items}}
</div>
</div>`;
new Clndr(container, {
render: (
vars: ClndrTemplateData & {
day?: () => string,
heading?: string,
}
) => {
vars.heading = vars.format(date, 'MMMM');
vars.day = function() {
return this.date?.getDate().toString() || '';
}
return Mustache.render(mustacheTemplate, vars);
},
});
Like EJS, Pug supports using JavaScript in the template:
import Clndr from 'clndr2';
import pug from 'pug';
const template = `
div
div(class='clndr-previous-button' role='button') ‹
div= format(date, 'MMMM')
div(class='clndr-next-button' role='button') ›
div(class='clndr-grid')
div(class='days-of-the-week')
each dayOfTheWeek in daysOfTheWeek
div(class='header-day')= dayOfTheWeek
div(class='days')
each day in items
div(class=day.classes)= day.date ? day.date.getDate() : ''`;
new Clndr(container, {render: pug.compile(template)});
Applying day.classes
/this.classes
in the examples, the calendar item will receive multiple CSS classes that are used by the calendar to determine the status of the calendar item as well as any action to be triggered when clicking on the item; That is, for example, whether the calendar item is supposed to be selected or whether the view is supposed to switch. Thus, click events will only work if item.classes
(day.classes
/this.classes
as used in the examples) is included in your item element's class
attribute as seen above.
Most of these classes may be customized per the classes
option to avoid potential class naming conflicts with your CSS.
The render
function is passed an object with a set of properties, and it must return the HTML result of the rendering operation. Details about the data passed to the template can be found in the technical documentation.
type ClndrTemplateData = {
/**
* `Date` indicating the current page for convenience; This is exactly the
* same as `interval.start`.
*/
date: Date
/**
* Start and end of the current page's interval.
*/
interval: {start: Date, end: Date}
/**
* When the calendar is configured to display multiple pages simultaneously
* per a view's `pagination` option, `pages` will contain a `Date` object for
* each page referring to (the start of) each page's interval. For rendering
* the items of each page, the `pages` can be looped over and the
* corresponding items be rendered like this:
* pages.forEach((page, pageIndex) => {
* ... items[pageIndex].forEach(item => ...) ...
* })
*/
pages: Date[]
/**
* The items of a calendar page, e.g. the reprensentations of the days on a
* month page, or of the months on a year page. When the calendar is
* configured to display multiple pages simultaneously per a view's
* `pagination` options, `items` will be a multidimensional array, one array
* of item objects per page.
* Some item properties will be undefined if the item is just an empty
* placeholder item, e.g. on a month view when the `showAdjacent` option is
* `false`.
*/
items: {
/**
* Start and end of the item.
*/
interval?: {start: Date, end: Date}
/**
* A `Date` object representing the item.
*/
date?: Date
/**
* The calendar events assigned to this item.
*/
events?: {/* Event object passed to `options.events` */}[]
/**
* CSS classes to be applied to the item's HTML element indicating its
* status and whether there are events assigned to this item.
*/
classes: string
/**
* Status indicators for the item.
*/
properties?: {
/**
* Whether the item represents "now", e.g. today' day on a month page, or
* the current month on a year page.
*/
isNow: boolean
/**
* Items are considered inactive when they are out of the range specified
* by the `constraints` option.
*/
isInactive: boolean
/**
* Whether an item is not actual part of the current page. Relevant only
* for the `month` view, in case the `showAdjacent` option is activated.
*/
isAdjacent: boolean
}
} | {/* same as above */}[][]
/**
* The events of the current page as well as the events of the previous and
* next page. `events.currentPage` is a multidimensional array if the
* pagination size of the current view is greater than 1.
* `events.previousPage` and `events.nextPage` may be used to get the events
* of adjacent pages if the `showAdjacent` option is turned on. Currently,
* that option is relevant for the `month` view only.
*/
events: {
currentPage: {/* Event object passed to `options.events` */}[]
| {/* Event object passed to `options.events` */}[][]
previousPage: {/* Event object passed to `options.events` */}[]
nextPage: {/* Event object passed to `options.events` */}[]
}
/**
* An array of day-of-the-week abbreviations, shifted as configured by the
* `weekStartsOn` option, i.e. `['S', 'M', 'T', etc...]`.
*/
daysOfTheWeek: string[]
/**
* A proxy for date-fns' `format` function being equipped with the locale
* provided to the `locale` option.
* See date-fns' `format` function: https://date-fns.org/docs/format
*/
format: (
date: Date | string | number, formatStr: string, options?: FormatOptions
) => string
/**
* Anything supplied per the `extras` option.
*/
extras: unknown | null
}
Events passed to the calendar may have either just a single date for specifying single-day events, or a start date and an end date for specifying multi-day events. Both types of events, single-day and multi-day, may also be mixed in the array of events that is passed to the calendar.
const mixedEvents = [
{
title: 'Monday to Friday Event',
start: '2015-11-04',
end: '2015-11-08',
}, {
title: 'Another multi-day Event',
start: new Date('2024-04-10'),
end: 1713112793934,
}, {
title: 'A single-day event',
date: '1992-10-15',
}, {
title: 'Also just a single-day event',
start: '2024-01-18',
end: '2024-01-18',
}, {
date: '2000-06-30',
custom: 'property',
another: {custom: 'property'},
},
];
new Clndr(document.getElementById('calendar'), {
render: template,
events: mixedEvents,
});
Generally, the event objects may consist of random properties, yet the calendar needs to find a date, or a start date and an end date in the object. By default, the parameters the calendar recognizes are date
, start
and end
. The names of these parameters may be customized using the dateParameter
option.
Just like being passed to the events
options, the event objects provided to the calendar are passed in their entirety to the template, filtered according to the calendar objects currently rendered. For example, a day calendar item will be populated only with the events that take place on that day. Multi-day events are passed to every single day within their interval.
Per the on
option, event callbacks may be provided for handling click, navigation or rendering events.
The following event callbacks are supported:
ready
: Triggered once the calendar has been initialized and rendered.beforeRender
: Triggered when the calendar is about to render.afterRender
: Triggered when the calendar is done rendering.click
: Triggered whenever a calendar item is clicked. This may be a "valid" calendar item, or an empty placeholder item.navigate
: Triggered whenever navigating the calendar, which is any navigation operation other than directly clicking a calendar item, i.e. clicking the "back" and "forward" buttons, clicking the "today" button etc.switchView
: Triggered whenever the view is switched. The callback is triggered before rendering, hence any update to the calendar events done in this callback, will be considered when rendering the new view.
The callbacks beforeRender
, afterRender
and switchView
are supposed to return a Promise
. Code execution / rendering will continue as soon as the promise is resolved.
The parameters passed to each callback are documented in the technical documentation.
An example use case for implementing callbacks would be displaying the events of the current day in a separate container when the corresponding day is clicked on:
const container = document.createElement('div');
// DOM structure with a container for the calendar, as well as for a list of
// events.
container.innerHTML = `
<div class="clndr"></div>
<div class="events hidden">
<div class="events-header">Events</div>
<div class="events-list"></div>
</div>
`;
new Clndr(container.querySelector('.clndr') as HTMLElement, {
/*...*/
on: {
click: target => {
if (!target.date) {
// Ignore the user clicking on an empty placeholder.
return;
}
const eventsContainer = document.querySelector('.events');
const eventList = eventsContainer?.querySelector('.events-list');
if (!eventsContainer || !eventList) {
// The HTML structure is not properly set up, you might want to do some
// error handling here.
return;
}
// The events assigned to the day clicked.
const events = target.events;
if (events.length === 0) {
// Hide and empty the list of events when there are no events on this
// day.
eventsContainer.classList.add('hidden');
eventList.innerHTML = '';
return;
}
// Create some HTML with the event data and fill the event list.
eventList.innerHTML = events.map(event => (
`<div class="event">
<div class="event-title">${event.title}</div>
<div class="event-body">${event.description}</div>
</div>`
)).join('');
// Show the list of events in case there are events on the day clicked.
eventsContainer.classList.remove('hidden');
},
},
/*...*/
});
CLNDR2 can be configured to render multiple pages at once. Even more, it may be configured how many steps the calendar should navigate when navigating backward or forward.
const clndr = new Clndr(container, {
render: {/*...*/},
pagination: {
month: {
// Render two months at the same time.
size: 2,
// Configure navigation to navigate by two months.
step: 2,
},
},
});
When rendering multiple pages simultaneously, the render
functions pages
parameter contains a Date
object per page to be rendered. Also, the items
parameter will be a two-dimensional array mapping to the index of the dates in the pages
array. Therefore, the render
function will need to consider this, for example:
const clndr = new Clndr(container, {
render: data => ejs.render(`
<div class="clndr-controls top">
<div class="clndr-previous-button" role="button">‹</div>
<div class="clndr-next-button" role="button">›</div>
</div>
<% pages.forEach((month, pageIndex) => { %>
<div class="cal">
<div class="month"><%= format(month, 'MMMM') %></div>
<div class="clndr-grid">
<div class="days-of-the-week">
<% daysOfTheWeek.forEach(day => { %>
<div class="header-day"><%= day %></div>
<% }) %>
</div>
<div class="days">
<% items[pageIndex].forEach(day => { %>
<div class="<%= day.classes %>"><%= day.date.getDate() %></div>
<% }) %>
</div>
</div>
</div>
<% }); %>
<div class="clndr-today-button" role="button">Today</div>
`, data),
pagination: {
month: {size: 2, step: 2},
},
});
CLNDR2 is capable of switching between different views for a better navigation experience. The available views are day
, week
, month
, year
and decade
. In order to activate the capability to switch between views, instead of a single render
function, a render
function needs to be provided for each view that should be possible to be switched to:
const clndr = new Clndr(container, {render: {
year: data => {/*...*/},
month: data => {/*...*/},
}});
Additionally, you may also customize the pagination for each view:
const clndr = new Clndr(container, {
render: {
year: data => {/*...*/},
month: data => {/*...*/},
},
pagination: {
// This will display two months at the same time, while there will still be
// displayed only one year at a time when on the year view. (One is the
// default page size.)
month: {size: 2},
},
});
Use the defaultView
option to customize the initial view (if a pagination is provided for month
, it is month
by default, otherwise the most granular view that pagination
is configured for is used):
const clndr = new Clndr(container, {
render: {
year: data => {/*...*/},
month: data => {/*...*/},
},
defaultView: 'year',
pagination: {
year: {size: 1},
month: {size: 2},
},
});
There is no need to configure defaultView
when either
- only using one view, or
- when the default view is supposed to be
month
whilerender
is a function instead of an object, or - when the default view is supposed to be the most granular view, i.e.
day
when configuring views forday
,month
andyear
.
The beforeRender
and afterRender
callbacks may be used to asynchronously load events and add them to the calendar.
For beforeRender
, the callback may be implemented like in the following simple example:
const cache: string[] = [];
async function beforeRender(
// Tell TypeScript about the function's context:
// https://www.typescriptlang.org/docs/handbook/2/functions.html#declaring-this-in-a-function
this: Clndr,
{element, interval}: {element: HTMLElement, interval: Interval}
) {
if (cache.includes(format(interval.start, 'yyyy-MM'))) {
// Events for this interval have already been fetched.
return;
}
// Set some loading indication in the UI.
element.querySelector('.clndr .loading')?.classList.add('show');
// Fetch events within the interval and add a reference for the interval to
// the cache.
const additionalEvents = await fetchEvents(interval);
cache.push(format(interval.start, 'yyyy-MM'));
this.addEvents(additionalEvents);
// Remove the loading indication.
element.querySelector('.clndr .loading')?.classList.remove('show');
}
new Clndr(container, {
/*...*/
on: {beforeRender}
/*...*/
});
Updating the calendar events on afterRender
has to be done slightly different:
async function afterRender(
this: Clndr,
{element, interval}: {element: HTMLElement, interval: Interval}
) {
/* ... same as in the `beforeRender` example ... */
// Since rendering has already been performed, it needs to be retriggered;
// yet only in case the calendar events were requested for the interval
// currently rendered. Otherwise, there might be race conditions when doing
// async operations like fetching data from a server. Therefore, doing this
// check prevents unneccesary re-renderings.
if (interval.start === this.getInterval().start) {
element.querySelector('.clndr .loading')?.classList.remove('show');
await this.render();
}
}
new Clndr(container, {
/*...*/
on: {afterRender}
/*...*/
});
Caching like in the examples will only work for a calendar setup featuring just one view. When configuring multiple views, more sophisticated caching is necessary. The most simple cache would be to assign an id
property to each calendar event and check if the event with that id
was already added to the calendar. However, that would be one of the least performant options, particularly when dealing with a large number of events, because the operation fetching events would still need to be triggered on all navigation, as well as the whole cached array of events would need to be compared to the (potentially duplicate) fetched events.
An improvement would be to cache the events on "view" level, i.e. per month
, year
etc. The events would be fetched in either beforeRender
or afterRender
. A callback triggered on switchView
would exchange the events rendered in the calendar calling setEvents()
. An example of this concept can be found in the Storybook demos.
For creating a datepicker, or specifically to prevent users from navigating out of a specific interval, the constraints
options can be set with start
, end
, or both specified:
new Clndr(container, {
render: data => {/*...*/},
constraints: {
start: '1992-10-15',
end: '2024-10-15',
}
});
This causes the calendar's next and previous buttons to work only within this date range. When they become disabled they will have the class inactive
applied to them, which can be used to make them visually appear disabled.
The items in the grid that are outside the current page's range, e.g. days of an adjacent month, will also have the inactive
class applied to them. Therefore, the click callbacks provided to relevant on
callbacks should check whether an item has the class inactive
applied:
new Clndr(container, {
render: data => {/*...*/},
constraints: {
start: '1992-10-15',
end: '2024-10-15',
},
on: {
click: target => {
if (target.element.classList.contains('inactive')) {
console.log('You picked a valid date!');
return;
}
console.log('That date is outside of the range.');
/*...*/
}
}
});
CLNDR2 has support for internationalization insofar as date-fns supports it. A date-fns locale may be passed to a calendar instance using the locale
option. This will have the following effects:
- If neither
daysOfTheWeek
, norformatWeekdayHeader
option is provided, the weekday abbreviations for the calendar header row will be guessed using the date-fns locale. - The
format
function passed to the template is a proxy for the date-fnsformat
function, injected with thelocale
by default.
For applying additional internationalization, the extras
option can be used to pass in required functionality.
For using the calendar with React, an adapter component can be created like in the following example.
import {Component, createRef} from 'react';
import {default as ClndrClass} from 'clndr2';
import type {ClndrOptions} from 'clndr2';
export default class Clndr extends Component<ClndrOptions, object> {
private elementRef = createRef<HTMLDivElement>();
public clndr?: ClndrClass;
render() {
return (
<div ref={this.elementRef}/>
);
}
componentDidMount() {
if (this.elementRef.current) {
this.clndr = new ClndrClass(this.elementRef.current, this.props);
}
}
componentWillUnmount() {
this.clndr?.destroy();
this.clndr = undefined;
}
componentDidUpdate() {
if (this.elementRef.current) {
const selectedDate = this.clndr?.getSelectedDate();
this.clndr?.destroy();
this.clndr = new ClndrClass(
this.elementRef.current,
{...this.props, selectedDate}
);
}
}
}
Using the adapter, the calendar options can be passed as props to the Clndr
component. The native Clndr
object may be accessed by passing a Ref
instance for using the public API functions.
function MyComponent() {
const clndrRef = React.useRef<Clndr>(null);
const forward = useCallback(() => {
clndrRef.current?.clndr?.next();
}, [clndrRef]);
return (
<>
<button onClick={forward}>Forward</button>
<Clndr ref={clndrRef} render={data => {/*...*/}}/>
</>
);
}
- The source code was completely recoded with a new, modular architecture and is now written in TypeScript rather than JavaScript.
- Instead of a jQuery plugin, CLNDR2 provides a
Clndr
class per an ES module. - The dependency on jQuery is removed.
- The moment dependency is replaced by date-fns.
- There is no soft dependency on Underscore anymore, the
template
option as well as the default template was removed. You just have to provide your ownrender
function. - CLNDR2 supports asynchronously updating the calendar events.
- CLNDR2 supports multiple views that may be switched between.
- There are now Storybook demos and there is source code documentation.
- The source code is automatically tested with a 100% code coverage.
First of all, along with installing CLNDR2, you will also have to install date-fns, which is supposed to be installed automatically as peer dependency.
Using CLNDR, in your code you have most likely done something like this:
$('#calendar').clndr({template: $('#calendar-template').html()});
The CLNDR2 equivalent is (using Underscore in the example):
import Clndr from 'clndr2';
import _ from 'underscore';
const template = _.compile(document.getElementById('calendar-template').innerHTML);
const clndr = new Clndr(document.getElementById('calendar'), {
render: template,
});
- While version 1.x of CLNDR2 was very close to CLNDR in terms of the public API (options, public methods), version 2.x has not only turned internals upside down, but also the public interface. In fact, version 2.x does not have much in common with the original CLNDR as it has been recoded bottom up. Therefore, you might want to consult the migration notes of CLNDR2 version 1.x to 2.x.
- The
template
option was removed to be even less opinionated about your template engine. Therefore, you now have to use therender
option for hooking up your template engine of choice. - In contrast to moment, date-fns is less opinionated about localisation. The consequence is that when setting a locale on date-fns, this does not automatically configure the day a week starts with. Therefore, you have to use the
weekStartsOn
option if you would like to have the week start with a day other than Sunday. - In CLNDR, the moment object was passed into the template along all template data. In CLNDR2, The only date specific function passed to the template is
format
, which is a proxy to date-fns'format
function with the locale defaulting to the locale provided per the newlocale
CLNDR2 option. No other date functions are passed to the template. If you want to use date functions in your template, i.e. for date calculations, provide those using theextras
option. - date-fns operates on standard
Date
objects. Passing moment objects to CLNDR2 will not work. - If you want CLNDR2 to localize the day heading and the month name passed to the template, provide a date-fns locale per the new
locale
option.