Przejdź do treści

Kalendarz w czystym JavaScript – bez jQuery

Dwa lata temu opisałem na blogu budowę kalendarza miesięcznego w HTML, CSS i jQuery – z refaktoryzacją na CSS Grid i nawigacją między miesiącami, a na końcu z tłumaczeniami pobieranymi AJAX-em z API w PHP. Tamta seria jest nadal poprawna i działa, ale powstała w realiach, w których jQuery było odruchem. Dziś ten odruch już się nie broni: wszystko, do czego sięgaliśmy po $, jest od lat wbudowane w przeglądarki. W tym wpisie odtwarzam dokładnie ten sam kalendarz, ale w czystym JavaScript – i przy okazji upraszczam całą trzecią część (PHP + MySQL + AJAX) do jednej linijki.

To nowa wersja, nie poprawka. Starą serię w jQuery (2024) zostawiam pod jej adresami bez zmian – oznaczam ją jako wersję archiwalną i linkuję stąd do niej. Zasada bloga: starych wpisów nie przepisuję w miejscu, modernizację publikuję jako osobny wpis.

Dlaczego bez jQuery

jQuery rozwiązywało realne problemy ery IE: niespójne API DOM, koszmar addEventListener vs attachEvent, brak wygodnego AJAX-a. Dziś każdy z tych problemów zniknął:

Po co sięgaliśmy po jQueryNatywny odpowiednik (od lat we wszystkich przeglądarkach)
$(sel) / $(el).find()document.querySelector / querySelectorAll
$('<div>').addClass()document.createElement + classList, lub innerHTML
$(el).on('click', …)el.addEventListener('click', …) + delegacja przez closest()
$.ajax / $.getJSONfetch()
tablice nazw miesięcy i dniIntl.DateTimeFormat (lokalizacja w przeglądarce)

W praktyce oznacza to 0 KB zależności zamiast ~30 KB samego jQuery, brak dodatkowego żądania sieciowego i kod, który zrozumie każda przeglądarka bez warstwy pośredniej.

Szkielet HTML

Pierwsza różnica widać już w <head> – znika <script> ładujący jQuery z CDN. Zostaje sam moduł:

<!DOCTYPE html>
<html lang="pl">
<head>
	<meta charset="UTF-8">
	<title>Kalendarz</title>
	<link rel="stylesheet" href="style.css">
	<script type="module" src="calendar.js"></script>
</head>
<body>
	<month-calendar locale="pl-PL"></month-calendar>
</body>
</html>

Zamiast pustego <div id="calendar">, który skrypt wypełni z zewnątrz, użyjemy własnego elementu <month-calendar>Web Componentu. To natywny mechanizm przeglądarki: definiujemy własny tag, który sam wie, jak się wyrenderować i jak reagować na kliknięcia. Atrybut type="module" daje nam zakres modułu (brak globalnych zmiennych) i defer za darmo.

Generowanie dni – createElement zamiast $.append

W starej wersji budowaliśmy dni tak:

// jQuery (2024)
for (let i = 1; i <= 31; i++) {
	$("#calendar").append("<div>" + i + "</div>");
}

Natywnie robi to to samo – i od razu dorzucamy optymalizację, którą w jQuery trzeba było świadomie dokładać (DocumentFragment). Tu budujemy gotowy ciąg HTML i wstawiamy go jedną operacją innerHTML, co daje dokładnie ten sam zysk (jeden reflow), ale bez dodatkowego obiektu:

const cells = [];
for (let i = 1; i <= 31; i++) {
	cells.push(`<div>${i}</div>`);
}
calendar.innerHTML = cells.join('');

Kalendarz: wyświetlenie liczb od 1 do 31.

Siatka CSS Grid

CSS się praktycznie nie zmienia – w drugim wpisie serii przeszliśmy już z hacków float/clear na CSS Grid i to była dobra decyzja, która broni się do dziś. Siedem kolumn, a układ tygodni wynika z samej siatki:

month-calendar {
	display: block;
	max-width: 400px;
}

.cal-grid {
	display: grid;
	grid-template-columns: repeat(7, 1fr);
	gap: 5px;
}

.cal-grid div {
	height: 50px;
	line-height: 50px;
	text-align: center;
	background: lightgrey;
}

.cal-grid .day-name { color: white; background: darkgrey; }
.cal-grid .weekend  { color: white; background: steelblue; }
.cal-grid .none     { background: transparent; }
.cal-grid .today    { color: white; background: crimson; }

Jedyna nowość to wystylowanie samego custom-elementu (month-calendar { display: block }) – nieznane przeglądarce tagi są domyślnie inline.

Nazwy miesięcy i dni – Intl zamiast tablic (i całego API)

To jest miejsce, w którym nowa wersja wygrywa najmocniej. W starej serii nazwy trzymaliśmy w tablicach:

// jQuery (2024)
var monthName = ["Styczeń", "Luty", "Marzec", /* … */];
var dayName   = ["Pon", "Wto", "Śro", "Czw", "Pią", "Sob", "Nie"];

…a w trzecim wpisie zbudowaliśmy wokół tego całe zaplecze: tabelę translations w MySQL, skrypt API w PHP i ładowanie przez AJAX – tylko po to, żeby kalendarz znał słowo „Czerwiec” po polsku i „June” po angielsku. Dziś przeglądarka wie to sama, przez Intl.DateTimeFormat:

const title = new Intl.DateTimeFormat('pl-PL', {
	month: 'long', year: 'numeric'
}).format(new Date(2026, 5, 1));   // "czerwiec 2026"

// Nazwy dni tygodnia – 1 stycznia 2024 wypadał w poniedziałek:
const fmt = new Intl.DateTimeFormat('pl-PL', { weekday: 'short' });
const weekdays = Array.from({ length: 7 },
	(_, i) => fmt.format(new Date(2024, 0, 1 + i)));
// ["pon.", "wt.", "śr.", "czw.", "pt.", "sob.", "niedz."]

Zmiana języka to teraz podmiana jednego ciągu ('pl-PL''en-GB') – bez bazy, bez backendu, bez żądania sieciowego. Intl zna nie tylko angielski, ale i kilkaset innych lokalizacji wraz z ich konwencjami (np. polskie nazwy miesięcy są poprawnie małą literą).

Kalendarz z panelem zmiany języka.

Pełny komponent – nawigacja, weekend i „dziś”

Składamy wszystko w jeden Web Component. Logika obliczania pierwszego dnia miesiąca i liczby dni jest identyczna jak w starej serii (ten sam new Date(...).getDay() i new Date(year, month, 0).getDate()) – zmienia się tylko sposób budowania DOM i obsługi zdarzeń:

class MonthCalendar extends HTMLElement {
	connectedCallback() {
		const now = new Date();
		this.year = now.getFullYear();
		this.month = now.getMonth();           // 0–11
		this.locale = this.getAttribute('locale') || 'pl-PL';
		this.render();

		// Delegacja zdarzeń – jeden listener na cały komponent,
		// odpowiednik $(document).on('click', '.nav', …)
		this.addEventListener('click', (e) => {
			const nav = e.target.closest('[data-nav]');
			if (nav) this.shift(Number(nav.dataset.nav));
		});
	}

	shift(delta) {
		const d = new Date(this.year, this.month + delta, 1);
		this.year = d.getFullYear();
		this.month = d.getMonth();
		this.render();                          // odpowiednik .empty().append(...)
	}

	render() {
		const first = new Date(this.year, this.month, 1);
		const startDay = (first.getDay() + 6) % 7;        // 0 = poniedziałek … 6 = niedziela
		const daysInMonth = new Date(this.year, this.month + 1, 0).getDate();

		const title = new Intl.DateTimeFormat(this.locale, { month: 'long', year: 'numeric' }).format(first);
		const dayFmt = new Intl.DateTimeFormat(this.locale, { weekday: 'short' });
		const weekdays = Array.from({ length: 7 }, (_, i) => dayFmt.format(new Date(2024, 0, 1 + i)));

		const today = new Date();
		const thisMonth = today.getFullYear() === this.year && today.getMonth() === this.month;

		const cells = weekdays.map((name, i) =>
			`<div class="day-name${i >= 5 ? ' weekend' : ''}">${name}</div>`);

		for (let i = 0; i < startDay; i++) cells.push('<div class="none"></div>');

		for (let d = 1; d <= daysInMonth; d++) {
			const col = (startDay + d - 1) % 7;            // 0 = pon … 6 = niedz
			let cls = '';
			if (thisMonth && d === today.getDate()) cls = ' class="today"';
			else if (col >= 5) cls = ' class="weekend"';
			cells.push(`<div${cls}>${d}</div>`);
		}

		this.innerHTML = `
			<div class="cal-head">
				<button type="button" data-nav="-1" aria-label="Poprzedni miesiąc">‹</button>
				<strong>${title}</strong>
				<button type="button" data-nav="1" aria-label="Następny miesiąc">›</button>
			</div>
			<div class="cal-grid">${cells.join('')}</div>`;
	}
}

customElements.define('month-calendar', MonthCalendar);

Kilka rzeczy, które „za darmo” załatwiła zmiana podejścia:

  • Delegacja zdarzeń przez closest('[data-nav]') – jeden addEventListener zamiast $(document).on('click', '.nav', …). Nie tracimy podpięcia po przerenderowaniu, bo listener siedzi na komponencie, a nie na przyciskach.
  • <button> zamiast <div> ze strzałką – nawigacja jest dostępna z klawiatury i dla czytników ekranu (aria-label), czego klikalne div-y nigdy nie dały.
  • „Dziś” tylko w bieżącym miesiącu – ten sam warunek month === (new Date()).getMonth(), którym w starej serii łataliśmy „urodziny co miesiąc”, jest tu od początku.

Kalendarz z nagłówkiem miesiąca, weekendami i zaznaczonym dniem.

Gdy Intl to za mało – fetch zamiast $.ajax

Intl świetnie poda nazwy miesięcy i dni, ale w prawdziwej aplikacji często mamy własne napisy (np. etykiety wydarzeń, święta), które trzymamy po stronie serwera. To jedyny moment, w którym wracamy do API z trzeciego wpisu – tyle że bez jQuery. Stary kod:

// jQuery (2024)
$.ajax({
	url: 'http://localhost/lang.php',
	data: { lang: lang },
	dataType: 'json',
	success: function(data) { translations = data; generateCalendar(month, year); },
	error: function(xhr, status) { alert('Błąd: ' + status); }
});

…to dziś natywny fetch z async/await i czytelną obsługą błędów:

async function loadTranslations(lang) {
	try {
		const res = await fetch(`/lang.php?lang=${lang}`);
		if (!res.ok) throw new Error(`HTTP ${res.status}`);
		return await res.json();
	} catch (err) {
		console.error('Nie udało się pobrać tłumaczeń:', err);
		return {};
	}
}

Sam backend (PHP + MySQL z trzeciego wpisu) zostaje bez zmian – kontrakt to nadal JSON pod /lang.php?lang=…. Zmienił się tylko klient.

Podsumowanie

Ten sam kalendarz – miesięczna siatka, nawigacja, weekendy, podświetlony dzień, wielojęzyczność – powstał bez jednej linijki jQuery:

  • DOM: innerHTML/createElement + classList zamiast $().append().addClass().
  • Zdarzenia: addEventListener + delegacja przez closest() zamiast $(document).on().
  • Lokalizacja: Intl.DateTimeFormat zamiast tablic nazw – co skasowało całą warstwę API/bazy dla nazw miesięcy i dni.
  • Sieć: fetch + async/await zamiast $.ajax, gdy potrzebujemy własnych napisów z serwera.
  • Opakowanie: Web Component (<month-calendar>) zamiast luźnego skryptu na #calendar – enkapsulacja i reużywalność bez frameworka.

Efekt: zero zależności, lepsza dostępność (prawdziwe <button>-y) i kod, który nie potrzebuje warstwy pośredniej. jQuery zrobiło swoje w swojej epoce – ale dziś platforma webowa dogoniła i wyprzedziła to, po co kiedyś po nie sięgaliśmy.

Jeśli chcesz prześledzić, skąd przyszliśmy, zajrzyj do archiwalnej serii w jQuery: budowa, refaktoryzacja i CSS Grid oraz tłumaczenia z API.