Membuat Komponen Kalender untuk ReactJS (1)

Komponen kalender / date-picker adalah salah satu komponen yang sering dipake di aplikasi. Dalam tutorial ini saya akan bahas tentang cara membuat sendiri komponen kalender.

Memang banyak komponen kalender yang bisa kita pake komponen dari pihak ketiga seperti React Datepicker, React Day Picker, dan lain-lain tapi ada kalanya kita ketemu use-case yang nggak disuport oleh komponen-komponen itu, jadi kita harus bikin sendiri. Logic buat kalender itu lumayan rumit, jadi kita nggak buat dari nol tapi kita manfaatin komponen/library yang sifatnya headless (tanpa UI) namanya Dayzed.

Starter-kit bisa diunduh di sini: My-React-Calendar-Starter . Ekstrak, lalu baca README-nya.

Layout

Layout dasarnya kurang lebih begini:

Props

Untuk props, kurang lebih kita butuh :

  • numberOfMonths untuk mengatur berapa bulan yang ditampilin
  • selectedDates untuk milih tanggal (bisa lebih dari satu), bisa juga untuk milih range tanggal ( dari tanggal sekian sampai tanggal sekian & bisa lebih dari satu range )
  • disabledDates untuk tanggal-tanggal yang non-aktif (nggak bisa dipilih)
  • disablePreviousDates untuk mengatur apakah tanggal-tanggal yang udah lewat masih bisa dipilih atau nggak
  • minDate & maxDate untuk bulan minimal & maksimal yang ditampilin
  • initialDate untuk nentuin bulan apa, di tahun berapa, yang ditampilin di awal
  • specials untuk nentuin tanggal-tanggal spesial ( hari-hari libur, misalnya)
  • lang untuk ngeset bahasa. Kita kasih opsi en & id.

Buka MyReactCalendar/index.jsx, kita bikin dulu definisi prop-types-nya. Ubah isinya jadi seperti ini (#1, #2):

import React from 'react';
import PropTypes from 'prop-types'; // 1

import "./calendar.scss";

function MyReactCalendar(props) {
  return <div className="my-react-calendar">Calendar</div>;
}

// 2
MyReactCalendar.propTypes = {
  numberOfMonths: PropTypes.number,
  selectedDates: PropTypes.oneOfType([ 
      // array of dates
      PropTypes.arrayOf(PropTypes.instanceOf(Date)),
      // Date range (from, to)
      PropTypes.shape({
          from: PropTypes.instanceOf(Date),
          to: PropTypes.instanceOf(Date)
      })
    ]),
  disabledDates: PropTypes.arrayOf(PropTypes.instanceOf(Date)),
  disablePreviousDates: PropTypes.bool,
  maxDate: PropTypes.instanceOf(Date),
  minDate: PropTypes.instanceOf(Date),
  initialDate: PropTypes.instanceOf(Date),
  specials: PropTypes.arrayOf(PropTypes.shape({
    date: PropTypes.instanceOf(Date).isRequired,
    description: PropTypes.string.isRequired
  })),
  lang: PropTypes.oneOf(['en','id'])
}

export default MyReactCalendar;

Callbacks

Berikutnya, tambahin beberapa callback :

  • onMonthChange dipanggil kalo bulannya berubah
  • onSelectionChange dipanggil kalo tanggal yang dipilih berubah
MyReactCalendar.propTypes = {
  // ... baris2 sebelumnya
    
  // callbacks
  onMonthChange: PropTypes.func,
  onSelectionChange: PropTypes.func 
}

Objek Dayzed

Hook useDayzed() balikannya adalah objek yang kita pake untuk mengakses API yang disediakan library ini (#1).

import React from 'react';
import PropTypes from 'prop-types';

import { useDayzed } from 'dayzed';

import "./calendar.scss";

function MyReactCalendar(props) {

  // #1
  const {calendars, getDateProps, getBackProps, getForwardProps} = useDayzed({})

  // #2
  console.log(calendars);

  return <div className="my-react-calendar">Calendar</div>;
}

Di #2 kita tampilin isi calendars di konsol. Isinya kurang lebih begini:

calendars adalah array berisi objek yang masing-masing mewakili data untuk 1 bulan. Jadi kalo kita set supaya Dayzed nampilin data untuk 2 bulan, calendars akan berisi 2 objek, kalo 3 bulan 3 objek, dan seterusnya. Array inilah yang nanti kita pake untuk bikin elemen-elemen UI.

Dalam setiap objek bulan ada array weeks yang isinya tanggal-tanggal dalam seminggu. String kosong itu hari/tanggal di luar bulan ini.

Coba kita buat renderer sederhana untuk satu bulan seperti begini:

function MyReactCalendar(props) {

  const {calendars, getDateProps, getBackProps, getForwardProps} = useDayzed({})

  console.log(calendars);

  return <div className="my-react-calendar">
    <table>
      <tbody>
        {
          calendars[0].weeks.map((week,idx)=>{
            return <tr key={idx}>
              {
                week.map( (day, idx) => {
                  if(!day){
                    return <td key={idx}>-</td>
                  }

                  return <td key={day.date.toString()}>{day.date.getDate()}</td>
                })
              }
            </tr>
          })
        }
      </tbody>
    </table>
  </div>;
}

API

Objek balikan dari  useDayzed() punya 3 fungsi prop-getter yang memungkinkan kita bebas membuat sendiri komponen untuk nampilin tanggal, bulan, tombol navigasi  berdasarkan prop/state dari Dayzed. Ketiga fungsi itu memberi kita objek berisi prop apa aja yang bisa/perlu kita pake untuk mengatur tampilan komponen yang kita buat.

Prop-getter adalah sebuah teknik untuk mendelegasikan proses rendering sebuah komponen atau bagian dari sebuah komponen ke penggunanya (komponen lain).

Fungsi pertama adalah getDateProps().  Sesuai namanya fungsi ini balikannya adalah objek berisi prop untuk nampilin hari/tanggal tertentu.

week.map( (day, idx) => {
  if(!day){
    return <td key={idx}>-</td>
  }

  console.log(getDateProps({
    dateObj: day
  }))

  return <td key={day.date.toString()}>{day.date.getDate()}</td>
})

getDateProps() harus dikasih satu argumen bentuknya objek yang punya field dateObj. Field ini mengacu pada objek yang ada di array weeks. Kalo kita pasang log seperti di atas, bisa kita liat di konsol props setiap tanggal dalam bulan yang sedang tampil.

Coba kita liat juga balikan dari getBackProps() & getForwardProps().

const currentMonth = calendars[0];
console.log('back props',getBackProps({
  calendars: currentMonth
}))

console.log('forward props',getForwardProps({
  calendars: currentMonth
}))

Jadi getBackProps() & getForwardProps() nanti kita pake untuk nampilin komponen navigasi untuk ganti bulan (mundur & maju).

Kembali ke hook useDayzed() , hook ini bisa nerima argumen berbentuk objek yang isinya props untuk si Dayzed itu sendiri:

  • onDateSelected: fungsi yang dipanggil kalo ada tanggal yang dipilih
  • selected: tanggal yang otomatis dipilih di awal
  • monthsToDisplay: berapa bulan yang harus ditampilin sekaligus
  • date: bulan apa & tahun berapa yang harus ditampilin di awal
  • maxDate & minDate: Bulan + tahun minimal dan maksimal yang bisa ditampilin
  • firstDayOfWeek: hari apa yang ditampilin sebagai awal minggu

Supaya bisa suport banyak bulan, kita modif kode jadi seperti ini:

function MyReactCalendar(props) {

  const {calendars, getDateProps, getBackProps, getForwardProps} = useDayzed({
    // #1
    monthsToDisplay: props.numberOfMonths
  })

  console.log(calendars);

  return <div className="my-react-calendar">
    {
      // #2
      calendars.map( cal => {
        console.log(cal);
        return <div key={`month-${cal.month}`} className="month">
          <div className="caption">{`${cal.month} ${cal.year}`}</div>
          <table>
            <tbody>
              {
                cal.weeks.map( (week, i) => {                
                  return <tr key={i} className="week">
                    {
                      week.map( (day, j) => {                      
                        return <td key={j} className="day">{
                          day ? <button>{day.date.getDate()}</button> : null
                        }</td>
                      })
                    }
                  </tr>    
                })
              }
            </tbody>
          </table>
        </div>
      })
    }
  </div>;
}

Jadi di #1 kita mapping props.numberOfMonths ke properti monthsToDisplay. Terus di #2, kita nggak lagi ambil calendars[0] tapi untuk setiap objek di calendars kita bikin div.month.

Atur tampilan UI-nya di calendar.scss.

.my-react-calendar{
  width: fit-content;

  .month {
    .caption {
      outline: 1px solid lightcoral;
    }
  
    table {
      outline: 1px solid lightblue;
    }

    .day {
      border: 1px solid lightgrey;
      
      button {
        cursor: pointer;
        width: 100%;
        height: 100%;
        border: none;      

        &:hover {
          background: lightgreen;
        }
      }
    }
  }
}

Di index.jsx, kita kasih numberOfMonths=2.

function App() {
  return (
    <div>
      <MyReactCalendar numberOfMonths={2} />
    </div>
  );
}

Tampilannya jadi seperti ini. Yang garis merah itu .caption, yang biru tabel yang isinya hari-hari dalam sebulan.

Ingat, indeks bulan di dalam JavaScript adalah 0 - 11, bukan 1 - 12.

Lanjut, kita tambahin 2 tombol untuk navigasi bulan & CSS-nya.

return <div className="my-react-calendar">
    
    <div className="nav">
      {
        ['prev', 'next'].map((str,i)=>{

          const btnProps = i === 0 ? getBackProps({calendars}) : getForwardProps({calendars});

          return <button {...btnProps} key={i} className={`${str}-btn`} 
            dangerouslySetInnerHTML={{
              __html: i === 0 ? '&larr;' : '&rarr;' 
            }}/>
        })
      }
    </div>

    {calendars.map( cal => {
      //... kode selanjutnya
    })
  }</div>
.my-react-calendar{
  width: fit-content;

  .nav {
    display: flex;
    justify-content: space-between;
  }

  /* ... kode selanjutnya */
}

Nama Bulan & Hari (Inggris & Indonesia)

Untuk nampilin nama bulan dan dari dalam bahasa Indonesia atau bahasa Inggris, kita buat dulu konstanta nama-namanya (#1, #2) & buat defaultProps untuk lang=id (#3).

// #1
const MONTHS = {
  en:['Jan', 'Feb', 'Mar', 'Apr', 'May','Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'],
  id:['Jan', 'Feb', 'Mar', 'Apr', 'Mei','Jun', 'Jul', 'Agu', 'Sep', 'Okt', 'Nov', 'Des']
}

// #2
const DAYS = {
  en:['Sun','Mon','Tue','Wed','Thu','Fri','Sat'],
  id:['Min','Sen','Sel','Rab','Kam','Jum','Sat']
}

function MyReactCalendar(props) {

  const monthNames = MONTHS[props.lang];
  const dayNames = DAYS[props.lang];

  //... kode selanjutnya
}

MyReactCalendar.defaultProps = {
  lang: 'id'
}

Lanjut ke blok kode yang me-render calendars 👇, di #1 kita ambil nama bulan dari monthNames pake cal.month sebagai nomor indeksnya. Di #2 kita buat <thead>  untuk nama-nama hari yang ada di dayNames.

calendars.map( cal => {        
  return <div key={`month-${cal.month}`} className="month">
    {/* #1 */}
    <div className="caption">{`${monthNames[cal.month]} ${cal.year}`}</div>
    <table>
      {/* #2 */}
      <thead>
        <tr className="day-names">
          {
            dayNames.map( n => {
              return <th key={n}>{n}</th>
            })
          }
        </tr>
      </thead>
      <tbody>              
        {
          cal.weeks.map( (week, i) => {                
            return <tr key={i} className="week">
              {
                week.map( (day, j) => {                  

                  console.log(day)

                  return <td key={j} className="day">{
                    day ? <button>{day.date.getDate()}</button> : null
                  }</td>
                })
              }
            </tr>    
          })
        }
      </tbody>
    </table>
  </div>
})

Update calendar.scss.

.my-react-calendar{
  width: fit-content;

  .nav {
    display: flex;
    justify-content: space-between;
  }

  .month {
    border: 1px solid lightslategrey;
    margin-top: 6px;

    .caption {
      text-align: center;
      padding: 8px;
      border-bottom: 1px solid lightgrey;
    }

    .day-names {
      th {
        width: 60px;
      }  
    }

    .day {
      border: 1px solid lightgrey;
      
      button {
        cursor: pointer;
        width: 100%;
        height: 100%;
        border: none;      

        &:hover {
          background: lightgreen;
        }
      }
    }
  }
}

Hari Ini

Untuk nandain tanggal hari ini, kita baca atribut today dari objek day.

Kita ubah blok yang me-render hari. Di #1 kita buat variabel untuk nama kelas, nilai awalnya day, sama seperti sebelumnya. Di #2 kalo atribut today=true , nama kelasnya kita tambah today, jadi day today. Di #3 variabel nama kelas ini kita pake untuk <td>.

<tbody>              
  {
    cal.weeks.map( (week, i) => {                
      return <tr key={i} className="week">
        {
          week.map( (day, j) => {                  

            console.log(day)
            // #1
            let cname = 'day';

            // #2
            if(day.today) {
              cname += ' today'
            }                        

            // #3
            return <td key={j} className={cname}>{
              day ? <button>{day.date.getDate()}</button> : null
            }</td>
          })
        }
      </tr>    
    })
  }
</tbody>
.day {
  /* ... kode yang lain*/

  &.today {
    button {
      background: yellow;
      color: blue;
    }
  }
}

Hari Minggu

Untuk hari Minggu, kita perlu tandain tanggalnya & juga nama harinya. Kita bisa pake indeks array aja (Minggu selalu 0).

calendars.map( cal => {        
  return <div key={`month-${cal.month}`} className="month">
    <div className="caption">{`${monthNames[cal.month]} ${cal.year}`}</div>
    <table>
      <thead>
        <tr className="day-names">
          {
            dayNames.map( (n, i) => {
              // #1
              const cname = i === 0 ? 'sunday' : ''
              // #2
              return <th key={n} className={cname}>{n}</th>
            })
          }
        </tr>
      </thead>
      <tbody>              
        {
          cal.weeks.map( (week, i) => {                
            return <tr key={i} className="week">
              {
                week.map( (day, j) => {
                  
                  // ...kode sebelumnya
                
                  // #3
                  if(j === 0) {
                    cname += ' sunday'
                  }

                  return <td key={j} className={cname}>{
                    day ? <button>{day.date.getDate()}</button> : null
                  }</td>
                })
              }
            </tr>    
          })
        }
      </tbody>
    </table>
  </div>
})
.day-names {
  th {
    width: 60px;
    
    /* #1 */
    &.sunday {
      color: red;
    }
  }  
}

.day {
  /* ... kode yang lain*/
  
  /* #2 */
  &.sunday {
    button {
      color: red;
    }
  }

}

Hari Spesial

Data hari-hari spesial (hari libur dll) dikirim dari luar lewat props specials. Jadi kita buat dulu di App.

function App() {

  const specials = [
    {
      date: new Date('2021, 10, 20'),
      description: 'Maulid Nabi Muhammad SAW'
    },
    {
      date: new Date('2021, 12, 25'),
      description: 'Hari raya Natal'
    },
    {
      date: new Date('2022, 1, 1'),
      description: 'Tahun baru 2022'
    },
    {
      date: new Date('2022, 1, 3'),
      description: 'Hari Rebahan'
    }
  ]

  return (
    <div>
      <MyReactCalendar numberOfMonths={2} specials={specials}/>
    </div>
  );
}

Nanti kita perlu komparasi tanggal, kita pake library namanya Date-Fns. Instal dulu paketnya:

npm i date-fns
function MyReactCalendar(props) {

  // ... kode lainnya

  return <div className="my-react-calendar">

    {/* ... kode lainnya*/}

            <tbody>              
              {
                cal.weeks.map( (week, i) => {                
                  return <tr key={i} className="week">
                    {
                      week.map( (day, j) => {                  

                        console.log(day)

                        let cname = 'day';

                        if(day.today) {
                          cname += ' today'
                        }               

                        if(j === 0) {
                          cname += ' sunday'
                        }
                        
                        // #1
                        if(props.specials?.length > 0) {
                          const sameDate = props.specials.find( sp => isEqual(sp.date, day.date));
                          if(sameDate) {
                            cname += ' specials'
                          }
                        }

                        return <td key={j} className={cname}>{
                          day ? <button>{day.date.getDate()}</button> : null
                        }</td>
                      })
                    }
                  </tr>    
                })
              }
            </tbody>
          </table>
        </div>
      })
    }
  </div>;
}

Di #1 kita pakai sintaks optional-chaining untuk ngecek apakah props.specials ada isinya. Kalo ada isinya, cari objek yang date-nya yang sama dengan day.date. Kalo ketemu (#1), nama kelasnya ditambahin specials.

.day {
  /* ... kode lain */

  &.sunday, &.specials {
    button {
      color: red;
    }
  }

}

Lanjut, kita tampilin deskripsi hari spesial yang ada di setiap bulan. Di bawah useDayzed() kita buat fungsi renderMonthSpecials() untuk me-render bagian ini. Untuk nyari tanggal spesial setiap bulan kita pake bantuan fungsi isSameMonth() dari date-fns.

Objek di dalam array calendars itu formatnya begini 👇. Jelas kita nggak bisa pake month karena untuk perbandingan isSameMonth() kita butuh objek Date. Jadi kita pake firstDayOfMonth.

const renderMonthSpecials = calObj => {    
  if(props.specials?.length > 0) {
    const specialsInMonth = props.specials.filter( sp => isSameMonth(sp.date, calObj.firstDayOfMonth))

    if(specialsInMonth) {
      return <div className="special-days">
        {
          specialsInMonth.map( sp =>{
            return <p key={sp.date.toString()}> 
              {`${sp.date.getDate()} - ${sp.description}`}
            </p>
          })
        }
      </div>
    }
  }
}

Fungsi ini kita panggil dibawah tag penutup </table>. Terus kita buat styling-nya.

</table>

{
  renderMonthSpecials(cal)
}
.month {
  border: 1px solid lightslategrey;
  margin-top: 6px;

  .caption {
    /* ... kode lainnya */
  }

  .day-names {
    /* ... kode lainnya */ 
  }

  .day {
    /* ... kode lainnya */
  }

  .special-days {

    padding: 6px;

    p {
      margin: 6px 0;
      color: red;        
    }
  }
}

Non-aktifkan Tanggal

Di dalam rencana props yang kita buat di atas, ada dua yang bisa kita pake untuk menon-aktifkan tanggal supaya nggak bisa diklik/dipilih.

Prop disabledDates

Yang pertama adalah disabledDates. Ini adalah array yang isinya tanggal-tanggal yang harus non-aktif.

function App() {

  // ... kode yg lain

  const disabled = [
    new Date('2021, 11, 29'),
    new Date('2021, 12, 5'),
    new Date('2021, 12, 15')
  ]

  return (
    <div>
      <MyReactCalendar 
        numberOfMonths={2} 
        specials={specials}
        disabledDates={disabled}  
        />
    </div>
  );
}

Di blok kode di bawah 👇, #1 kita buat flag untuk kondisi <button> . #2 cari apa tanggal day.date ada di dalam props.disabledDates. Kalo ada, set flag jadi true (#3). Flag ini dipake untuk atribut disabled punya <button>.

{
  cal.weeks.map( (week, i) => {                
    return <tr key={i} className="week">
      {
        week.map( (day, j) => {                  

          //... kode yang lain
          
          // #1
          let disabled = false;
          if(props.disabledDates) {
            // #2
            const sameDate = props.disabledDates.find( dt => isEqual(dt, day.date));
            // #3
            disabled = !!sameDate;
          }

          return <td key={j} className={cname}>{
            day ? <button disabled={disabled}>
              {day.date.getDate()}</button> : null
          }</td>
        })
      }
    </tr>    
  })
}
.day {
    border: 1px solid lightgrey;
    
    button {
      /* ... kode lainnya */

      &:disabled{
        cursor: not-allowed;
        background: initial !important;
      }
    }

    &.today {
      /* ... kode lainnya */
    }

    &.sunday, &.specials {
      button {
        color: red;

        &:disabled {
          color: lightpink;
        }
      }
    }
  }
}

Prop disablePreviousDates

Prop yang kedua adalah disablePreviousDates. Ini dipake untuk menon-aktifkan semua tanggal sebelum tanggal hari ini. Di #1 kita buat konstanta untuk tanggal hari ini. Di #2 kalo disablePreviousDates=true & day.date bukan hari ini, periksa apakah day.date adalah salah satu tanggal sebelum hari ini pake fungsi isBefore() .

function MyReactCalendar(props) {

  //... kode lainnya

  // #1
  const today = new Date();

  return <div className="my-react-calendar">
    {/* ... kode lainnya */}

    {      
      calendars.map( cal => {        
        return <div key={`month-${cal.month}`} className="month">
          
          {/* ... kode lainnya */}
          
            <tbody>              
              {
                cal.weeks.map( (week, i) => {                
                  return <tr key={i} className="week">
                    {
                      week.map( (day, j) => {                  

                        // ... kode lainnya

                        let disabled = false;
                        if(props.disabledDates) {
                          const sameDate = props.disabledDates.find( dt => isEqual(dt, day.date));
                          
                          disabled = !!sameDate;
                        }
                        
                        // #2
                        if(props.disablePreviousDates && !day.today) {
                          disabled = isBefore(day.date, today)
                        }

                        return <td key={j} className={cname}>{
                          day ? <button disabled={disabled}>{day.date.getDate()}</button> : null
                        }</td>
                      })
                    }
                  </tr>    
                })
              }
            </tbody>
          </table>

          {
            renderMonthSpecials(cal)
          }
        </div>
      })
    }
  </div>;
}

Min Date & Max Date

Ini gampang ya. Tinggal kirim props-nya ke useDayzed(). Di index.jsx kita set prop minDate & maxDate. Masing-masing bulan September & Desember.

function App() {

  const specials = [
    //... kode lainnya
  ]

  return (
    <div>
      <MyReactCalendar 
        minDate={new Date('2021, 9, 1')}
        maxDate={new Date('2021, 12, 1')}
        numberOfMonths={2} 
        specials={specials}        
        />
    </div>
  );
}
function MyReactCalendar(props) {

  const monthNames = MONTHS[props.lang];
  const dayNames = DAYS[props.lang];

  const {calendars, getDateProps, getBackProps, getForwardProps} = useDayzed({
    monthsToDisplay: props.numberOfMonths,

    // atur minDate & maxDate 
    minDate: props.minDate,
    maxDate: props.maxDate
  })
  
  // ... kode selanjutnya
}

Jadi kalo bulan September udah ditampilin, tombol navigasi kiri ("prev") nggak bisa diklik lagi. Sebaliknya kalo Desember udah ditampilin, yang kanan nggak bisa diklik.

Initial Date

Prop initialDate menentukan bulan apa yang pertama kali ditampilkan.

const {calendars, getDateProps, getBackProps, getForwardProps} = useDayzed({
  monthsToDisplay: props.numberOfMonths,
  minDate: props.minDate,
  maxDate: props.maxDate,
  date: props.initialDate
})
<MyReactCalendar 
  initialDate={new Date('2020, 5, 1')}        
  numberOfMonths={2} 
  specials={specials}        
  />

Bagian 1 cukup sampe di sini dulu udah lumayan panjang soalnya. Ada lanjutannya di Bagian 2.