Tworzymy kalendarz

W tym artykule spróbujemy stworzyć prosty datepicker, czyli kalendarz przy polu, którzy pozwala pobierać datę.

Artykuł ten traktuj jako formę treningu, poznanie kilku metod, które możesz w przyszłości wykorzystać - np. do stworzenia kalendarza o zupełnie innym wyglądzie.
W praktyce ręczne tworzenie takiego datepickera staje się trochę sztuką dla sztuki, ponieważ na rynku istnieją już naprawdę dobre pluginy do takich rzeczy - chociażby pickadate.js czy Bootstrap Datepicker.

Klasa kalendarza

Kalendarz będzie mógł być podpięty pod dowolną liczbę pól, dlatego zróbmy go na bazie klasy:


class Calendar {
    constructor(input) {
        this.now = new Date();
        this.day = this.now.getDate();
        this.month = this.now.getMonth();
        this.year = this.now.getFullYear();

        this.input = input; //zmienna trzymająca referencję do inputa tekstowego do którego podepniemy kalendarz
        this.divCnt = null; //kontener kalendarza
        this.divHeader = null; //nagłówek kalendarza
        this.divTable = null; //kontener tabeli z kalendarzem
        this.divDateText = null; //kontener z nazwą miesiąca i roku
        this.divButtons = null; //kontener z przyciskami przełączającymi miesiąc
    }
}

Pierwsze zmienne jakie tworzymy wykorzystamy przy renderowaniu dni i przełączaniu dat kalendarza.

Kolejne zmienne będą zawierały w sobie części layotu według poniższej grafiki:

Kalendarz

Tworzenie layoutu kalendarza

Pierwszą metodą którą napiszemy będzie metoda init(), która stworzy składowe naszego kalendarza oraz odpali kilka metod. Na początku skupmy się na składowych layoutu kalendarza:


class Calendar {
    ...

    //metoda inicjująca
    init() {
        //kontener całego kalendarza
        this.divCnt = document.createElement("div");
        this.divCnt.classList.add("calendar");

        //tworzymy div zawierający przyciski prev, next
        this.divButtons = document.createElement("div");
        this.divButtons.className = "calendar-prev-next";

        //tworzymy div z nazwą miesiąca i roku
        this.divDateText = document.createElement("div");
        this.divDateText.className = "date-name";

        //div z przyciskami prev, next i div z nazwą miesiąca
        //wrzucamy do wspólnego div .calendar-header - dla lepszej logiki
        this.divHeader = document.createElement("div");
        this.divHeader.classList.add("calendar-header");

        this.divHeader.appendChild(this.divButtons);
        this.divHeader.appendChild(this.divDateText);
        this.divCnt.appendChild(this.divHeader);

        //tworzymy div do którego trafi tabela kalendarza
        this.divTable = document.createElement("div");
        this.divTable.className = "calendar-table-cnt";
        this.divCnt.appendChild(this.divTable);
    }
}

Od razu dodajmy stylowanie dla tych elementów. Nie jest to kurs CSS, dlatego nie będę się tutaj skupiać na samych stylach (ale jak chcesz daj znać to się powalczy):


/* wrapper dla inputa kalendarza */
.input-calendar-cnt {
    position: relative;
    display: inline-block;
}

/* input kalendarza */
.input-calendar {
    //ikonka wzięta ze strony https://boxicons.com/
    background-image: url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAAnklEQVRIS2NkoDFgpLH5DMRY8B/qCHS1uMRR3Ex3CxwYGBjmMzAwKJAZdA8YGBgSGRgYDsD0o/sApECeTMNh2kBmKOKyABauFNqBiFuyIg6P7RgRP+AWEBtkMIeS7IOhbwGpqYnkIBq1AKM0JaqEHFIZbehF8gcGBgZ+Up2Npv4hcn2CXtiBKpwFFNQJIMMT8FU4FDoeUzsxdTJFlgIAdwEsGTsi/XsAAAAASUVORK5CYII=");
    background-position: calc(100% - 10px) center;
    background-repeat: no-repeat;
    border:1px solid #ddd;
    cursor: pointer;
}

/* kalendarz */
.calendar {
    position: absolute;
    top:100%;
    left:0;
    width: 300px;
    background: #fff;
    min-height: 300px;
    padding: 5px;
    border: 1px solid #ddd;
    box-shadow:2px 2px 1px rgba(0,0,0,0.1);
    font-family: sans-serif;
    display: block;
    z-index: 100;
    opacity: 0;
    transition: 0.4s opacity;
    pointer-events: none;
}
.calendar.calendar-show {
    opacity: 1;
    pointer-events: all;
}

/* przyciski prev-next */
.calendar-prev-next {
    height:30px;
    display: flex;
    justify-content: space-between;
}
.calendar .input-prev,
.calendar .input-next {
    position: relative;
    z-index: 1;
    cursor:pointer;
    width: 30px;
    font-family: sans-serif;
    background-color: transparent;
    border:0;
    color: #333;
}
.calendar .input-prev:hover,
.calendar .input-next:hover {
    background:#D1EBFD;
}

/* nazwa miesiąca i roku */
.calendar .date-name {
    font-size:12px;
    color: #666;
    padding: 0 40px;
    text-align: center;
    line-height: 30px;
    position: absolute;
    left:0;
    top:0;
    width:100%;
    text-align: center;
    box-sizing: border-box;
}

/* nagłówek kalendarza */
.calendar .calendar-header {
    height:30px;
    position: relative;
}

/* tabela kalendarza */
.calendar .calendar-table-cnt {
    position: relative;
    padding-top:10px;
}
.calendar .calendar-table {
    font-size:12px;
    color: #666;
    width: 100%;
}
.calendar .calendar-table td {
    text-align: center;
    width:14.2857%;
}
.calendar .calendar-table th {
    font-family: sans-serif;
    font-weight: normal;
    padding-bottom: 10px;
    font-size:11px;
    color:#aaa;
}
.calendar .calendar-table td.day {
    background: #fafafa;
    height:40px;
    font-size:11px;
    font-family: sans-serif;
    font-weight:bold;
}
.calendar .calendar-table td.day:hover {
    background: #D1EBFD;
    cursor: pointer
}
.calendar .calendar-table td.current-day {
    background-color: #F15C5C;
    color:#fff;
}
.calendar .calendar-table td.current-day:hover {
    background-color: #DE5858;
}

Po kliknięciu na input, kalendarz będzie się pojawiał pod takim polem (zresztą widać to na powyższym demie).

Moglibyśmy wrzucać nasz kalendarz bezpośrednio do body i pozycjonować go absolutnie pobierając pozycję inputa (offsetTop i offsetLeft), ale jest to problematyczne rozwiązanie.

Struktura formularza czy samej strony może się dynamicznie zmieniać (np. mogą powyżej naszego pola dochodzić dynamicznie generowane elementy) co zmuszało by nas do każdorazowego wyliczania pozycji kalendarza na nowo.

O wiele łatwiejszym podejściem jest pozycjonować kalendarz względem elementu, którym okryjemy nasz input (pamiętaj - pozycjonowanie absolutne jest względem pierwszego rodzica, który ma pozycjonowanie inne niż static):

pozycjonowanie kalendarza

class Calendar {
    ...

    //metoda inicjująca obiekt
    init() {
        //zmieniany inputowi dodaję klasę - doda ona ikonkę kalendarza i zmieni jego kursor
        this.input.classList.add("input-calendar");

        //kontener całego kalendarza
        this.divCnt = document.createElement("div");
        this.divCnt.classList.add("calendar");

        //tworzymy div zawierający przyciski prev, next
        this.divButtons = document.createElement("div");
        this.divButtons.className = "calendar-prev-next";

        //tworzymy div z nazwą miesiąca i roku
        this.divDateText = document.createElement("div");
        this.divDateText.className = "date-name";

        //div z przyciskami prev, next i div z nazwą miesiąca
        //wrzucamy do wspólnego div .calendar-header - dla lepszej logiki
        this.divHeader = document.createElement("div");
        this.divHeader.classList.add("calendar-header");

        this.divHeader.appendChild(this.divButtons);
        this.divHeader.appendChild(this.divDateText);
        this.divCnt.appendChild(this.divHeader);

        //tworzymy div do którego trafi tabela kalendarza
        this.divTable = document.createElement("div");
        this.divTable.className = "calendar-table-cnt";
        this.divCnt.appendChild(this.divTable);

        //tworzymy wrapper dla input
        this.calendarWrapper = document.createElement("div");
        this.calendarWrapper.classList.add("input-calendar-cnt");
        this.input.parentElement.insertBefore(this.calendarWrapper, this.input);
        this.calendarWrapper.appendChild(this.input);
    }
}

Wykorzystaliśmy tutaj pewną sztuczkę. Wstawiliśmy przed input nasz calendarWrapper. Następnie do tego wrappera wstawiliśmy sam input. Pojedynczy element na stronie nie może istnieć równocześnie w 2 miejscach (chyba, że jest to kopia) dlatego nasz input został przeniesiony do wrappera. Pozostało dorzucić do tego wrappera kontener z kalendarzem.

Kolejnym krokiem będzie podpięcie pokazywania kalendarza.
Po kliknięciu na input kalendarz powinien się pokazać. Zrobimy to przez dodatkową klasę, która będzie zmieniać display kalendarza:


.input-calendar-cnt {
    position: relative;
}

.calendar {
    position: absolute;
    top:100%;
    left:0;
    width: 300px;
    background: #fff;
    min-height: 300px;
    padding: 5px;
    border: 1px solid #ddd;
    box-shadow:2px 2px 1px rgba(0,0,0,0.1);
    font-family: sans-serif;
    z-index: 100;
    opacity: 0;
    transition: 0.4s opacity;
    pointer-events: none;
}

.calendar.calendar-show {
    opacity: 1;
    pointer-events: all;
}
...

class Calendar {
    ...

    //metoda inicjująca obiekt
    init() {
        ...

        //tworzymy wrapper dla input
        this.calendarWrapper = document.createElement("div");
        this.calendarWrapper.classList.add("input-calendar-cnt");
        this.input.parentElement.insertBefore(this.calendarWrapper, this.input);
        this.calendarWrapper.appendChild(this.input);

        this.input.addEventListener("click", e => this.divCnt.classList.toggle("calendar-show"));
    }
}

Zwykłe dołączanie lub przełączanie klasy do kontenera z kalendarzem. O wiele lepiej taki kod przenieść do oddzielnych metod, które - a nóż może kiedyś się przydadzą:


class Calendar() {
    ...

    toggleShow() {
        this.divCnt.classList.toggle("calendar-show");
    }

    show() {
        this.divCnt.classList.add("calendar-show");
    }

    hide() {
        this.divCnt.classList.remove("calendar-show");
    }

    //metoda inicjująca obiekt
    init() {
        ...

        //tworzymy wrapper dla input
        this.calendarWrapper = document.createElement("div");
        this.calendarWrapper.classList.add("input-calendar-cnt");
        this.input.parentElement.insertBefore(this.calendarWrapper, this.input);
        this.calendarWrapper.appendChild(this.input);

        this.input.addEventListener("click", e => this.toggleShow());
    }
}

Po kliknięciu na input nasz kalendarz się pokazuje lub ukrywa. Zwiększając użyteczność powinniśmy umożliwić użytkownikowi ukrywanie naszego kalendarza poprzez kliknięcie wszędzie poza kalendarzem. Wystarczy dodać do dokumentu nasłuchiwanie click, które ukryje kalendarz:


class Calendar {
    ...

    //metoda inicjująca obiekt
    init() {
        ...

        this.input.addEventListener("click", e => this.toggleShow());
        document.addEventListener("click", e => this.hide());
    }
}

Pamiętaj, że eventy idą w górę dokumentu. Jeżeli klikniemy na kalendarz albo input, wtedy ten klik dojdzie aż do dokumentu, co spowoduje, że kalendarz się pojawi i od razu zostanie ukryty. Aby przerwać podróż takiego klika do góry dokumentu musimy zastosować stopImmediatePropagation dla inputa i kontenera z kalendarzem. Dzięki temu klikając na te elementy zdarzenie kliknięcia zatrzyma się na klikniętym elemencie:


class Calendar {
    ...

    //metoda inicjująca obiekt
    init() {
        ...

        this.input.classList.add("input-calendar");
        this.input.addEventListener("click", e => this.toggleShow());
        this.input.addEventListener("click", e => e.stopImmediatePropagation());
        this.divCnt.addEventListener("click", e => e.stopImmediatePropagation());
        document.addEventListener("click", e => this.hide());
    }
}

Nasz cały kod na chwilę obecną wygląda tak:


class Calendar {
    constructor(input) {
        this.now = new Date();
        this.day = this.now.getDate();
        this.month = this.now.getMonth();
        this.year = this.now.getFullYear();
        this.input = input;
        this.divCnt = null;
        this.divTable = null;
        this.divDateText = null;
        this.divButtons = null;
    }

    toggleShow() {
        this.divCnt.classList.toggle("calendar-show");
    }

    show() {
        this.divCnt.classList.add("calendar-show");
    }

    hide() {
        this.divCnt.classList.remove("calendar-show");
    }

    //metoda inicjująca obiekt
    init() {
        //zmieniany inputowi dodaję klasę - doda ona ikonkę kalendarza i zmieni jego kursor
        this.input.classList.add("input-calendar");

        //tworzymy div z całą zawartością
        this.divCnt = document.createElement("div");
        this.divCnt.classList.add("calendar");

        //tworzymy wrapper dla input
        this.calendarWrapper = document.createElement("div");
        this.calendarWrapper.classList.add("input-calendar-cnt");
        this.input.parentElement.insertBefore(this.calendarWrapper, this.input);
        this.calendarWrapper.appendChild(this.input);

        //tworzymy div z guzikami
        this.divButtons = document.createElement("div");
        this.divButtons.className = "calendar-prev-next";

        //tworzymy div z nazwą miesiąca
        this.divDateText = document.createElement("div");
        this.divDateText.className = "date-name";

        this.divHeader = document.createElement("div");
        this.divHeader.classList.add("calendar-header");

        this.divHeader.appendChild(this.divButtons);
        this.divHeader.appendChild(this.divDateText);
        this.divCnt.appendChild(this.divHeader);

        //tworzymy div z tabelą kalendarza
        this.divTable = document.createElement("div");
        this.divTable.className = "calendar-table-cnt";
        this.divCnt.appendChild(this.divTable);

        this.input.addEventListener("click", e => this.toggleShow());
        this.input.addEventListener("click", e => e.stopImmediatePropagation());
        this.divCnt.addEventListener("click", e => e.stopImmediatePropagation());
        document.addEventListener("click", e => this.hide());

        //nasz div z zawartością wrzucamy na koniec body
        this.calendarWrapper.appendChild(this.divCnt);
    }
}

const input = document.querySelector(".input-demo2");
const cal = new Calendar(input);
cal.init();

Tworzenie nazwy miesiąca i roku

Jedna z najprostszych metod naszego kalendarza - czyli wyświetlenie nazwy miesiąca i roku w elemencie divDateText. Po jej napisaniu musimy ją odpalić w metodzie init:


class Calendar {
    ...

    createDateText() {
        const monthNames = ["styczeń", "luty", "marzec", "kwiecień", "maj", "czerwiec", "lipiec", "sierpień", "wrzesień", "październik", "listopad", "grudzień"];

        this.divDateText.innerHTML = monthNames[this.month] + " " + this.year;
    }

    //metoda inicjująca obiekt
    init() {
        ...

        //tworzymy div z nazwą miesiąca
        this.divDateText = document.createElement("div");
        this.divDateText.className = "date-name";
        this.createDateText();
    }

    ...
}

Metoda ta na podstawie wcześniej stworzonych zmiennych (te z początku konstruktora) dodaje do elementu divDateText nazwę miesiąca i roku.

Tabela kalendarza

Rozpoczynamy tworzyć tabelę z dniami miesiąca. Wrzucimy ją do elementu divTable.
Tabela taka będzie dynamicznie generowana przy przełączaniu miesięcy, dlatego musimy za każdym razem czyścić zawartość takiego diva.


class Calendar {
    ...

    //metoda tworząca tabele z kalendarzem
    createCalendarTable() {
        //czyścimy div z tabelą
        this.divTable.innerHTML = "";

        //tworzymy tabelę z dniami kalendarza
        const tab = document.createElement("table");
        tab.classList.add("calendar-table");

        //tabelę dołączamy do div divTable
        this.divTable.appendChild(tab);

        ...
    }

    ...
};

Rozpoczynamy od wygenerowania rzędu z nazwami dni tygodnia:


class Calendar {
    ...

    //metoda tworząca tabele z kalendarzem
    createCalendarTable() {
        //czyścimy div z tabelą
        this.divTable.innerHTML = "";

        //tworzymy tabelę z dniami kalendarza
        const tab = document.createElement("table");
        tab.classList.add("calendar-table");

        //tabelę dołączamy do div divTable
        this.divTable.appendChild(tab);

        //tworzymy nagłówki dni
        let tr = document.createElement("tr");
        tr.classList.add("calendar-table-days-names");
        const days = ["Pon", "Wto", "Śro", "Czw", "Pią", "Sob", "Nie"];
        days.forEach(day => {
            const th = document.createElement("th");
            th.innerHTML = day;
            tr.appendChild(th);
        });
        tab.appendChild(tr);
    }

    ...
};

Pozostaje wygenerować rzędy z dniami. Zanim cokolwiek stworzymy, musimy pobrać liczbę dni dla danego miesiąca:

Jak wiesz miesiąc może mieć 31, 30 lub 28/29 dni w zależności od tego czy jest to rok przestępny czy nie.

Na szczęście JavaScript zajmie się tymi wyliczeniami za nas. Metoda getDate() zwraca aktualny dzień w miesiącu. Jeżeli użyjemy jej wraz z generowaniem określonej daty, zwróci nam dzień wygenerowanej daty. Dlatego możemy ją wykorzystać w sztuczce zwracającej liczbę dni w danym miesiącu.


class Calendar {
    ...

    //metoda tworząca tabele z kalendarzem
    createCalendarTable() {
        //czyścimy div z tabelą
        this.divTable.innerHTML = "";

        //tworzymy tabelę z dniami kalendarza
        const tab = document.createElement("table");
        tab.classList.add("calendar-table");

        //tabelę dołączamy do div divTable
        this.divTable.appendChild(tab);

        //tworzymy nagłówki dni
        let tr = document.createElement("tr");
        tr.classList.add("calendar-table-days-names");
        const days = ["Pon", "Wto", "Śro", "Czw", "Pią", "Sob", "Nie"];
        days.forEach(day => {
            const th = document.createElement("th");
            th.innerHTML = day;
            tr.appendChild(th);
        });
        tab.appendChild(tr);

        //pobieramy wszystkie dni w miesiącu
        const daysInMonth = new Date(this.year, this.month+1, 0).getDate();
    }

    ...
};

Następnie pobieramy pierwszy dzień miesiąca. Miesiąc może zaczynać się od 1 dnia, albo od kolejnych. W Polskim kalendarzu tydzień zaczyna się od poniedziałku. W JavaScript-owym kalendarzu (angielskim) tydzień zaczyna się od niedzieli, a kończy na poniedziałku. Stąd kolejnym krokiem jest zrobienie korekty w razie czego przestawiając pierwszy dzień na 7.


class Calendar {
    ...

    //metoda tworząca tabele z kalendarzem
    createCalendarTable() {
        //czyścimy div z tabelą
        this.divTable.innerHTML = "";

        //tworzymy tabelę z dniami kalendarza
        const tab = document.createElement("table");
        tab.classList.add("calendar-table");

        //tworzymy nagłówki dni
        let tr = document.createElement("tr");
        tr.classList.add("calendar-table-days-names");
        const days = ["Pon", "Wto", "Śro", "Czw", "Pią", "Sob", "Nie"];
        days.forEach(day => {
            const th = document.createElement("th");
            th.innerHTML = day;
            tr.appendChild(th);
        });
        tab.appendChild(tr);

        //tabelę dołączamy do div divTable
        this.divTable.appendChild(tab);

        //pobieramy liczbę dni w miesiącu
        const daysInMonth = new Date(this.year, this.month+1, 0).getDate();

        //pobieramy pierwszy dzień miesiąca
        const tempDate = new Date(this.year, this.month, 1);
        let firstMonthDay = tempDate.getDay();

        //w JavaScript  pierwszym dniem jest niedziela, a poniedziałek ostatnim
        //w razie czego robimy korektę
        if (firstMonthDay === 0) {
            firstMonthDay = 7;
        }
    }

    ...
};

Ostatni krok to ustawienie zmiennej j, która będzie przechowywać całkowitą liczbę komórek na dany miesiąc.


class Calendar {
    ...

    //metoda tworząca tabele z kalendarzem
    createCalendarTable() {
        ...

        //w JavaScript  pierwszym dniem jest niedziela, a poniedziałek ostatnim
        //w razie czego robimy korektę
        if (firstMonthDay === 0) {
            firstMonthDay = 7;
        }

        //liczba wszystkich komórek - i pustych i z dniami
        const j = daysInMonth + firstMonthDay - 1;
    }

    ...
}

Miesiąc może się zacząć od n-tego dnia, dlatego by wygenerować tabelę dla niego powinniśmy wygenerować komórki pustych dni (czyli komórki dni przed rozpoczęciem miesiąca) a następnie w kolejnej pętli wygenerować komórki dni danego miesiąca:

Puste dni miesiąca

class Calendar {
    ...

    //metoda tworząca tabele z kalendarzem
    createCalendarTable() {
        this.divTable.innerHTML = "";

        //tworzymy nazwy dni
        const tab = document.createElement("table");
        tab.classList.add("calendar-table");

        //tworzymy nagłówki dni
        let tr = document.createElement("tr");
        tr.classList.add("calendar-table-days-names");
        const days = ["Pon", "Wto", "Śro", "Czw", "Pią", "Sob", "Nie"];
        days.forEach(day => {
            const th = document.createElement("th");
            th.innerHTML = day;
            tr.appendChild(th);
        });
        tab.appendChild(tr);

        //pobieramy liczbę dni w miesiącu
        const daysInMonth = new Date(this.year, this.month+1, 0).getDate();

        //pobieramy pierwszy dzień miesiąca
        const tempDate = new Date(this.year, this.month, 1);
        let firstMonthDay = tempDate.getDay();

        //normalnie pierwszym dniem jest niedziela, a poniedziałek ostatnim
        //w razie czego robimy korektę
        if (firstMonthDay === 0) {
            firstMonthDay = 7;
        }

        //liczba wszystkich komórek - i pustych i z dniami
        const j = daysInMonth + firstMonthDay - 1;

        //jeżeli pierwszy dzień miesiąca nie zaczyna się od początku, tworzymy dodatkowe TR
        if (firstMonthDay-1 !== 0) {
            tr = document.createElement("tr");
            tab.appendChild(tr);
        }

        //na początku generujemy puste komórki (czyli te przed rozpoczęciem miesiąca)
        for (let i=0; i<firstMonthDay-1; i++) {
            const td = document.createElement("td");
            td.innerHTML = "";
            tr.appendChild(td);
        }

        //generujemy komórki z dniami miesiąca
        for (let i = firstMonthDay-1; i<j; i++) {
            if(i % 7 === 0){
                tr = document.createElement("tr");
                tab.appendChild(tr);
            }

            const td = document.createElement("td");
            td.innerText = i - firstMonthDay + 2;
            td.dayNr = i - firstMonthDay + 2;
            td.classList.add("day");

            tr.appendChild(td);
        }
    }

    ...
}

Aby wskazać aktualny dzień, wystarczy w ostatniej pętli dokonać sprawdzenia:


class Calendar {
    ...

    //metoda tworząca tabele z kalendarzem
    createCalendarTable() {
        ...

        //generujemy komórki z dniami miesiąca
        for (let i = firstMonthDay-1; i<j; i++) {
            if (i % 7 === 0){
                tr = document.createElement("tr");
                tab.appendChild(tr);
            }

            const td = document.createElement("td");
            td.innerText = i - firstMonthDay + 2;
            td.dayNr = i - firstMonthDay + 2;
            td.classList.add("day");


            if (this.year === this.now.getFullYear() && this.month === this.now.getMonth() && this.day === i - firstMonthDay + 2) {
                td.classList.add("current-day")
            }

            tr.appendChild(td);
        }
    }

    ...
}

To koniec naszej metody tworzącej tabelę. Pozostaje ją wywołać w metodzie init:


class Calendar {
    //metoda tworząca tabele z kalendarzem
    createCalendarTable() {
        this.divTable.innerHTML = "";

        //tworzymy nazwy dni
        const tab = document.createElement("table");
        tab.classList.add("calendar-table");

        //tworzymy nagłówki dni
        let tr = document.createElement("tr");
        tr.classList.add("calendar-table-days-names");
        const days = ["Pon", "Wto", "Śro", "Czw", "Pią", "Sob", "Nie"];
        days.forEach(day => {
            const th = document.createElement("th");
            th.innerHTML = day;
            tr.appendChild(th);
        });
        tab.appendChild(tr);

        //tworzymy rzędy dni
        const daysInMonth = new Date(this.year, this.month+1, 0).getDate();

        const tempDate = new Date(this.year, this.month, 1);
        let firstMonthDay = tempDate.getDay();

        if (firstMonthDay === 0) {
            firstMonthDay = 7;
        }

        const j = daysInMonth + firstMonthDay - 1;

        if (firstMonthDay - 1 !== 0) {
            tr = document.createElement("tr");
            tab.appendChild(tr);
        }

        //tworzymy puste komórki przed dniami miesiąca
        for (let i=0; i < firstMonthDay - 1; i++) {
            const td = document.createElement("td");
            td.innerHTML = "";
            tr.appendChild(td);
        }

        //tworzymy komórki dni
        for (let i = firstMonthDay-1; i<j; i++) {
            if(i % 7 === 0){
                tr = document.createElement("tr");
                tab.appendChild(tr);
            }

            const td = document.createElement("td");
            td.innerText = i - firstMonthDay + 2;
            td.dayNr = i - firstMonthDay + 2;
            td.classList.add("day");

            if (this.year === this.now.getFullYear() && this.month === this.now.getMonth() && this.day === i - firstMonthDay + 2) {
                td.classList.add("current-day")
            }

            tr.appendChild(td);
        }

        tab.appendChild(tr);

        this.divTable.appendChild(tab);
    }

    //metoda inicjująca obiekt
    init() {
        ...

        //tworzymy div z tabelą kalendarza
        this.divTable = document.createElement("div");
        this.divTable.className = "calendar-table-cnt";
        this.divCnt.appendChild(this.divTable);
        this.createCalendarTable();

        ...
    }
}

Podłączenie przycisków

Kolejnym krokiem jaki robimy to wygenerowanie i podłączenie przycisków służących do zmieniania miesięcy. Służyć temu będzie metoda createButtons()


class Calendar {
    ...

    //metoda tworząca guziczki
    createButtons() {
        //tworzę przycisk "Poprzedni miesiąc"
        const buttonPrev = document.createElement("button");
        buttonPrev.innerText = "<";
        buttonPrev.type = "button";
        buttonPrev.classList.add("input-prev");
        buttonPrev.addEventListener("click", e => {
            this.month--;
            if (this.month < 0) {
                this.month = 11;
                this.year--;
            }
            this.createCalendarTable();
            this.createDateText();
        });
        this.divButtons.appendChild(buttonPrev);

        //tworzę przycisk "Następny miesiąc"
        const buttonNext = document.createElement("button");
        buttonNext.classList.add("input-next");
        buttonNext.innerText = ">";
        buttonNext.type = "button";
        buttonNext.addEventListener("click", e => {
            this.month++;
            if (this.month > 11) {
                this.month = 0;
                this.year++;
            }
            this.createCalendarTable();
            this.createDateText();
        });
        this.divButtons.appendChild(buttonNext);
    };

    ...

};

Po kliknięciu każdego przycisku zmieniamy miesiąc i w razie czego rok. Dodatkowo odpalamy wcześniej napisane metody createCalendarTable() i createDateText() dzięki czemu widok kalendarza się aktualizuje.

Jak w poprzednich przypadkach powyższą metodę musimy odpalić w metodzie init():


class Calendar {
    ...

    //metoda tworząca guziczki
    createButtons() {
        ...
    }

    init() {
        ...
        //tworzymy div z guzikami
        this.divButtons = document.createElement("div");
        this.divButtons.className = "calendar-prev-next";
        this.createButtons();
        ...
    }
}

Podpięcie eventów dla dni

Zbliżamy się szybko ku końcowi. Kolejnym krokiem będzie podpięcie eventów dla dni kalendarza. Moglibyśmy to robić w metodzie createCalendarTable() tam gdzie generujemy kolejne komórki kalendarza.
Jest to jednak mniej optymalne rozwiązanie. Raz, że zamiast jednego wspólnego eventu mielibyśmy wiele - dla każdej komórki jeden. W naszym przypadku może to nie zaboli, bo przecież komórek jest maksymalnie 31, ale... Drugi problem wiązał by się z usuwaniem komórek. Przy zmianie miesiąca poprzez przyciski [poprzedni-następny] za każdym razem czyścimy div z tabelą i generujemy tabele na nowo, czyli każdorazowo też generujemy nowe eventy dla nowych komórek. A co ze starymi eventami dla komórek które właśnie usunęliśmy? Nasze wcześniej podpięte eventy mógłby by zalegać w pamięci, co by powodowało wyciek pamięci. W rozdziale o eventach poznaliśmy lepszy sposób, który polega na podpięciu eventu dla rodzica. Wykorzystajmy go tutaj. W naszym przypadku rodzicem może być dla przykładu divTable:


class Calendar {
    createCalendarTable() {
        ...
    }

    bindTableDaysEvent() {
        this.divTable.addEventListener("click", e => {
            if (e.target.tagName.toLowerCase() === "td" && e.target.classList.contains("day")) {
                alert(e.target.dayNr + "-" + (this.month + 1) + "-" + this.year);
            }
        });
    }

    ...
}

Po kliknięciu na dzień wypisujemy w alert dzień, miesiąc i rok. Na razie. Później wrócimy i rozbudujemy to działanie.

Tak jak z poprzednimi metodami i tą musimy odpalić w metodzie init():


class Calendar {
    ...

    bindTableDaysEvent() {
        ...
    }

    init() {
        ...

        //tworzymy div z tabelą kalendarza
        this.divTable = document.createElement("div");
        this.divTable.className = "calendar-table-cnt";
        this.divCnt.appendChild(this.divTable);
        this.createCalendarTable();
        this.bindTableDaysEvent();

        ...
    }
}

Opcje

W naszym kalendarzu dwie rzeczy wymagają poprawy. Po pierwsze po wybraniu daty kalendarz się nie zamyka. Czy powinien się zamykać? Jak to mawiają Amerykańscy naukowcy - i tak i nie. Najlepiej gdyby używający tego kodu mógł zdecydować. To pierwsza opcja którą chcielibyśmy móc ustawiać.

Drugą opcją jest rzecz jasna zachowanie kalendarza po wybraniu daty. Domyślnie powinno to być uzupełnienie treści inputa wybraną datą. Ale to zachowanie też powinno być możliwe do zmiany, bo może programista chciałby nie tylko wpisać do pola datę, ale może zapisać ją w innym formacie, a może wrzucić ją dodatkowo do jakiegoś elementu w html?

Podejście z przekazywaniem obiektu opcji omówiłem dokładnie tutaj. Do naszego konstruktora przekazujemy obiekt z ustawieniami, który następnie scalamy z drugim obiektem, który zawiera domyślne ustawienia. W wyniku tej operacji dostajemy złączony obiekt opcji, który ma domyślne opcje oraz te które zostały nadpisane przez programistę.


class Calendar {
    constructor(input, options) {
        this.now = new Date();
        this.day = this.now.getDate();
        this.month = this.now.getMonth();
        this.year = this.now.getFullYear();
        this.input = input;
        this.divCnt = null;
        this.divTable = null;
        this.divDateText = null;
        this.divButtons = null;

        const defaultOptions = {
            //...defaultowe opcje...
        }
        this.options = Object.assign({}, defaultOptions, options);
    }

    ...
}

W wyniku działania powyższego kodu dostaniemy właściwość options, która będzie połączeniem defaultOptions oraz tego co przekaże nam programista tworząc nowy obiekt.

Jak już powiedzieliśmy nas interesują 2 opcje. Pierwsza z nich to zamykanie kalendarza jeżeli użytkownik wybierze datę (kliknie na dzień w kalendarzu). Właściwość tą wykorzystamy w metodzie podpinającej kliknięcie pod komórki tabeli czyli bindTableDaysEvent():


class Calendar {
    constructor(input, options) {
        this.now = new Date();
        this.day = this.now.getDate();
        this.month = this.now.getMonth();
        this.year = this.now.getFullYear();
        this.input = input;
        this.divCnt = null;
        this.divTable = null;
        this.divDateText = null;
        this.divButtons = null;

        const defaultOptions = {
            closeOnSelect : false,
        }
        this.options = Object.assign({}, defaultOptions, options);
    }
    ...

    bindTableDaysEvent() {
        this.divTable.addEventListener("click", e => {
            if (e.target.tagName.toLowerCase() === "td" && e.target.classList.contains("day")) {
                alert(e.target.dayNr + "-" + (this.month + 1) + "-" + this.year);
                if (this.options.closeOnSelect) {
                    this.hide();
                }
            }
        });
    }

    ...
}

Druga opcja to zachowanie po wyborze daty - czyli jakaś funkcja, która będzie odpalana gdy użytkownik kliknie na dzień w kalendarzu. Domyślnie wybrana data powinna być wpisywana w input. Musimy więc zdefiniować domyślną funkcję dla tej akcji. Ale i ta opcja powinna być możliwa do zmiany. Tą opcję też podpinamy w zdarzeniu click dla komórek tabeli:


class Calendar {
    constructor(input, options) {
        this.now = new Date();
        this.day = this.now.getDate();
        this.month = this.now.getMonth();
        this.year = this.now.getFullYear();
        this.input = input;
        this.divCnt = null;
        this.divTable = null;
        this.divDateText = null;
        this.divButtons = null;

        const defaultOptions = {
            closeOnSelect : false,
            onDateSelect : (day, month, year) => {
                const monthText = ((month + 1) < 10) ? "0" + (month + 1) : month + 1;
                const dayText =  (day < 10) ? "0" + day : day;
                this.input.value = `${dayText}-${monthText}-${this.year}`;
            }
        }
        this.options = Object.assign({}, defaultOptions, options);
    }

    ...

    bindTableDaysEvent() {
        this.divTable.addEventListener("click", e => {
            if (e.target.tagName.toLowerCase() === "td" && e.target.classList.contains("day")) {
                alert(e.target.dayNr + "-" + (this.month + 1) + "-" + this.year);
                if (this.options.closeOnSelect) {
                    this.hide();
                }
                this.options.onDateSelect(e.target.dayNr, this.month + 1, this.year);
            }
        });
    }

    ...
}

Od tej pory przy tworzeniu naszych kalendarzy programista może każdorazowo zmieniać ich opcje:


//dla 1 inputa wyłączam tylko zamykanie przy wyborze daty
const input1 = document.querySelector("#demo1");
const calendar1 = new Calendar(input1, {
    closeOnSelect : false
});
calendar1.init();


//dla 2 inputa nadpisuję tylko akcję po wyborze daty
const input2 = document.querySelector("#demo2");
const calendar2 = new Calendar(input2, {
    onDateSelect(day, month, year) {
        const dayText = (day < 10) ? "0" + day : day;
        const monthText = (month < 10) ? "0" + month : month;
        const date = `${dayText} / ${monthText} / ${year}`;
        input2.value = date;
        document.querySelector("#demo2text").innerHTML = `Wybrałeś właśnie datę: <strong>${date}</strong>`;
    }
});
calendar2.init();

Cały kod

Gdybyś zgubił się w powyższym tekście (nie dziwne), poniżej zamieszczam cały kod:


class Calendar {
    constructor(input, options) {
        this.now = new Date();
        this.day = this.now.getDate();
        this.month = this.now.getMonth();
        this.year = this.now.getFullYear();
        this.input = input;
        this.divCnt = null;
        this.divTable = null;
        this.divDateText = null;
        this.divButtons = null;

        const defaultOptions = {
            closeOnSelect : false,
            onDateSelect : (day, month, year) => {
                const monthText = ((month + 1) < 10) ? "0" + (month + 1) : month + 1;
                const dayText =  (day < 10) ? "0" + day : day;
                this.input.value = `${dayText}-${monthText}-${this.year}`;
            }
        }
        this.options = Object.assign({}, defaultOptions, options);
    }

    //metoda tworząca przyciski prev-next
    createButtons() {
        const buttonPrev = document.createElement("button");
        buttonPrev.innerText = "<";
        buttonPrev.type = "button";
        buttonPrev.classList.add("input-prev");
        buttonPrev.addEventListener("click", e => {
            this.month--;
            if (this.month < 0) {
                this.month = 11;
                this.year--;
            }
            this.createCalendarTable();
            this.createDateText();
        });
        this.divButtons.appendChild(buttonPrev);

        const buttonNext = document.createElement("button");
        buttonNext.classList.add("input-next");
        buttonNext.innerText = ">";
        buttonNext.type = "button";
        buttonNext.addEventListener("click", e => {
            this.month++;
            if (this.month > 11) {
                this.month = 0;
                this.year++;
            }
            this.createCalendarTable();
            this.createDateText();
        });
        this.divButtons.appendChild(buttonNext);
    }

    //metoda wypisująca nazwę miesiąca i roku
    createDateText() {
        const monthNames = ["styczeń", "luty", "marzec", "kwiecień", "maj", "czerwiec", "lipiec", "sierpień", "wrzesień", "październik", "listopad", "grudzień"];
        this.divDateText.innerHTML = monthNames[this.month] + " " + this.year;
    }

    //metoda tworząca tabele z kalendarzem
    createCalendarTable() {
        this.divTable.innerHTML = "";

        //tworzymy nazwy dni
        const tab = document.createElement("table");
        tab.classList.add("calendar-table");

        //tworzymy nagłówki dni
        let tr = document.createElement("tr");
        tr.classList.add("calendar-table-days-names");
        const days = ["Pon", "Wto", "Śro", "Czw", "Pią", "Sob", "Nie"];
        days.forEach(day => {
            const th = document.createElement("th");
            th.innerHTML = day;
            tr.appendChild(th);
        });
        tab.appendChild(tr);

        //pobieramy wszystkie dni danego miesiąca
        const daysInMonth = new Date(this.year, this.month+1, 0).getDate();

        //pobieramy pierwszy dzień miesiąca
        const tempDate = new Date(this.year, this.month, 1);
        let firstMonthDay = tempDate.getDay();
        if (firstMonthDay === 0) {
            firstMonthDay = 7;
        }

        //wszystkie komórki w tabeli
        const j = daysInMonth + firstMonthDay - 1;

        if (firstMonthDay - 1 !== 0) {
            tr = document.createElement("tr");
            tab.appendChild(tr);
        }

        //tworzymy puste komórki przed dniami miesiąca
        for (let i=0; i < firstMonthDay - 1; i++) {
            const td = document.createElement("td");
            td.innerHTML = "";
            tr.appendChild(td);
        }

        //tworzymy komórki dni
        for (let i = firstMonthDay-1; i<j; i++) {
            if(i % 7 === 0){
                tr = document.createElement("tr");
                tab.appendChild(tr);
            }

            const td = document.createElement("td");
            td.innerText = i - firstMonthDay + 2;
            td.dayNr = i - firstMonthDay + 2;
            td.classList.add("day");

            if (this.year === this.now.getFullYear() && this.month === this.now.getMonth() && this.day === i - firstMonthDay + 2) {
                td.classList.add("current-day")
            }

            tr.appendChild(td);
        }

        tab.appendChild(tr);

        this.divTable.appendChild(tab);
    }

    //podpinamy klik pod dni w tabeli kalendarza
    bindTableDaysEvent() {
        this.divTable.addEventListener("click", e => {
            if (e.target.tagName.toLowerCase() === "td" && e.target.classList.contains("day")) {
                if (this.options.closeOnSelect) {
                    this.hide();
                }
                this.options.onDateSelect(e.target.dayNr, this.month + 1, this.year);
            }
        });
    }

    //metoda ukrywa/pokazuje kalendarz
    toggleShow() {
        this.divCnt.classList.toggle("calendar-show");
    }

    //metoda pokazuje kalendarz
    show() {
        this.divCnt.classList.add("calendar-show");
    }

    //metoda ukrywa kalendarz
    hide() {
        this.divCnt.classList.remove("calendar-show");
    }

    //metoda inicjująca obiekt
    init() {
        //tworzymy div z całą zawartością
        this.divCnt = document.createElement("div");
        this.divCnt.classList.add("calendar");

        //tworzymy div z guzikami
        this.divButtons = document.createElement("div");
        this.divButtons.className = "calendar-prev-next";
        this.createButtons();

        //tworzymy div z nazwą miesiąca
        this.divDateText = document.createElement("div");
        this.divDateText.className = "date-name";
        this.createDateText();

        //tworzymy nagłówek kalendarza
        this.divHeader = document.createElement("div");
        this.divHeader.classList.add("calendar-header");

        this.divHeader.appendChild(this.divButtons);
        this.divHeader.appendChild(this.divDateText);
        this.divCnt.appendChild(this.divHeader);

        //tworzymy div z tabelą kalendarza
        this.divTable = document.createElement("div");
        this.divTable.className = "calendar-table-cnt";
        this.divCnt.appendChild(this.divTable);
        this.createCalendarTable();
        this.bindTableDaysEvent();

        //tworzymy wrapper dla input
        this.calendarWrapper = document.createElement("div");
        this.calendarWrapper.classList.add("input-calendar-cnt");
        this.input.parentElement.insertBefore(this.calendarWrapper, this.input);
        this.calendarWrapper.appendChild(this.input);
        this.calendarWrapper.appendChild(this.divCnt);

        this.input.classList.add("input-calendar");
        this.input.addEventListener("click", e => this.toggleShow());
        this.input.addEventListener("click", e => e.stopImmediatePropagation());
        this.divCnt.addEventListener("click", e => e.stopImmediatePropagation());
        document.addEventListener("click", e => this.hide());
    }
}

Oraz przykładowe wywołanie:


const input = document.querySelector(".input-date");
const cal = new Calendar(input, {
    closeOnSelect : true,
    onDateSelect(day, month, year) {
        console.log(day);
        console.log(month);
        console.log(year);

        const dayText = ((day + 1) < 10) ? "0" + (day + 1) : day + 1;
        const monthsNames = ["styczeń", "luty", "marzec", "kwiecień", "maj", "czerwiec", "lipiec", "sierpień", "wrzesień", "październik", "listopad", "grudzień"];

        input.value = dayText + " " + monthsNames[month] + " " + year;
    }
});
cal.init();

Demo

Rezultat naszych działań możesz zobaczyć poniżej. Kliknij na pole tekstowe. Potem włącz pole kalendarzowe a następnie znowu kliknij na pole tekstowe.

Wszelkie prawa zastrzeżone. Jeżeli chcesz używać jakiejś części tego kursu, skontaktuj się z autorem. Aha - i ta strona korzysta z ciasteczek.

Menu