DateGallery Concepts
1. Overview
The DateGallery is a component that can be used to create date based components, such as date pickers, date range pickers and even fully fledged calendars:
A. datepickerA datepicker component from which the user can select a single date
- S
- M
- T
- W
- T
- F
- S
View code
import { DateGallery } from '@uiloos/core';
const datepickerEl = document.querySelector('.datepicker-example');
const titleEl = datepickerEl.querySelector('.title');
const resultEl = document.querySelector('#datepicker-example-result');
// December 2000
export const monthYearFormatter = new Intl.DateTimeFormat('en-US', {
month: 'long',
year: 'numeric',
});
// 12-31-2000
export const dateFormatter = new Intl.DateTimeFormat('en-US', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
});
const config = {
mode: 'month',
maxSelectionLimit: 1,
canSelect(dateObj) {
// Must lie in the past
return dateObj.date < Date.now();
},
};
function subscriber(datepicker) {
titleEl.textContent = monthYearFormatter.format(
datepicker.firstFrame.anchorDate
);
const formatted = dateFormatter.format(
datepicker.selectedDates.at(0)
);
resultEl.textContent = `Selected date: ${formatted}`;
const datesEl = datepickerEl.querySelector('.dates');
datesEl.innerHTML = '';
datepicker.firstFrame.dates.forEach((dateObj) => {
const dayEl = document.createElement('li');
dayEl.innerHTML = `
<button
aria-label="Select ${dateFormatter.format(dateObj.date)}"
type="button"
>
<time datetime="${dateObj.date.toISOString()}">
${dateObj.date.getDate()}
</time>
</button>
`;
dayEl.style.gridColumn = dateObj.date.getDay() + 1;
// Get the button and apply styles based on the dateObj's state.
const buttonEl = dayEl.firstElementChild;
if (!dateObj.canBeSelected) {
buttonEl.disabled = true;
}
if (dateObj.isSelected) {
buttonEl.classList.add('selected');
}
if (dateObj.isToday) {
buttonEl.classList.add('today');
}
dayEl.onclick = () => {
dateObj.toggle();
};
datesEl.appendChild(dayEl);
});
}
const datepicker = new DateGallery(config, subscriber);
datepickerEl.querySelector('.previous').onclick = () => {
datepicker.previous();
};
datepickerEl.querySelector('.next').onclick = () => {
datepicker.next();
};
datepickerEl.querySelector('.goto-today').onclick = () => {
datepicker.today();
};
<div class="datepicker-example">
<div class="topbar">
<button aria-label="previous" type="button" class="previous">‹</button>
<span class="title"></span>
<button aria-label="next" type="button" class="next">›</button>
</div>
<ul class="daygrid">
<li><abbr title="Sunday">S</abbr></li>
<li><abbr title="Monday">M</abbr></li>
<li><abbr title="Tuesday">T</abbr></li>
<li><abbr title="Wednesday">W</abbr></li>
<li><abbr title="Thursday">T</abbr></li>
<li><abbr title="Friday">F</abbr></li>
<li><abbr title="Saturday">S</abbr></li>
</ul>
<ul class="dates daygrid"></ul>
<div class="bottombar">
<button type="button" class="goto-today">Today</button>
</div>
</div>
<p id="datepicker-example-result"></p>
.datepicker-example {
display: grid;
justify-items: center;
height: 360px;
}
.datepicker-example .topbar {
display: flex;
justify-content: space-around;
align-items: center;
width: 100%;
align-self: start;
}
.datepicker-example .topbar .month {
font-size: 16px;
}
.datepicker-example .topbar .year {
width: 42px;
}
.datepicker-example .topbar button {
width: 42px;
font-size: 32px;
background-color: white;
}
.datepicker-example .daygrid {
display: grid;
grid-template-columns: repeat(7, 42px);
justify-items: center;
align-self: end;
}
.datepicker-example .dates button {
width: 42px;
height: 42px;
background-color: white;
}
.datepicker-example .dates button:disabled {
color: gray;
}
.datepicker-example .dates .today {
background-color: limegreen;
font-weight: bold;
border-radius: 100%;
}
.datepicker-example .dates .selected {
background-color: skyblue;
font-weight: bold;
border-radius: 100%;
}
.datepicker-example .bottombar {
display: flex;
justify-content: space-around;
align-items: center;
width: 100%;
gap: 8px;
}
.datepicker-example .bottombar button {
padding: 4px;
}
For a more complete datepicker example that triggers on an input element, which also has keyboard support see the: DateGallery examples
Perhaps the input type date or input type datetime-local can fulfill your needs instead of rolling your own date picker, be sure to check them out!
B. date rangepickerA date rangepicker component in which the user can select a range of dates.
- S
- M
- T
- W
- T
- F
- S
View code
import { DateGallery } from '@uiloos/core';
const dateRangePickerEl = document.querySelector('.daterangepicker-example');
const titleEl = dateRangePickerEl.querySelector('.title');
const resultEl = document.querySelector('#daterangepicker-example-result');
// December 2000
export const monthYearFormatter = new Intl.DateTimeFormat('en-US', {
month: 'long',
year: 'numeric',
});
// 12-31-2000
export const dateFormatter = new Intl.DateTimeFormat('en-US', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
});
const nextWeek = new Date();
nextWeek.setDate(nextWeek.getDate() + 1);
const config = {
mode: 'month',
selectedDates: [new Date(), nextWeek],
};
// Will store the date that was selected first by the user
let firstSelectedDate = null;
function subscriber(dateRangePicker) {
titleEl.textContent = monthYearFormatter.format(
dateRangePicker.firstFrame.anchorDate
);
// Sort from past to future.
const sorted = [...dateRangePicker.selectedDates].sort(
(a, b) => a.getTime() - b.getTime()
);
// The first date is the start date.
const startDate = sorted.at(0);
// The last date is the end date
const endDate = sorted.at(-1);
if (startDate && endDate && startDate !== endDate) {
const startFormatted = dateFormatter.format(startDate);
const endFormatted = dateFormatter.format(endDate);
resultEl.textContent = `You selected ${startFormatted} to ${endFormatted}.`;
} else {
resultEl.textContent = 'Please make a selection';
}
const datesEl = dateRangePickerEl.querySelector('.dates');
datesEl.innerHTML = '';
dateRangePicker.firstFrame.dates.forEach((dateObj) => {
const dayEl = document.createElement('li');
dayEl.innerHTML = `
<button
aria-label="Select ${dateFormatter.format(dateObj.date)}"
type="button"
>
<time datetime="${dateObj.date.toISOString()}">
${dateObj.date.getDate()}
</time>
</button>
`;
dayEl.style.gridColumn = dateObj.date.getDay() + 1;
// Get the button and apply styles based on the dateObj's state.
const buttonEl = dayEl.firstElementChild;
if (!dateObj.canBeSelected) {
buttonEl.disabled = true;
}
if (dateObj.isSelected) {
buttonEl.classList.add('selected');
if (dateRangePicker.isSameDay(startDate, dateObj.date)) {
buttonEl.classList.add('start-of-range');
} else if (dateRangePicker.isSameDay(endDate, dateObj.date)) {
buttonEl.classList.add('end-of-range');
}
}
dayEl.onclick = () => {
// If the user has not selected any date yet
if (firstSelectedDate === null) {
// Store that date that was clicked
firstSelectedDate = dateObj.date;
// Treat it as the user wanting to start
// a new selection on that date.
dateRangePicker.deselectAll();
// Also visually select this date so it becomes blue.
dateRangePicker.selectDate(firstSelectedDate);
} else {
/*
If the user has already selected a date the
second click should close the range.
Note: selectRange does not care in which order
it receives the parameters, it will find the
earlier and later dates itself.
*/
dateRangePicker.selectRange(firstSelectedDate, dateObj.date);
// Now reset the firstSelectedDate so the next click
// is treated as the user wanting to change the range
//again.
firstSelectedDate = null;
}
};
datesEl.appendChild(dayEl);
});
}
const dateRangePicker = new DateGallery(config, subscriber);
dateRangePickerEl.querySelector('.previous').onclick = () => {
dateRangePicker.previous();
};
dateRangePickerEl.querySelector('.next').onclick = () => {
dateRangePicker.next();
};
dateRangePickerEl.querySelector('.goto-today').onclick = () => {
dateRangePicker.today();
};
<div class="daterangepicker-example">
<div class="topbar">
<button aria-label="previous" type="button" class="previous">‹</button>
<span class="title"></span>
<button aria-label="next" type="button" class="next">›</button>
</div>
<ul class="daygrid">
<li><abbr title="Sunday">S</abbr></li>
<li><abbr title="Monday">M</abbr></li>
<li><abbr title="Tuesday">T</abbr></li>
<li><abbr title="Wednesday">W</abbr></li>
<li><abbr title="Thursday">T</abbr></li>
<li><abbr title="Friday">F</abbr></li>
<li><abbr title="Saturday">S</abbr></li>
</ul>
<ul class="dates daygrid" tabindex="0"></ul>
<div class="bottombar">
<button type="button" class="goto-today">Today</button>
</div>
</div>
<p id="daterangepicker-example-result"></p>
.daterangepicker-example {
display: grid;
justify-items: center;
height: 360px;
width: 300px;
margin: 0 auto;
}
.daterangepicker-example .topbar {
display: flex;
justify-content: space-around;
align-items: center;
width: 100%;
align-self: start;
}
.daterangepicker-example .topbar .title {
flex-grow: 1;
text-align: center;
}
.daterangepicker-example .topbar button {
width: 42px;
font-size: 32px;
background-color: white;
}
.daterangepicker-example .daygrid {
display: grid;
grid-template-columns: repeat(7, 42px);
justify-items: center;
align-self: end;
}
.daterangepicker-example .dates button {
width: 42px;
height: 42px;
background-color: white;
}
.daterangepicker-example .dates .selected {
background-color: skyblue;
font-weight: bold;
}
.daterangepicker-example .dates .start-of-range {
border-top-left-radius: 100%;
border-bottom-left-radius: 100%;
}
.daterangepicker-example .dates .end-of-range {
border-top-right-radius: 100%;
border-bottom-right-radius: 100%;
}
.daterangepicker-example .dates button:disabled {
color: gray;
}
.daterangepicker-example .bottombar {
display: flex;
justify-content: space-around;
align-items: center;
width: 100%;
gap: 8px;
}
.daterangepicker-example .bottombar button {
padding: 4px;
color: white;
background-color: #6b21a8;
}
A month calendar view which shows all events within a month.
- Sun
- Mon
- Tue
- Wed
- Thu
- Fri
- Sat
View code
import { DateGallery } from '@uiloos/core';
const calendarExampleEl = document.querySelector('.calendar-month-example');
const calendarWrapperEl = calendarExampleEl.querySelector('.calendar-wrapper');
const titleEl = calendarExampleEl.querySelector('.calendar-title');
const calendarMonthTemplate = document.querySelector(
'#calendar-month-template'
);
const calendarDayTemplate = document.querySelector(
'#calendar-month-daycell-template'
);
const eventTemplate = document.querySelector('#calendar-month-event-template');
// December 2002
export const monthAndYearFormatter = new Intl.DateTimeFormat('en-US', {
month: 'long',
year: 'numeric',
});
// 12:45
export const timeFormatter = new Intl.DateTimeFormat('en-US', {
hour: '2-digit',
minute: '2-digit',
hourCycle: 'h23', // 00:00 to 23:59 instead of 24:00
});
const config = {
mode: 'month-six-weeks',
events: generateEvents(),
};
function subscriber(monthCalendar) {
titleEl.textContent = monthAndYearFormatter.format(
monthCalendar.firstFrame.anchorDate
);
const calendarMonthEl = clone(calendarMonthTemplate);
const cellsEl = calendarMonthEl.querySelector('.calendar-month-daycells');
monthCalendar.firstFrame.dates.forEach((dateObj) => {
const dayEl = clone(calendarDayTemplate);
// Set the aria label of the button to something sensible
const date = new Date(dateObj.date);
// Set the date to the current hour, and to the closest 5 minutes
const now = new Date();
date.setHours(now.getHours(), Math.round(now.getMinutes() / 5) * 5);
if (dateObj.isPadding) {
dayEl.classList.add('padding');
}
// Now set the number of the date in the right corner
const dayNumberEl = dayEl.querySelector('.calendar-month-daycell-number');
dayNumberEl.textContent = dateObj.date.getDate();
const eventsEl = dayEl.querySelector('.calendar-month-daycell-events');
const noRows = dateObj.events.length;
eventsEl.style.gridTemplateRows = `repeat(${noRows}, 32px)`;
for (const event of dateObj.events) {
const eventEl = clone(eventTemplate);
// Needed for the ::before event dot
eventEl.style.setProperty('--color', event.data.color);
const eventTitleEl = eventEl.querySelector('.calendar-month-event-title');
eventTitleEl.title = event.data.title;
const timeEl = eventEl.querySelector('.calendar-month-event-time');
// When an event happens on a single day show the title and start time.
eventTitleEl.textContent = event.data.title;
timeEl.textContent = timeFormatter.format(event.startDate);
eventsEl.appendChild(eventEl);
}
cellsEl.appendChild(dayEl);
});
calendarWrapperEl.innerHTML = '';
calendarWrapperEl.appendChild(calendarMonthEl);
}
const monthCalendar = new DateGallery(config, subscriber);
calendarExampleEl.querySelector('.previous').onclick = () => {
monthCalendar.previous();
};
calendarExampleEl.querySelector('.next').onclick = () => {
monthCalendar.next();
};
// Helpers
export function clone(template) {
return template.content.cloneNode(true).firstElementChild;
}
// Generate some events for the current year
export function generateEvents() {
let id = 0;
function eventId() {
id++;
return id;
}
const currentYear = new Date().getFullYear();
const events = [];
for (const year of [currentYear - 1, currentYear, currentYear + 1]) {
for (let i = 1; i < 13; i++) {
const month = i > 9 ? i : '0' + i;
// Add a hair salon appointment at every first of the month
events.push({
data: {
id: eventId(),
title: 'Hairsalon',
color: '#ef4444',
},
startDate: new Date(`${year}-${month}-01T12:00:00`),
endDate: new Date(`${year}-${month}-01T12:45:00`),
});
// Add a gym appointment at the 20th
events.push({
data: {
id: eventId(),
title: 'Gym with friends from work',
color: '#f97316',
},
startDate: new Date(`${year}-${month}-20T20:00:00`),
endDate: new Date(`${year}-${month}-20T22:00:00`),
});
// Add DnD appointment on 15th
events.push({
data: {
id: eventId(),
title: 'DnD',
color: '#84cc16',
},
startDate: new Date(`${year}-${month}-15T20:00:00`),
endDate: new Date(`${year}-${month}-15T22:00:00`),
});
// Add Pinball tournament on the 25th
events.push({
data: {
id: eventId(),
title: 'Pinball',
color: '#10b981',
},
startDate: new Date(`${year}-${month}-25T20:00:00`),
endDate: new Date(`${year}-${month}-25T22:00:00`),
});
// Add JS meetup on 15th as well
events.push({
data: {
id: eventId(),
title: 'JS Meetup',
color: '#3b82f6',
},
startDate: new Date(`${year}-${month}-15T20:00:00`),
endDate: new Date(`${year}-${month}-15T21:00:00`),
});
}
}
return events;
}
<div class="calendar-month-example">
<div class='calendar-controls'>
<button aria-label="previous" type="button" class="previous calendar-button">‹</button>
<span class="calendar-title"></span>
<button aria-label="next" type="button" class="next calendar-button">›</button>
</div>
<div class="calendar-wrapper"></div>
</div>
<template id="calendar-month-template">
<div class="calendar-month">
<ul class="calendar-month-daynames">
<li class="calendar-month-dayname">Sun</li>
<li class="calendar-month-dayname">Mon</li>
<li class="calendar-month-dayname">Tue</li>
<li class="calendar-month-dayname">Wed</li>
<li class="calendar-month-dayname">Thu</li>
<li class="calendar-month-dayname">Fri</li>
<li class="calendar-month-dayname">Sat</li>
</ul>
<ul class="calendar-month-daycells"></ul>
</div>
</template>
<template id="calendar-month-daycell-template">
<li class="calendar-month-daycell">
<button class="calendar-month-daycell-number"></button>
<ul class="calendar-month-daycell-events">
</ul>
</li>
</template>
<template id="calendar-month-event-template">
<li class="calendar-month-event">
<button class="calendar-month-event-wrapper">
<span class="calendar-month-event-title"></span>
<span class="calendar-month-event-time"></span>
</button>
</li>
</template>
.calendar-month-example
.calendar-controls {
display: grid;
grid-template-columns: 1fr 2fr 1fr;
place-items: center;
}
.calendar-month-example .calendar-button {
padding: 4px;
background-color: white;
font-size: 32px;
}
.calendar-month-example .calendar-title {
font-size: 26px;
}
.calendar-month-example .calendar-month-daynames {
display: grid;
grid-template-columns: repeat(7, minmax(32px, 1fr));
gap: 2px;
}
.calendar-month-example .calendar-month-dayname {
display: grid;
place-content: left;
height: 100px;
font-size: 22px;
}
.calendar-month-example .calendar-month-daycells {
display: grid;
grid-template-columns: repeat(7, minmax(32px, 1fr));
grid-auto-rows: 1fr;
gap: 0;
}
.calendar-month-example .calendar-month-daycell {
display: grid;
gap: 4px;
align-content: start;
box-shadow: 0 0 0 1px black;
min-height: 120px;
font-size: 22px;
background-color: white;
cursor: pointer;
padding-bottom: 8px;
overflow: hidden;
}
.calendar-month-example .calendar-month-daycell.padding {
color: gray;
}
.calendar-month-example .calendar-month-daycell-number {
padding: 4px;
justify-self: left;
background-color: white;
font-size: 22px;
color: inherit;
}
.calendar-month-example .calendar-month-daycell-events {
display: grid;
grid-template-rows: 32px;
gap: 4px;
height: 100%;
}
.calendar-month-example .calendar-month-event {
--color: #000000; /* color is set from js */
display: flex;
justify-content: space-between;
align-items: center;
gap: 4px;
background-color: white;
font-size: 16px;
min-height: 32px;
}
.calendar-month-example .calendar-month-event::before {
content: "";
border-radius: 25px;
background-color: var(--color);
width: 10px;
height: 10px;
margin-left: 4px;
}
.calendar-month-example .calendar-month-event-wrapper {
display: flex;
justify-content: space-between;
flex-grow: 1;
background-color: transparent;
padding: 0 4px;
}
.calendar-month-example .calendar-month-event-title {
width: 75px;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
text-align: left;
}
A week calendar view shows 7 days.
Note: the code supports overlapping events! Due to it visually looking a bit cramped in the space here, all generated events do not overlap.
View code
import { DateGallery } from '@uiloos/core';
const calendarExampleEl = document.querySelector('.calendar-week-example');
const calendarWrapperEl = calendarExampleEl.querySelector('.calendar-wrapper');
const titleEl = calendarExampleEl.querySelector('.calendar-title');
const calendarWeekTemplate = document.querySelector('#calendar-week-template');
const eventTemplate = document.querySelector('#calendar-week-event-template');
const START_HOUR = 9;
const END_HOUR = 18;
const HEIGHT = (END_HOUR - START_HOUR -1) * 60
calendarExampleEl.style.setProperty('--height', HEIGHT);
// M 1
export const weekDayFormatter = new Intl.DateTimeFormat('en-US', {
day: 'numeric',
weekday: 'narrow',
});
// December 2002
export const monthAndYearFormatter = new Intl.DateTimeFormat('en-US', {
month: 'long',
year: 'numeric',
});
// 12:45
export const timeFormatter = new Intl.DateTimeFormat('en-US', {
hour: '2-digit',
minute: '2-digit',
hourCycle: 'h23', // 00:00 to 23:59 instead of 24:00
});
const config = {
mode: 'week',
events: generateEvents(),
};
function subscriber(weekCalendar) {
titleEl.textContent = monthAndYearFormatter.format(
weekCalendar.firstFrame.anchorDate
);
const weekEl = clone(calendarWeekTemplate);
const gridEl = weekEl.querySelector('.calendar-week-grid');
renderLeftHours(gridEl);
weekCalendar.firstFrame.dates.forEach((dateObj, index) => {
const eventColumns = calculateEventColumns(dateObj);
const dayNameEl = document.createElement('button');
dayNameEl.className = 'calendar-week-dayname';
dayNameEl.innerHTML = `
<time datetime="${dateObj.date.toISOString()}">
${weekDayFormatter.format(dateObj.date)}
</time>
`;
dayNameEl.style.gridColumn = index + 2;
gridEl.appendChild(dayNameEl);
// Will be a subgrid for each day containing the events
const dayEl = document.createElement('ul');
// Create sub grid for each day in the week.
dayEl.className = 'calendar-week-day-grid';
dayEl.style.gridColumn = dateObj.date.getDay() + 2;
dateObj.events.forEach((event) => {
const eventEl = clone(eventTemplate);
eventEl.style.backgroundColor = event.data.color;
const buttonEl = eventEl.querySelector('button');
buttonEl.style.backgroundColor = event.data.color;
buttonEl.style.color = 'white';
const titleEl = eventEl.querySelector('i');
titleEl.title = event.data.title;
titleEl.textContent = event.data.title;
const [startTimeEl, endTimeEl] = Array.from(
eventEl.querySelectorAll('b')
);
const start = getMinutesSinceStart(event.startDate);
const end = getMinutesSinceStart(event.endDate);
eventEl.style.gridRow = `${start + 2} / ${end + 2}`;
startTimeEl.textContent = timeFormatter.format(event.startDate);
endTimeEl.textContent = timeFormatter.format(event.endDate);
eventEl.style.gridColumn = eventColumns.get(event);
dayEl.appendChild(eventEl);
});
gridEl.appendChild(dayEl);
});
calendarWrapperEl.innerHTML = '';
calendarWrapperEl.appendChild(weekEl);
}
const weekCalendar = new DateGallery(config, subscriber);
calendarExampleEl.querySelector('.previous').onclick = () => {
weekCalendar.previous();
};
calendarExampleEl.querySelector('.next').onclick = () => {
weekCalendar.next();
};
function renderLeftHours(gridEl) {
// Render the hours on the left
for (let i = START_HOUR; i < END_HOUR; i++) {
const hourEl = document.createElement('span');
hourEl.className = 'calendar-week-hour';
hourEl.ariaHidden = true;
const row = (i - START_HOUR) * 60 + 3;
hourEl.style.gridRow = `${row} / ${row + 60}`;
const date = new Date();
date.setHours(i, 0, 0, 0);
const time = timeFormatter.format(date);
hourEl.textContent = time;
gridEl.appendChild(hourEl);
}
}
// Packs all events on an axis (row / column) as tightly as possible
// with the least amount of rows / columns needed.
export function packEventsOnAxis(events) {
// Note: the code below uses the term columns / column for clarity
// but it also works for rows.
// Step one: we place all events into the least amount of columns
const columns = [[]];
events.forEach((event) => {
// Shortcut if the event does not overlap we can
// safely place it in the first column.
if (!event.isOverlapping) {
columns[0].push(event);
return;
}
// Find the first column we do not have overlapping events in,
// since that is the place the event can fit into. By finding
// the first column it fits into we make sure we use as little
// columns as possible.
const column = columns.find((column) => {
return column.every(
(otherEvent) => !event.overlappingEvents.includes(otherEvent)
);
});
if (column) {
// If we find a columm, great add the event to it.
column.push(event);
} else {
// If we cannot find a column the event fits into
// we create a new column.
columns.push([event]);
}
});
return columns;
}
/*
Takes a DateGalleryDate and returns a Map to which the events
are keys, and the values are CSS gridColumn strings.
For example:
{eventA: '1 / 2', eventB: '2 / 3', eventC: '3 / 4'}
The events are packed as tight as possible so the least
amount of columns are used.
Note: since we are using a CSS grid we do get one limitation:
you cannot always divide a CSS grid equally over multiple events.
This is because CSS grids cannot have varying columns / rows,
meaning you cannot make one row have three columns, and the other
row have two.
This is a problem for us: say you have a day with five events,
three of which are overlapping, and the other two overlap as well.
This means we end up with 3 columns total to hold the three
overlapping events, but then the other 2 events also need to be
divided over three columns.
In an ideal world we would be able to say: CSS Grid make those
two events take the same amount of space in the 3 columns.
Essentially making the 2 events the same size, but unfortunately
CSS cannot do this.
So my solution is to make one of the two events take up 2/3 and
the other 1/3. Not ideal but it works
*/
function calculateEventColumns(dateObj) {
// Step one: we place all events into the least amount of columns
const columns = packEventsOnAxis(dateObj.events);
// Step two: we take the columns array and turn it into a Map of CSS
// grid column strings.
const eventColumns = new Map();
dateObj.events.forEach((event) => {
// Shortcut if the event does not overlap make it span
// all columns.
if (!event.isOverlapping) {
eventColumns.set(event, `1 / ${columns.length + 1}`);
return;
}
// The start column is the first column this event can be found in.
const startColumn = columns.findIndex((column) => column.includes(event));
// Now that we have found the start, we need to find the end in the
// remaining columns.
const remainingColumns = columns.slice(startColumn);
// The end column is the first column an overlapping event can be found in,
// since it has to share the column with that event.
let endColumn = remainingColumns.findIndex((column) =>
column.some((otherEvent) => event.overlappingEvents.includes(otherEvent))
);
// If we cannot find an endColumn it means it was already on the
// last column, so place it there.
if (endColumn === -1) {
endColumn = columns.length;
} else {
// If the endColumn can be found we need to add the startColumn
// to it since the remainingColumns start counting at 0 again,
// so we need to make up the difference.
endColumn += startColumn;
}
// Finally we know where to place the event, but we need to
// account for the fact that CSS grid starts counting at one
// and not zero. So we +1 the columns.
eventColumns.set(event, `${startColumn + 1} / ${endColumn + 1}`);
});
return eventColumns;
}
// When given a date it returns the number of minutes
// that have passed since START_HOUR.
export function getMinutesSinceStart(date) {
// First get the number of minutes since midnight: for example
// if the time was 12:30 you would get 12 * 60 + 30 = 750 minutes.
const midnight = date.getHours() * 60 + date.getMinutes();
// Since we start on 09:00 hours we need to treat 09:00 hours
// as the starting point, so shift the minutes back by 09:00
// hours.
return midnight - START_HOUR * 60;
}
// Helpers
export function clone(template) {
return template.content.cloneNode(true).firstElementChild;
}
// Generate some events for the current year
export function generateEvents() {
let id = 0;
function eventId() {
id++;
return id;
}
const currentYear = new Date().getFullYear();
const events = [];
for (const year of [currentYear - 1, currentYear, currentYear + 1]) {
for (let i = 1; i < 13; i++) {
const month = i > 9 ? i : '0' + i;
events.push({
data: {
id: eventId(),
title: 'Smith',
color: '#ef4444',
},
startDate: new Date(`${year}-${month}-01T10:30:00`),
endDate: new Date(`${year}-${month}-01T16:15:00`),
});
events.push({
data: {
id: eventId(),
title: 'Smith',
color: '#ef4444',
},
startDate: new Date(`${year}-${month}-02T10:00:00`),
endDate: new Date(`${year}-${month}-02T14:45:00`),
});
events.push({
data: {
id: eventId(),
title: 'Steel',
color: '#3b82f6',
},
startDate: new Date(`${year}-${month}-04T09:00:00`),
endDate: new Date(`${year}-${month}-04T17:00:00`),
});
events.push({
data: {
id: eventId(),
title: 'Steel',
color: '#3b82f6',
},
startDate: new Date(`${year}-${month}-07T15:00:00`),
endDate: new Date(`${year}-${month}-07T17:00:00`),
});
events.push({
data: {
id: eventId(),
title: 'Smith',
color: '#ef4444',
},
startDate: new Date(`${year}-${month}-07T09:00:00`),
endDate: new Date(`${year}-${month}-07T15:00:00`),
});
events.push({
data: {
id: eventId(),
title: 'Rose',
color: '#f97316',
},
startDate: new Date(`${year}-${month}-10T10:00:00`),
endDate: new Date(`${year}-${month}-10T15:30:00`),
});
events.push({
data: {
id: eventId(),
title: 'York',
color: '#3b82f6',
},
startDate: new Date(`${year}-${month}-12T10:00:00`),
endDate: new Date(`${year}-${month}-12T16:00:00`),
});
events.push({
data: {
id: eventId(),
title: 'Vala',
color: '#84cc16',
},
startDate: new Date(`${year}-${month}-15T09:00:00`),
endDate: new Date(`${year}-${month}-15T14:00:00`),
});
events.push({
data: {
id: eventId(),
title: 'Vala',
color: '#84cc16',
},
startDate: new Date(`${year}-${month}-17T12:00:00`),
endDate: new Date(`${year}-${month}-17T15:00:00`),
});
events.push({
data: {
id: eventId(),
title: 'Vala',
color: '#84cc16',
},
startDate: new Date(`${year}-${month}-18T12:00:00`),
endDate: new Date(`${year}-${month}-18T15:00:00`),
});
events.push({
data: {
id: eventId(),
title: 'York',
color: '#3b82f6',
},
startDate: new Date(`${year}-${month}-19T10:00:00`),
endDate: new Date(`${year}-${month}-19T16:00:00`),
});
events.push({
data: {
id: eventId(),
title: 'York',
color: '#3b82f6',
},
startDate: new Date(`${year}-${month}-20T11:00:00`),
endDate: new Date(`${year}-${month}-20T17:00:00`),
});
events.push({
data: {
id: eventId(),
title: 'Rose',
color: '#f97316',
},
startDate: new Date(`${year}-${month}-23T10:00:00`),
endDate: new Date(`${year}-${month}-23T15:30:00`),
});
events.push({
data: {
id: eventId(),
title: 'Croft',
color: '#10b981',
},
startDate: new Date(`${year}-${month}-25T12:00:00`),
endDate: new Date(`${year}-${month}-25T13:00:00`),
});
events.push({
data: {
id: eventId(),
title: 'Rose',
color: '#f97316',
},
startDate: new Date(`${year}-${month}-28T10:00:00`),
endDate: new Date(`${year}-${month}-28T13:00:00`),
});
events.push({
data: {
id: eventId(),
title: 'Vala',
color: '#84cc16',
},
startDate: new Date(`${year}-${month}-25T13:00:00`),
endDate: new Date(`${year}-${month}-25T17:00:00`),
});
}
}
return events;
}
<div class="calendar-week-example">
<div class='calendar-controls'>
<button aria-label="previous" type="button" class="previous calendar-button">‹</button>
<span class="calendar-title"></span>
<button aria-label="next" type="button" class="next calendar-button">›</button>
</div>
<div class="calendar-wrapper"></div>
</div>
<template id="calendar-week-template">
<div class="calendar-week">
<div class="calendar-week-grid"></div>
</div>
</template>
<template id="calendar-week-event-template">
<li class="calendar-week-event">
<button>
<span class="text-container">
<span class="inner-text-container">
<b></b>
<i></i>
</span>
<b class="end-time"></b>
</span>
</button>
</li>
</template>
.calendar-week-example {
/* Is set from within JavaScript based on the visible hours */
--height: 0;
}
.calendar-week-example
.calendar-controls {
display: grid;
grid-template-columns: 1fr 2fr 1fr;
place-items: center;
}
.calendar-week-example .calendar-button {
padding: 4px;
background-color: white;
font-size: 32px;
}
.calendar-week-example .calendar-title {
font-size: 26px;
}
.calendar-week-example .calendar-week-grid {
display: grid;
grid-template-columns: 100px repeat(7, minmax(0, 1fr));
grid-template-rows: 50px repeat(var(--height), 1px);
}
.calendar-week-example .calendar-week-dayname {
align-self: self-end;
margin-bottom: 14px;
text-align: center;
font-size: 22px;
background-color: white;
}
.calendar-week-example .calendar-week-hour {
grid-column: 1 / 9;
margin-top: -20px; /* Align the hour bar on the grid */
height: 60px;
margin-left: 40px;
}
.calendar-week-example .calendar-week-hour::after {
display: block;
content: " ";
background-color: lightgray;
height: 1px;
width: calc(100% - 64px);
position: relative;
bottom: 10px;
left: 64px;
z-index: -1;
}
.calendar-week-example .calendar-week-day-grid {
grid-row: 2 / calc(var(--height) + 2);
display: grid;
grid-template-rows: repeat(var(--height), 1px);
grid-auto-columns: 1fr;
grid-auto-flow: column;
border-right: 1px solid black;
column-gap: 8px;
padding: 0 4px;
cursor: pointer;
}
.calendar-week-example .calendar-week-event {
opacity: 0.9;
overflow: hidden;
text-align: left;
}
.calendar-week-example .calendar-week-event button {
text-align: left;
display: flex;
flex-direction: column;
justify-content: space-between;
height: 100%;
}
.calendar-week-example .calendar-week-event .text-container {
padding: 0 4px;
flex-grow: 1;
overflow: hidden;
display: grid;
}
.calendar-week-example .calendar-week-event .text-container .inner-text-container {
display: flex;
flex-direction: column;
gap: 4px;
}
.calendar-week-example .calendar-week-event .text-container .end-time {
align-self: end;
}
A day calendar view which shows the events horizontally.
View code
import { DateGallery } from '@uiloos/core';
const calendarExampleEl = document.querySelector('.calendar-day-example');
const calendarWrapperEl = calendarExampleEl.querySelector('.calendar-wrapper');
const titleEl = calendarExampleEl.querySelector('.calendar-title');
const calendarDayTemplate = document.querySelector('#calendar-day-template');
const eventTemplate = document.querySelector('#calendar-day-event-template');
const START_HOUR = 9;
const END_HOUR = 18;
const WIDTH = (END_HOUR - START_HOUR) * 60;
calendarExampleEl.style.setProperty('--width', WIDTH);
// 12-31-2000
export const dateFormatter = new Intl.DateTimeFormat('en-US', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
});
// 12:45
export const timeFormatter = new Intl.DateTimeFormat('en-US', {
hour: '2-digit',
minute: '2-digit',
hourCycle: 'h23', // 00:00 to 23:59 instead of 24:00
});
const config = {
mode: 'day',
events: generateEvents(),
};
function subscriber(dayCalendar) {
const dayDate = dayCalendar.firstFrame.anchorDate;
titleEl.textContent = dateFormatter.format(dayDate);
const gridEl = clone(calendarDayTemplate);
const eventRows = calculateEventRows(dayCalendar);
// Render top bar hours
renderHours(dayCalendar, gridEl, eventRows);
dayCalendar.firstFrame.events.forEach((event) => {
const eventEl = clone(eventTemplate);
eventEl.style.backgroundColor = event.data.color;
const buttonEl = eventEl.querySelector('button');
buttonEl.style.backgroundColor = event.data.color;
buttonEl.style.color = 'white';
const titleEl = eventEl.querySelector('i');
titleEl.title = event.data.title;
titleEl.textContent = event.data.title;
const [startTimeEl, endTimeEl] = Array.from(eventEl.querySelectorAll('b'));
const start = getMinutesSinceStart(event.startDate);
const end = getMinutesSinceStart(event.endDate);
eventEl.style.gridColumn = `${start + 1} / ${end + 1}`;
// When fully in this day show both times
startTimeEl.textContent = timeFormatter.format(event.startDate);
endTimeEl.textContent = timeFormatter.format(event.endDate);
eventEl.style.gridRow = eventRows.get(event);
gridEl.appendChild(eventEl);
});
calendarWrapperEl.innerHTML = '';
calendarWrapperEl.appendChild(gridEl);
}
const dayCalendar = new DateGallery(config, subscriber);
calendarExampleEl.querySelector('.previous').onclick = () => {
dayCalendar.previous();
};
calendarExampleEl.querySelector('.next').onclick = () => {
dayCalendar.next();
};
// Packs all events on an axis (row / column) as tightly as possible
// with the least amount of rows / columns needed.
export function packEventsOnAxis(events) {
// Note: the code below uses the term columns / column for clarity
// but it also works for rows.
// Step one: we place all events into the least amount of columns
const columns = [[]];
events.forEach((event) => {
// Shortcut if the event does not overlap we can
// safely place it in the first column.
if (!event.isOverlapping) {
columns[0].push(event);
return;
}
// Find the first column we do not have overlapping events in,
// since that is the place the event can fit into. By finding
// the first column it fits into we make sure we use as little
// columns as possible.
const column = columns.find((column) => {
return column.every(
(otherEvent) => !event.overlappingEvents.includes(otherEvent)
);
});
if (column) {
// If we find a columm, great add the event to it.
column.push(event);
} else {
// If we cannot find a column the event fits into
// we create a new column.
columns.push([event]);
}
});
return columns;
}
/*
Takes a DateGallery and returns a Map to which the events
are keys, and the values are CSS gridRow strings.
For example:
{eventA: '1 / 2', eventB: '2 / 3', eventC: '3 / 4'}
The events are packed as tight as possible so the least
amount of rows are used.
*/
function calculateEventRows(dateGallery) {
// Step one: we place all events into the least amount of rows
const rows = packEventsOnAxis(dateGallery.firstFrame.events);
// Step two: we take the rows array and turn it into a Map of CSS
// grid row strings.
const eventRows = new Map();
dateGallery.firstFrame.events.forEach((event) => {
const row = rows.findIndex((row) => row.includes(event));
// Finally we know where to place the event, but we need to
// account for the fact that CSS grid starts counting at one
// and not zero. So we +1 the rows. Also we now that the first
// row shows the hours so another +1 is needed.
eventRows.set(event, `${row + 2}`);
});
return eventRows;
}
function renderHours(dayCalendar, gridEl, eventRows) {
// Render the hours on the top
for (let i = START_HOUR; i < END_HOUR; i++) {
const hourEl = document.createElement('li');
hourEl.className = 'calendar-day-hour';
hourEl.ariaHidden = true;
const column = (i - START_HOUR) * 60 + 1;
hourEl.style.gridColumn = `${column} / ${column + 60}`;
hourEl.style.gridRow = `1 / ${getNoRows(eventRows)}`;
const date = new Date(dayCalendar.firstFrame.anchorDate);
date.setHours(i, 0, 0, 0);
const time = timeFormatter.format(date);
hourEl.textContent = time;
gridEl.appendChild(hourEl);
}
}
function getNoRows(eventRows) {
let noRows = 0;
eventRows.forEach((x) => {
noRows = Math.max(parseInt(x, 10), noRows);
});
return noRows < 10 ? 10 : noRows;
}
// When given a date it returns the number of minutes
// that have passed since the START_HOUR.
export function getMinutesSinceStart(date) {
// First get the number of minutes since midnight: for example
// if the time was 12:30 you would get 12 * 60 + 30 = 750 minutes.
const midnight = date.getHours() * 60 + date.getMinutes();
// Since we start on 09:00 hours we need to treat 09:00 hours
// as the starting point, so shift the minutes back by 09:00
// hours.
return midnight - START_HOUR * 60;
}
// Helpers
export function clone(template) {
return template.content.cloneNode(true).firstElementChild;
}
// Generate some events for the current month
export function generateEvents() {
let id = 0;
function eventId() {
id++;
return id;
}
const events = [];
const yearGallery = new DateGallery({ mode: 'month' });
yearGallery.firstFrame.dates.forEach((dateObj) => {
const startDate = new Date(dateObj.date);
const endDate = new Date(dateObj.date);
startDate.setHours(randomNumberBetween(10, 12), 30, 0);
endDate.setHours(randomNumberBetween(14, 16), 15, 0);
events.push({
data: {
id: eventId(),
title: 'Jane',
color: '#ef4444',
},
startDate: new Date(startDate),
endDate: new Date(endDate),
});
startDate.setHours(randomNumberBetween(9, 15), 30, 0);
endDate.setHours(17, 0, 0);
events.push({
data: {
id: eventId(),
title: 'Diane',
color: '#c026d3',
},
startDate: new Date(startDate),
endDate: new Date(endDate),
});
startDate.setHours(10, 0, 0);
endDate.setHours(randomNumberBetween(14, 17), 0, 0);
events.push({
data: {
id: eventId(),
title: 'John',
color: '#3b82f6',
},
startDate: new Date(startDate),
endDate: new Date(endDate),
});
startDate.setHours(randomNumberBetween(12, 14), 45, 0);
endDate.setHours(randomNumberBetween(15, 17), 0, 0);
events.push({
data: {
id: eventId(),
title: 'Ian',
color: '#10b981',
},
startDate: new Date(startDate),
endDate: new Date(endDate),
});
startDate.setHours(randomNumberBetween(9, 15), 0, 0);
endDate.setHours(16, 30, 0);
events.push({
data: {
id: eventId(),
title: 'Eva',
color: '#f97316',
},
startDate: new Date(startDate),
endDate: new Date(endDate),
});
startDate.setHours(9, 0, 0);
endDate.setHours(randomNumberBetween(14, 17), 45, 0);
events.push({
data: {
id: eventId(),
title: 'Dirk',
color: '#84cc16',
},
startDate: new Date(startDate),
endDate: new Date(endDate),
});
});
return events;
}
function randomNumberBetween(min, max) {
return Math.random() * (max - min) + min;
}
<div class="calendar-day-example">
<div class='calendar-controls'>
<button aria-label="previous" type="button" class="previous calendar-button">‹</button>
<span class="calendar-title"></span>
<button aria-label="next" type="button" class="next calendar-button">›</button>
</div>
<div class="calendar-wrapper"></div>
</div>
<template id="calendar-day-template">
<ul class="calendar-day-grid"></ul>
</template>
<template id="calendar-day-event-template">
<li class="calendar-day-event">
<button>
<span class="text-container">
<b></b>
<i></i>
<b class="end-time"></b>
</span>
</button>
</li>
</template>
.calendar-day-example {
/* Is set from within JavaScript based on the visible hours */
--width: 0;
}
.calendar-day-example
.calendar-controls {
display: grid;
grid-template-columns: 1fr 2fr 1fr;
place-items: center;
}
.calendar-day-example .calendar-button {
padding: 4px;
background-color: white;
font-size: 32px;
}
.calendar-day-example .calendar-title {
font-size: 26px;
}
.calendar-day-example .calendar-day-grid {
margin-top: 10px;
display: grid;
grid-template-columns: repeat(var(--width), minmax(1px, 1fr));
grid-template-rows: repeat(7, 30px);
row-gap: 8px;
}
.calendar-day-example .calendar-day-hour {
display: flex;
margin-left: -1px;
padding-left: 4px;
border-left: solid lightgray 1px;
cursor: pointer;
background-color: white;
font-size: 16px;
}
.calendar-day-example .calendar-day-current-time {
background-color: orangered;
width: 1px;
}
.calendar-day-example .calendar-day-current-time:hover::after {
content: attr(time);
display: flex;
justify-content: center;
align-items: center;
background-color: orangered;
width: 62px;
height: 32px;
}
.calendar-day-example .calendar-day-event {
display: flex;
justify-content: space-between;
align-items: center;
gap: 8px;
opacity: 0.9;
height: 30px;
overflow: hidden;
cursor: pointer;
z-index: 1;
}
.calendar-day-example .calendar-day-event button {
text-align: left;
display: flex;
justify-content: space-between;
align-items: center;
width: 100%;
}
.calendar-day-example .calendar-day-event .text-container {
flex-grow: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
text-align: left;
display: flex;
gap: 4px;
}
.calendar-day-example .calendar-day-event .end-time {
flex-grow: 1;
text-align: right;
}
The name DateGallery is a pun on the fact that date based components tend to show you a collection of dates.
What the DateGallery provides is an API to move through a frame of dates, for examples: move from one month to the next month. It also provides an API to select dates, and has support for having events on dates, events are in the sense of a "doctors appointment" or "meeting".
A DateGallery structurally consists of a couple of classes: The first is a DateGalleryFrame, which represent a group of dates that are currently visible, a date is represented by a DateGalleryDate.
A DateGalleryDate has all the information about the day it represents: whether or not it is selected, if there are any events, or if the date it represents is today, and more.
How many dates are contained in the DateGalleryFrame depends on the mode the DateGallery is in. For example when the mode is "year" a whole year of dates are shown, when the mode is "week" only 7 days are shown.
The DateGalleryEvent represents the events of the DateGallery. An event always has a start and an end date, and optionally can hold any other data you might want to give it: such as a title or a description. A DateGalleryEvent also knows which events overlap him.
2. Initialization
The DateGallery can be initialized by calling the constructor. The constructor takes two arguments the config and an optional subscriber.
The config allows for the DateGallery to be tuned to the use case. In the next sections we dive deeper into what can be configured.
The second argument is an optional subscriber, the subscriber is a callback function, allowing you to observe changes of the DateGallery. When using vanilla JavaScript the callback is the place to perform any DOM manipulations. The callback receives the event that occurred so you can act on it.
When using reactive frameworks such as React, Vue, Angular or Svelte etc etc. The subscriber is not necessary since your framework of choice will do the heavy lifting of syncing the state of the DateGallery with the DOM. For more information see "Usage with Frameworks"
Initialization code example
import { DateGallery } from '@uiloos/core';
const config = {
mode: 'week'
};
function subscriber(dateGallery, event) {
console.log(event);
}
const dateGallery = new DateGallery(config, subscriber)
import { DateGallery, DateGallerySubscriberEvent } from '@uiloos/core';
type EventData = {
title: string;
description: string;
color: string;
};
const config = {
mode: 'week'
};
function subscriber(
dateGallery: DateGallery<EventData>,
event: DateGallerySubscriberEvent<EventData>
) {
console.log(event);
}
const dateGallery = new DateGallery<EventData>(config, subscriber);
3. Live properties
The DateGallery tracks the status of itself in "live" properties. These live properties will sync automatically whenever you perform an action (call a method) of the DateGallery, such as adding or removing an event, or jumping to the next or previous frame.
In other words: each time you call a method to alter the DateGallery, all "live" properties will have been updated to reflect the current status.
- mode mode the DateGallery is in, for example: day, week or month-six-weeks etc etc.
- events contains all events from within the DateGallery.
- selectedDates contains Date objects which are selected. selectedDates determine if a DateGalleryEvent is selected.
- frames all frames that are currently visible. The number of frames is determined by numberOfFrames.
- firstFrame the first item in the frames array, handy shortcut for when only showing one frame at a time.
- numberOfFrames the number of frames that are visible to the user at the same time. For example when mode is set to month and the numberOfFrames to 3, three months are shown.
4. Modes
The DateGallery has modes which determine how many dates are shown in a single frame.
For example if you set the mode to "week", each frame will have seven days. When calling next() the frame will contain the next seven days.
You can change the mode of a DateGallery by calling changeConfig() with a DateGalleryChangeConfig that has a mode property.
There are 6 modes in total:
- "day" a single day per frame, useful for when creating calendars with a "day" view.
- "week" seven days per frame, starting at the configured firstDayOfWeek. Handy when creating a calendar with a "week" view.
- "month" all days within a calendar month per frame. A frame will then always start on the first of the month, and end on the last day of the month.
-
"month-six-weeks" all days within a calendar month, but padded out to six weeks. Meaning that there are always 42 days in the frame. Useful for when you want you calendar / datepicker to be visually stable height wise. Starts the days on the configured firstDayOfWeek.
-
"month-pad-to-week" all days within a calendar month, but padded out to the closest firstDayOfWeek. For example given that firstDayOfWeek is set to 0 / Sunday: if the first day of the month starts on Wednesday it will pad to the previous Sunday of the previous month. If the month ends on a friday, it will add the next saturday of the next month. Starts the days on the configured firstDayOfWeek.
- "year" a frame will contain all 365 days (or 366 when a leap year) within a year.
The default mode is "month-six-weeks".
On padding: the modes "month-six-weeks" and "month-pad-to-week" both pad out their month, to contain extra dates for aesthetic reasons. In most UIs these padding days tend to be greyed out. You can tell if a DateGalleryDate is padding if the isPadding boolean is set to true.
5. Frames
In some situations you want to show multiple frames of a mode at the same time. For example you might want to show 12 "month" calendars in a grid to show an overview of an entire year.
Or you want to create a date range picker which shows not just one month, but three months side by side to make picking, for example: a vacation easier.
In these scenarios you want to use numberOfFrames, it tells the DateGallery just how many items the frames array should have.
For the first scenario you would set the mode to "month" and "numberOfFrames" to 12
For the second scenario you would set the mode to "month" (or another month mode) and "numberOfFrames" to 3.
All visible frames are stored in the frames array and are represented by the DateGalleryFrames class. Which holds a reference to the dates and events which are in the frame.
Each frame also an anchorDate which represents the first date this frame represents. This date can be used to render the "title" of the frame, for example in a month based calendar the name of the month, or in a year based calendar the year that is displayed. The anchorDate will always have its time set to midnight.
There also exists firstFrame property which is a shortcut to the first object within the frames array. This is useful for when the numberOfFrames is set to 1, so you do not have to write a nested loop.
6. Dates
A day is represented by the DateGalleryDate class, and in the example code it is often given the variable name dateObj.
The DateGalleryDate main purpose is to be iterated / looped over to display the dates of the DateGalleryFrames.
The most important property of the DateGalleryDate is its date, a JavaScript Date object, which has its time set to midnight. It is the date property you use to render a string which represents this date, in a month based calendar this would be the days number for example.
A DateGalleryDate also knows which events are happening on that day, knows if it is selected or not, and if the date it represents is on today. You can use all of this information to drive which CSS classes you would apply on the DOM element.
It also has methods to select / deselect and toggle the selection of that date.
7. Events
The main point of creating calendar UIs is to show events on that calendar, for example which movies are showing at the cinema, which appointment and meetings a person has etc etc.
An event is represented by the DateGalleryEvent class.
Events can be added via to the DateGallery via the addEvent method, or via the config on the events property.
The only thing the DateGallery wants to know about an event, is what the startDate and startDate of an event are. These are JavaScript Date objects and therefore can also hold the time.
The rest of the information you want to add to the event you can add on the data property. The value data can by anything you want, from an array, to an object, or even strings or numbers. For example: you could set date to be an object containing for example the title, description, guest, and color of the event.
The DateGalleryEvent also contains meta information about an event: if the event spans multiple days, which can be used to give the event a different appearance. It also knows what all overlapping events are. and when that happens you might give a visual clue to warn the user.
There are also several methods on the DateGalleryEvent, which allow you to move the event, remove the event, and change the data of an event.
The events are stored on the DateGallery on the events array. The events array sorted from earlier to later / past to future.
Each DateGalleryDate also knows which events happen on that specific date. This allows you to iterate / loop over all events of a specific day.
A DateGalleryFrame also knows which events occur on it. This allows you to iterate over each event to render all events of that frame. This is useful if you want to create a month calendar, but display the events in a separate panel for example.
8. Selection
When writing date pickers or date range pickers you want the user to be able to select dates. So they can pick the time of a meeting, or select the range of dates to go on vacation.
Within the DateGallery selection is done based on JavaScript Date objects, which are stored in an array called selectedDates. All dates inside of selectedDates are set to midnight. The array is sorted on insertion order.
Whenever a DateGalleryDate is created it is checked if it matches one of the dates of the selectedDates to see if it is selected.
You can select / deselect dates via methods on the DateGalleryDate, or via methods of the DateGallery, it is even possible to select ranges via selectRange.
You also can predefine selected dates via the config by setting the selectedDates property.
Sometimes you want to prevent a date from being able to be selected. For example you do not want users to be able to book appointments on the weekend, or in the future / past.
In this scenario you can provide a canSelect function when configuring the DateGallery. The canSelect is called with a DateGalleryDate instance and you should return a boolean for whether or not the date can be selected.
Every DateGalleryDate has a property called canBeSelected which is the result of calling canSelect with that DateGalleryDate. You can use canBeSelected to give dates that cannot be selected a different set of CSS classes.
If no canSelect is provided all dates can be selected.
You can also limit the number of dates that can be selected setting maxSelectionLimit to a number.
9. I18N
The DateGallery component does not provide ways to format JavaScript Date objects. Instead we advise you to use the excellent built in date formatters in JavaScript itself.
This API is called: DateTimeFormat, and has been in JavaScript for quite some time. It allows you to format days in various languages, and in various formats, without having to download one extra bit.
The one thing the DateGallery does support is setting what the firstDayOfWeek is considered to be. This is used in various modes such as the "week" mode to determine on which day a week begins.
It defaults to 0 which stands for sunday.
10. Dealing with Date
Many of the DateGallery's methods accept dates as arguments, these can either be given as a string or a Date object.
When providing Date's the DateGallery will use the Date as is but it will always create a copy, so the original is not mutated.
When providing a string that string is used as an argument to the Date constructor. When using string it is important that you understand how the Date constructor treats strings! We recommend that you read this section on MDN carefully: Date time string format.
An alternative is to parse dates one of the JavaScript date libraries out there, to make sure your string dates are in fact valid.
Another thing to note is that "uiloos" does not provide any date manipulation functions / utility functions such as adding hours to dates, get differences between dates etc etc
Instead you should choose one of the many excellent date utility libraries out there, for all your parsing, and date manipulation needs.
Here are some recommendations, in no particular order:
- date-fns has a function based API where each operation is a function call away.
- date-fns a very small in kB object oriented API. Great for chaining together operations in a readable way.
- Luxon is the successor to Moment.js, they share a lot of DNA but the main difference is that Luxon is immutable whereas Moment.js is mutable. Has an object oriented API.
- Moment.js is an older library but one that is still used a lot. Our advice is to use Luxon / Day.js if you like a object oriented API, but if you already use Moment.js extensively it is fine.
11. UTC
It is possible to set the DateGallery to UTC mode via the isUTC configuration.
UTC allows you to create calendars / date pickers which look the same all across the world, no matter what timezone you are in. Most of the time this is not what you want!
When is isUTC is set to true all operations the DateGallery internally performs on JavaScript Date objects, are done with the UTC variants.
Also all Date objects the DateGallery gives back to you are set in UTC.
12. History
The DateGallery can keep track of all events that occurred, by default is it configured not to keep any history.
When you set keepHistoryFor to a number, the DateGallery will make sure the history array will never exceed that size.
If the size is exceeded the oldest event is removed to make place for the new event. This is based on the first in first out principle.