React
직접 제작한 React Dayjs 기반 Calendar 컴포넌트: 사용 방법 및 예제
눙엉
2024. 5. 16. 12:31
여러 라이브러리를 사용해서 만들어서 사용하는 것보다 훨씬 간단하게 사용할 수 있으나... 분명 한 번은 css 커스텀에서 막히는 일이 생겨서 차라리 한번 만들어보자 싶어 직접 만들어 보았습니다.
함께 사용한 라이브러리입니다.
- 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 라이브러리에 대해 조금 더 알게 되는 경험도 한 것 같습니다.
기본 기능 외 추가적인 커스텀 기능을 제외하여 코드를 올렸는데 혹시나 빠진 부분이 있어 동작이 원하는 대로 하지 않으면 댓글에 남겨주시면 감사하겠습니다.