[React & NextJS] 켈린더 만들기 (DateCalendar) (feat. Day.js)
Day.js를 사용하여 만들었습니다. (날짜 라이브러리)
Before you learn
영상
라이브러리 선택과 이유
● 달력 라이브러리
2. MUI
처음에 완성된 달력 라이브러리를 사용하려고 하였으나,
ReactDatePicker와 MUI 둘 다 React를 중점으로 사용하기에
Next.js(필자는 Next.js를 사용 중임)에서 잘 작동되지 않았다.
그리고, 기존에는 input의 type="date"를 사용하였으나,
이번 기회에 스스로 만들어 보고 싶었기에 만들어진 달력 라이브러리를 사용하지 않고,
날짜 라이브러리 비교를 통해 Day.js를 사용하여 켈린더를 구현해 보았다.
날짜 라이브러리는 선택 이유는 윗 링크(날짜 라이브러리 비교)를 통해 적어놓았기에 생략하겠다.
구현 상황
회원가입 목록 중 생년월일 입력 기능을 구현하기 위해 만듦
켈린더 기능
1. Day.js(날짜 라이브러리)를 사용하여 날짜를 가져옴
2. input에 직접 값을 변경할 수 없도록 함
3. 현재 날짜가 포함된 월이 보이도록 함
4. 화살표 버튼으로 과거, 미래의 날짜를 보여줌
5. 날짜 선택 시 input에 값 입력
6. 초기화 버튼 (input값 초기화 및 현재 날짜로 설정됨)
7. 모달 닫기 버튼
8. 연도 변경 기능 (현재 날짜를 기준으로 200년 전까지 연도를 표시함)
9. 선택한 날짜, 연도 표시 기능
10. 모달 이외의 곳을 누르면 모달 닫힘 기능
설치
// day.js (날짜 라이브러리)
npm i dayjs
파일 구조
signup.jsx > DateCalendar.jsx
전체 코드
sinup.jsx
// singup.jsx (CSS는 생략하였습니다.)
import { useState } from "react";
import DateCalendar from "@/components/DateCalendar";
export default function SignUp() {
// 생년월일 달력 모달 상태
const [showDateCalendarModal, setShowDateCalendarModal] = useState(false);
// 생년월일 달력 모달 on, off 버튼
const handleShowDateCalendarBtn = () => {
setShowDateCalendarModal(!showDateCalendarModal);
};
// 생년월일
const [birthDate, setBirthDate] = useState("");
// API 호출
const onClickSubmitBtn = async () => {
...
}
return (
<>
<main>
<form onSubmit={(event) => event.preventDefault()}>
<section>
...
{/* 생년월일 */}
<section>
...
<div className="relative">
<input
readOnly // 유저가 직접 입력하여 값을 변경할 수 없도록 함, input 오른쪽의 캘린더 아이콘 없앰
required
id="birthDate"
type="date"
onClick={handleShowDateCalendarBtn}
value={birthDate}
className="border-[#cccccc] border-[1px] rounded-md w-full h-[55px] text-md p-4 text-gray-600 cursor-pointer"
/>
{showDateCalendarModal && (
<DateCalendar
birthDate={birthDate}
setBirthDate={setBirthDate}
showDateCalendarModal={showDateCalendarModal}
setShowDateCalendarModal={setShowDateCalendarModal}
/>
)}
</div>
</section>
{/* 버튼을 누르면 서버에 제출되도록 함수 만들기 */}
<button type="submit" onClick={onClickSubmitBtn}>
회원가입
</button>
</section>
</form>
</main>
</>
);
}
//
DateCalendar.jsx
// DateCalendar.jsx
import dayjs from "dayjs";
import { useState } from "react";
dayjs.locale("ko");
import ArrowBtn from "./Buttons/ArrowBtn";
import BlackBtn from "./Buttons/BlackBtn";
export default function DateCalendar({
birthDate,
setBirthDate,
showDateCalendarModal,
setShowDateCalendarModal,
}) {
// 연도 변환 모달
const [showYearModal, setShowYearModal] = useState(false);
// 현재 날짜를 가져옵니다.
const currentDate = dayjs();
const currentYear = currentDate.year();
// 200년 전의 연도를 계산합니다.
const pastYear = currentDate.subtract(200, "year").year();
// 현재 연도와 200년 전까지의 연도를 배열에 담습니다.
const years = [];
for (let year = currentYear; year >= pastYear; year--) {
years.push(String(year));
}
// 요일
const dayOfTheWeek = ["일", "월", "화", "수", "목", "금", "토"];
// 날짜 상태관리
const [today, setToday] = useState(dayjs());
// 해당 달의 전체일수를 구함
const daysInMonth = today.daysInMonth();
// 이번 달의 1일에 대한 정보
const firstDayOfMonth = dayjs(today).startOf("month").locale("ko");
// 1일부터 마지막 날까지 배열에 순차적으로 넣음
const dates = [];
for (let i = 1; i <= daysInMonth; i++) {
const date = dayjs(firstDayOfMonth).add(i - 1, "day");
dates.push(date);
}
// 공백 날
// firstDayOfMonth.day() // 0 ~ 6 (일 ~ 토)
const emptyDates = new Array(firstDayOfMonth.day()).fill(null);
// 1일의 요일 만큼 앞에 빈 공백 넣어준다.
const calenderData = [...emptyDates, ...dates];
// 이전 달
const onClickPastMonth = () => {
setToday(dayjs(today).subtract(1, "month"));
};
// 다음 달
const onClickNextMonth = () => {
setToday(dayjs(today).add(1, "month"));
};
// 초기화 (이번 달로 이동함)
const onClickResetBtn = () => {
setToday(dayjs());
setBirthDate("");
};
// 연도 선택(변경)
const onClickChangeYear = (year) => {
setToday(dayjs(today).set("year", year));
showYearModalBtn();
};
// 날짜 선택(변경) (= input 값 변경)
const onClickChangeDate = (date) => {
setBirthDate(date.format("YYYY-MM-DD"));
showDateCalendarModalBtn();
};
// 연도 모달 on, off
const showYearModalBtn = () => {
setShowYearModal(!showYearModal);
};
// DateCalendar 모달 on, off
const showDateCalendarModalBtn = () => {
setShowDateCalendarModal(false);
};
return (
<div>
{/* Date Calendar */}
<div className="absolute top-[-360px] right-0 z-[200]">
{/* 달력 모달과 연도 선택 모달이 둘 다 켜진 경우, 구분을 위해 달력 모달에 배경색을 입힌다. */}
<div
className={`w-[330px] p-4 rounded-2xl shadow-xxx ${
showDateCalendarModal && showYearModal ? "bg-gray-300" : "bg-[#fff]"
}`}
>
<section className="w-full">
{/* 달력의 헤더 */}
<header>
<div className="relative flex flex-row justify-center pb-[22px]">
{/* 연도 선택 버튼 */}
<div onClick={showYearModalBtn} className="cursor-pointer">
{today.format("YYYY년 M월")}
</div>
{/* 이전 달로 변경 */}
<div className="absolute left-4">
<ArrowBtn onClick={onClickPastMonth} direction={"Left"} />
</div>
{/* 다음 달로 변경 */}
<div className="absolute right-4">
<ArrowBtn onClick={onClickNextMonth} direction={"Right"} />
</div>
{/* 연도 변경 모달 */}
{showYearModal && (
<section className="absolute top-8 z-[201] bg-[#fff] border-[1px] w-full h-[280px] p-6 rounded-lg shadow-xxx ">
<ul className="w-full h-[200px] flex flex-row flex-wrap overflow-y-scroll scrollbar-hide">
{years.map((year, index) => (
<li
key={index}
onClick={() => onClickChangeYear(year)}
className={`w-[25%] h-[30px] flex flex-row justify-center items-center text-lg text-gray-600 rounded-full cursor-pointer
${
year === today.format("YYYY")
? "bg-black text-white"
: "hover:font-bold hover:bg-gray-200 hover:text-black "
}`}
>
{year}
</li>
))}
</ul>
{/* 연도 모달 닫기 버튼 */}
<div className="flex flex-row justify-end pt-2">
<BlackBtn
type={"button"}
onClick={showYearModalBtn}
px={"4"}
py={"2"}
textSize={"sm"}
text={"닫기"}
/>
</div>
</section>
)}
</div>
{/* 요일 */}
<ul className="flex flex-row justify-around pb-2">
{dayOfTheWeek.map((el, index) => (
<li key={index} className="cursor-default">
{el}
</li>
))}
</ul>
</header>
{/* 날짜 표시 */}
<main>
<ul className="flex flex-row flex-wrap">
{calenderData.map((date, index) => (
<li
key={index}
className="w-[14.28%] aspect-square flex flex-row "
>
{date !== null ? (
<div
onClick={() => onClickChangeDate(date)}
className={`cursor-pointer w-full flex flex-row justify-center items-center rounded-full ${
date.format("YYYY-MM-DD") === birthDate
? "bg-black text-white"
: "hover:bg-gray-200"
}`}
>
{date.format("D")}
</div>
) : (
""
)}
</li>
))}
</ul>
</main>
</section>
{/* 모달 하단 부분 */}
<section className="flex flex-row justify-between items-center px-2">
{/* 초기화 버튼 */}
<button
type="button"
onClick={onClickResetBtn}
className="text-blue-500 hover:underline"
>
초기화
</button>
{/* 켈린더 모달 전체 닫기 버튼 */}
<BlackBtn
type={"button"}
onClick={showDateCalendarModalBtn}
px={"4"}
py={"2"}
textSize={"sm"}
text={"닫기"}
/>
</section>
</div>
</div>
{/* DateCalendar 모달 바깥 부분 */}
<div
onClick={showDateCalendarModalBtn}
className="fixed top-0 left-0 w-full h-full z-[199]"
></div>
</div>
);
}
코드 설명
// 요일
const dayOfTheWeek = ["일", "월", "화", "수", "목", "금", "토"];
// 해당 달의 전체일수를 구함
const daysInMonth = today.daysInMonth();
// 선택한 달의 1일에 대한 정보
const firstDayOfMonth = dayjs(today).startOf("month").locale("ko");
// 1일부터 마지막 날까지 배열에 순차적으로 넣음
const dates = [];
for (let i = 1; i <= daysInMonth; i++) {
const date = dayjs(firstDayOfMonth).add(i - 1, "day");
dates.push(date);
}
선택한 달의 1일에 대한 정보를 firstDayOfMonth 함수를 통해 가져오고,
해당 달의 전체일수를 dayInMonth 함수를 통해 구하여,
1일부터 마지막 날까지 dates 배열에 순차적으로 입력한다.
// 선택한 달의 1일에 대한 정보
const firstDayOfMonth = dayjs(today).startOf("month").locale("ko");
// 공백 날
// firstDayOfMonth.day() // 0 ~ 6 (일 ~ 토)
const emptyDates = new Array(firstDayOfMonth.day()).fill(null);
// 1일의 요일 만큼 앞에 빈 공백 넣어준다.
const calendarData = [...emptyDates, ...dates];
달력의 매달 1일의 앞에 있는 공백을 입력하기 위해 firstDayOfMonth 정보를 사용한다.
firstDayOfMonth.day() 함수를 통하여 1일에 대한 정보가 무슨 요일로 시작하는지 알 수 있고,
해당하는 요일 수 만큼 emptyDates의 배열로 공백 날을 추가한 뒤,
구조분해할당 형식으로 달력의 전체 날짜를 입력한다.
firstDayOfMonth.day()의 값은 0 ~ 6으로 나타난다. (일요일 ~ 토요일을 의미한다.)
(예를 들면 2024년 3월의 달력을 보면 3월 1일은 금요일이기에 1일 앞에 일요일 ~ 목요일까지 공백처리로 비워두는 것이다.
2024년 3월인 경우, calendarData = [null, null, null, null, null, 1, 2, 3, ..., 31] 처럼 되어 있다.)
// 날짜 선택(변경) (= input 값 변경)
const onClickChangeDate = (date) => {
setBirthDate(date.format("YYYY-MM-DD"));
showDateCalendarModalBtn();
};
// 요일
<ul className="flex flex-row justify-around pb-2">
{dayOfTheWeek.map((el, index) => (
<li key={index} className="cursor-default">
{el}
</li>
))}
</ul>
// 날짜
<ul className="flex flex-row flex-wrap">
{calendarData.map((date, index) => (
<li
key={index}
className="w-[14.28%] aspect-square flex flex-row "
>
{date !== null ? (
<div
onClick={() => onClickChangeDate(date)}
className={`cursor-pointer w-full flex flex-row justify-center items-center rounded-full ${
date.format("YYYY-MM-DD") === birthDate
? "bg-black text-white"
: "hover:bg-gray-200"
}`}
>
{date.format("D")}
</div>
) : (
""
)}
</li>
))}
</ul>
윗 코드처럼 만든 데이터들 map를 통해 퍼트려서 유저에게 보여주었다.
달력을 만들 때 <table> <tbody> <tr> <tb> 등을 사용해서 만드는 곳이 많은데,
필자는 flex로 만들어 보았다.
날짜를 클릭하면, 해당 날짜가 onClick 이벤트로 onClickChangeDate 함수로 전달되고,
전달된 date 값을 알맞는 형식으로 변경한 뒤, birthDate의 상태를 업데이트 해주었다.
선택한 날짜의 값(date.format("YYYY-MM-DD")과 input에 입력된 값(birthDate)이 같으면,
해당 날짜에 배경색과 글자색이 변경되도록 하였다.
// 이전 달
const onClickPastMonth = () => {
setToday(dayjs(today).subtract(1, "month"));
};
// 다음 달
const onClickNextMonth = () => {
setToday(dayjs(today).add(1, "month"));
};
// 초기화 (이번 달로 이동함)
const onClickResetBtn = () => {
setToday(dayjs());
setBirthDate("");
};
// 이전 달로 변경
<div className="absolute left-4">
<ArrowBtn onClick={onClickPastMonth} direction={"Left"} />
</div>
// 다음 달로 변경
<div className="absolute right-4">
<ArrowBtn onClick={onClickNextMonth} direction={"Right"} />
</div>
// 초기화 버튼
<button
type="button"
onClick={onClickResetBtn}
className="text-blue-500 hover:underline"
>
초기화
</button>
이전 달, 다음 달, 초기화 버튼(이번 달 버튼으로 사용 가능함)을 만들었고,
day.js의 사용법을 조금이라도 봤으면 바로 이해될 것이라 생각한다.
<ArrowBtn>은 기존에 만든 화살표 버튼 컴포넌트라 코드를 약간 수정하여 재사용하였다.
여기까지 했으면 연도 변경 기능을 제외한 대부분의 기능이 구현되었다.
하지만, 생년월일에서 연도 기능을 없이 구현한다면, 사용자 입장에서 너무 불편하기에 구현해보았다.
// 현재 날짜를 가져옵니다.
const currentDate = dayjs();
const currentYear = currentDate.year();
// 200년 전의 연도를 계산합니다.
const pastYear = currentDate.subtract(200, "year").year();
// 현재 연도와 200년 전까지의 연도를 배열에 담습니다.
const years = [];
for (let year = currentYear; year >= pastYear; year--) {
years.push(String(year));
}
아주 먼 옛날까지의 달력을 구현하는 것이 아니라,
회원가입의 생년월일을 구현하려고 하였기에,
현재 날짜로부터 200년 전까지 연도를 연도 선택 모달로 선택할 수 있게 하였다.
year이 기존에는 Number 타입이었으나, 바로 밑의 코드에서 나오는 것과 같이,
해당 연도에 조건식을 설정하기 위해 타입을 string으로 변경하였다. (year === today.format("YYYY"))
해당 조건이 맞으면 (= 현재 내가 선택한 연도) 배경색과 글자색이 변경되도록 하였다.
// 연도 모달 on, off
const showYearModalBtn = () => {
setShowYearModal(!showYearModal);
};
// 연도 선택(변경)
const onClickChangeYear = (year) => {
setToday(dayjs(today).set("year", year));
showYearModalBtn();
};
// 연도 선택 버튼
<div onClick={showYearModalBtn} className="cursor-pointer">
{today.format("YYYY년 M월")}
</div>
// 연도 변경 모달
{showYearModal && (
<section className="absolute top-8 z-[201] bg-[#fff] border-[1px] w-full h-[280px] p-6 rounded-lg shadow-xxx ">
<ul className="w-full h-[200px] flex flex-row flex-wrap overflow-y-scroll scrollbar-hide">
{years.map((year, index) => (
<li
key={index}
onClick={() => onClickChangeYear(year)}
className={`w-[25%] h-[30px] flex flex-row justify-center items-center text-lg text-gray-600 rounded-full cursor-pointer
${year === today.format("YYYY")
? "bg-black text-white"
: "hover:font-bold hover:bg-gray-200 hover:text-black "
}`}
>
{year}
</li>
))}
</ul>
// 연도 모달 닫기 버튼
<div className="flex flex-row justify-end pt-2">
<BlackBtn
type={"button"}
onClick={showYearModalBtn}
px={"4"}
py={"2"}
textSize={"sm"}
text={"닫기"}
/>
</div>
</section>
)}
달력의 헤더인 연도와 월이 표시된 부분을 누르면 연도 선택 모달이 나타난다.
위의 날짜 선택시 input의 값이 변경되는 것과 유사하게,
연도를 선택하면, 해당 연도가 onClick 이벤트로 onClickChangeYear 함수로 전달되고,
전달된 year 값을 set()으로 현재 연도를 변경하고, 변경된 값으로 today 상태를 업데이트 해주었다.
// set 사용 예시 (year, month, date, day, hour, minute, second ...이 있음)
let date = dayjs("2024-04-05 00:00:00");
date.format(); // 2024-04-05T00:00:00+09:00
date.set("year", 2023).format(); // 2023-04-05T00:00:00+09:00
date.set("y", 2023).format(); // 2023-04-05T00:00:00+00:00
// DateCalendar 모달 on, off
const showDateCalendarModalBtn = () => {
setShowDateCalendarModal(false);
};
<div>
{/* Date Calendar */}
<main>
...
</main>
{/* DateCalendar 모달 바깥 부분 */}
<div
onClick={showDateCalendarModalBtn}
className="fixed top-0 left-0 w-full h-full z-[199]"
></div>
</div>
달력 모달이 ON 되어 있는 상태에서,
달력 모달부분을 제외한 나머지 모든 화면의 부분을 클릭하면, 달력 모달창이 닫히도록 구현하였다.
(달력 모달의 z-index는 200, 연도 변경 모달은 201로 입력하였다.)