React

직접 제작한 React Dayjs 기반 Calendar 컴포넌트: 사용 방법 및 예제

눙엉 2024. 5. 16. 12:31

여러 라이브러리를 사용해서 만들어서 사용하는 것보다 훨씬 간단하게 사용할 수 있으나... 분명 한 번은 css 커스텀에서 막히는 일이 생겨서 차라리 한번 만들어보자 싶어 직접 만들어 보았습니다.

 

Calender 컴포넌트 렌더링 화면

 

함께 사용한 라이브러리입니다.

- Dayjs

- emotion

 

폴더구조

- calender

   - date

      - CalendarDate.styled.ts

      - CalendarDate.tsx

   - hooks

      - useCalendar.ts

      - useMonth.ts

   - Calendar.steyld.ts

   - Calendar.tsx

 

 

Calendar 컴포넌트는 Calendar.tsx와 CalendarDate.tsx로 구성되어있습니다.

CalendarDate는 Calender 내부에서 날짜를 나타내는 컴포넌트와 Calendar는 CalendarDate를 감싸는 Wrapper입니다.

 

// Test 페이지에서 Calender 컴포넌트 사용 예시

import React, { useState } from "react";
import dayjs from "dayjs";

import { Calendar } from "@/components";

// Celander는 날짜 선택을 1일 또는 기간으로 선택 할 수 있습니다.
type SelectDate = dayjs.Dayjs | [dayjs.Dayjs, dayjs.Dayjs] | null;

const TestPage = () => {
  const [selectDate, setSelectDate] = useState<SelectDate>(null);

  const handleSelectDate = (selectDate: SelectDate): void => {
    setSelectDate(selectDate);
  };

  return <Calendar handleSelectDate={handleSelectDate} />;
};

export default TestPage;

 

// Calendar.tsx

import dayjs from "dayjs";

export type SelectDate = dayjs.Dayjs | [dayjs.Dayjs, dayjs.Dayjs] | null;

interface CalendarProps {
  handleSelectDate: (selectDate: SelectDate) => void;
}

const Calendar = ({ handleSelectDate }: CalendarProps) => {
  const {
    selectDate,
    daysOfMonth,
    handleClickPrevMonth,
    handleClickNextMonth,
    handleClickDate,
  } = useCalendar({ handleSelectDate });
  const { currentMonth, nextMonth, currentMonthDays, nextMonthDays } = useMonth(
    { daysOfMonth }
  );

  return (
    <>
      <S.Wrapper>
        <S.PrevMonthButton type="button" onClick={handleClickPrevMonth}>
          이전
        </S.PrevMonthButton>
        <S.NextMonthButton type="button" onClick={handleClickNextMonth}>
          다음
        </S.NextMonthButton>
        <S.Month>{currentMonth}</S.Month>
        <S.Month>{nextMonth}</S.Month>
      </S.Wrapper>
      <S.Wrapper>
        <S.Calendar>
          {Object.values(DAY_OF_THE_WEEK).map((day) => (
            <S.Date key={day}>{day.charAt(0)}</S.Date>
          ))}
          {currentMonthDays.map((day, i) =>
            day ? (
              <CalendarDate
                key={i}
                date={day}
                selectDate={selectDate}
                handleClickDate={handleClickDate(day)}
              />
            ) : null
          )}
        </S.Calendar>
        <S.Calendar>
          {Object.values(DAY_OF_THE_WEEK).map((day) => (
            <S.Date key={day}>{day.charAt(0)}</S.Date>
          ))}
          {nextMonthDays.map((day, i) =>
            day ? (
              <CalendarDate
                key={i}
                date={day}
                selectDate={selectDate}
                handleClickDate={handleClickDate(day)}
              />
            ) : null
          )}
        </S.Calendar>
      </S.Wrapper>
    </>
  );
};

 

// Calendar.styled.ts

import { css } from "@emotion/react";
import styled from "@emotion/styled";
import dayjs from "dayjs";

export const Wrapper = styled.div`
  position: relative;
  display: flex;
  column-gap: 40px;
`;

export const PrevMonthButton = styled.button`
  position: absolute;
  top: 50%;
  transform: translateY(-50%);
`;

export const NextMonthButton = styled.button`
  position: absolute;
  top: 50%;
  right: 0;
  transform: translateY(-50%);
`;

export const Month = styled.div`
  display: flex;
  justify-content: center;
  align-content: center;
  width: 100%;
  border-bottom: 1px solid #e6e6e6;
  padding: 20px;
  font-size: 20px;
  font-weight: 600;
`;

export const Calendar = styled.div`
  display: grid;
  grid-template-columns: repeat(7, 48px);
  align-items: center;
  width: 100%;
  height: 336px;
  margin-top: 20px;
`;

export const Date = styled.div`
  display: flex;
  justify-content: center;
  align-items: center;
  width: 100%;
  height: 48px;
`;

export const DateButton = styled.button<{
  selectDate: dayjs.Dayjs | [dayjs.Dayjs, dayjs.Dayjs] | null;
  isStartDay: boolean;
  isEndDay: boolean;
  isSelectDate: boolean;
  isBetweenSelectDay: boolean;
}>`
  ${({
    selectDate,
    isStartDay,
    isEndDay,
    isSelectDate,
    isBetweenSelectDay,
  }) => css`
    position: relative;
    width: 100%;
    height: 100%;
    border-radius: ${!isBetweenSelectDay && "100px"};
    color: ${isSelectDate && "white"};
    background-color: ${isSelectDate
      ? "black"
      : isBetweenSelectDay && "rgba(23, 23, 23, 0.1)"};

    :hover {
      background-color: ${!isSelectDate && "rgba(23, 23, 23, 0.1)"};
    }

    ::after {
      position: absolute;
      top: 0;
      left: 0;
      width: 100%;
      height: 100%;
      border-radius: ${!Array.isArray(selectDate)
        ? "100px"
        : isStartDay
        ? "100px 0 0 100px"
        : isEndDay
        ? "0 100px  100px 0"
        : "0px"};
      background-color: ${isSelectDate && "rgba(23, 23, 23, 0.1)"};
      content: "";
    }
  `}
`;

 

// useCalender.ts

import { useState, useEffect } from "react";
import dayjs from "dayjs";

export type SelectDate = dayjs.Dayjs | [dayjs.Dayjs, dayjs.Dayjs] | null;

interface useCalendarProps {
  handleSelectDate: (selectDate: SelectDate) => void;
}

const mapper = {
  date: {
    monthFirstDay: (day: dayjs.Dayjs): dayjs.Dayjs => {
    // 인수로 전해준 날짜가 포함된 달의 첫째날을 반환하는 함수
      return day.startOf("month").startOf("month");
    },
    prevMonthFirstDay: (day: dayjs.Dayjs): dayjs.Dayjs => {
    // 인수로 전해준 날짜 기준 이전 달의 첫째날을 반환하는 함수
      return day.startOf("month").subtract(1, "day").startOf("month");
    },
    nextMonthFirstDay: (day: dayjs.Dayjs): dayjs.Dayjs => {
    // 인수로 전해준 날짜 기준 다음 달의 첫째날을 반환하는 함수
      return day.endOf("month").add(1, "day").startOf("month");
    },
    dateOfMonths: (day: dayjs.Dayjs): dayjs.Dayjs[] => {
    // 인수로 전해준 날짜가 포함된 달의 모든 일을 반환하는 함수
      return Array.from(
        { length: mapper.date.monthFirstDay(day).daysInMonth() },
        (_, i) => dayjs(day).startOf("month").add(i, "day")
      );
    },
  },
};

const useCalendar = ({ handleSelectDate }: useCalendarProps) => {
  const [selectDate, setSelectDate] = useState<SelectDate>(null);
  const [daysOfMonth, setDaysOfMonth] = useState(
    mapper.date.dateOfMonths(dayjs())
  );

  const handleClickPrevMonth = () => {
    setDaysOfMonth(
      mapper.date.dateOfMonths(mapper.date.prevMonthFirstDay(daysOfMonth[0]))
    );
  };

  const handleClickNextMonth = () => {
    setDaysOfMonth(
      mapper.date.dateOfMonths(mapper.date.nextMonthFirstDay(daysOfMonth[0]))
    );
  };

  const handleClickDate = (date: dayjs.Dayjs) => () => {
    if (selectDate) {
    // 이미 선택한 날짜가 있을 때
      if (Array.isArray(selectDate)) {
        if (selectDate.length === 2) {
        // 선택했던 날짜가 구간 선택 일 때
          if (date.isSame(selectDate[0]) || date.isSame(selectDate[1])) {
          // 새롭게 선택한 날짜가 선택했던 날짜와 같을 때 
            if (date.isSame(selectDate[0])) {
            // 새롭게 선택한 날짜가 선택했던 날짜의 첫번째와 같으면 첫번째 날짜 선택 취소
              setSelectDate(selectDate[1]);
            }

            if (date.isSame(selectDate[1])) {
            // 새롭게 선택한 날짜가 선택했던 날짜의 두번째와 같으면 두번째 날짜 선택 취소
              setSelectDate(selectDate[0]);
            }
          } else {
          // 새롭게 선택한 날짜가 선택했던 날짜와 같지 않을 때
          // 현재 하루만 선택되어 있고 두번째로 선택한 날짜와 이미 선택되어 있는 날짜를
          // sort를 해서 날짜 순서대로 배열을 정리 후 상태에 추가
            const sortDates = [selectDate[0], date].sort((a, b) =>
              a.isAfter(b) ? 1 : -1
            ) as [dayjs.Dayjs, dayjs.Dayjs];

            setSelectDate(sortDates);
          }
        }
      } else {
      // 선택했던 날짜가 하루 일 때
        if (date.isSame(selectDate)) {
        // 선택했던 날짜와 선택한 날짜가 같을 때 선택 해제
          setSelectDate(null);
        } else {
        // 선택했던 날짜와 선택한 날짜가 같지 않아서
        // 날짜 구간선택 (정렬 로직은 위와 동일)
          const sortDates = [selectDate, date].sort((a, b) =>
            a.isAfter(b) ? 1 : -1
          ) as [dayjs.Dayjs, dayjs.Dayjs];

          setSelectDate(sortDates);
        }
      }
    } else {
    // 이미 선택한 날짜가 없을 때
      setSelectDate(date);
    }
  };

  useEffect(() => {
    // Calendar 내부에서 사용하는 상태 값을
    // Calendar 외부에서 사용하는 상태 값으로 전달하는 핸들러를 전달받아
    // 날짜를 선택할 때 마다 상태 값을 업데이트 하는 useEffect
    handleSelectDate(selectDate);
  }, [selectDate]);

  return {
    selectDate,
    daysOfMonth,
    handleClickPrevMonth,
    handleClickNextMonth,
    handleClickDate,
  };
};

export default useCalendar;

 

// useMonth.ts

import dayjs from "dayjs";

interface useMonthProps {
  daysOfMonth: dayjs.Dayjs[];
}

const mapper = {
  date: {
    monthFirstDay: (day: dayjs.Dayjs): dayjs.Dayjs => {
      return day.startOf("month").startOf("month");
    },
    prevMonthFirstDay: (day: dayjs.Dayjs): dayjs.Dayjs => {
      return day.startOf("month").subtract(1, "day").startOf("month");
    },
    nextMonthFirstDay: (day: dayjs.Dayjs): dayjs.Dayjs => {
      return day.endOf("month").add(1, "day").startOf("month");
    },
    dateOfMonths: (day: dayjs.Dayjs): dayjs.Dayjs[] => {
      return Array.from(
        { length: mapper.date.monthFirstDay(day).daysInMonth() },
        (_, i) => dayjs(day).startOf("month").add(i, "day")
      );
    },
  },
};

const useMonth = ({ daysOfMonth }: useMonthProps) => {
  const currentMonth = daysOfMonth[0].format("YYYY년 M월");
  const nextMonth = mapper.date
    .nextMonthFirstDay(daysOfMonth[0])
    .format("YYYY년 M월");
  const currentMonthDays = [
    ...Array.from({ length: daysOfMonth[0].day() }, () => null),
    ...daysOfMonth,
  ];
  const nextMonthDays = [
    ...Array.from(
      {
        length: mapper.date
          .dateOfMonths(mapper.date.nextMonthFirstDay(daysOfMonth[0]))[0]
          .day(),
      },
      () => null
    ),
    ...mapper.date.dateOfMonths(mapper.date.nextMonthFirstDay(daysOfMonth[0])),
  ];

  return {
    currentMonth,
    nextMonth,
    currentMonthDays,
    nextMonthDays,
  };
};

export default useMonth;

 

// CalendarDate.tsx

import React from "react";
import dayjs from "dayjs";

import * as S from "./CalendarDate.styled";

type SelectDate = dayjs.Dayjs | [dayjs.Dayjs, dayjs.Dayjs] | null;

interface CalendarDateProps {
  className?: string;
  date: dayjs.Dayjs;
  selectDate: SelectDate;
  handleClickDate: () => void;
}

const CalendarDate = ({
  className,
  date,
  selectDate,
  handleClickDate,
}: CalendarDateProps) => {
  const isStartDay =
    selectDate === null
      ? false
      : Array.isArray(selectDate)
      ? selectDate[0].isSame(date, "day")
      : false;

  const isEndDay =
    selectDate === null
      ? false
      : Array.isArray(selectDate)
      ? selectDate[1].isSame(date, "day")
      : false;

  const isSelectDate =
    selectDate === null
      ? false
      : Array.isArray(selectDate)
      ? selectDate[0].isSame(date, "day") || selectDate[1].isSame(date, "day")
      : selectDate.isSame(date, "day");

  const isBetweenSelectDay =
    selectDate === null || !date
      ? false
      : Array.isArray(selectDate)
      ? date.isAfter(selectDate[0], "day") &&
        date.isBefore(selectDate[1], "day")
      : false;

  return (
    <S.Date className={className}>
      <S.DateButton
        type="button"
        disabled={
          !(date.isSame(dayjs(), "day") || date.isAfter(dayjs(), "day"))
        }
        selectDate={selectDate}
        isStartDay={isStartDay}
        isEndDay={isEndDay}
        isSelectDate={isSelectDate}
        isBetweenSelectDay={isBetweenSelectDay}
        onClick={handleClickDate}
      >
        {date.format("D")}
      </S.DateButton>
    </S.Date>
  );
};

export default CalendarDate;

 

import styled from "@emotion/styled";
import { css } from "@emotion/react";
import dayjs from "dayjs";

type SelectDate = dayjs.Dayjs | [dayjs.Dayjs, dayjs.Dayjs] | null;

export const Date = styled.div`
  display: flex;
  justify-content: center;
  align-items: center;
  width: 100%;
  height: 48px;
`;

export const DateButton = styled.button<{
  selectDate: SelectDate;
  isStartDay: boolean;
  isEndDay: boolean;
  isSelectDate: boolean;
  isBetweenSelectDay: boolean;
}>`
  ${({
    selectDate,
    isStartDay,
    isEndDay,
    isSelectDate,
    isBetweenSelectDay,
  }) => css`
    position: relative;
    width: 100%;
    height: 100%;
    border-radius: ${!isBetweenSelectDay && "100px"};
    color: ${isSelectDate && "white"};
    background-color: ${isSelectDate
      ? "black"
      : isBetweenSelectDay && "rgba(23, 23, 23, 0.1)"};

    :hover {
      background-color: ${!isSelectDate && "rgba(23, 23, 23, 0.1)"};
    }

    ::after {
      position: absolute;
      top: 0;
      left: 0;
      width: 100%;
      height: 100%;
      border-radius: ${!Array.isArray(selectDate)
        ? "100px"
        : isStartDay
        ? "100px 0 0 100px"
        : isEndDay
        ? "0 100px  100px 0"
        : "0px"};
      background-color: ${isSelectDate && "rgba(23, 23, 23, 0.1)"};
      content: "";
    }
  `}
`;

 

dayjs 라이브러리를 사용해서 Calendar컴포넌트를 직접 만들어보니 생각보다 어렵게 느껴지지는 않았다. css 관련돼서 커스텀이 추가로 필요하다면 조금 시간이 걸릴 것 같으나 라이브러리를 사용해야 될 만큼 로직이 복잡하지는 않아서 한번 직접 만들어 보는 것도 추천드립니다.

덕분에 dayjs 라이브러리에 대해 조금 더 알게 되는 경험도 한 것 같습니다.

 

기본 기능 외 추가적인 커스텀 기능을 제외하여 코드를 올렸는데 혹시나 빠진 부분이 있어 동작이 원하는 대로 하지 않으면 댓글에 남겨주시면 감사하겠습니다.