Something went wrong on our end
-
Volker Schukai authoredVolker Schukai authored
month-calendar.mjs 17.55 KiB
/**
* Copyright © schukai GmbH and all contributing authors, {{copyRightYear}}. All rights reserved.
* Node module: @schukai/monster
*
* This source code is licensed under the GNU Affero General Public License version 3 (AGPLv3).
* The full text of the license can be found at: https://www.gnu.org/licenses/agpl-3.0.en.html
*
* For those who do not wish to adhere to the AGPLv3, a commercial license is available.
* Acquiring a commercial license allows you to use this software without complying with the AGPLv3 terms.
* For more information about purchasing a commercial license, please contact schukai GmbH.
*/
import { instanceSymbol } from "../../constants.mjs";
import { addAttributeToken } from "../../dom/attributes.mjs";
import {
ATTRIBUTE_ERRORMESSAGE,
ATTRIBUTE_ROLE,
} from "../../dom/constants.mjs";
import { CustomControl } from "../../dom/customcontrol.mjs";
import {
CustomElement,
getSlottedElements,
initMethodSymbol,
} from "../../dom/customelement.mjs";
import {
assembleMethodSymbol,
registerCustomElement,
} from "../../dom/customelement.mjs";
import { findTargetElementFromEvent } from "../../dom/events.mjs";
import { isFunction, isString } from "../../types/is.mjs";
import { fireCustomEvent } from "../../dom/events.mjs";
import { getLocaleOfDocument } from "../../dom/locale.mjs";
import { addErrorAttribute } from "../../dom/error.mjs";
import { MonthCalendarStyleSheet } from "./stylesheet/month-calendar.mjs";
import {
datasourceLinkedElementSymbol,
handleDataSourceChanges,
} from "../datatable/util.mjs";
import { findElementWithSelectorUpwards } from "../../dom/util.mjs";
import { Datasource } from "../datatable/datasource.mjs";
import { Observer } from "../../types/observer.mjs";
export { MonthCalendar };
/**
* @private
* @type {symbol}
*/
export const calendarElementSymbol = Symbol("calendarElement");
/**
* A Calendar
*
* @fragments /fragments/components/time/calendar/
*
* @example /examples/components/time/calendar-simple
*
* @since 3.112.0
* @copyright schukai GmbH
* @summary A beautiful Calendar that can make your life easier and also looks good.
*/
class MonthCalendar extends CustomElement {
/**
* This method is called by the `instanceof` operator.
* @returns {symbol}
*/
static get [instanceSymbol]() {
return Symbol.for("@schukai/monster/components/time/calendar@@instance");
}
[initMethodSymbol]() {
super[initMethodSymbol]();
const def = generateCalendarData.call(this);
this.setOption("calendarDays", def.calendarDays);
this.setOption("calendarWeekdays", def.calendarWeekdays);
}
/**
*
* @return {Components.Time.Calendar
*/
[assembleMethodSymbol]() {
super[assembleMethodSymbol]();
initControlReferences.call(this);
initDataSource.call(this);
initEventHandler.call(this);
return this;
}
/**
* To set the options via the HTML Tag, the attribute `data-monster-options` must be used.
* @see {@link https://monsterjs.org/en/doc/#configurate-a-monster-control}
*
* The individual configuration values can be found in the table.
*
* @property {Object} templates Template definitions
* @property {string} templates.main Main template
* @property {Object} labels Label definitions
* @property {Object} actions Callbacks
* @property {string} actions.click="throw Error" Callback when clicked
* @property {Object} features Features
* @property {Object} classes CSS classes
* @property {boolean} disabled=false Disabled state
*/
get defaults() {
return Object.assign({}, super.defaults, {
templates: {
main: getTemplate(),
},
labels: {},
classes: {},
disabled: false,
features: {},
actions: {},
locale: {
weekdayFormat: "short",
},
startDate: "",
calendarDays: [],
calendarWeekdays: [],
data: [],
datasource: {
selector: null,
},
});
}
/**
* This method is called when the component is created.
* @return {Promise}
*/
refresh() {
// makes sure that handleDataSourceChanges is called
return new Promise((resolve) => {
this.setOption("data", {});
queueMicrotask(() => {
handleDataSourceChanges.call(this);
placeAppointments();
resolve();
});
});
}
/**
* @return {string}
*/
static getTag() {
return "monster-month-calendar";
}
/**
* @return {CSSStyleSheet[]}
*/
static getCSSStyleSheet() {
return [MonthCalendarStyleSheet];
}
}
/**
* Calculates how many days of an appointment are distributed across calendar rows (weeks).
* Uses the start date of the calendar grid (e.g., from generateCalendarData()) as a reference.
*
* @param {Date} appointmentStart - Start date of the appointment.
* @param {Date} appointmentEnd - End date of the appointment (inclusive).
* @param {Date} calendarGridStart - The first day of the calendar grid (e.g., the Monday from generateCalendarData()).
* @returns {number[]} Array indicating how many days the appointment spans per row.
*
* Example:
* - Appointment: 01.03.2025 (Saturday) to 01.03.2025:
* -> getAppointmentRowsUsingCalendar(new Date("2025-03-01"), new Date("2025-03-01"), calendarGridStart)
* returns: [1] (since it occupies only one day in the first row, starting at column 6).
*
* - Appointment: 01.03.2025 to 03.03.2025:
* -> returns: [2, 1] (first row: Saturday and Sunday, second row: Monday).
*/
function getAppointmentRowsUsingCalendar(
appointmentStart,
appointmentEnd,
calendarGridStart,
) {
const oneDayMs = 24 * 60 * 60 * 1000;
// Calculate the offset (in days) from the calendar start to the appointment start
const offset = Math.floor((appointmentStart - calendarGridStart) / oneDayMs);
// Determine the column index in the calendar row (Monday = 0, ..., Sunday = 6)
let startColumn = offset % 7;
if (startColumn < 0) {
startColumn += 7;
}
// Calculate the total number of days for the appointment (including start and end date)
const totalDays =
Math.floor((appointmentEnd - appointmentStart) / oneDayMs) + 1;
// The first calendar block can accommodate at most (7 - startColumn) days.
const firstRowDays = Math.min(totalDays, 7 - startColumn);
const rows = [firstRowDays];
let remainingDays = totalDays - firstRowDays;
// Handle full weeks (7 days per row)
while (remainingDays > 7) {
rows.push(7);
remainingDays -= 7;
}
// Handle the last row if there are any remaining days
if (remainingDays > 0) {
rows.push(remainingDays);
}
return rows;
}
/**
* @private
* @param format
* @returns {*[]}
*/
function getWeekdays(format = "long") {
const locale = getLocaleOfDocument();
const weekdays = [];
for (let i = 1; i < 8; i++) {
const date = new Date(1970, 0, 4 + i); // 4. Jan. 1970 = Sonntag
weekdays.push(
new Intl.DateTimeFormat(locale, { weekday: format }).format(date),
);
}
return weekdays;
}
/**
* Assigns a "line" property to the provided segments (with "startIndex" and "columns").
* It checks for horizontal overlaps within each calendar row (7 boxes).
* Always assigns the lowest available "line".
*
* @private
*
* @param {Array} segments - Array of segments, e.g.
* [
* {"columns":6,"label":"03/11/2025 - 04/05/2025","start":"2025-03-11","startIndex":15},
* {"columns":7,"label":"03/11/2025 - 04/05/2025","start":"2025-03-17","startIndex":21},
* {"columns":7,"label":"03/11/2025 - 04/05/2025","start":"2025-03-24","startIndex":28},
* {"columns":6,"label":"03/11/2025 - 04/05/2025","start":"2025-03-31","startIndex":35}
* ]
* @returns {Array} The segments with assigned "line" property
*/
function assignLinesToSegments(segments) {
const groups = {};
segments.forEach((segment) => {
const week = Math.floor(segment.startIndex / 7);
if (!groups[week]) {
groups[week] = [];
}
groups[week].push(segment);
});
Object.keys(groups).forEach((weekKey) => {
const weekSegments = groups[weekKey];
weekSegments.sort((a, b) => a.startIndex - b.startIndex);
const lineEnds = [];
weekSegments.forEach((segment) => {
const segStart = segment.startIndex;
const segEnd = segment.startIndex + segment.columns - 1;
let placed = false;
for (let line = 0; line < lineEnds.length; line++) {
if (segStart >= lineEnds[line] + 1) {
segment.line = line;
lineEnds[line] = segEnd;
placed = true;
break;
}
}
if (!placed) {
segment.line = lineEnds.length;
lineEnds.push(segEnd);
}
});
});
return segments;
}
/**
* @private
*/
function initDataSource() {
setTimeout(() => {
if (!this[datasourceLinkedElementSymbol]) {
const selector = this.getOption("datasource.selector");
if (isString(selector)) {
const element = findElementWithSelectorUpwards(this, selector);
if (element === null) {
addErrorAttribute(
this,
"the selector must match exactly one element",
);
return;
}
if (!(element instanceof Datasource)) {
addErrorAttribute(this, "the element must be a datasource");
return;
}
this[datasourceLinkedElementSymbol] = element;
element.datasource.attachObserver(
new Observer(handleDataSourceChanges.bind(this)),
);
handleDataSourceChanges.call(this);
placeAppointments.call(this);
} else {
addErrorAttribute(
this,
"the datasource selector is missing or invalid",
);
}
}
}, 10);
}
function placeAppointments() {
const self = this;
const currentWithOfGridCell =
this[calendarElementSymbol].getBoundingClientRect().width / 7;
const appointments = this.getOption("data");
const segments = [];
let maxLineHeight = 0;
appointments.forEach((appointment) => {
if (!appointment?.startDate || !appointment?.endDate) {
addErrorAttribute(this, "Missing start or end date in appointment");
return;
}
const startDate = appointment?.startDate;
let container = self.shadowRoot.querySelector(
`[data-monster-day="${startDate}"]`,
);
if (!container) {
addErrorAttribute(
this,
"Invalid, missing or out of range date in appointment" + startDate,
);
return;
}
// calc length of appointment
const start = new Date(startDate);
const end = new Date(appointment?.endDate);
const calendarDays = this.getOption("calendarDays");
const appointmentRows = getAppointmentRowsUsingCalendar(
start,
end,
calendarDays[0].date,
);
let date = appointment.startDate;
const label =
start !== end
? `${start.toLocaleDateString()} - ${end.toLocaleDateString()}`
: start.toLocaleDateString();
for (let i = 0; i < appointmentRows.length; i++) {
const cols = appointmentRows[i];
const calendarStartDate = new Date(calendarDays[0].date); // First day of the calendar grid
const appointmentDate = new Date(date);
const startIndex = Math.floor(
(appointmentDate - calendarStartDate) / (24 * 60 * 60 * 1000),
);
segments.push({
columns: cols,
label: label,
start: date,
startIndex: startIndex,
appointment: appointment,
});
maxLineHeight = Math.max(maxLineHeight, getTextHeight.call(this, label));
const nextKeyDate = new Date(start.setDate(start.getDate() + cols));
date =
nextKeyDate.getFullYear() +
"-" +
("00" + (nextKeyDate.getMonth() + 1)).slice(-2) +
"-" +
("00" + nextKeyDate.getDate()).slice(-2);
}
});
let container = null;
const sortedSegments = assignLinesToSegments(segments);
for (let i = 0; i < sortedSegments.length; i++) {
const segment = sortedSegments[i];
container = self.shadowRoot.querySelector(
`[data-monster-day="${segment.start}"]`,
);
if (!container) {
addErrorAttribute(
this,
"Invalid, missing or out of range date in appointment" + segment.start,
);
return;
}
const appointmentSegment = document.createElement(
"monster-appointment-segment",
);
appointmentSegment.className = "appointment-segment";
appointmentSegment.style.backgroundColor = segment.appointment.color;
// search a color that is readable on the background color
const rgb = appointmentSegment.style.backgroundColor.match(/\d+/g);
const brightness = Math.round(
(parseInt(rgb[0]) * 299 +
parseInt(rgb[1]) * 587 +
parseInt(rgb[2]) * 114) /
1000,
);
if (brightness > 125) {
appointmentSegment.style.color = "#000000";
} else {
appointmentSegment.style.color = "#ffffff";
}
appointmentSegment.style.width = `${currentWithOfGridCell * segment.columns}px`;
appointmentSegment.style.height = maxLineHeight + "px";
appointmentSegment.style.top = `${segment.line * maxLineHeight + maxLineHeight + 10}px`;
appointmentSegment.setOption("labels.text", segment.label);
container.appendChild(appointmentSegment);
}
}
/**
* Generates two arrays: one for the calendar grid (42 days) and one for the weekday headers (7 days).
* The grid always starts on the Monday of the week that contains the 1st of the given month.
*
* @returns {Object} An object containing:
* - calendarDays: Array of 42 objects, each representing a day.
* - calendarWeekdays: Array of 7 objects, each representing a weekday header.
*/
function generateCalendarData() {
let selectedDate = this.getOption("startDate");
if (!(selectedDate instanceof Date)) {
if (typeof selectedDate === "string") {
try {
selectedDate = new Date(selectedDate);
} catch (e) {
addErrorAttribute(this, "Invalid calendar date");
return { calendarDays, calendarWeekdays };
}
} else {
addErrorAttribute(this, "Invalid calendar date");
return { calendarDays, calendarWeekdays };
}
}
const calendarDays = [];
let calendarWeekdays = [];
if (!(selectedDate instanceof Date)) {
addErrorAttribute(this, "Invalid calendar date");
return { calendarDays, calendarWeekdays };
}
// Get the year and month from the provided date
const year = selectedDate.getFullYear();
const month = selectedDate.getMonth(); // 0-based index (0 = January)
// Create a Date object for the 1st of the given month
const firstDayOfMonth = new Date(year, month, 1);
// Determine the weekday index of the 1st day, ensuring Monday = 0
const weekdayIndex = (firstDayOfMonth.getDay() + 6) % 7;
// Calculate the start date: move backward to the Monday of the starting week
const startDate = new Date(firstDayOfMonth);
startDate.setDate(firstDayOfMonth.getDate() - weekdayIndex);
// Generate 42 days (6 weeks × 7 days)
for (let i = 0; i < 42; i++) {
const current = new Date(startDate);
current.setDate(startDate.getDate() + i);
const label = current.getDate().toString();
calendarDays.push({
date: current,
//day: current.getDate(),
month: current.getMonth() + 1, // 1-based month (1-12)
year: current.getFullYear(),
isCurrentMonth: current.getMonth() === month,
label: label,
index: i,
day:
current.getFullYear() +
"-" +
("00" + (current.getMonth() + 1)).slice(-2) +
"-" +
("00" + current.getDate()).slice(-2),
classes:
"day-cell " +
(current.getMonth() === month ? "current-month" : "other-month") +
(current.getDay() === 0 || current.getDay() === 6 ? " weekend" : "") +
(current.toDateString() === new Date().toDateString() ? " today" : ""),
});
}
// Generate weekday header array (Monday through Sunday)
let format = this.getOption("locale.weekdayFormat");
if (!["long", "short", "narrow"].includes(format)) {
addErrorAttribute(this, "Invalid weekday format option " + format);
format = "short";
}
const weekdayNames = getWeekdays(format);
calendarWeekdays = weekdayNames.map((name, index) => {
return {
label: name,
index: index,
};
});
return { calendarDays, calendarWeekdays };
}
/**
* @private
* @return {initEventHandler}
* @fires monster-calendar-clicked
*/
function initEventHandler() {
const self = this;
this.attachObserver(
new Observer(() => {
placeAppointments.call(this);
}),
);
return this;
}
function getTextHeight(text) {
// Ein unsichtbares div erstellen
const div = document.createElement("div");
div.style.position = "absolute";
div.style.whiteSpace = "nowrap";
div.style.visibility = "hidden";
div.textContent = text;
this.shadowRoot.appendChild(div);
const height = div.clientHeight;
this.shadowRoot.removeChild(div);
return height;
}
/**
* @private
* @return {void}
*/
function initControlReferences() {
this[calendarElementSymbol] = this.shadowRoot.querySelector(
`[${ATTRIBUTE_ROLE}="control"]`,
);
}
/**
* @private
* @return {string}
*/
function getTemplate() {
// language=HTML
return `
<template id="cell">
<div data-monster-attributes="class path:cell.classes,
data-monster-index path:cell.index">
<div data-monster-replace="path:cell.label"></div>
<div data-monster-role="appointment-container"
data-monster-attributes="data-monster-day path:cell.day,
data-monster-calendar-index path:cell.index"></div>
</div>
</template>
<template id="calendar-weekday-header">
<div data-monster-attributes="class path:calendar-weekday-header.classes,
data-monster-index path:calendar-weekday-header.index"
data-monster-replace="path:calendar-weekday-header.label"></div>
</template>
<div data-monster-role="control" part="control">
<div class="weekday-header">
<div data-monster-role="weekdays"
data-monster-insert="calendar-weekday-header path:calendarWeekdays"></div>
<div class="calendar-body">
<div data-monster-role="appointments">
<slot></slot>
</div>
<div data-monster-role="cells" data-monster-insert="cell path:calendarDays"></div>
</div>
</div>
`;
}
registerCustomElement(MonthCalendar);