├── 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 | [](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 |
94 |
102 |
103 |
104 |
105 |
106 |
122 |
123 |
124 |
131 |
132 |
139 |
140 |
141 |
142 |
143 |
144 |
145 |
150 |
151 |
155 |
156 |
157 |
158 |
159 |
160 |
161 |
162 |
169 |
170 |
373 |
374 |
426 |
427 |
453 |
454 |
497 |
498 |
499 |
500 |
501 | - Sun
502 | - Mon
503 | - Tue
504 | - Wed
505 | - Thu
506 | - Fri
507 | - Sat
508 |
509 |
510 |
514 |
515 |
516 |
517 |
518 |
519 |
520 |
523 |
524 |
525 |
526 |
527 |
528 |
530 |
531 |
533 |
534 |
535 |
536 |
537 | -
538 |
539 |
540 | -
541 |
542 |
543 | -
544 |
545 |
546 | -
547 |
548 |
549 | -
550 |
551 |
552 | -
553 |
554 |
555 | -
556 |
557 |
558 | -
559 |
560 |
561 | -
562 |
563 |
564 | -
565 |
566 |
567 | -
568 |
569 |
570 | -
571 |
572 |
573 | -
574 |
575 |
576 | -
577 |
578 |
579 | -
580 |
581 |
582 | -
583 |
584 |
585 | -
586 |
587 |
588 | -
589 |
590 |
591 | -
592 |
593 |
594 | -
595 |
596 |
597 | -
598 |
599 |
600 | -
601 |
602 |
603 | -
604 |
605 |
606 | -
607 |
608 |
609 |
610 |
611 |
612 |
613 |
614 |
615 |
616 |
617 |
618 |
619 |
620 |
624 |
625 |
626 |
627 |
628 |
629 |
630 |
631 |
632 |
633 |
634 |
635 |
636 |
637 |
638 |
639 |
640 |
641 |
642 |
643 |
644 |
645 |
646 |
647 |
648 |
649 |
650 |
651 |
652 |
653 |
654 |
655 |
656 |
657 |
658 |
659 |
660 |
661 |
662 |
663 |
664 |
665 |
666 |
667 |
668 |
675 |
676 |
677 |
678 |
679 |
680 |
681 |
682 |
683 |
684 |
--------------------------------------------------------------------------------