상현에 하루하루
All 개발자의 하루

다중체크 캘린더 만들기

( 업데이트: )

유틸성 기능을 가진 캘린더를 제작해야하는 일이 생겼다. 웹에서 제공해주는 React 서드파티 라이브러리들중에 내가 원하는 기능을 가진 캘린더가 딱히 없어서 제작하면서 궁금했던 것들을 정리해봅니다.

원했던 기능

  • 일, 월, 화, 수, 목, 금, 토 를 클릭하면 보여지고있는 달력의 클릭한 요일을 모든 일자를 선택된 상태로 만드는 것
  • 공휴일을 선택할수 없게 만드는 것
  • 범위로 선택하는 것이아닌 각각의 여러개의 날짜를 선택할 수 있게 만드는 것

캘린더 라이브러리

  • airbnb/react-dates
    라이브되고있는 서비스에서 지속적으로 사용하고있는 라이브러리라 업데이트에 걱정이없다.
    원활한 유저와 소통이 되고있고 의외로 커스텀할때 자료들이 많다.
  • material-ul/Date-pickers
  • Hacker0x01/react-datepicker
    예제가 엄청많아서 보기 좋았다 여러가지의 케이스별로 보고서 커스텀할 수 있다는 장점이있다.

🤾‍♂️ Let’s go airbnb/react-dates

라이브러리의 지속성, multicheck 관련된 예제를 찾았기 때문에 바로 실행할 수 있었다는 생각이 들어서 바로 airbnb/react-dates를 선택했다.

👀 Preview

MultiCheck 구현하기

기본 캘린더에서 MultiCheck가 되는 캘린더로 구성해야하는 것이 첫번째 목표였다.

https://github.com/airbnb/react-dates/issues/190#issuecomment-392323696

내가 원하는 니즈를 한꺼번애 담고있는 예제였다.

Components

DayPickerSingleDateController를 가지고 각각의 renderCalendarDay를 렌더링할때 MultipleCalendar에서 상태값을 확인해서 체크 상태를 추가해서 적용하는 방식이다.

Class Type


import React from 'react'
import PropTypes from 'prop-types'
import { DayPickerSingleDateController, CalendarDay } from 'react-dates'

export class MultiDatePicker extends React.Component {
  static defaultProps = {
    dates: []
  }

  constructor (props) {
    this.state = {
      dates: props.dates
    }

    this.handleChange = this.handleChange.bind(this)
  }

  handleChange (date) {
    const { dates } = this.state

    const newDates = dates.includes(date) ? dates.filter(d => !date.isSame(d)) : [...dates, d]

    this.setState({ dates: newDates })
    this.props.onChange && this.props.onChange(newDates.toJS())
  }

  render () {
    return (
      <DayPickerSingleDateController
        numberOfMonths={1}
        onDateChange={this.handleChange}
        renderCalendarDay={props => {
          const { day, modifiers } = props

          if (this.state.dates.includes(day)) {
            modifiers && modifiers.add('selected')
          }
          else {
            modifiers && modifiers.delete('selected')
          }

          return (
            <CalendarDay { ...props } modifiers={modifiers} />
          )
        }} />
    )
  }
}

export default MultiDatePicker

Hook Type

import React, { useState, useEffect } from 'react';
import { DayPickerSingleDateController, CalendarDay } from 'react-dates';

const MultiDatePickerCalendar = () => {
  const [dates, setDates] = useState([]);
  const handleChange = (date) => {
    const newDates = dates.includes(date)
      ? dates.filter((d) => !date.isSame(d))
      : [...dates, date];

    setDates(newDates);
  };
  return (
    <DayPickerSingleDateController
      numberOfMonths={1}
      onDateChange={handleChange}
      hideKeyboardShortcutsPanel
      noBorder
      monthFormat="YYYY년 MMMM"
      renderCalendarDay={(props) => {
        const { day, modifiers } = props;

        if (dates.includes(day)) {
          modifiers && modifiers.add('selected');
        } else {
          modifiers && modifiers.delete('selected');
        }

        return <CalendarDay {...props} modifiers={modifiers} />;
      }}
    />
  );
};
export default MultiDatePickerCalendar;

이제 각각의 날짜를 선택할수 있는 캘린더가 만들어졌다.

이제 이 date 컴포넌트를 기준으로 시작!

월단위 해당 요일 모두 선택하기

맨처음에 일,월,화.. 와 같이 주간 요일나타나는 곳에 선택할 수 있는 버튼컨트롤러를 만들어서 추가하면 어떨까? 고민을했다. 하지만 각 날짜를 체크하고 그 체크한 것을 통해서 선택해야할 것같은데? 그럼 매번 클릭 이벤트에 로직을 바꿔야하나? 고민을 했다.

결국 렌더링후에 해당 버튼컨트롤에 이벤트를 계속 바꾸기가 좋지 않았다. 그래서 각 월마다 이미 이벤트가 추가된 상태의 바뀌지않는 버튼컨트롤러를 만들어서 추가하기로 했다. 월 기준으로 element를 추가할 수 있는 renderMonthElement 속성에 추가하기로 했다.

월단위 캘린더에서 체크할 수 있는 체크 박스 추가하기

<DayPickerSingleDateController
        numberOfMonths={1}
        onDateChange={handleChange}
        hideKeyboardShortcutsPanel
        noBorder
        renderMonthElement={(props) =>
          MonthHeader({ ...props, handleChangeMulti })
        }

calendar.js에서 MothHeader에 이벤트핸들러까지 전달해서 캘린더의 상태를 변경하도록 하였다.

const MultipleDatePickerCalendar = () => {
  const [dates, setDates] = useState([]);
  const handleChangeMulti = (days) => {
    // dates array
    // 선택되어있지 않은 값들만 전달받음
    setDates([...dates, ...days]);
  };

선택되어있지 않는 값들은 MonthHeader에서 버블링해서 상위 컴포넌트에 전달될 것이다.

const WeekHeaderInput = ({ month, handleChangeMulti }) => {
  const dayHandle = (dayName) => {
    const dddd = document.querySelectorAll(
      `table.CalendarMonth_table td[aria-label*="/${month}-${dayName}요일"]:not(.CalendarDay__blocked_calendar):not(.CalendarDay__blocked_out_of_range)`,
    );
    let result = [];
    dddd.forEach((day) => {
      const date = day.getAttribute('aria-label').replace(/\/.*/g, '');
      result.push(
        moment(date).set({
          hour: 12,
          minute: 0,
          second: 0,
          millisecond: 0,
        }),
      );
    });
    handleChangeMulti(result);
  };
  return (
    <UL className="DayPicker_weekHeader_ul DayPicker_weekHeader_ul_1">
      <li
        className="DayPicker_weekHeader_li DayPicker_weekHeader_li_1"
        style={{ width: '39px' }}
      >
        <Btn onClick={() => dayHandle('일')}>
          <FaCheck />
        </Btn>
      </li>
      <li
        className="DayPicker_weekHeader_li DayPicker_weekHeader_li_1"
        style={{ width: '39px' }}
      >
        <Btn onClick={() => dayHandle('월')}>
          <FaCheck />
        </Btn>
      </li>
      <li
        className="DayPicker_weekHeader_li DayPicker_weekHeader_li_1"
        style={{ width: '39px' }}
      >
        <Btn onClick={() => dayHandle('화')}>
          <FaCheck />
        </Btn>
      </li>
      <li
        className="DayPicker_weekHeader_li DayPicker_weekHeader_li_1"
        style={{ width: '39px' }}
      >
        <Btn onClick={() => dayHandle('수')}>
          <FaCheck />
        </Btn>
      </li>
      <li
        className="DayPicker_weekHeader_li DayPicker_weekHeader_li_1"
        style={{ width: '39px' }}
      >
        <Btn onClick={() => dayHandle('목')}>
          <FaCheck />
        </Btn>
      </li>
      <li
        className="DayPicker_weekHeader_li DayPicker_weekHeader_li_1"
        style={{ width: '39px' }}
      >
        <Btn onClick={() => dayHandle('금')}>
          <FaCheck />
        </Btn>
      </li>
      <li
        className="DayPicker_weekHeader_li DayPicker_weekHeader_li_1"
        style={{ width: '39px' }}
      >
        <Btn onClick={() => dayHandle('토')}>
          <FaCheck />
        </Btn>
      </li>
    </UL>
  );
};

const MonthHeader = ({ month, handleChangeMulti }) => {
  return (
    <>
      <strong id={`MonthTitle${month.format('MM')}`}>
        {month.format('YYYY년 MMMM')}
      </strong>
      <WeekHeaderInput
        month={month.format('MM')}
        handleChangeMulti={handleChangeMulti}
      />
    </>
  );
};

export default MonthHeader;

여기서 문제점은 리액트로 렌더링된 요소들을가지고 document.querySelectorAll로 접근했다는것이 내가 생각할 수 있는 최선의 해결방법이었다.

const dayHandle = (dayName) => {
  const dddd = document.querySelectorAll(
    `table.CalendarMonth_table td[aria-label*="/${month}-${dayName}요일"]:not(.CalendarDay__blocked_calendar):not(.CalendarDay__blocked_out_of_range)`,
  );
  let result = [];
  dddd.forEach((day) => {
    const date = day.getAttribute('aria-label').replace(/\/.*/g, '');
    result.push(
      moment(date).set({
        hour: 12,
        minute: 0,
        second: 0,
        millisecond: 0,
      }),
    );
  });
  handleChangeMulti(result);
};

이 부분이 핵심 로직이다. 각 요일로 받은 값을 가지고 react-date에서 만들어낸 규칙적인 요소들을 선택하고 해당 선택된 요소들을 moment를 통해서 date의 값을 규칙적으로 수정해서 calendar.js 상태로 넘긴다. handleChangeMulticalendar.js에서 받은 함수이다. setDates를 적용시키는

이렇게 각 월마다 요일을 모두 선택할 수 있는 버튼이 생성되었다!