├── thumbnail.jpg ├── styles ├── event-list.css ├── responsive.css ├── input.css ├── sidebar.css ├── fab.css ├── form.css ├── scroll.css ├── event-details.css ├── select.css ├── color-select.css ├── nav.css ├── toaster.css ├── mini-calendar.css ├── event.css ├── button.css ├── month-calendar.css ├── index.css ├── dialog.css └── week-calendar.css ├── scripts ├── animation.js ├── hamburger.js ├── mobile-sidebar.js ├── notifications.js ├── sync.js ├── view-select.js ├── event-list.js ├── event-create-button.js ├── responsive.js ├── event-delete-dialog.js ├── url.js ├── index.js ├── event-form-dialog.js ├── dialog.js ├── toaster.js ├── calendar.js ├── event-store.js ├── nav.js ├── event-details-dialog.js ├── date.js ├── month-calendar.js ├── event-form.js ├── mini-calendar.js ├── event.js └── week-calendar.js ├── manifest.webmanifest ├── README.md └── index.html /thumbnail.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mateuszziomekit/vanilla-calendar/HEAD/thumbnail.jpg -------------------------------------------------------------------------------- /styles/event-list.css: -------------------------------------------------------------------------------- 1 | .event-list { 2 | display: flex; 3 | flex-direction: column; 4 | gap: 0.125rem; 5 | list-style: none; 6 | } 7 | 8 | .event-list__item { 9 | display: flex; 10 | } -------------------------------------------------------------------------------- /styles/responsive.css: -------------------------------------------------------------------------------- 1 | @media (min-width: 768px) { 2 | .mobile-only { 3 | display: none; 4 | } 5 | } 6 | 7 | @media (max-width: 767px) { 8 | .desktop-only { 9 | display: none; 10 | } 11 | } -------------------------------------------------------------------------------- /scripts/animation.js: -------------------------------------------------------------------------------- 1 | export function waitUntilAnimationsFinish(element) { 2 | const animationPromises = element.getAnimations().map(animation => animation.finished); 3 | 4 | return Promise.allSettled(animationPromises); 5 | } -------------------------------------------------------------------------------- /styles/input.css: -------------------------------------------------------------------------------- 1 | .input { 2 | font-size: var(--font-size-sm); 3 | font-weight: 400; 4 | background-color: transparent; 5 | border-radius: var(--border-radius-md); 6 | border: 1px solid var(--color-gray-300); 7 | padding: 0 0.75rem; 8 | height: 2.125rem; 9 | } 10 | 11 | .input--fill { 12 | width: 100%; 13 | } -------------------------------------------------------------------------------- /styles/sidebar.css: -------------------------------------------------------------------------------- 1 | .sidebar { 2 | border-right: 1px solid var(--color-gray-300); 3 | width: 17rem; 4 | display: flex; 5 | flex-direction: column; 6 | gap: 1rem; 7 | padding: 1rem; 8 | } 9 | 10 | .sidebar__logo { 11 | display: flex; 12 | align-items: center; 13 | gap: 0.5rem; 14 | font-weight: 500; 15 | } -------------------------------------------------------------------------------- /scripts/hamburger.js: -------------------------------------------------------------------------------- 1 | export function initHamburger() { 2 | const hamburgetButtonElement = document.querySelector("[data-hamburger-button]"); 3 | 4 | hamburgetButtonElement.addEventListener("click", () => { 5 | hamburgetButtonElement.dispatchEvent(new CustomEvent("mobile-sidebar-open-request", { 6 | bubbles: true 7 | })); 8 | }); 9 | } -------------------------------------------------------------------------------- /styles/fab.css: -------------------------------------------------------------------------------- 1 | .fab { 2 | position: fixed; 3 | right: 1.5rem; 4 | bottom: 1.5rem; 5 | width: 3rem; 6 | height: 3rem; 7 | border-radius: 50%; 8 | background-color: var(--color-blue-600); 9 | color: var(--color-text-light); 10 | cursor: pointer; 11 | box-shadow: var(--box-shadow-lg); 12 | border: 0.125rem solid var(--color-white); 13 | } -------------------------------------------------------------------------------- /scripts/mobile-sidebar.js: -------------------------------------------------------------------------------- 1 | import { initDialog } from "./dialog.js"; 2 | 3 | export function initMobileSidebar() { 4 | const dialog = initDialog("mobile-sidebar"); 5 | 6 | document.addEventListener("mobile-sidebar-open-request", () => { 7 | dialog.open(); 8 | }); 9 | 10 | document.addEventListener("date-change", () => { 11 | dialog.close(); 12 | }); 13 | } -------------------------------------------------------------------------------- /styles/form.css: -------------------------------------------------------------------------------- 1 | .form__fields { 2 | display: flex; 3 | flex-direction: column; 4 | gap: 1rem; 5 | } 6 | 7 | .form__split { 8 | display: flex; 9 | gap: 1rem; 10 | } 11 | 12 | .form__field { 13 | display: flex; 14 | flex-direction: column; 15 | gap: 0.5rem; 16 | } 17 | 18 | .form__split .form__field { 19 | flex: 1; 20 | } 21 | 22 | .form__label { 23 | font-size: var(--font-size-sm); 24 | line-height: var(--line-height-sm); 25 | } -------------------------------------------------------------------------------- /styles/scroll.css: -------------------------------------------------------------------------------- 1 | ::-webkit-scrollbar { 2 | width: 0.5rem; 3 | } 4 | 5 | ::-webkit-scrollbar-track { 6 | background-color: transparent; 7 | } 8 | 9 | ::-webkit-scrollbar-thumb { 10 | background-color: var(--color-gray-300); 11 | border-radius: var(--border-radius-md); 12 | } 13 | 14 | ::-webkit-scrollbar-thumb:hover { 15 | background-color: var(--color-gray-400); 16 | } 17 | 18 | ::-webkit-scrollbar-thumb:active { 19 | background-color: var(--color-gray-500); 20 | } -------------------------------------------------------------------------------- /manifest.webmanifest: -------------------------------------------------------------------------------- 1 | { 2 | "categories": [ 3 | "education", 4 | "productivity", 5 | "utilities" 6 | ], 7 | "description": "Vanilla Calendar is a fully responsive calendar app built with HTML, CSS, and JavaScript. Create, manage, and view events in real-time, all built from scratch without libraries or frameworks.", 8 | "display": "fullscreen", 9 | "name": "Vanilla Calendar", 10 | "orientation": "portrait", 11 | "start_url": "https://vanilla-calendar.mateuszziomekit.com", 12 | "theme_color": "#2563eb" 13 | } -------------------------------------------------------------------------------- /scripts/notifications.js: -------------------------------------------------------------------------------- 1 | import { initToaster } from "./toaster.js"; 2 | 3 | export function initNotifications() { 4 | const toaster = initToaster(document.body); 5 | 6 | document.addEventListener("event-create", () => { 7 | toaster.success("Event has been created"); 8 | }); 9 | 10 | document.addEventListener("event-delete", () => { 11 | toaster.success("Event has been deleted"); 12 | }); 13 | 14 | document.addEventListener("event-edit", () => { 15 | toaster.success("Event has been edited"); 16 | }); 17 | } -------------------------------------------------------------------------------- /scripts/sync.js: -------------------------------------------------------------------------------- 1 | const broadcastChannel = new BroadcastChannel("events-change-channel"); 2 | 3 | export function initSync() { 4 | broadcastChannel.addEventListener("message", () => { 5 | document.dispatchEvent(new CustomEvent("events-change", { 6 | detail: { 7 | source: "broadcast-channel" 8 | }, 9 | bubbles: true 10 | })); 11 | }); 12 | 13 | document.addEventListener("events-change", (event) => { 14 | if (event?.detail?.source !== "broadcast-channel") { 15 | broadcastChannel.postMessage({}); 16 | } 17 | }); 18 | } -------------------------------------------------------------------------------- /scripts/view-select.js: -------------------------------------------------------------------------------- 1 | import { getUrlView } from "./url.js"; 2 | 3 | export function initViewSelect() { 4 | const viewSelectElement = document.querySelector("[data-view-select]"); 5 | viewSelectElement.value = getUrlView(); 6 | 7 | viewSelectElement.addEventListener("change", (event) => { 8 | viewSelectElement.dispatchEvent(new CustomEvent("view-change", { 9 | detail: { 10 | view: viewSelectElement.value 11 | }, 12 | bubbles: true 13 | })); 14 | }); 15 | 16 | document.addEventListener("view-change", (event) => { 17 | viewSelectElement.value = event.detail.view; 18 | }); 19 | } -------------------------------------------------------------------------------- /styles/event-details.css: -------------------------------------------------------------------------------- 1 | .event-details { 2 | display: flex; 3 | align-items: stretch; 4 | gap: 1rem; 5 | } 6 | 7 | .event-details__line { 8 | flex-shrink: 0; 9 | width: 0.5rem; 10 | background-color: var(--event-color); 11 | border-radius: var(--border-radius-md); 12 | } 13 | 14 | .event-details__content { 15 | display: flex; 16 | flex-direction: column; 17 | grid-area: 0.5rem; 18 | } 19 | 20 | .event-details__title { 21 | font-size: var(--font-size-lg); 22 | line-height: var(--line-height-lg); 23 | } 24 | 25 | .event-details__time { 26 | font-size: var(--font-size-sm); 27 | line-height: var(--line-height-sm); 28 | } -------------------------------------------------------------------------------- /scripts/event-list.js: -------------------------------------------------------------------------------- 1 | import { initStaticEvent } from "./event.js"; 2 | 3 | const eventListItemTemplateElement = document.querySelector("[data-template='event-list-item']"); 4 | 5 | export function initEventList(parent, events) { 6 | const eventListElement = parent.querySelector("[data-event-list]"); 7 | 8 | eventListElement.addEventListener("click", (event) => { 9 | event.stopPropagation(); 10 | }); 11 | 12 | for (const event of events) { 13 | const eventListItemContent = eventListItemTemplateElement.content.cloneNode(true); 14 | const eventListItemElement = eventListItemContent.querySelector("[data-event-list-item]"); 15 | 16 | initStaticEvent(eventListItemElement, event); 17 | 18 | eventListElement.appendChild(eventListItemElement); 19 | } 20 | } -------------------------------------------------------------------------------- /styles/select.css: -------------------------------------------------------------------------------- 1 | .select { 2 | position: relative; 3 | color: var(--color-text-dark); 4 | } 5 | 6 | .select--fill { 7 | width: 100%; 8 | } 9 | 10 | .select__select { 11 | font-size: var(--font-size-sm); 12 | line-height: var(--line-height-sm); 13 | font-weight: 400; 14 | color: var(--color-text-dark); 15 | background-color: transparent; 16 | border-radius: var(--border-radius-md); 17 | border: 1px solid var(--color-gray-300); 18 | padding: 0 2rem 0 0.75rem; 19 | height: 2.125rem; 20 | cursor: pointer; 21 | appearance: none; 22 | } 23 | 24 | .select--fill .select__select { 25 | width: 100%; 26 | } 27 | 28 | .select__icon { 29 | position: absolute; 30 | top: 50%; 31 | right: 0.5rem; 32 | transform: translateY(-50%); 33 | width: 1.125rem; 34 | pointer-events: none; 35 | } -------------------------------------------------------------------------------- /scripts/event-create-button.js: -------------------------------------------------------------------------------- 1 | import { getUrlDate } from "./url.js"; 2 | 3 | export function initEventCreateButtons() { 4 | const buttonElements = document.querySelectorAll("[data-event-create-button]"); 5 | 6 | for (const buttonElement of buttonElements) { 7 | initEventCreateButton(buttonElement); 8 | } 9 | } 10 | 11 | function initEventCreateButton(buttonElement) { 12 | let selectedDate = getUrlDate(); 13 | 14 | buttonElement.addEventListener("click", () => { 15 | buttonElement.dispatchEvent(new CustomEvent("event-create-request", { 16 | detail: { 17 | date: selectedDate, 18 | startTime: 600, 19 | endTime: 960 20 | }, 21 | bubbles: true 22 | })); 23 | }); 24 | 25 | document.addEventListener("date-change", (event) => { 26 | selectedDate = event.detail.date; 27 | }); 28 | } -------------------------------------------------------------------------------- /styles/color-select.css: -------------------------------------------------------------------------------- 1 | .color-select { 2 | display: flex; 3 | flex-wrap: wrap; 4 | gap: 0.5rem; 5 | } 6 | 7 | .color-select__item { 8 | position: relative; 9 | border-radius: 50%; 10 | cursor: pointer; 11 | } 12 | 13 | .color-select__input { 14 | position: absolute; 15 | opacity: 0; 16 | height: 0; 17 | width: 0; 18 | } 19 | 20 | .color-select__color { 21 | width: 2rem; 22 | height: 2rem; 23 | padding: 0.25rem; 24 | border-radius: 50%; 25 | border: 0.125rem solid var(--color-gray-300); 26 | transition: border-color var(--duration-sm) ease-out; 27 | } 28 | 29 | .color-select__input:checked+.color-select__color { 30 | border-color: var(--color-select-item-color); 31 | } 32 | 33 | .color-select__color-inner { 34 | background-color: var(--color-select-item-color); 35 | width: 100%; 36 | height: 100%; 37 | border-radius: 50%; 38 | } -------------------------------------------------------------------------------- /styles/nav.css: -------------------------------------------------------------------------------- 1 | .nav { 2 | border-bottom: 1px solid var(--color-gray-300); 3 | display: flex; 4 | padding: 0.5rem 1rem; 5 | gap: 1rem; 6 | } 7 | 8 | .nav__date-info { 9 | flex: 1; 10 | display: flex; 11 | align-items: center; 12 | justify-content: space-between; 13 | flex-direction: row-reverse; 14 | } 15 | 16 | .nav__controls { 17 | display: flex; 18 | gap: 0.125rem; 19 | } 20 | 21 | .nav__arrows { 22 | display: flex; 23 | gap: 0.125rem; 24 | } 25 | 26 | .nav__date { 27 | font-size: var(--font-size-lg); 28 | line-height: var(--line-height-lg); 29 | } 30 | 31 | @media (min-width: 768px) { 32 | .nav { 33 | justify-content: space-between; 34 | gap: 0; 35 | padding: 0.5rem; 36 | } 37 | 38 | .nav__date-info { 39 | flex-direction: row; 40 | justify-content: flex-start; 41 | gap: 1rem; 42 | } 43 | 44 | .nav__controls { 45 | gap: 0.5rem; 46 | } 47 | } -------------------------------------------------------------------------------- /scripts/responsive.js: -------------------------------------------------------------------------------- 1 | const isDesktopMediaQuery = window.matchMedia("(min-width: 768px)"); 2 | 3 | export function initResponsive() { 4 | if (currentDeviceType() === "mobile") { 5 | document.dispatchEvent(new CustomEvent("view-change", { 6 | detail: { 7 | view: "week" 8 | }, 9 | bubbles: true 10 | })); 11 | } 12 | 13 | isDesktopMediaQuery.addEventListener("change", () => { 14 | const deviceType = currentDeviceType(); 15 | 16 | document.dispatchEvent(new CustomEvent("device-type-change", { 17 | detail: { 18 | deviceType 19 | }, 20 | bubbles: true 21 | })); 22 | 23 | if (deviceType === "mobile") { 24 | document.dispatchEvent(new CustomEvent("view-change", { 25 | detail: { 26 | view: "week" 27 | }, 28 | bubbles: true 29 | })); 30 | } 31 | }); 32 | } 33 | 34 | export function currentDeviceType() { 35 | return isDesktopMediaQuery.matches ? "desktop" : "mobile"; 36 | } 37 | -------------------------------------------------------------------------------- /scripts/event-delete-dialog.js: -------------------------------------------------------------------------------- 1 | import { initDialog } from "./dialog.js"; 2 | 3 | export function initEventDeleteDialog() { 4 | const dialog = initDialog("event-delete"); 5 | 6 | const deleteButtonElement = dialog.dialogElement.querySelector("[data-event-delete-button]"); 7 | 8 | let currentEvent = null; 9 | 10 | document.addEventListener("event-delete-request", (event) => { 11 | currentEvent = event.detail.event; 12 | fillEventDeleteDialog(dialog.dialogElement, event.detail.event); 13 | dialog.open(); 14 | }); 15 | 16 | deleteButtonElement.addEventListener("click", () => { 17 | dialog.close(); 18 | deleteButtonElement.dispatchEvent(new CustomEvent("event-delete", { 19 | detail: { 20 | event: currentEvent 21 | }, 22 | bubbles: true 23 | })); 24 | }); 25 | } 26 | 27 | function fillEventDeleteDialog(parent, event) { 28 | const eventDeleteTitleElement = parent.querySelector("[data-event-delete-title]"); 29 | 30 | eventDeleteTitleElement.textContent = event.title; 31 | } -------------------------------------------------------------------------------- /styles/toaster.css: -------------------------------------------------------------------------------- 1 | .toaster { 2 | display: flex; 3 | flex-direction: column; 4 | align-items: center; 5 | gap: 1rem; 6 | max-width: calc(100vw - 2rem); 7 | position: fixed; 8 | left: 0; 9 | right: 0; 10 | bottom: 1rem; 11 | margin: auto; 12 | pointer-events: none; 13 | } 14 | 15 | .toast { 16 | font-weight: 500; 17 | color: var(--color-text-light); 18 | padding: 0.5rem 1rem; 19 | border-radius: var(--border-radius-md); 20 | animation: toast-in var(--duration-md) ease, 21 | toast-out var(--duration-md) ease var(--duration-2xl); 22 | } 23 | 24 | .toast--success { 25 | background-color: var(--color-green-600); 26 | } 27 | 28 | .toast--error { 29 | background-color: var(--color-red-600); 30 | } 31 | 32 | @keyframes toast-in { 33 | from { 34 | opacity: 0; 35 | transform: translateY(1rem); 36 | } 37 | 38 | to { 39 | opacity: 1; 40 | transform: translateY(0); 41 | } 42 | } 43 | 44 | @keyframes toast-out { 45 | from { 46 | opacity: 1; 47 | } 48 | 49 | to { 50 | opacity: 0; 51 | } 52 | } -------------------------------------------------------------------------------- /scripts/url.js: -------------------------------------------------------------------------------- 1 | import { today } from "./date.js"; 2 | 3 | export function initUrl() { 4 | let selectedView = getUrlView(); 5 | let selectedDate = getUrlDate(); 6 | 7 | function updateUrl() { 8 | const url = new URL(window.location); 9 | 10 | url.searchParams.set("view", selectedView); 11 | url.searchParams.set("date", selectedDate.toISOString()); 12 | 13 | history.replaceState(null, "", url); 14 | } 15 | 16 | document.addEventListener("view-change", (event) => { 17 | selectedView = event.detail.view; 18 | updateUrl(); 19 | }); 20 | 21 | document.addEventListener("date-change", (event) => { 22 | selectedDate = event.detail.date; 23 | updateUrl(); 24 | }); 25 | } 26 | 27 | export function getUrlView() { 28 | const urlParams = new URLSearchParams(window.location.search); 29 | 30 | return urlParams.get("view") || "month"; 31 | } 32 | 33 | export function getUrlDate() { 34 | const urlParams = new URLSearchParams(window.location.search); 35 | const date = urlParams.get("date"); 36 | 37 | return date ? new Date(date) : today(); 38 | } 39 | -------------------------------------------------------------------------------- /scripts/index.js: -------------------------------------------------------------------------------- 1 | import { initCalendar } from "./calendar.js"; 2 | import { initEventCreateButtons } from "./event-create-button.js"; 3 | import { initEventDeleteDialog } from "./event-delete-dialog.js"; 4 | import { initEventDetailsDialog } from "./event-details-dialog.js"; 5 | import { initEventFormDialog } from "./event-form-dialog.js"; 6 | import { initEventStore } from "./event-store.js"; 7 | import { initHamburger } from "./hamburger.js"; 8 | import { initMiniCalendars } from "./mini-calendar.js"; 9 | import { initMobileSidebar } from "./mobile-sidebar.js"; 10 | import { initNav } from "./nav.js"; 11 | import { initNotifications } from "./notifications.js"; 12 | import { initViewSelect } from "./view-select.js"; 13 | import { initResponsive } from "./responsive.js"; 14 | import { initUrl } from "./url.js"; 15 | import { initSync } from "./sync.js"; 16 | 17 | const eventStore = initEventStore(); 18 | initCalendar(eventStore); 19 | initEventCreateButtons(); 20 | initEventDeleteDialog(); 21 | initEventDetailsDialog(); 22 | initEventFormDialog(); 23 | initHamburger(); 24 | initMiniCalendars(); 25 | initMobileSidebar(); 26 | initNav(); 27 | initNotifications(); 28 | initViewSelect(); 29 | initUrl(); 30 | initResponsive(); 31 | initSync(); -------------------------------------------------------------------------------- /scripts/event-form-dialog.js: -------------------------------------------------------------------------------- 1 | import { initDialog } from "./dialog.js"; 2 | import { initEventForm } from "./event-form.js"; 3 | import { initToaster } from "./toaster.js"; 4 | 5 | export function initEventFormDialog() { 6 | const dialog = initDialog("event-form"); 7 | const toaster = initToaster(dialog.dialogElement); 8 | const eventForm = initEventForm(toaster); 9 | 10 | const dialogTitleElement = dialog.dialogElement.querySelector("[data-dialog-title]"); 11 | 12 | document.addEventListener("event-create-request", (event) => { 13 | dialogTitleElement.textContent = "Create event"; 14 | eventForm.switchToCreateMode( 15 | event.detail.date, 16 | event.detail.startTime, 17 | event.detail.endTime 18 | ); 19 | dialog.open(); 20 | }); 21 | 22 | document.addEventListener("event-edit-request", (event) => { 23 | dialogTitleElement.textContent = "Edit event"; 24 | eventForm.switchToEditMode(event.detail.event); 25 | dialog.open(); 26 | }); 27 | 28 | dialog.dialogElement.addEventListener("close", () => { 29 | eventForm.reset(); 30 | }); 31 | 32 | eventForm.formElement.addEventListener("event-create", () => { 33 | dialog.close(); 34 | }); 35 | 36 | eventForm.formElement.addEventListener("event-edit", () => { 37 | dialog.close(); 38 | }); 39 | } -------------------------------------------------------------------------------- /scripts/dialog.js: -------------------------------------------------------------------------------- 1 | import { waitUntilAnimationsFinish } from "./animation.js"; 2 | 3 | export function initDialog(name) { 4 | const dialogElement = document.querySelector(`[data-dialog=${name}]`); 5 | const closeButtonElements = document.querySelectorAll("[data-dialog-close-button]"); 6 | 7 | function close() { 8 | dialogElement.classList.add("dialog--closing"); 9 | 10 | return waitUntilAnimationsFinish(dialogElement) 11 | .then(() => { 12 | dialogElement.classList.remove("dialog--closing"); 13 | dialogElement.close(); 14 | }) 15 | .catch((error) => { 16 | console.error("Finish dialog animation promise failed", error); 17 | }); 18 | } 19 | 20 | for (const closeButtonElement of closeButtonElements) { 21 | closeButtonElement.addEventListener("click", () => { 22 | close(); 23 | }); 24 | } 25 | 26 | dialogElement.addEventListener("click", (event) => { 27 | if (event.target === dialogElement) { 28 | close(); 29 | } 30 | }); 31 | 32 | dialogElement.addEventListener("cancel", (event) => { 33 | event.preventDefault(); 34 | close(); 35 | }); 36 | 37 | return { 38 | dialogElement, 39 | open() { 40 | dialogElement.showModal(); 41 | }, 42 | close() { 43 | return close(); 44 | } 45 | }; 46 | } -------------------------------------------------------------------------------- /styles/mini-calendar.css: -------------------------------------------------------------------------------- 1 | .mini-calendar { 2 | display: flex; 3 | flex-direction: column; 4 | gap: 0.75rem; 5 | } 6 | 7 | .mini-calendar__header { 8 | display: flex; 9 | justify-content: space-between; 10 | align-items: center; 11 | } 12 | 13 | .mini-calendar__date { 14 | font-size: var(--font-size-sm); 15 | line-height: var(--line-height-sm); 16 | } 17 | 18 | .mini-calendar__controls { 19 | display: flex; 20 | gap: 0.125rem; 21 | } 22 | 23 | .mini-calendar__content { 24 | display: flex; 25 | flex-direction: column; 26 | gap: 0.5rem; 27 | } 28 | 29 | .mini-calendar__day-of-week-list { 30 | list-style: none; 31 | display: grid; 32 | grid-template-columns: repeat(7, minmax(0, 1fr)); 33 | gap: 0.25rem; 34 | } 35 | 36 | .mini-calendar__day-of-week { 37 | text-align: center; 38 | font-size: var(--font-size-xs); 39 | line-height: var(--line-height-xs); 40 | font-weight: 500; 41 | } 42 | 43 | .mini-calendar__day-list { 44 | list-style: none; 45 | display: grid; 46 | grid-template-columns: repeat(7, minmax(0, 1fr)); 47 | gap: 0.25rem; 48 | } 49 | 50 | .mini-calendar__day-list-item { 51 | text-align: center; 52 | } 53 | 54 | .mini-calendar__day { 55 | width: 100%; 56 | border: 1px solid transparent; 57 | } 58 | 59 | .mini-calendar__day--other { 60 | color: var(--color-gray-500); 61 | } 62 | 63 | .mini-calendar__day--highlight { 64 | border-color: var(--color-blue-600); 65 | } -------------------------------------------------------------------------------- /styles/event.css: -------------------------------------------------------------------------------- 1 | .event { 2 | width: 100%; 3 | display: block; 4 | font-size: var(--font-size-xs); 5 | line-height: var(--line-height-xs); 6 | color: var(--color-text-dark); 7 | text-align: left; 8 | background-color: transparent; 9 | border-radius: var(--border-radius-md); 10 | border: 0; 11 | padding: 0.125rem 0.5rem; 12 | white-space: nowrap; 13 | overflow: hidden; 14 | text-overflow: ellipsis; 15 | cursor: pointer; 16 | } 17 | 18 | .event--filled { 19 | background-color: var(--event-color); 20 | color: var(--color-text-light); 21 | } 22 | 23 | .event--dynamic { 24 | position: absolute; 25 | width: unset; 26 | border: 1px solid var(--color-white); 27 | white-space: unset; 28 | overflow: unset; 29 | display: flex; 30 | flex-direction: column; 31 | align-items: stretch; 32 | } 33 | 34 | .event--dynamic .event__title { 35 | display: -webkit-box; 36 | -webkit-line-clamp: var(--event-title-max-lines); 37 | -webkit-box-orient: vertical; 38 | overflow: hidden; 39 | text-overflow: ellipsis; 40 | } 41 | 42 | .event__color { 43 | display: inline-block; 44 | border-radius: 50%; 45 | width: 0.5rem; 46 | height: 0.5rem; 47 | background-color: var(--event-color); 48 | margin-right: 0.25rem; 49 | } 50 | 51 | .event--filled .event__color { 52 | display: none; 53 | } 54 | 55 | .event__time { 56 | display: none; 57 | } 58 | 59 | .event--dynamic .event__time { 60 | display: block; 61 | white-space: nowrap; 62 | overflow: hidden; 63 | text-overflow: ellipsis; 64 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Vanilla Calendar 2 | 3 | Hey there! In this video, we’ll create a fully responsive calendar app using just HTML, CSS, and JavaScript. Follow along to learn how to build an interactive calendar with features like event creation, real-time updates, and more. No frameworks or libraries—everything will be built from scratch! 4 | 5 | [![Vanilla Calendar Thumbnail](https://github.com/mateuszziomekit/vanilla-calendar/blob/main/thumbnail.jpg)](https://www.youtube.com/watch?v=PXOsddcWL4g) 6 | 7 | - [🍿 YouTube Video](https://www.youtube.com/watch?v=PXOsddcWL4g) 8 | - [🚀 Live Website](https://vanilla-calendar.mateuszziomekit.com/) 9 | - [💻 Source Code](https://github.com/mateuszziomekit/vanilla-calendar) 10 | 11 | Useful links: 12 | 13 | - [📝 Visual Studio Code](https://code.visualstudio.com/) 14 | - [🎨 Lucide Icons](https://lucide.dev/icons) 15 | - [🌍 Netlify Drop](https://app.netlify.com/drop) 16 | 17 | Features: 18 | 19 | - 📆 Month, week, and day calendar views 20 | - 🔄 Easy date navigation 21 | - ✏️ Create, update, and delete events 22 | - ✅ Form validation for event details 23 | - 💾 Persist events across page refreshes 24 | - ⏰ Support for all-day events 25 | - 💬 Animated dialogs and toasts for smoother interactions 26 | - 🗂️ Mini calendar for quick navigation 27 | - 📱 Fully responsive design 28 | - 📑 Mobile-friendly sidebar 29 | - 🔗 URL state persistence 30 | - 🌐 Real-time updates across multiple tabs 31 | - 🚀 Put the website live on the internet 32 | 33 | Enjoy the tutorial, and don’t forget to like, share, and subscribe if you find it helpful! 34 | -------------------------------------------------------------------------------- /styles/button.css: -------------------------------------------------------------------------------- 1 | .button { 2 | font-size: var(--font-size-sm); 3 | line-height: var(--line-height-sm); 4 | font-weight: 400; 5 | border-radius: var(--border-radius-md); 6 | border: none; 7 | padding: 0 1rem; 8 | height: 2.125rem; 9 | cursor: pointer; 10 | transition: background-color var(--duration-sm) ease-out; 11 | } 12 | 13 | .button--secondary { 14 | background-color: var(--color-white); 15 | border: 1px solid var(--color-gray-300); 16 | color: var(--color-text-dark); 17 | } 18 | 19 | .button--secondary:hover { 20 | background-color: var(--color-gray-100); 21 | } 22 | 23 | .button--primary { 24 | background-color: var(--color-blue-600); 25 | color: var(--color-text-light); 26 | } 27 | 28 | .button--primary:hover { 29 | background-color: var(--color-blue-500); 30 | } 31 | 32 | .button--danger { 33 | background-color: var(--color-red-600); 34 | color: var(--color-text-light); 35 | } 36 | 37 | .button--danger:hover { 38 | background-color: var(--color-red-500); 39 | } 40 | 41 | .button--lg { 42 | font-weight: 500; 43 | height: 2.5rem; 44 | } 45 | 46 | .button--sm { 47 | font-size: var(--font-size-xs); 48 | line-height: var(--line-height-xs); 49 | height: 1.75rem; 50 | padding: 0 0.5rem; 51 | } 52 | 53 | .button--icon { 54 | display: flex; 55 | align-items: center; 56 | justify-content: center; 57 | width: 2.125rem; 58 | padding: 0; 59 | border: none; 60 | } 61 | 62 | .button--icon.button--sm { 63 | width: 1.75rem; 64 | } 65 | 66 | .button__icon { 67 | width: 1rem; 68 | } 69 | 70 | .button--sm .button__icon { 71 | width: 0.75rem; 72 | } -------------------------------------------------------------------------------- /scripts/toaster.js: -------------------------------------------------------------------------------- 1 | import { waitUntilAnimationsFinish } from "./animation.js"; 2 | 3 | export function initToaster(parent) { 4 | const toasterElement = document.createElement("div"); 5 | 6 | toasterElement.classList.add("toaster"); 7 | parent.appendChild(toasterElement); 8 | 9 | return { 10 | success(message) { 11 | showToast(toasterElement, message, "success"); 12 | }, 13 | error(message) { 14 | showToast(toasterElement, message, "error"); 15 | } 16 | }; 17 | } 18 | 19 | function showToast(toasterElement, message, type) { 20 | const toastElement = createToast(message, type); 21 | animateToast(toasterElement, toastElement); 22 | } 23 | 24 | function createToast(message, type) { 25 | const toastElement = document.createElement("div"); 26 | toastElement.textContent = message; 27 | toastElement.classList.add("toast"); 28 | toastElement.classList.add(`toast--${type}`); 29 | 30 | return toastElement; 31 | } 32 | 33 | function animateToast(toasterElement, toastElement) { 34 | const heightBefore = toasterElement.offsetHeight; 35 | toasterElement.appendChild(toastElement); 36 | const heightAfter = toasterElement.offsetHeight; 37 | const heightDiff = heightAfter - heightBefore; 38 | 39 | const toasterAnimation = toasterElement.animate([ 40 | { transform: `translate(0, ${heightDiff}px)` }, 41 | { transform: "translate(0, 0)" } 42 | ], { 43 | duration: 150, 44 | easing: "ease-out" 45 | }); 46 | 47 | toasterAnimation.startTime = document.timeline.currentTime; 48 | 49 | waitUntilAnimationsFinish(toastElement) 50 | .then(() => { 51 | toasterElement.removeChild(toastElement); 52 | }) 53 | .catch((error) => { 54 | console.error("Finish toast animation promise failed", error); 55 | }); 56 | } -------------------------------------------------------------------------------- /scripts/calendar.js: -------------------------------------------------------------------------------- 1 | import { initMonthCalendar } from "./month-calendar.js"; 2 | import { initWeekCalendar } from "./week-calendar.js"; 3 | import { currentDeviceType } from "./responsive.js"; 4 | import { getUrlDate, getUrlView } from "./url.js"; 5 | 6 | export function initCalendar(eventStore) { 7 | const calendarElement = document.querySelector("[data-calendar]"); 8 | 9 | let selectedView = getUrlView(); 10 | let selectedDate = getUrlDate(); 11 | let deviceType = currentDeviceType(); 12 | 13 | function refreshCalendar() { 14 | const calendarScrollableElement = calendarElement.querySelector("[data-calendar-scrollable]"); 15 | 16 | const scrollTop = calendarScrollableElement === null ? 0 : calendarScrollableElement.scrollTop; 17 | 18 | calendarElement.replaceChildren(); 19 | 20 | if (selectedView === "month") { 21 | initMonthCalendar(calendarElement, selectedDate, eventStore); 22 | } else if (selectedView === "week") { 23 | initWeekCalendar(calendarElement, selectedDate, eventStore, false, deviceType); 24 | } else { 25 | initWeekCalendar(calendarElement, selectedDate, eventStore, true, deviceType); 26 | } 27 | 28 | calendarElement.querySelector("[data-calendar-scrollable]").scrollTo({ top: scrollTop }); 29 | } 30 | 31 | document.addEventListener("view-change", (event) => { 32 | selectedView = event.detail.view; 33 | refreshCalendar(); 34 | }); 35 | 36 | document.addEventListener("date-change", (event) => { 37 | selectedDate = event.detail.date; 38 | refreshCalendar(); 39 | }); 40 | 41 | document.addEventListener("device-type-change", (event) => { 42 | deviceType = event.detail.deviceType; 43 | refreshCalendar(); 44 | }); 45 | 46 | document.addEventListener("events-change", () => { 47 | refreshCalendar(); 48 | }); 49 | 50 | refreshCalendar(); 51 | } -------------------------------------------------------------------------------- /styles/month-calendar.css: -------------------------------------------------------------------------------- 1 | .month-calendar { 2 | display: flex; 3 | flex-direction: column; 4 | height: 100%; 5 | } 6 | 7 | .month-calendar__day-of-week-list { 8 | list-style: none; 9 | display: grid; 10 | grid-template-columns: repeat(7, minmax(0, 1fr)); 11 | border-bottom: 1px solid var(--color-gray-300); 12 | padding: 0.75rem 0; 13 | } 14 | 15 | .month-calendar__day-of-week { 16 | font-size: var(--font-size-md); 17 | line-height: var(--line-height-md); 18 | text-align: center; 19 | font-weight: 500; 20 | } 21 | 22 | .month-calendar__day-list-wrapper { 23 | position: relative; 24 | flex: 1; 25 | } 26 | 27 | .month-calendar__day-list { 28 | list-style: none; 29 | position: absolute; 30 | inset: 0; 31 | display: grid; 32 | grid-template-columns: repeat(7, minmax(0, 1fr)); 33 | overflow-y: auto; 34 | } 35 | 36 | .month-calendar--four-week .month-calendar__day-list { 37 | grid-template-rows: repeat(4, minmax(auto, 1fr)); 38 | } 39 | 40 | .month-calendar--five-week .month-calendar__day-list { 41 | grid-template-rows: repeat(5, minmax(auto, 1fr)); 42 | } 43 | 44 | .month-calendar--six-week .month-calendar__day-list { 45 | grid-template-rows: repeat(6, minmax(auto, 1fr)); 46 | } 47 | 48 | .month-calendar__day { 49 | display: flex; 50 | flex-direction: column; 51 | border-right: 1px solid var(--color-gray-300); 52 | border-bottom: 1px solid var(--color-gray-300); 53 | } 54 | 55 | .month-calendar__day--highlight { 56 | background-color: var(--color-blue-50); 57 | } 58 | 59 | .month-calendar__day:nth-child(7n) { 60 | border-right: 0; 61 | } 62 | 63 | .month-calendar--four-week .month-calendar__day:nth-child(n + 22) { 64 | border-bottom: 0; 65 | } 66 | 67 | .month-calendar--five-week .month-calendar__day:nth-child(n + 29) { 68 | border-bottom: 0; 69 | } 70 | 71 | .month-calendar--six-week .month-calendar__day:nth-child(n + 36) { 72 | border-bottom: 0; 73 | } 74 | 75 | .month-calendar__day-label { 76 | color: var(--color-text-dark); 77 | width: 100%; 78 | padding: 0.5rem 0; 79 | background-color: transparent; 80 | border: 0; 81 | cursor: pointer; 82 | } 83 | 84 | .month-calendar__event-list-wrapper { 85 | flex-grow: 1; 86 | padding-bottom: 1.5rem; 87 | } -------------------------------------------------------------------------------- /styles/index.css: -------------------------------------------------------------------------------- 1 | @import "./button.css"; 2 | @import "./color-select.css"; 3 | @import "./dialog.css"; 4 | @import "./event-details.css"; 5 | @import "./event-list.css"; 6 | @import "./event.css"; 7 | @import "./fab.css"; 8 | @import "./form.css"; 9 | @import "./input.css"; 10 | @import "./mini-calendar.css"; 11 | @import "./nav.css"; 12 | @import "./month-calendar.css"; 13 | @import "./scroll.css"; 14 | @import "./select.css"; 15 | @import "./sidebar.css"; 16 | @import "./toaster.css"; 17 | @import "./week-calendar.css"; 18 | @import "./responsive.css"; 19 | 20 | * { 21 | font-family: ui-sans-serif, system-ui, sans-serif; 22 | margin: 0; 23 | padding: 0; 24 | box-sizing: border-box; 25 | } 26 | 27 | body { 28 | font-size: 16px; 29 | line-height: 1.5; 30 | color: var(--color-text-dark); 31 | } 32 | 33 | :root { 34 | --font-size-xs: 0.75rem; 35 | --font-size-sm: 0.875rem; 36 | --font-size-md: 1rem; 37 | --font-size-lg: 1.125rem; 38 | --font-size-2xl: 1.5rem; 39 | 40 | --line-height-xs: 1rem; 41 | --line-height-sm: 1.25rem; 42 | --line-height-md: 1.5rem; 43 | --line-height-lg: 1.75rem; 44 | --line-height-2xl: 2rem; 45 | 46 | --border-radius-md: 0.25rem; 47 | 48 | --duration-sm: 100ms; 49 | --duration-md: 300ms; 50 | --duration-2xl: 3000ms; 51 | 52 | --color-blue-50: #eff6ff; 53 | --color-blue-500: #3b82f6; 54 | --color-blue-600: #2563eb; 55 | 56 | --color-red-500: #ef4444; 57 | --color-red-600: #dc2626; 58 | 59 | --color-green-600: #16a34a; 60 | 61 | --color-gray-100: #f3f4f6; 62 | --color-gray-300: #d1d5db; 63 | --color-gray-400: #9ca3af; 64 | --color-gray-500: #6b7280; 65 | 66 | --color-white: #ffffff; 67 | --color-black: #000000; 68 | 69 | --color-text-light: #f9fafb; 70 | --color-text-dark: #030712; 71 | 72 | --box-shadow-md: 0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px -1px rgba(0, 0, 0, 0.1); 73 | --box-shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -4px rgba(0, 0, 0, 0.1); 74 | } 75 | 76 | .app { 77 | height: 100vh; 78 | } 79 | 80 | .main { 81 | display: flex; 82 | flex-direction: column; 83 | height: 100%; 84 | } 85 | 86 | .calendar { 87 | height: 100%; 88 | } 89 | 90 | @media (min-width: 768px) { 91 | .app { 92 | display: grid; 93 | grid-template-columns: auto 1fr; 94 | } 95 | } -------------------------------------------------------------------------------- /scripts/event-store.js: -------------------------------------------------------------------------------- 1 | import { isTheSameDay } from "./date.js"; 2 | 3 | export function initEventStore() { 4 | document.addEventListener("event-create", (event) => { 5 | const createdEvent = event.detail.event; 6 | const events = getEventsFromLocalStorage(); 7 | events.push(createdEvent) 8 | saveEventsIntoLocalStorage(events); 9 | 10 | document.dispatchEvent(new CustomEvent("events-change", { 11 | bubbles: true 12 | })); 13 | }); 14 | 15 | document.addEventListener("event-delete", (event) => { 16 | const deletedEvent = event.detail.event; 17 | const events = getEventsFromLocalStorage().filter((event) => { 18 | return event.id !== deletedEvent.id; 19 | }) 20 | saveEventsIntoLocalStorage(events); 21 | 22 | document.dispatchEvent(new CustomEvent("events-change", { 23 | bubbles: true 24 | })); 25 | }); 26 | 27 | document.addEventListener("event-edit", (event) => { 28 | const editedEvent = event.detail.event; 29 | const events = getEventsFromLocalStorage().map((event) => { 30 | return event.id === editedEvent.id ? editedEvent : event; 31 | }); 32 | saveEventsIntoLocalStorage(events); 33 | 34 | document.dispatchEvent(new CustomEvent("events-change", { 35 | bubbles: true 36 | })); 37 | }); 38 | 39 | return { 40 | getEventsByDate(date) { 41 | const events = getEventsFromLocalStorage(); 42 | const filteredEvents = events.filter((event) => isTheSameDay(event.date, date)); 43 | 44 | return filteredEvents; 45 | } 46 | }; 47 | } 48 | 49 | function saveEventsIntoLocalStorage(events) { 50 | const safeToStringifyEvents = events.map((event) => ({ 51 | ...event, 52 | date: event.date.toISOString() 53 | })); 54 | 55 | let stringifiedEvents; 56 | try { 57 | stringifiedEvents = JSON.stringify(safeToStringifyEvents); 58 | } catch (error) { 59 | console.error("Stringify events failed", error); 60 | } 61 | 62 | localStorage.setItem("events", stringifiedEvents); 63 | } 64 | 65 | function getEventsFromLocalStorage() { 66 | const localStorageEvents = localStorage.getItem("events"); 67 | if (localStorageEvents === null) { 68 | return []; 69 | } 70 | 71 | let parsedEvents; 72 | try { 73 | parsedEvents = JSON.parse(localStorageEvents); 74 | } catch (error) { 75 | console.error("Parse events failed", error); 76 | return []; 77 | } 78 | 79 | const events = parsedEvents.map((event) => ({ 80 | ...event, 81 | date: new Date(event.date) 82 | })); 83 | 84 | return events; 85 | } -------------------------------------------------------------------------------- /scripts/nav.js: -------------------------------------------------------------------------------- 1 | import { today, addDays, addMonths, subtractDays, subtractMonths } from "./date.js"; 2 | import { getUrlDate, getUrlView } from "./url.js"; 3 | 4 | const dateFormatter = new Intl.DateTimeFormat("en-US", { 5 | month: "long", 6 | year: "numeric" 7 | }); 8 | 9 | export function initNav() { 10 | const todayButtonElements = document.querySelectorAll("[data-nav-today-button]"); 11 | const previousButtonElement = document.querySelector("[data-nav-previous-button]"); 12 | const nextButtonElement = document.querySelector("[data-nav-next-button]"); 13 | const dateElement = document.querySelector("[data-nav-date]"); 14 | 15 | let selectedView = getUrlView(); 16 | let selectedDate = getUrlDate(); 17 | 18 | for (const todayButtonElement of todayButtonElements) { 19 | todayButtonElement.addEventListener("click", () => { 20 | todayButtonElement.dispatchEvent(new CustomEvent("date-change", { 21 | detail: { 22 | date: today() 23 | }, 24 | bubbles: true 25 | })); 26 | }); 27 | } 28 | 29 | previousButtonElement.addEventListener("click", () => { 30 | previousButtonElement.dispatchEvent(new CustomEvent("date-change", { 31 | detail: { 32 | date: getPreviousDate(selectedView, selectedDate) 33 | }, 34 | bubbles: true 35 | })); 36 | }); 37 | 38 | nextButtonElement.addEventListener("click", () => { 39 | nextButtonElement.dispatchEvent(new CustomEvent("date-change", { 40 | detail: { 41 | date: getNextDate(selectedView, selectedDate) 42 | }, 43 | bubbles: true 44 | })); 45 | }); 46 | 47 | document.addEventListener("view-change", (event) => { 48 | selectedView = event.detail.view; 49 | }); 50 | 51 | document.addEventListener("date-change", (event) => { 52 | selectedDate = event.detail.date; 53 | refreshDateElement(dateElement, selectedDate); 54 | }); 55 | 56 | refreshDateElement(dateElement, selectedDate); 57 | } 58 | 59 | function refreshDateElement(dateElement, selectedDate) { 60 | dateElement.textContent = dateFormatter.format(selectedDate); 61 | } 62 | 63 | function getPreviousDate(selectedView, selectedDate) { 64 | if (selectedView === "day") { 65 | return subtractDays(selectedDate, 1); 66 | } 67 | 68 | if (selectedView === "week") { 69 | return subtractDays(selectedDate, 7); 70 | } 71 | 72 | return subtractMonths(selectedDate, 1); 73 | } 74 | 75 | function getNextDate(selectedView, selectedDate) { 76 | if (selectedView === "day") { 77 | return addDays(selectedDate, 1); 78 | } 79 | 80 | if (selectedView === "week") { 81 | return addDays(selectedDate, 7); 82 | } 83 | 84 | return addMonths(selectedDate, 1); 85 | } -------------------------------------------------------------------------------- /scripts/event-details-dialog.js: -------------------------------------------------------------------------------- 1 | import { initDialog } from "./dialog.js"; 2 | import { eventTimeToDate } from "./event.js"; 3 | 4 | const eventDateFormatter = new Intl.DateTimeFormat("en-US", { 5 | weekday: 'short', 6 | day: 'numeric', 7 | month: 'long', 8 | year: 'numeric' 9 | }); 10 | 11 | const eventTimeFormatter = new Intl.DateTimeFormat("en-US", { 12 | hour: 'numeric', 13 | minute: 'numeric' 14 | }); 15 | 16 | export function initEventDetailsDialog() { 17 | const dialog = initDialog("event-details"); 18 | 19 | const deleteButtonElemenet = dialog.dialogElement.querySelector("[data-event-details-delete-button]"); 20 | 21 | const editButtonElement = dialog.dialogElement.querySelector("[data-event-details-edit-button]"); 22 | 23 | let currentEvent = null; 24 | 25 | document.addEventListener("event-click", (event) => { 26 | currentEvent = event.detail.event; 27 | fillEventDetailsDialog(dialog.dialogElement, event.detail.event); 28 | dialog.open(); 29 | }); 30 | 31 | deleteButtonElemenet.addEventListener("click", () => { 32 | dialog 33 | .close() 34 | .then(() => { 35 | deleteButtonElemenet.dispatchEvent(new CustomEvent("event-delete-request", { 36 | detail: { 37 | event: currentEvent 38 | }, 39 | bubbles: true 40 | })); 41 | }); 42 | }); 43 | 44 | editButtonElement.addEventListener("click", () => { 45 | dialog 46 | .close() 47 | .then(() => { 48 | editButtonElement.dispatchEvent(new CustomEvent("event-edit-request", { 49 | detail: { 50 | event: currentEvent 51 | }, 52 | bubbles: true 53 | })); 54 | }); 55 | }); 56 | } 57 | 58 | function fillEventDetailsDialog(parent, event) { 59 | const eventDetailsElement = parent.querySelector("[data-event-details]"); 60 | const eventDetailsTitleElement = eventDetailsElement.querySelector("[data-event-details-title]"); 61 | const eventDetailsDateElement = eventDetailsElement.querySelector("[data-event-details-date]"); 62 | const eventDetailsStartTimeElement = eventDetailsElement.querySelector("[data-event-details-start-time]"); 63 | const eventDetailsEndTimeElement = eventDetailsElement.querySelector("[data-event-details-end-time]"); 64 | 65 | eventDetailsTitleElement.textContent = event.title; 66 | eventDetailsDateElement.textContent = eventDateFormatter.format(event.date); 67 | eventDetailsStartTimeElement.textContent = eventTimeFormatter.format( 68 | eventTimeToDate(event, event.startTime) 69 | ); 70 | eventDetailsEndTimeElement.textContent = eventTimeFormatter.format( 71 | eventTimeToDate(event, event.endTime) 72 | ); 73 | 74 | eventDetailsElement.style.setProperty("--event-color", event.color); 75 | } -------------------------------------------------------------------------------- /scripts/date.js: -------------------------------------------------------------------------------- 1 | export function today() { 2 | const now = new Date(); 3 | 4 | return new Date( 5 | now.getFullYear(), 6 | now.getMonth(), 7 | now.getDate(), 8 | 12 9 | ); 10 | } 11 | 12 | export function addMonths(date, months) { 13 | const firstDayOfMonth = new Date( 14 | date.getFullYear(), 15 | date.getMonth() + months, 16 | 1, 17 | date.getHours() 18 | ); 19 | const lastDayOfMonth = getLastDayOfMonthDate(firstDayOfMonth); 20 | 21 | const dayOfMonth = Math.min(date.getDate(), lastDayOfMonth.getDate()); 22 | 23 | return new Date( 24 | date.getFullYear(), 25 | date.getMonth() + months, 26 | dayOfMonth, 27 | date.getHours() 28 | ); 29 | } 30 | 31 | export function subtractMonths(date, months) { 32 | return addMonths(date, -months); 33 | } 34 | 35 | export function addDays(date, days) { 36 | return new Date( 37 | date.getFullYear(), 38 | date.getMonth(), 39 | date.getDate() + days, 40 | date.getHours() 41 | ); 42 | } 43 | 44 | export function subtractDays(date, days) { 45 | return addDays(date, -days); 46 | } 47 | 48 | export function generateMonthCalendarDays(currentDate) { 49 | const calendarDays = []; 50 | 51 | const lastDayOfPreviousMonthDate = getLastDayOfMonthDate( 52 | subtractMonths(currentDate, 1) 53 | ); 54 | 55 | const lastDayOfPreviousMonthWeekDay = lastDayOfPreviousMonthDate.getDay(); 56 | if (lastDayOfPreviousMonthWeekDay !== 6) { 57 | for (let i = lastDayOfPreviousMonthWeekDay; i >= 0; i -= 1) { 58 | const calendarDay = subtractDays(lastDayOfPreviousMonthDate, i); 59 | calendarDays.push(calendarDay); 60 | } 61 | } 62 | 63 | const lastDayOfCurrentMonthDate = getLastDayOfMonthDate(currentDate); 64 | for (let i = 1; i <= lastDayOfCurrentMonthDate.getDate(); i += 1) { 65 | const calendarDay = addDays(lastDayOfPreviousMonthDate, i); 66 | calendarDays.push(calendarDay); 67 | } 68 | 69 | const totalWeeks = Math.ceil(calendarDays.length / 7); 70 | const totalDays = totalWeeks * 7; 71 | const missingDayAmount = totalDays - calendarDays.length; 72 | for (let i = 1; i <= missingDayAmount; i += 1) { 73 | const calendarDay = addDays(lastDayOfCurrentMonthDate, i); 74 | calendarDays.push(calendarDay); 75 | } 76 | 77 | return calendarDays; 78 | } 79 | 80 | export function isTheSameDay(dateA, dateB) { 81 | return dateA.getFullYear() === dateB.getFullYear() && dateA.getMonth() === dateB.getMonth() && dateA.getDate() === dateB.getDate(); 82 | } 83 | 84 | export function generateWeekDays(date) { 85 | const weekDays = []; 86 | const firstWeekDay = subtractDays(date, date.getDay()); 87 | 88 | for (let i = 0; i <= 6; i += 1) { 89 | const weekDay = addDays(firstWeekDay, i); 90 | weekDays.push(weekDay); 91 | } 92 | 93 | return weekDays; 94 | } 95 | 96 | function getLastDayOfMonthDate(date) { 97 | return new Date( 98 | date.getFullYear(), 99 | date.getMonth() + 1, 100 | 0, 101 | 12 102 | ); 103 | } -------------------------------------------------------------------------------- /scripts/month-calendar.js: -------------------------------------------------------------------------------- 1 | import { generateMonthCalendarDays, today, isTheSameDay } from "./date.js"; 2 | import { isEventAllDay, eventStartsBefore } from "./event.js"; 3 | import { initEventList } from "./event-list.js"; 4 | 5 | const calendarTemplateElemenent = document.querySelector("[data-template='month-calendar']"); 6 | const calendarDayTemplateElement = document.querySelector("[data-template='month-calendar-day']"); 7 | 8 | const calendarWeekClasses = { 9 | 4: "four-week", 10 | 5: "five-week", 11 | 6: "six-week" 12 | }; 13 | 14 | export function initMonthCalendar(parent, selectedDate, eventStore) { 15 | const calendarContent = calendarTemplateElemenent.content.cloneNode(true); 16 | const calendarElement = calendarContent.querySelector("[data-month-calendar]"); 17 | const calendarDayListElement = calendarElement.querySelector("[data-month-calendar-day-list]"); 18 | 19 | const calendarDays = generateMonthCalendarDays(selectedDate); 20 | const calendarWeeks = calendarDays / 7; 21 | 22 | const calendarWeekClass = calendarWeekClasses[calendarWeeks]; 23 | calendarElement.classList.add(calendarWeekClass); 24 | 25 | for (const calendarDay of calendarDays) { 26 | const events = eventStore.getEventsByDate(calendarDay); 27 | sortCalendarDayEvents(events); 28 | 29 | initCalendarDay(calendarDayListElement, calendarDay, events); 30 | } 31 | 32 | parent.appendChild(calendarElement); 33 | } 34 | 35 | function initCalendarDay(parent, calendarDay, events) { 36 | const calendarDayContent = calendarDayTemplateElement.content.cloneNode(true); 37 | const calendarDayElemenent = calendarDayContent.querySelector("[data-month-calendar-day]"); 38 | const calendarDayLabelElemenent = calendarDayContent.querySelector("[data-month-calendar-day-label]"); 39 | const calendarEventListWrapper = calendarDayElemenent.querySelector("[data-month-calendar-event-list-wrapper]"); 40 | 41 | if (isTheSameDay(today(), calendarDay)) { 42 | calendarDayElemenent.classList.add("month-calendar__day--highlight"); 43 | } 44 | 45 | calendarDayLabelElemenent.textContent = calendarDay.getDate(); 46 | 47 | calendarDayLabelElemenent.addEventListener("click", () => { 48 | document.dispatchEvent(new CustomEvent("date-change", { 49 | detail: { 50 | date: calendarDay 51 | }, 52 | bubbles: true 53 | })); 54 | 55 | document.dispatchEvent(new CustomEvent("view-change", { 56 | detail: { 57 | view: 'day' 58 | }, 59 | bubbles: true 60 | })); 61 | }); 62 | 63 | calendarEventListWrapper.addEventListener("click", () => { 64 | document.dispatchEvent(new CustomEvent("event-create-request", { 65 | detail: { 66 | date: calendarDay, 67 | startTime: 600, 68 | endTime: 960 69 | }, 70 | bubbles: true 71 | })); 72 | }); 73 | 74 | initEventList(calendarDayElemenent, events); 75 | 76 | parent.appendChild(calendarDayElemenent); 77 | } 78 | 79 | function sortCalendarDayEvents(events) { 80 | events.sort((eventA, eventB) => { 81 | if (isEventAllDay(eventA)) { 82 | return -1; 83 | } 84 | 85 | if (isEventAllDay(eventB)) { 86 | return 1; 87 | } 88 | 89 | return eventStartsBefore(eventA, eventB) ? -1 : 1; 90 | }); 91 | } -------------------------------------------------------------------------------- /scripts/event-form.js: -------------------------------------------------------------------------------- 1 | import { validateEvent, generateEventId } from "./event.js"; 2 | 3 | export function initEventForm(toaster) { 4 | const formElement = document.querySelector("[data-event-form]"); 5 | 6 | let mode = "create"; 7 | 8 | formElement.addEventListener("submit", (event) => { 9 | event.preventDefault(); 10 | const formEvent = formIntoEvent(formElement); 11 | const validationError = validateEvent(formEvent); 12 | if (validationError !== null) { 13 | toaster.error(validationError); 14 | return; 15 | } 16 | 17 | if (mode === "create") { 18 | formElement.dispatchEvent(new CustomEvent("event-create", { 19 | detail: { 20 | event: formEvent 21 | }, 22 | bubbles: true 23 | })); 24 | } 25 | 26 | if (mode === "edit") { 27 | formElement.dispatchEvent(new CustomEvent("event-edit", { 28 | detail: { 29 | event: formEvent 30 | }, 31 | bubbles: true 32 | })); 33 | } 34 | }); 35 | 36 | return { 37 | formElement, 38 | switchToCreateMode(date, startTime, endTime) { 39 | mode = "create"; 40 | fillFormWithDate(formElement, date, startTime, endTime); 41 | }, 42 | switchToEditMode(event) { 43 | mode = "edit"; 44 | fillFormWithEvent(formElement, event); 45 | }, 46 | reset() { 47 | formElement.querySelector("#id").value = null; 48 | formElement.reset(); 49 | } 50 | }; 51 | } 52 | 53 | function fillFormWithDate(formElement, date, startTime, endTime) { 54 | const dateInputElement = formElement.querySelector("#date"); 55 | const startTimeSelectElement = formElement.querySelector("#start-time"); 56 | const endTimeSelectElement = formElement.querySelector("#end-time"); 57 | 58 | dateInputElement.value = date.toISOString().substr(0, 10); 59 | startTimeSelectElement.value = startTime; 60 | endTimeSelectElement.value = endTime; 61 | } 62 | 63 | function fillFormWithEvent(formElement, event) { 64 | const idInputElement = formElement.querySelector("#id"); 65 | const titleInputElement = formElement.querySelector("#title"); 66 | const dateInputElement = formElement.querySelector("#date"); 67 | const startTimeSelectElement = formElement.querySelector("#start-time"); 68 | const endTimeSelectElement = formElement.querySelector("#end-time"); 69 | const colorInputElement = formElement.querySelector(`[value='${event.color}']`); 70 | 71 | idInputElement.value = event.id; 72 | titleInputElement.value = event.title; 73 | dateInputElement.value = event.date.toISOString().substr(0, 10); 74 | startTimeSelectElement.value = event.startTime; 75 | endTimeSelectElement.value = event.endTime; 76 | colorInputElement.checked = true; 77 | } 78 | 79 | function formIntoEvent(formElement) { 80 | const formData = new FormData(formElement); 81 | const id = formData.get("id"); 82 | const title = formData.get("title"); 83 | const date = formData.get("date"); 84 | const startTime = formData.get("start-time"); 85 | const endTime = formData.get("end-time"); 86 | const color = formData.get("color"); 87 | 88 | const event = { 89 | id: id ? Number.parseInt(id, 10) : generateEventId(), 90 | title, 91 | date: new Date(date), 92 | startTime: Number.parseInt(startTime, 10), 93 | endTime: Number.parseInt(endTime, 10), 94 | color 95 | }; 96 | 97 | return event; 98 | } -------------------------------------------------------------------------------- /scripts/mini-calendar.js: -------------------------------------------------------------------------------- 1 | import { today, subtractMonths, addMonths, generateMonthCalendarDays, isTheSameDay } from "./date.js"; 2 | import { getUrlDate } from "./url.js"; 3 | 4 | const calendarDayListItemTemplateElement = document.querySelector("[data-template='mini-calendar-day-list-item']"); 5 | 6 | const dateFormatter = new Intl.DateTimeFormat("en-US", { 7 | month: 'long', 8 | year: 'numeric' 9 | }); 10 | 11 | export function initMiniCalendars() { 12 | const calendarElements = document.querySelectorAll("[data-mini-calendar]"); 13 | 14 | for (const calendarElement of calendarElements) { 15 | initMiniCalendar(calendarElement); 16 | } 17 | } 18 | 19 | function initMiniCalendar(calendarElement) { 20 | const calendarPreviousButtonElement = calendarElement.querySelector("[data-mini-calendar-previous-button]"); 21 | const calendarNextButtonElement = calendarElement.querySelector("[data-mini-calendar-next-button]"); 22 | 23 | let selectedDate = getUrlDate(); 24 | let miniCalendarDate = getUrlDate(); 25 | 26 | function refreshMiniCalendar() { 27 | refreshDateElement(calendarElement, miniCalendarDate); 28 | refreshDayListElement( 29 | calendarElement, 30 | miniCalendarDate, 31 | selectedDate 32 | ); 33 | } 34 | 35 | calendarPreviousButtonElement.addEventListener("click", () => { 36 | miniCalendarDate = subtractMonths(miniCalendarDate, 1); 37 | refreshMiniCalendar(); 38 | }); 39 | 40 | calendarNextButtonElement.addEventListener("click", () => { 41 | miniCalendarDate = addMonths(miniCalendarDate, 1); 42 | refreshMiniCalendar(); 43 | }); 44 | 45 | document.addEventListener("date-change", (event) => { 46 | selectedDate = event.detail.date; 47 | miniCalendarDate = event.detail.date; 48 | refreshMiniCalendar(); 49 | }); 50 | 51 | refreshMiniCalendar(); 52 | } 53 | 54 | function refreshDateElement(parent, date) { 55 | const calendarDateElement = parent.querySelector("[data-mini-calendar-date]"); 56 | 57 | calendarDateElement.textContent = dateFormatter.format(date); 58 | } 59 | 60 | function refreshDayListElement(parent, miniCalendarDate, selectedDate) { 61 | const calendarDayListElement = parent.querySelector("[data-mini-calendar-day-list]"); 62 | 63 | calendarDayListElement.replaceChildren(); 64 | const calendarDays = generateMonthCalendarDays(miniCalendarDate); 65 | for (const calendarDay of calendarDays) { 66 | const calendarDayListItemContent = calendarDayListItemTemplateElement.content.cloneNode(true); 67 | const calendarDayListItemElement = calendarDayListItemContent.querySelector("[data-mini-calendar-day-list-item]"); 68 | const calendarDayElement = calendarDayListItemElement.querySelector("[data-mini-calendar-day]"); 69 | 70 | calendarDayElement.textContent = calendarDay.getDate(); 71 | 72 | if (miniCalendarDate.getMonth() !== calendarDay.getMonth()) { 73 | calendarDayElement.classList.add("mini-calendar__day--other"); 74 | } 75 | 76 | if (isTheSameDay(selectedDate, calendarDay)) { 77 | calendarDayElement.classList.add("button--primary"); 78 | } else { 79 | calendarDayElement.classList.add("button--secondary"); 80 | } 81 | 82 | if (isTheSameDay(today(), calendarDay)) { 83 | calendarDayElement.classList.add("mini-calendar__day--highlight"); 84 | } 85 | 86 | calendarDayElement.addEventListener("click", () => { 87 | calendarDayElement.dispatchEvent(new CustomEvent("date-change", { 88 | detail: { 89 | date: calendarDay 90 | }, 91 | bubbles: true 92 | })); 93 | }); 94 | 95 | calendarDayListElement.appendChild(calendarDayListItemElement); 96 | } 97 | } -------------------------------------------------------------------------------- /scripts/event.js: -------------------------------------------------------------------------------- 1 | const eventTemplateElement = document.querySelector("[data-template='event']"); 2 | 3 | const dateFormatter = new Intl.DateTimeFormat("en-US", { 4 | hour: "numeric", 5 | minute: "numeric" 6 | }); 7 | 8 | export function initStaticEvent(parent, event) { 9 | const eventElement = initEvent(event); 10 | 11 | if (isEventAllDay(event)) { 12 | eventElement.classList.add("event--filled"); 13 | } 14 | 15 | parent.appendChild(eventElement); 16 | } 17 | 18 | export function initDynamicEvent(parent, event, dynamicStyles) { 19 | const eventElement = initEvent(event); 20 | 21 | eventElement.classList.add("event--filled"); 22 | eventElement.classList.add("event--dynamic"); 23 | 24 | eventElement.style.top = dynamicStyles.top; 25 | eventElement.style.left = dynamicStyles.left; 26 | eventElement.style.bottom = dynamicStyles.bottom; 27 | eventElement.style.right = dynamicStyles.right; 28 | 29 | eventElement.dataset.eventDynamic = true; 30 | 31 | parent.appendChild(eventElement); 32 | } 33 | 34 | function initEvent(event) { 35 | const eventContent = eventTemplateElement.content.cloneNode(true); 36 | const eventElement = eventContent.querySelector("[data-event]"); 37 | const eventTitleElement = eventElement.querySelector("[data-event-title]"); 38 | const eventStartTimeElement = eventElement.querySelector("[data-event-start-time]"); 39 | const eventEndTimeElement = eventElement.querySelector("[data-event-end-time]"); 40 | 41 | const startDate = eventTimeToDate(event, event.startTime); 42 | const endDate = eventTimeToDate(event, event.endTime); 43 | 44 | eventElement.style.setProperty("--event-color", event.color); 45 | eventTitleElement.textContent = event.title; 46 | eventStartTimeElement.textContent = dateFormatter.format(startDate); 47 | eventEndTimeElement.textContent = dateFormatter.format(endDate); 48 | 49 | eventElement.addEventListener("click", () => { 50 | eventElement.dispatchEvent(new CustomEvent("event-click", { 51 | detail: { 52 | event, 53 | }, 54 | bubbles: true 55 | })); 56 | }); 57 | 58 | return eventElement; 59 | } 60 | 61 | export function isEventAllDay(event) { 62 | return event.startTime === 0 && event.endTime === 1440; 63 | } 64 | 65 | export function eventStartsBefore(eventA, eventB) { 66 | return eventA.startTime < eventB.startTime; 67 | } 68 | 69 | export function eventEndsBefore(eventA, eventB) { 70 | return eventA.endTime < eventB.eventTime; 71 | } 72 | 73 | export function eventCollidesWith(eventA, eventB) { 74 | const maxStartTime = Math.max(eventA.startTime, eventB.startTime); 75 | const minEndTime = Math.min(eventA.endTime, eventB.endTime); 76 | 77 | return minEndTime > maxStartTime; 78 | } 79 | 80 | export function eventTimeToDate(event, eventTime) { 81 | return new Date( 82 | event.date.getFullYear(), 83 | event.date.getMonth(), 84 | event.date.getDate(), 85 | 0, 86 | eventTime 87 | ); 88 | } 89 | 90 | export function validateEvent(event) { 91 | if (event.startTime >= event.endTime) { 92 | return "Event end time must be after start time"; 93 | } 94 | 95 | return null; 96 | } 97 | 98 | export function adjustDynamicEventMaxLines(dynamicEventElement) { 99 | const availableHeight = dynamicEventElement.offsetHeight; 100 | const lineHeight = 16; 101 | const padding = 8; 102 | const maxTitleLines = Math.floor((availableHeight - lineHeight - padding) / lineHeight); 103 | 104 | dynamicEventElement.style.setProperty("--event-title-max-lines", maxTitleLines); 105 | } 106 | 107 | export function generateEventId() { 108 | return Date.now(); 109 | } -------------------------------------------------------------------------------- /styles/dialog.css: -------------------------------------------------------------------------------- 1 | .dialog { 2 | border: 0; 3 | margin: 0; 4 | max-height: unset; 5 | max-width: unset; 6 | align-items: center; 7 | justify-content: center; 8 | width: 100%; 9 | height: 100vh; 10 | background-color: transparent; 11 | overflow: hidden; 12 | } 13 | 14 | .dialog--sidebar { 15 | justify-content: flex-start; 16 | } 17 | 18 | .dialog[open] { 19 | display: flex; 20 | animation: open-dialog var(--duration-sm) forwards ease-out; 21 | } 22 | 23 | .dialog--sidebar[open] { 24 | animation-name: open-sidebar-dialog; 25 | animation-duration: var(--duration-md); 26 | } 27 | 28 | .dialog--closing[open] { 29 | animation: close-dialog var(--duration-sm) forwards ease-out; 30 | } 31 | 32 | .dialog--sidebar.dialog--closing[open] { 33 | animation-name: close-sidebar-dialog; 34 | animation-duration: var(--duration-md); 35 | } 36 | 37 | .dialog[open]::backdrop { 38 | background-color: var(--color-black); 39 | animation: open-backdrop var(--duration-sm) forwards ease-out; 40 | } 41 | 42 | .dialog--sidebar[open]::backdrop { 43 | animation-duration: var(--duration-md); 44 | } 45 | 46 | .dialog--closing[open]::backdrop { 47 | animation: close-backdrop var(--duration-sm) forwards ease-out; 48 | } 49 | 50 | .dialog--sidebar.dialog--closing[open]::backdrop { 51 | animation-duration: var(--duration-md); 52 | } 53 | 54 | .dialog__wrapper { 55 | margin: auto; 56 | border-radius: var(--border-radius-md); 57 | background-color: var(--color-white); 58 | display: flex; 59 | flex-direction: column; 60 | width: 30rem; 61 | max-width: calc(100vw - 2rem); 62 | gap: 1.5rem; 63 | padding: 1.5rem 0; 64 | max-height: calc(100vh - 2rem); 65 | } 66 | 67 | .dialog--sidebar .dialog__wrapper { 68 | margin: 0; 69 | max-height: unset; 70 | height: 100%; 71 | width: 18rem; 72 | padding: 1rem; 73 | border-radius: 0; 74 | } 75 | 76 | .dialog__header { 77 | flex: 0 0 auto; 78 | display: flex; 79 | justify-content: space-between; 80 | align-items: center; 81 | gap: 1rem; 82 | padding: 0 1.5rem; 83 | } 84 | 85 | .dialog__header-actions { 86 | display: flex; 87 | align-items: stretch; 88 | gap: 0.5rem; 89 | } 90 | 91 | .dialog__header-actions-divider { 92 | flex-shrink: 0; 93 | width: 1px; 94 | background-color: var(--color-gray-100); 95 | } 96 | 97 | .dialog__title { 98 | font-size: var(--font-size-2xl); 99 | line-height: var(--line-height-2xl); 100 | font-weight: 500; 101 | } 102 | 103 | .dialog__content { 104 | flex: 1 1 auto; 105 | overflow-y: auto; 106 | padding: 0 1.5rem; 107 | } 108 | 109 | .dialog--sidebar .dialog__content { 110 | padding: 0; 111 | } 112 | 113 | .dialog__footer { 114 | flex: 0 0 auto; 115 | padding: 0 1.5rem; 116 | } 117 | 118 | .dialog__actions { 119 | display: flex; 120 | justify-content: flex-end; 121 | gap: 0.5rem; 122 | } 123 | 124 | @keyframes open-dialog { 125 | from { 126 | transform: scale(0.9); 127 | opacity: 0; 128 | } 129 | 130 | to { 131 | transform: scale(1); 132 | opacity: 1; 133 | } 134 | } 135 | 136 | @keyframes close-dialog { 137 | from { 138 | transform: scale(1); 139 | opacity: 1; 140 | } 141 | 142 | to { 143 | transform: scale(0.9); 144 | opacity: 0; 145 | } 146 | } 147 | 148 | @keyframes open-sidebar-dialog { 149 | from { 150 | transform: translateX(-100%); 151 | } 152 | 153 | to { 154 | transform: translateX(0); 155 | } 156 | } 157 | 158 | @keyframes close-sidebar-dialog { 159 | from { 160 | transform: translate(0); 161 | } 162 | 163 | to { 164 | transform: translateX(-100%); 165 | } 166 | } 167 | 168 | @keyframes open-backdrop { 169 | from { 170 | opacity: 0; 171 | } 172 | 173 | to { 174 | opacity: 0.85; 175 | } 176 | } 177 | 178 | @keyframes close-backdrop { 179 | from { 180 | opacity: 0.85; 181 | } 182 | 183 | to { 184 | opacity: 0; 185 | } 186 | } -------------------------------------------------------------------------------- /styles/week-calendar.css: -------------------------------------------------------------------------------- 1 | .week-calendar { 2 | display: flex; 3 | flex-direction: column; 4 | height: 100%; 5 | } 6 | 7 | .week-calendar__day-of-week-list { 8 | list-style: none; 9 | display: grid; 10 | grid-template-columns: repeat(7, minmax(0, 1fr)); 11 | padding: 0.5rem 0.5rem 0 0.5rem; 12 | } 13 | 14 | .week-calendar--day .week-calendar__day-of-week-list { 15 | grid-template-columns: repeat(1, minmax(0, 1fr)); 16 | } 17 | 18 | .week-calendar__day-of-week { 19 | display: flex; 20 | justify-content: center; 21 | } 22 | 23 | .week-calendar__day-of-week-button { 24 | display: flex; 25 | gap: 0.25rem; 26 | align-items: center; 27 | justify-content: center; 28 | border: 2px solid transparent; 29 | padding: 0; 30 | border-radius: var(--border-radius-md); 31 | background-color: transparent; 32 | cursor: pointer; 33 | transition: background-color var(--duration-sm) ease-out; 34 | flex-direction: column; 35 | } 36 | 37 | .week-calendar__day-of-week-day { 38 | font-size: var(--font-size-sm); 39 | line-height: var(--line-height-sm); 40 | color: var(--color-gray-500); 41 | } 42 | 43 | .week-calendar__day-of-week-number { 44 | border-radius: var(--border-radius-md); 45 | font-size: var(--font-size-md); 46 | line-height: var(--line-height-md); 47 | color: var(--color-text-dark); 48 | padding: 0.25rem 0.5rem; 49 | border: 1px solid transparent; 50 | } 51 | 52 | .week-calendar__day-of-week-button--highlight .week-calendar__day-of-week-number { 53 | border-color: var(--color-blue-600); 54 | } 55 | 56 | .week-calendar__day-of-week-button--selected .week-calendar__day-of-week-number { 57 | background-color: var(--color-blue-600); 58 | color: var(--color-text-light); 59 | } 60 | 61 | .week-calendar__all-day-list { 62 | position: relative; 63 | list-style: none; 64 | display: grid; 65 | grid-template-columns: repeat(1, minmax(0, 1fr)); 66 | padding: 0.5rem; 67 | } 68 | 69 | .week-calendar__all-day-list::after { 70 | content: ""; 71 | position: absolute; 72 | bottom: 0; 73 | left: 0; 74 | right: 0; 75 | height: 1px; 76 | box-shadow: var(--box-shadow-md); 77 | } 78 | 79 | .week-calendar__all-day-list-item { 80 | padding: 0 0.125rem; 81 | } 82 | 83 | .week-calendar__content { 84 | position: relative; 85 | flex: 1; 86 | } 87 | 88 | .week-calendar__content-inner { 89 | position: absolute; 90 | left: 0; 91 | top: 0; 92 | right: 0; 93 | bottom: 0; 94 | display: flex; 95 | overflow-y: auto; 96 | } 97 | 98 | .week-calendar__time-list { 99 | width: 4.5rem; 100 | } 101 | 102 | .week-calendar__time-item { 103 | height: 4rem; 104 | text-align: center; 105 | } 106 | 107 | .week-calendar__time { 108 | font-size: var(--font-size-xs); 109 | line-height: var(--line-height-xs); 110 | color: var(--color-gray-500); 111 | } 112 | 113 | .week-calendar__columns { 114 | flex-grow: 1; 115 | display: grid; 116 | grid-template-columns: repeat(1, minmax(0, 1fr)); 117 | padding-top: 0.75rem; 118 | } 119 | 120 | .week-calendar__column { 121 | position: relative; 122 | } 123 | 124 | .week-calendar__cell { 125 | height: 4rem; 126 | border-top: 1px solid var(--color-gray-300); 127 | border-left: 1px solid var(--color-gray-300); 128 | } 129 | 130 | @media (min-width: 768px) { 131 | .week-calendar__day-of-week-button { 132 | flex-direction: row; 133 | padding: 0.125rem 0.5rem; 134 | gap: 0.5rem; 135 | } 136 | 137 | .week-calendar__day-of-week-button:hover { 138 | background-color: var(--color-gray-100); 139 | } 140 | 141 | .week-calendar__day-of-week-button--highlight { 142 | border-color: var(--color-blue-600); 143 | } 144 | 145 | .week-calendar__day-of-week-number { 146 | padding: 0; 147 | border: 0; 148 | } 149 | 150 | .week-calendar__day-of-week-button--selected .week-calendar__day-of-week-number { 151 | background-color: transparent; 152 | color: var(--color-text-dark); 153 | } 154 | 155 | .week-calendar__day-of-week-list { 156 | grid-template-columns: repeat(7, minmax(0, 1fr)); 157 | padding-left: 4.5rem; 158 | } 159 | 160 | .week-calendar--day .week-calendar__day-of-week-list { 161 | grid-template-columns: repeat(1, minmax(0, 1fr)); 162 | } 163 | 164 | .week-calendar__all-day-list { 165 | grid-template-columns: repeat(7, minmax(0, 1fr)); 166 | padding-left: 4.5rem; 167 | } 168 | 169 | .week-calendar--day .week-calendar__all-day-list { 170 | grid-template-columns: repeat(1, minmax(0, 1fr)); 171 | } 172 | 173 | .week-calendar__columns { 174 | grid-template-columns: repeat(7, minmax(0, 1fr)); 175 | } 176 | 177 | .week-calendar--day .week-calendar__columns { 178 | grid-template-columns: repeat(1, minmax(0, 1fr)); 179 | } 180 | } -------------------------------------------------------------------------------- /scripts/week-calendar.js: -------------------------------------------------------------------------------- 1 | import { generateWeekDays, isTheSameDay, today } from "./date.js"; 2 | import { isEventAllDay, eventStartsBefore, eventEndsBefore, initDynamicEvent, eventCollidesWith, adjustDynamicEventMaxLines } from "./event.js"; 3 | import { initEventList } from "./event-list.js"; 4 | 5 | const calendarTemplateElement = document.querySelector("[data-template='week-calendar']"); 6 | const calendarDayOfWeekTemplateElement = document.querySelector("[data-template='week-calendar-day-of-week']"); 7 | const calendarAllDayListItemTemplateElement = document.querySelector("[data-template='week-calendar-all-day-list-item']"); 8 | const calendarColumnTemplateElement = document.querySelector("[data-template='week-calendar-column']"); 9 | 10 | const dateFormatter = new Intl.DateTimeFormat("en-US", { 11 | weekday: 'short' 12 | }); 13 | 14 | export function initWeekCalendar(parent, selectedDate, eventStore, isSingleDay, deviceType) { 15 | const calendarContent = calendarTemplateElement.content.cloneNode(true); 16 | const calendarElement = calendarContent.querySelector("[data-week-calendar]"); 17 | const calendarDayOfWeekListElement = calendarElement.querySelector("[data-week-calendar-day-of-week-list]"); 18 | const calendarAllDayListElement = calendarElement.querySelector("[data-week-calendar-all-day-list]"); 19 | const calendarColumnsElement = calendarElement.querySelector("[data-week-calendar-columns]"); 20 | 21 | const weekDays = isSingleDay ? [selectedDate] : generateWeekDays(selectedDate); 22 | for (const weekDay of weekDays) { 23 | const events = eventStore.getEventsByDate(weekDay); 24 | const allDayEvents = events.filter((event) => isEventAllDay(event)); 25 | const nonAllDayEvents = events.filter((event) => !isEventAllDay(event)); 26 | 27 | sortEventsByTime(nonAllDayEvents); 28 | 29 | initDayOfWeek(calendarDayOfWeekListElement, selectedDate, weekDay, deviceType); 30 | 31 | if (deviceType === "desktop" || (deviceType === "mobile" && isTheSameDay(weekDay, selectedDate))) { 32 | initAllDayListItem(calendarAllDayListElement, allDayEvents); 33 | initColumn(calendarColumnsElement, weekDay, nonAllDayEvents); 34 | } 35 | } 36 | 37 | if (isSingleDay) { 38 | calendarElement.classList.add("week-calendar--day"); 39 | } 40 | 41 | parent.appendChild(calendarElement); 42 | 43 | const dynamicEventElements = calendarElement.querySelectorAll("[data-event-dynamic]"); 44 | 45 | for (const dynamicEventElement of dynamicEventElements) { 46 | adjustDynamicEventMaxLines(dynamicEventElement); 47 | } 48 | } 49 | 50 | function initDayOfWeek(parent, selectedDate, weekDay, deviceType) { 51 | const calendarDayOfWeekContent = calendarDayOfWeekTemplateElement.content.cloneNode(true); 52 | const calendarDayOfWeekElement = calendarDayOfWeekContent.querySelector("[data-week-calendar-day-of-week]"); 53 | const calendarDayOfWeekButtonElement = calendarDayOfWeekElement.querySelector("[data-week-calendar-day-of-week-button]"); 54 | const calendarDayOfWeekDayElement = calendarDayOfWeekElement.querySelector("[data-week-calendar-day-of-week-day]"); 55 | const calendarDayOfWeekNumberElement = calendarDayOfWeekElement.querySelector("[data-week-calendar-day-of-week-number]"); 56 | 57 | calendarDayOfWeekNumberElement.textContent = weekDay.getDate(); 58 | calendarDayOfWeekDayElement.textContent = dateFormatter.format(weekDay); 59 | 60 | if (isTheSameDay(weekDay, today())) { 61 | calendarDayOfWeekButtonElement.classList.add("week-calendar__day-of-week-button--highlight"); 62 | } 63 | 64 | if (isTheSameDay(weekDay, selectedDate)) { 65 | calendarDayOfWeekButtonElement.classList.add("week-calendar__day-of-week-button--selected"); 66 | } 67 | 68 | calendarDayOfWeekButtonElement.addEventListener("click", () => { 69 | document.dispatchEvent(new CustomEvent("date-change", { 70 | detail: { 71 | date: weekDay 72 | }, 73 | bubbles: true 74 | })); 75 | 76 | if (deviceType !== "mobile") { 77 | document.dispatchEvent(new CustomEvent("view-change", { 78 | detail: { 79 | view: "day" 80 | }, 81 | bubbles: true 82 | })); 83 | } 84 | }); 85 | 86 | parent.appendChild(calendarDayOfWeekElement); 87 | } 88 | 89 | function initAllDayListItem(parent, events) { 90 | const calendarAllDayListItemContent = calendarAllDayListItemTemplateElement.content.cloneNode(true); 91 | const calendarAllDayListItemElement = calendarAllDayListItemContent.querySelector("[data-week-calendar-all-day-list-item]"); 92 | 93 | initEventList(calendarAllDayListItemElement, events); 94 | 95 | parent.appendChild(calendarAllDayListItemElement); 96 | } 97 | 98 | function initColumn(parent, weekDay, events) { 99 | const calendarColumnContent = calendarColumnTemplateElement.content.cloneNode(true); 100 | const calendarColumnElement = calendarColumnContent.querySelector("[data-week-calendar-column]"); 101 | const calendarColumnCellElements = calendarColumnElement.querySelectorAll("[data-week-calendar-cell]"); 102 | 103 | const eventsWithDynamicStyles = calculateEventsDynamicStyles(events); 104 | for (const eventWithDynamicStyles of eventsWithDynamicStyles) { 105 | initDynamicEvent( 106 | calendarColumnElement, 107 | eventWithDynamicStyles.event, 108 | eventWithDynamicStyles.styles 109 | ); 110 | } 111 | 112 | for (const calendarColumnCellElement of calendarColumnCellElements) { 113 | const cellStartTime = Number.parseInt( 114 | calendarColumnCellElement.dataset.weekCalendarCell, 115 | 10 116 | ); 117 | const cellEndTime = cellStartTime + 60; 118 | 119 | calendarColumnCellElement.addEventListener("click", () => { 120 | document.dispatchEvent(new CustomEvent("event-create-request", { 121 | detail: { 122 | date: weekDay, 123 | startTime: cellStartTime, 124 | endTime: cellEndTime 125 | }, 126 | bubbles: true 127 | })); 128 | }); 129 | } 130 | 131 | parent.appendChild(calendarColumnElement); 132 | } 133 | 134 | function calculateEventsDynamicStyles(events) { 135 | const { eventGroups, totalColumns } = groupEvents(events); 136 | const columnWidth = 100 / totalColumns; 137 | const initialEventGroupItems = []; 138 | 139 | for (const eventGroup of eventGroups) { 140 | for (const eventGroupItem of eventGroup) { 141 | if (eventGroupItem.isInitial) { 142 | initialEventGroupItems.push(eventGroupItem); 143 | } 144 | } 145 | } 146 | 147 | return initialEventGroupItems.map((eventGroupItem) => { 148 | const topPercentage = 100 * (eventGroupItem.event.startTime / 1440); 149 | const bottomPercentage = 100 - 100 * (eventGroupItem.event.endTime / 1440); 150 | const leftPercentage = columnWidth * eventGroupItem.columnIndex; 151 | const rightPercentage = columnWidth * (totalColumns - eventGroupItem.columnIndex - eventGroupItem.columnSpan); 152 | 153 | return { 154 | event: eventGroupItem.event, 155 | styles: { 156 | top: `${topPercentage}%`, 157 | bottom: `${bottomPercentage}%`, 158 | left: `${leftPercentage}%`, 159 | right: `${rightPercentage}%` 160 | } 161 | } 162 | }); 163 | } 164 | 165 | function groupEvents(events) { 166 | if (events.length === 0) { 167 | return { eventGroups: [], totalColumns: 0 }; 168 | } 169 | 170 | const firstEventGroup = [ 171 | { 172 | event: events[0], 173 | columnIndex: 0, 174 | isInitial: true, 175 | eventIndex: 0 176 | } 177 | ]; 178 | 179 | const eventGroups = [firstEventGroup]; 180 | 181 | for (let i = 1; i < events.length; i += 1) { 182 | const lastEventGroup = eventGroups[eventGroups.length - 1]; 183 | const loopEvent = events[i]; 184 | 185 | const lastEventGroupCollidingItems = lastEventGroup.filter((eventGroupItem) => eventCollidesWith(eventGroupItem.event, loopEvent)); 186 | 187 | if (lastEventGroupCollidingItems.length === 0) { 188 | const newEventGroupItem = { 189 | event: loopEvent, 190 | columnIndex: 0, 191 | isInitial: true, 192 | eventIndex: i 193 | }; 194 | 195 | const newEventGroup = [newEventGroupItem]; 196 | eventGroups.push(newEventGroup); 197 | continue; 198 | } 199 | 200 | if (lastEventGroupCollidingItems.length === lastEventGroup.length) { 201 | const newEventGroupItem = { 202 | event: loopEvent, 203 | columnIndex: lastEventGroup.length, 204 | isInitial: true, 205 | eventIndex: i 206 | }; 207 | 208 | lastEventGroup.push(newEventGroupItem); 209 | continue; 210 | } 211 | 212 | let newColumnIndex = 0; 213 | while (true) { 214 | const isColumnIndexInUse = lastEventGroupCollidingItems.some((eventGroupItem) => eventGroupItem.columnIndex === newColumnIndex); 215 | 216 | if (isColumnIndexInUse) { 217 | newColumnIndex += 1; 218 | } else { 219 | break; 220 | } 221 | } 222 | 223 | const newEventGroupItem = { 224 | event: loopEvent, 225 | columnIndex: newColumnIndex, 226 | isInitial: true, 227 | eventIndex: i 228 | }; 229 | 230 | const newEventGroup = [ 231 | ...lastEventGroupCollidingItems.map((eventGroupItem) => ({ 232 | ...eventGroupItem, 233 | isInitial: false 234 | })), 235 | newEventGroupItem 236 | ]; 237 | 238 | eventGroups.push(newEventGroup); 239 | } 240 | 241 | let totalColumns = 0; 242 | for (const eventGroup of eventGroups) { 243 | for (const eventGroupItem of eventGroup) { 244 | totalColumns = Math.max(totalColumns, eventGroupItem.columnIndex + 1); 245 | } 246 | } 247 | 248 | for (const eventGroup of eventGroups) { 249 | eventGroup.sort((columnGroupItemA, columnGroupItemB) => { 250 | return columnGroupItemA.columnIndex < columnGroupItemB.columnIndex ? -1 : 1; 251 | }); 252 | 253 | for (let i = 0; i < eventGroup.length; i += 1) { 254 | const loopEventGroupItem = eventGroup[i]; 255 | if (i === eventGroup.length - 1) { 256 | loopEventGroupItem.columnSpan = totalColumns - loopEventGroupItem.columnIndex; 257 | } else { 258 | const nextLoopEventGroupItem = eventGroup[i + 1]; 259 | loopEventGroupItem.columnSpan = nextLoopEventGroupItem.columnIndex - loopEventGroupItem.columnIndex; 260 | } 261 | } 262 | } 263 | 264 | for (let i = 0; i < events.length; i += 1) { 265 | let lowestColumnSpan = Infinity; 266 | 267 | for (const eventGroup of eventGroups) { 268 | for (const eventGroupItem of eventGroup) { 269 | if (eventGroupItem.eventIndex === i) { 270 | lowestColumnSpan = Math.min(lowestColumnSpan, eventGroupItem.columnSpan); 271 | } 272 | } 273 | } 274 | 275 | for (const eventGroup of eventGroups) { 276 | for (const eventGroupItem of eventGroup) { 277 | if (eventGroupItem.eventIndex === i) { 278 | eventGroupItem.columnSpan = lowestColumnSpan; 279 | } 280 | } 281 | } 282 | } 283 | 284 | return { eventGroups, totalColumns }; 285 | } 286 | 287 | function sortEventsByTime(events) { 288 | events.sort((eventA, eventB) => { 289 | if (eventStartsBefore(eventA, eventB)) { 290 | return -1; 291 | } 292 | 293 | if (eventStartsBefore(eventB, eventA)) { 294 | return 1 295 | } 296 | 297 | return eventEndsBefore(eventA, eventB) ? 1 : -1; 298 | }); 299 | } -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | Vanilla Calendar | Responsive App with HTML, CSS & JavaScript - Mateusz Ziomek 11 | 13 | 14 | 15 | 16 | 17 | 18 | 20 | 21 | 22 | 23 | 24 | 25 | 27 | 28 | 29 | 30 | 32 | 33 | 35 | 36 | 37 | 38 |
39 | 92 |
93 | 157 |
158 |
159 |
160 |
161 | 162 | 169 | 170 | 171 |
172 |
173 |
174 |

175 | 183 |
184 | 185 |
186 |
187 | 188 | 189 |
190 | 191 | 193 |
194 | 195 |
196 | 197 | 198 |
199 | 200 |
201 |
202 | 203 |
204 | 254 | 255 | 258 | 259 | 260 |
261 |
262 | 263 |
264 | 265 |
266 | 316 | 317 | 320 | 321 | 322 |
323 |
324 |
325 | 326 |
327 | 328 |
329 | 335 | 341 | 347 | 353 | 359 |
360 |
361 |
362 |
363 | 364 | 370 |
371 |
372 |
373 | 374 | 375 |
376 |
377 |

Event details

378 |
379 | 388 | 389 | 398 | 399 |
400 | 401 | 409 |
410 |
411 | 412 |
413 |
414 |
415 |
416 |
417 |
418 |
419 | - 420 |
421 |
422 |
423 |
424 |
425 |
426 | 427 | 428 |
429 |
430 |

Delete event

431 | 432 | 439 |
440 | 441 |
442 |

Do you really want to delete ?

443 |
444 | 445 | 451 |
452 |
453 | 454 | 455 |
456 |
457 |
458 |
459 | 460 | 461 |
462 | 469 | 470 | 477 |
478 |
479 | 480 |
481 |
    482 |
  • S
  • 483 |
  • M
  • 484 |
  • T
  • 485 |
  • W
  • 486 |
  • T
  • 487 |
  • F
  • 488 |
  • S
  • 489 |
490 | 491 |
    492 |
    493 |
    494 |
    495 |
    496 |
    497 | 498 | 516 | 517 | 525 | 526 | 617 | 618 | 626 | 627 | 632 | 633 | 661 | 662 | 666 | 667 | 676 | 677 | 682 | 683 | 684 | --------------------------------------------------------------------------------