NextJS에서 캘린더 컴포넌트 개발하기

Nowon LeeNowon Lee
5 min read

요구사항

캘린더 컴포넌트를 개발하게 된 계기는 다음과 같다.

  • 공개된 라이브러리는 프로젝트에 맞게 UI를 변경하기 까다롭다.

  • 서버 사이드 렌더링 프레임워크의 경우 렌더링에서 에러가 발생하는 경우가 종종 있다.

  • 라이브러리마다 지원하는 기능의 범위가 다르다.

Next.js 프로젝트를 세팅하고 기본적인 달력 기능을 하는 페이지를 만들어 본 과정을 기록했다.

컴포넌트를 작성하기 전 다음과 같은 작업을 거쳤다.

  • create-next로 Next.js 프로젝트 생성

  • Tailwind CSS 설치 및 세팅

  • lucide-react 아이콘 라이브러리 설치

  • date-fns 날짜 라이브러리 설치

작업

우선 패키지를 하나 설치한다.

npm install @nwleedev/use-calendar

이 패키지에서 불러올 수 있는 useCalendar Hook은 다음과 같은 기능을 제공한다.

  • Hook에 Date 객체를 전달함으로써 초기 상태를 지정할 수 있다.

  • 현재 상태에 대해서 일 목록, 월 목록, 10년 주기의 연 목록을 가져올 수 있다.

  • 캘린더의 상태를 업데이트할 수 있는 함수가 존재한다.

기본적인 달력 페이지를 작성했다.

  • 캘린더 헤더에서 좌우 화살표를 통해 이전 달, 다음 달로 이동할 수 있다.

  • 헤더 중앙에는 현재 캘린더가 가리키는 연도와 월이 표시된다.

  • 캘린더에는 현재 캘린더의 날짜들만 보이게 된다.

  • 사용자가 클릭한 날짜는 검은색 원으로 표시된다.

"use client";

import useCalendar, { DateLibs } from "@nwleedev/use-calendar";
import { format } from "date-fns";
import { ChevronLeft, ChevronRight } from "lucide-react";
import { useState } from "react";

const Calendar = () => {
  const [selectedDate, setSelectedDate] = useState(new Date());
  const { days, date, onMonthChange } = useCalendar({
    defaultValue: selectedDate,
  });
  const month = date.getMonth();

  return (
    <div className="w-full h-full gap-y-4 flex flex-col justify-center items-center">
      <div className="w-full h-10 flex justify-center items-center gap-x-2">
        <button onClick={() => onMonthChange(month - 1)}>
          <ChevronLeft />
        </button>
        <h2 className="text-xl font-semibold">{format(date, "MMM, yyyy")}</h2>
        <button onClick={() => onMonthChange(month + 1)}>
          <ChevronRight />
        </button>
      </div>
      <div className="grid grid-cols-7 gap-y-1 gap-x-2">
        {days.map((day) => {
          const classNames = getClassNames(date, day, selectedDate);
          if (!classNames) {
            return <div key={day.getTime()} className="w-10 h-10" />;
          }
          return (
            <button
              key={day.getTime()}
              className={classNames?.div}
              onClick={() => setSelectedDate(day)}
            >
              <span className={classNames?.span}>{day.getDate()}</span>
            </button>
          );
        })}
      </div>
    </div>
  );
};

const getClassNames = (date: Date, day: Date, selectedDate: Date) => {
  if (!DateLibs.isMonthEqual(date, day)) {
    return;
  }
  if (
    DateLibs.isYearEqual(day, selectedDate) &&
    DateLibs.isMonthEqual(day, selectedDate) &&
    DateLibs.isDateEqual(day, selectedDate)
  ) {
    return {
      div: "flex justify-center items-center w-10 h-10 rounded-full bg-black",
      span: "text-white",
    };
  }
  if (day.getDay() === 0) {
    return {
      div: "flex justify-center items-center w-10 h-10",
      span: "text-red-500",
    };
  }
  if (day.getDay() === 6) {
    return {
      div: "flex justify-center items-center w-10 h-10",
      span: "text-blue-600",
    };
  }
  return {
    div: "flex justify-center items-center w-10 h-10",
    span: "text-gray-700",
  };
};
export default Calendar;

기초적인 캘린더에서 다음과 같은 기능을 추가할 수 있다.

  • 헤더 중앙을 클릭하면 현재 연도에 대해서 달을 선택할 수 있는 화면으로 전환되어야 한다.

  • 월 목록 화면에서 헤더 좌우 화살표를 클릭하면 이전 연도, 다음 연도로 이동할 수 있다.

  • 각 월을 클릭하면 캘린더에는 현재 연도 & 클릭한 월에 해당하는 날짜 목록이 보여지게 된다.

  • 캘린더의 상태는 stage로 관리할 수 있다. 스테이지 값에 따라서 날짜 목록을 표시할지 월 목록을 표시할지 결정된다.

"use client";

import useCalendar, { CalendarStage, DateLibs } from "@nwleedev/use-calendar";
import { format } from "date-fns";
import { ChevronLeft, ChevronRight } from "lucide-react";
import { useState } from "react";

const Calendar = () => {
  const [selectedDate, setSelectedDate] = useState(new Date());
  const {
    days,
    date,
    months,
    stage,
    onMonthChange,
    onYearChange,
    onStageChange,
  } = useCalendar({
    defaultValue: selectedDate,
  });
  const month = date.getMonth();
  const year = date.getFullYear();

  return (
    <div className="w-full h-full gap-y-4 flex flex-col justify-center items-center">
      {stage === CalendarStage.DAYS && (
        <div className="w-full h-10 flex justify-center items-center gap-x-2">
          <button onClick={() => onMonthChange(month - 1)}>
            <ChevronLeft />
          </button>
          <h2
            className="text-xl font-semibold"
            role="button"
            onClick={() => {
              onStageChange(CalendarStage.MONTHS);
            }}
          >
            {format(date, "MMM, yyyy")}
          </h2>
          <button onClick={() => onMonthChange(month + 1)}>
            <ChevronRight />
          </button>
        </div>
      )}
      {stage === CalendarStage.DAYS && (
        <div className="grid grid-cols-7 gap-y-1 gap-x-2">
          {days.map((day) => {
            const classNames = getClassNames(date, day, selectedDate);
            if (!classNames) {
              return <div key={day.getTime()} className="w-10 h-10" />;
            }
            return (
              <button
                key={day.getTime()}
                className={classNames?.div}
                onClick={() => setSelectedDate(day)}
              >
                <span className={classNames?.span}>{day.getDate()}</span>
              </button>
            );
          })}
        </div>
      )}
      {stage === CalendarStage.MONTHS && (
        <div className="w-full h-10 flex justify-center items-center gap-x-2">
          <button onClick={() => onYearChange(year - 1)}>
            <ChevronLeft />
          </button>
          <h2 className="text-xl font-semibold">{format(date, "yyyy")}</h2>
          <button onClick={() => onYearChange(year + 1)}>
            <ChevronRight />
          </button>
        </div>
      )}
      {stage === CalendarStage.MONTHS && (
        <div className="grid grid-cols-3 gap-y-1 gap-x-2 w-full max-w-[320px]">
          {months.map((month) => {
            return (
              <button
                key={month.getTime()}
                className="h-10"
                onClick={() => {
                  onMonthChange(month.getMonth());
                  onStageChange(CalendarStage.DAYS);
                }}
              >
                <span>{format(month, "MMMM")}</span>
              </button>
            );
          })}
        </div>
      )}
    </div>
  );
};

const getClassNames = (date: Date, day: Date, selectedDate: Date) => {
  // 이전 코드의 함수와 같음
};
export default Calendar;

월간 날짜 목록만 아니라 주간 날짜 목록을 표시하는 데에도 useCalendar Hook을 도입할 수 있다.

  • 헤더에서 좌우 버튼을 클릭하면 이전 주간, 다음 주간으로 달력이 업데이트된다.
"use client";

import useCalendar, { DateLibs } from "@nwleedev/use-calendar";
import { format, getWeek } from "date-fns";
import { ChevronLeft, ChevronRight } from "lucide-react";
import { useState } from "react";

const Calendar = () => {
  const [selectedDate, setSelectedDate] = useState(new Date());
  const { date, week, onWeekChange } = useCalendar({
    defaultValue: selectedDate,
  });

  return (
    <div className="w-full h-full gap-y-4 flex flex-col justify-center items-center">
      <div className="w-full h-10 flex justify-center items-center gap-x-2">
        <button onClick={() => onWeekChange(getWeek(date) - 1)}>
          <ChevronLeft />
        </button>
        <h2 className="text-xl font-semibold">{format(date, "MMM, yyyy")}</h2>
        <button onClick={() => onWeekChange(getWeek(date) + 1)}>
          <ChevronRight />
        </button>
      </div>
      <div className="grid grid-cols-7 gap-x-2">
        {week.map((day) => {
          const classNames = getClassNames(day, selectedDate);
          return (
            <button
              className={classNames?.div}
              key={day.getTime()}
              onClick={() => {
                setSelectedDate(day);
              }}
            >
              <span className={classNames?.span}>{format(day, "dd")}</span>
            </button>
          );
        })}
      </div>
    </div>
  );
};

const getClassNames = (day: Date, selectedDate: Date) => {
  if (
    DateLibs.isYearEqual(day, selectedDate) &&
    DateLibs.isMonthEqual(day, selectedDate) &&
    DateLibs.isDateEqual(day, selectedDate)
  ) {
    return {
      div: "flex justify-center items-center w-10 h-10 rounded-full bg-black",
      span: "text-white",
    };
  }
  if (day.getDay() === 0) {
    return {
      div: "flex justify-center items-center w-10 h-10",
      span: "text-red-500",
    };
  }
  if (day.getDay() === 6) {
    return {
      div: "flex justify-center items-center w-10 h-10",
      span: "text-blue-600",
    };
  }
  return {
    div: "flex justify-center items-center w-10 h-10",
    span: "text-gray-700",
  };
};
export default Calendar;

이렇게 useCalendar 훅을 통해서 유연한 캘린더 컴포넌트를 작성할 수 있다.

패키지

useCalendar 훅은 다음 패키지를 설치하면 사용할 수 있다.

0
Subscribe to my newsletter

Read articles from Nowon Lee directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Nowon Lee
Nowon Lee