UNPKG

@schukai/monster

Version:

Monster is a simple library for creating fast, robust and lightweight websites.

1,029 lines (876 loc) 29.4 kB
/** * 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"; import { positionPopper } from "../form/util/floating-ui.mjs"; import { Segment as AppointmentSegment } from "./timeline/segment.mjs"; import { Appointment as AppointmentAppointment } from "./timeline/appointment.mjs"; export { MonthCalendar }; /** * @private * @type {symbol} */ const calendarElementSymbol = Symbol("calendarElement"); /** * @private * @type {symbol} */ const calendarBodyElementSymbol = Symbol("calendarBodyElement"); /** * A Calendar * * @fragments /fragments/components/time/calendar/ * * @example /examples/components/time/calendar-simple * * @since 3.112.0 * @copyright schukai GmbH * @summary A beautiful month Calendar that can display appointments. It is possible to use a datasource to load the appointments. */ 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](); setTimeout(() => { initControlReferences.call(this); initEventHandler.call(this); // refresh the labels for updater this.setOption("labels.__update", true); initDataSource.call(this); }, 0); 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 Labels for the control * @property {Object} classes Classes for the control * @property {boolean} disabled Disables the control * @property {Object} features Features for the control * @property {boolean} features.showWeekend Show the weekend * @property {boolean} features.monthOneLine Show the month in one line * @property {Object} actions Actions for the control * @property {Object} locale Locale for the control * @property {string} locale.weekdayFormat Weekday format * @property {string} startDate Start date * @property {string} endDate End date * @property {Object} datasource Datasource * @property {string} datasource.selector Datasource selector */ get defaults() { const startDate = new Date(); const startDateString = startDate.getFullYear() + "-" + ("00" + (startDate.getMonth() + 1)).slice(-2) + "-" + ("00" + startDate.getDate()).slice(-2); return Object.assign({}, super.defaults, { templates: { main: getTemplate(), }, labels: getTranslations(), classes: {}, disabled: false, features: { showWeekend: true, monthOneLine: false, }, actions: {}, locale: { weekdayFormat: "short", }, startDate: startDateString, endDate: "", 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]; } } /** * @private * @returns {object} */ function getTranslations() { const locale = getLocaleOfDocument(); switch (locale.language) { case "de": return { more: "weitere Termin", }; default: return { more: "more appointments", }; } } /** * 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); } /** * Places and organizes appointments within a calendar grid according to their start and end dates, * ensuring proper alignment and rendering within the calendar day containers. * * Appointments are evaluated to determine their appropriate positions, lengths, and styles based on calendar * configuration and available rendering space. * * Errors are logged for invalid appointment data or when * rendering issues are encountered. * * @private * @return {void} No return value; the method operates on the DOM and internal state of the calendar element to render appointments visually. */ function placeAppointments() { const self = this; const currentWithOfGridCell = this[calendarElementSymbol].getBoundingClientRect().width / 7; const appointments = this.getOption("data"); const segments = []; let maxLineHeight = 0; const calendarDays = this.getOption("calendarDays"); const calenderStartDate = new Date(calendarDays[0].date); const calenderEndDate = new Date(calendarDays[calendarDays.length - 1].date); const app = getAppointmentsPerDay( appointments, calenderStartDate, calenderEndDate, ); calendarDays.forEach((day) => { const k = day.date.getFullYear() + "-" + ("00" + (day.date.getMonth() + 1)).slice(-2) + "-" + ("00" + day.date.getDate()).slice(-2); day.appointments = app[k]; }); appointments.forEach((appointment) => { if (!appointment?.startDate || !appointment?.endDate) { addErrorAttribute(this, "Missing start or end date in appointment"); return; } const startDate = appointment?.startDate; // calc length of appointment const start = new Date(startDate); const end = new Date(appointment?.endDate); const appointmentRows = getAppointmentRowsUsingCalendar( start, end, calendarDays[0].date, ); let date = appointment.startDate; const s = start.getFullYear() + "-" + ("00" + (start.getMonth() + 1)).slice(-2) + "-" + ("00" + start.getDate()).slice(-2); const e = end.getFullYear() + "-" + ("00" + (end.getMonth() + 1)).slice(-2) + "-" + ("00" + end.getDate()).slice(-2); let label; if (appointment.label) { label = appointment.label.replace(/\n/g, "<br>"); } else { label = s !== e ? `${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 k = new Date(start.setDate(start.getDate() + cols)); // calc from k to next day date = k.getFullYear() + "-" + ("00" + (k.getMonth() + 1)).slice(-2) + "-" + ("00" + k.getDate()).slice(-2); } }); let container = null; const sortedSegments = assignLinesToSegments(segments); const h = calcHeaderAndFooterHeight.call(this); const marginAppointmentContainer = h.maxFooterHeight + h.maxHeaderHeight; const popper = self.shadowRoot.querySelectorAll( "monster-appointment-segment", ); if (popper) { for (const p of popper) { p.remove(); } } const daysWithMoreAppointments = new Set(); const moreAppointments = new Map(); for (let i = 0; i < sortedSegments.length; i++) { const segment = sortedSegments[i]; container = self.shadowRoot.querySelector( `[data-monster-day="${segment.start}"]`, ); if (!container) { continue; } let containerHeight = container?.getBoundingClientRect()?.height || 0; if (containerHeight === 0) { addErrorAttribute(this, "Unable to retrieve container height"); continue; } let availableHeight = containerHeight - maxLineHeight - marginAppointmentContainer; let linesThatCanBeShown = Math.floor(availableHeight / maxLineHeight); if (segment.line > linesThatCanBeShown) { daysWithMoreAppointments.add(segment.start); const calendarDays = this.getOption("calendarDays"); const currentDay = calendarDays.find((day) => day.day === segment.start); if (currentDay) { if (!moreAppointments.has(currentDay.index)) { moreAppointments.set(currentDay.index, []); } moreAppointments.get(currentDay.index).push(segment); } continue; } if (!container) { addErrorAttribute( this, "Invalid, missing or out of range date in appointment" + segment.start, ); continue; } 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 + h.maxHeaderHeight}px`; appointmentSegment.setOption("labels.text", segment.label); container.appendChild(appointmentSegment); } // mark days with more appointments, show a hint / link for (const day of daysWithMoreAppointments) { for (const cell of this.getOption("calendarDays")) { if (cell.day === day) { this.setOption( "calendarDays." + cell.index + ".classes.more-appointments", "visible", ); } } } // get popper for (const [index, appointments] of moreAppointments) { const moreAppointmentsContainer = this.shadowRoot.querySelector( `[data-monster-role=day-cell][data-monster-index="${index}"] > [data-monster-role="appointment-popper"] `, ); if (!moreAppointmentsContainer) { continue; } moreAppointmentsContainer.innerHTML = ""; for (const appointment of appointments) { const appointmentSegment = document.createElement("monster-appointment"); appointmentSegment.style.backgroundColor = appointment.appointment.color; appointmentSegment.style.position = "relative"; appointmentSegment.style.top = "unset"; // 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 = "100%"; appointmentSegment.style.height = maxLineHeight + "px"; appointmentSegment.setOption("labels.text", appointment.label); moreAppointmentsContainer.appendChild(appointmentSegment); } } } /** * @private * @returns {{maxHeaderHeight: number, maxFooterHeight: number}} */ function calcHeaderAndFooterHeight() { let maxHeaderHeight = 0; let maxFooterHeight = 0; const days = this.getOption("calendarDays"); for (const day of days) { const current = day.date; const dayKey = current.getFullYear() + "-" + ("00" + (current.getMonth() + 1)).slice(-2) + "-" + ("00" + current.getDate()).slice(-2); const cell = this.shadowRoot.querySelector( `[data-monster-day="${dayKey}"]`, ); if (!(cell instanceof HTMLDivElement)) { continue; } const header = cell.querySelector("[data-monster-role='day-header']"); if (header instanceof HTMLDivElement) { maxHeaderHeight = Math.max( maxHeaderHeight, header.getBoundingClientRect().height + parseFloat(getComputedStyle(header).paddingTop) + parseFloat(getComputedStyle(header).paddingBottom) + parseFloat(getComputedStyle(header).marginTop) + parseFloat(getComputedStyle(header).marginBottom) + parseFloat(getComputedStyle(header.parentElement).paddingTop) + parseFloat(getComputedStyle(header.parentElement).paddingBottom) + parseFloat(getComputedStyle(header.parentElement).marginTop), ); } const footer = cell.querySelector("[data-monster-role='day-footer']"); if (footer instanceof HTMLDivElement) { maxFooterHeight = Math.max( maxFooterHeight, footer.getBoundingClientRect().height + parseFloat(getComputedStyle(footer).paddingTop) + parseFloat(getComputedStyle(footer).paddingBottom) + parseFloat(getComputedStyle(footer).marginTop) + parseFloat(getComputedStyle(footer).marginBottom) + parseFloat(getComputedStyle(footer.parentElement).paddingTop) + parseFloat(getComputedStyle(footer.parentElement).paddingBottom) + parseFloat(getComputedStyle(footer.parentElement).marginTop), ); } } return { maxHeaderHeight, maxFooterHeight }; } /** * 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 first of the given month. * * @returns {Object} An object containing: * - calendarDays: Array of 42 objects, each representing a day. * - calendarWeekdays: Array of seven 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(); const dayKey = current.getFullYear() + "-" + ("00" + (current.getMonth() + 1)).slice(-2) + "-" + ("00" + current.getDate()).slice(-2); 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: dayKey, classes: { "more-appointments": "hidden", cell: "day-cell " + (current.getMonth() === month ? "current-month" : "other-month") + (current.getDay() === 0 || current.getDay() === 6 ? " weekend" : "") + (current.toDateString() === new Date().toDateString() ? " today" : ""), }, appointments: [], }); } // 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 }; } /** * Generates a map that contains an array of appointments for each day within the calendar range. * Multi-day appointments will appear on each day they span. * * @param {Array} appointments - Array of appointment objects. Expected properties: "startDate" and "endDate". * @param {Date|string} start - Calendar start date. * @param {Date|string} end - Calendar end date. * @returns {Object} A map in the format { "YYYY-MM-DD": [appointment1, appointment2, ...] } */ function getAppointmentsPerDay(appointments, start, end) { const appointmentsMap = {}; // Convert start and end to Date objects if needed const startDate = start instanceof Date ? start : new Date(start); const endDate = end instanceof Date ? end : new Date(end); // Create an empty entry for each day in the calendar range let current = new Date(startDate); while (current <= endDate) { const key = current.toISOString().slice(0, 10); appointmentsMap[key] = []; current.setDate(current.getDate() + 1); } // Assign each appointment to the corresponding days appointments.forEach((appointment) => { if (!appointment.startDate || !appointment.endDate) { // Skip appointments with missing data return; } const appStart = new Date(appointment.startDate); const appEnd = new Date(appointment.endDate); // Determine the effective start and end dates to ensure appointments outside the calendar are ignored const effectiveStart = appStart < startDate ? startDate : appStart; const effectiveEnd = appEnd > endDate ? endDate : appEnd; let currentAppDate = new Date(effectiveStart); while (currentAppDate <= effectiveEnd) { const key = currentAppDate.toISOString().slice(0, 10); if (appointmentsMap[key]) { appointmentsMap[key].push(appointment); } currentAppDate.setDate(currentAppDate.getDate() + 1); } }); return appointmentsMap; } /** * @private * @return {initEventHandler} */ function initEventHandler() { const self = this; setTimeout(() => { this.attachObserver( new Observer(() => { placeAppointments.call(this); }), ); this[calendarElementSymbol] .querySelectorAll("[data-monster-role='day-cell']") .forEach((element) => { element.addEventListener("click", (event) => { const hoveredElement = this.shadowRoot.elementFromPoint( event.clientX, event.clientY, ); if (hoveredElement instanceof AppointmentSegment) { return; } const element = findTargetElementFromEvent( event, "data-monster-role", "more-appointments", ); if (!element) { return; } // parent is footer, and parent of footer is cell const popper = element.parentElement.parentElement.querySelector( '[data-monster-role="appointment-popper"]', ); if (!popper) { return; } //const appointments = getAppointmentsPerDay() || []; const container = element.parentElement.parentElement; positionPopper(container, popper, { placement: "bottom", }); popper.style.width = container.getBoundingClientRect().width + "px"; popper.style.zIndex = 1000; popper.style.display = "block"; }); element.addEventListener("mouseleave", (event) => { const element = findTargetElementFromEvent( event, "data-monster-role", "day-cell", ); if (!element) { return; } element.classList.remove("hover"); const popper = element.querySelector( '[data-monster-role="appointment-popper"]', ); if (!popper) { return; } setTimeout(() => { popper.style.display = "none"; }, 0); }); }); }, 0); 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"]`, ); this[calendarBodyElementSymbol] = this.shadowRoot.querySelector( `[${ATTRIBUTE_ROLE}="calendar-body"]`, ); } /** * @private * @return {string} */ function getTemplate() { // language=HTML return ` <template id="cell"> <div data-monster-role="day-cell" data-monster-attributes="class path:cell.classes.cell, data-monster-day path:cell.day, data-monster-index path:cell.index"> <div class="header" data-monster-role="day-header"> <div data-monster-replace="path:cell.label"></div> </div> <div class="footer" data-monster-role="day-footer"> <div data-monster-replace="path:labels.more" data-monster-attributes="class path:cell.classes.more-appointments" data-monster-role="more-appointments"></div> </div> <div data-monster-role="appointment-popper" class="popper"></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" data-monster-role="calendar-body"> <div data-monster-role="cells" data-monster-insert="cell path:calendarDays"></div> </div> </div> `; } registerCustomElement(MonthCalendar);