src
┣ components
┃ ┗ calendar
┃ ┃ ┣ dates
┃ ┃ ┃ ┣ DateCell.tsx
┃ ┃ ┃ ┗ index.tsx
┃ ┃ ┣ month
┃ ┃ ┃ ┣ WeekdayHeader.tsx
┃ ┃ ┃ ┣ Weekdays.tsx
┃ ┃ ┃ ┗ index.tsx
┃ ┃ ┣ BookingDatesView.tsx
┃ ┃ ┣ MonthNavigation.tsx
┃ ┃ ┗ index.tsx
┣ constants
┃ ┣ daysOfWeek.ts
┃ ┣ format.ts
┃ ┗ languages.ts
┣ context
┃ ┗ CalendarContext.tsx
┣ hooks
┃ ┣ useGetSavedPeriod.ts
┃ ┣ useHandleClickDate.ts
┃ ┗ useUpdateCheckInOut.ts
┣ styles
┃ ┗ GlobalStyles.tsx
┣ types
┃ ┗ index.ts
┣ utils
┃ ┗ dateUtils.ts
┣ App.tsx
┣ index.ts
┣ main.tsx
┗ vite-env.d.ts
export { default as Calendar } from "./App";
import React from "react";
import ReactDOM from "react-dom/client";
import App from "./App";
ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
<React.StrictMode>
<App />
</React.StrictMode>
);
이렇게 import된 App
컴포넌트는 이후 ReactDOM.createRoot(...).render(...)
메소드를 통해 렌더링되게 된다. (createRoot
로 브라우저 DOM 노드 안에 React 컴포넌트를 표시하는 루트를 생성할 수 있다.)
document.getElementById("root") as HTMLElement
는 HTML 문서에서 "root"라는 ID를 가진 엘리먼트를 찾아낸다.
<React.StrictMode>
는 개발 모드에서만 활성화되며, 잠재적인 문제를 찾아내기 위한 도구이다.
<App />
는 애플리케이션의 루트 컴포넌트를 렌더링한다.
function App(props: CalendarProps) {
const { mainColor, subMainColor, onCheckInOutChange } = props;
return (
<ThemeProvider theme={{ mainColor, subMainColor }}>
<GlobalStyle />
<CalendarProvider
calendarProps={props}
onCheckInOutChange={onCheckInOutChange}
>
<Calendar />
</CalendarProvider>
</ThemeProvider>
);
}
export default App;
<ThemeProvider/>
: 어떤 컴포넌트가 몇 단계를 거쳐서 depth를 갖더라도 루트에 ThemeProvider
가 자리잡고 있다면 모든 렌더 트리의 자식에는 다 theme 속성을 갖게된다.
<GlobalStyle />
: 전역 스타일 설정 파일, 자식을 허용하지 않는 스타일드 컴포넌트를 반환한다. React 트리의 맨 위에 배치하면 컴포넌트가 "렌더링"될 때 전역 스타일이 주입된다.
<CalendarProvider/>
const defaultProps: CalendarProps = {
startDay: 0,
numMonths: 2,
language: "en",
maximumMonths: 12,
showBookingDatesView: true,
isRectangular: false,
resetStyle: false,
defaultCheckIn: dayjs().add(7, "day"),
defaultCheckOut: dayjs().add(8, "day"),
};
type CalendarContextType = {
today: dayjs.Dayjs;
currentMonth: dayjs.Dayjs;
setCurrentMonth: (num: number) => void;
bookingDates: BookingDatesType;
setBookingDates: React.Dispatch<React.SetStateAction<BookingDatesType>>;
calendarSettings: CalendarProps;
setCalendarSettings: React.Dispatch<React.SetStateAction<CalendarProps>>;
onCheckInOutChange?: CheckInOutChangeType;
};
const initialContextValue: CalendarContextType = {
today: dayjs(),
currentMonth: dayjs(),
setCurrentMonth: () => {},
bookingDates: {
checkIn: undefined,
checkOut: undefined,
},
setBookingDates: () => {},
calendarSettings: defaultProps,
setCalendarSettings: () => {},
onCheckInOutChange: () => {},
};
type CalendarProviderProps = {
children: ReactNode;
calendarProps: CalendarProps;
onCheckInOutChange?: (checkInDate?: Date, checkOutDate?: Date) => void;
};
const CalendarContext = createContext<CalendarContextType>(initialContextValue);
const CalendarProvider = ({
children,
calendarProps = defaultProps,
onCheckInOutChange,
}: CalendarProviderProps) => {
const [currentMonth, _setCurrentMonth] = useState<dayjs.Dayjs>(dayjs());
const [bookingDates, setBookingDates] = useState<{
checkIn?: dayjs.Dayjs;
checkOut?: dayjs.Dayjs;
}>({
checkIn: calendarProps.defaultCheckIn
? dayjs(calendarProps.defaultCheckIn)
: dayjs().add(7, "day"),
checkOut: calendarProps.defaultCheckOut
? dayjs(calendarProps.defaultCheckOut)
: calendarProps.defaultCheckIn
? dayjs(calendarProps.defaultCheckIn).add(1, "day")
: dayjs().add(8, "day"),
});
const setCurrentMonth = (num: number) => {
_setCurrentMonth((prevMonth) => prevMonth.add(num, "month"));
};
const [calendarSettings, setCalendarSettings] = useState<CalendarProps>({
...defaultProps,
...calendarProps,
});
useGetSavedPeriod(setBookingDates);
useUpdateCheckInOut(bookingDates, onCheckInOutChange);
const value: CalendarContextType = {
today: dayjs(),
currentMonth,
setCurrentMonth,
bookingDates,
setBookingDates,
calendarSettings,
setCalendarSettings,
onCheckInOutChange,
};
return (
<CalendarContext.Provider value={value}>
{children}
</CalendarContext.Provider>
);
};
export { CalendarContext, CalendarProvider };
CalendarProps
의 기본값을 정의하며, CalendarProvider
가 props로 calendarProps
를 받지 않았을 때 이를 사용한다.initialContextValue
는 CalendarContext
의 초기값을 설정하는 데 사용된다. 이 값은 createContext
에 전달되어 컨텍스트가 처음 만들어질 때 사용되며, CalendarProvider
컴포넌트에서 이 값을 덮어쓸 수 있다.CalendarContext
는 React의 컨텍스트 API를 통해 만들어진 객체로, 이를 통해 컴포넌트 트리에 걸쳐 데이터를 공유할 수 있다. CalendarContext.Provider
는 CalendarContext
의 값을 제공하는 컴포넌트이며, CalendarProvider
는 이 Provider
컴포넌트를 캡슐화하여 필요한 로직과 상태를 관리하는 역할을 한다.currentMonth
, bookingDates
, calendarSettings
)과 상태 변경 함수(setCurrentMonth
, setBookingDates
, setCalendarSettings
)가 context에 제공된다.즉, defaultProps 이 있고, initialContextValue 는 말그대로 context의 초기값을 말하고, CalendarContext, CalendarProvider 를 만들어주는것!
type SetBookingDates = React.Dispatch<
React.SetStateAction<{
checkIn?: dayjs.Dayjs;
checkOut?: dayjs.Dayjs;
}>
>;
const STORAGE_KEY = "stayPeriod";
const useGetSavedPeriod = (setBookingDates: SetBookingDates) => {
useEffect(() => {
const periodData = localStorage.getItem(STORAGE_KEY);
if (periodData) {
const { checkIn, checkOut } = JSON.parse(periodData);
setBookingDates({
checkIn: dayjs(checkIn),
checkOut: dayjs(checkOut),
});
}
}, [setBookingDates]);
};
export default useGetSavedPeriod;
로컬 스토리지에서 저장된 체크인 및 체크아웃 날짜를 읽어와서 주어진 상태 설정 함수(setBookingDates)를 사용하여 해당 상태를 업데이트하는 커스텀 훅
type BookingDates = {
checkIn?: dayjs.Dayjs;
checkOut?: dayjs.Dayjs;
};
type CheckInOutChangeFunction = (
checkInDate?: Date,
checkOutDate?: Date
) => void;
export const useUpdateCheckInOut = (
bookingDates: BookingDates,
onCheckInOutChange: CheckInOutChangeFunction | undefined
) => {
useEffect(() => {
if (onCheckInOutChange) {
onCheckInOutChange(
bookingDates.checkIn?.toDate(),
bookingDates.checkOut?.toDate()
);
}
}, [bookingDates, onCheckInOutChange]);
};
export default useUpdateCheckInOut;
예약 날짜(체크인 날짜와 체크아웃 날짜)에 변화가 생기면, 해당 변화를 인자로 받는 함수 onCheckInOutChange를 실행하는 커스텀 훅
<Calendar/>
const Calendar = () => {
const { calendarSettings } = useContext(CalendarContext);
const { numMonths, showBookingDatesView } = calendarSettings;
return (
<>
{showBookingDatesView && <BookingDatesView />}
<MonthNavigation />
<CalendarContainer>
{[...Array(numMonths)].map((_, index) => (
<MonthView key={`month-view-${index}`} index={index} />
))}
</CalendarContainer>
</>
);
};
export default Calendar;
calendarSettings 에서 numMonths,showBookingDatesView 의 prop 으로 받아온 값을 가져와서, showBookingDatesView 의 여부에따라 BookingDatesView 를 보여줄지 말지 결정하고 numMonths 에 따라서 MonthView를 몇개 보여줄지 결정한다. 기본값은 2개를 보여주는것
const BookingDatesView = () => {
const { bookingDates, calendarSettings } = useContext(CalendarContext);
const { language = "en" } = calendarSettings;
const { checkIn: checkInText, checkOut: checkOutText } =
languageTextMap[language];
const renderDateView = (title: string, date?: dayjs.Dayjs) => (
<BookingDatesViewBox>
<BookingDatesTitle>
{title} {date?.format(DATE_FORMAT)}
</BookingDatesTitle>
</BookingDatesViewBox>
);
return (
<BookingDatesViewContainer>
{renderDateView(checkInText, bookingDates.checkIn)}
{renderDateView(checkOutText, bookingDates.checkOut)}
</BookingDatesViewContainer>
);
};
export default BookingDatesView;
이 컴포넌트는 체크인과 체크아웃 날짜를 각각 별도의 박스에 표시한다
const MonthNavigation = () => {
const { today, currentMonth, setCurrentMonth, calendarSettings } =
useContext(CalendarContext);
const { maximumMonths = 12 } = calendarSettings;
const laterMonthDate = useMemo(
() => today.add(maximumMonths - 1, "month").toDate(),
[today, maximumMonths]
);
const isPrevButtonDisabled =
today.year() >= currentMonth.year() &&
today.month() >= currentMonth.month();
const isNextButtonDisabled =
laterMonthDate.getFullYear() <= currentMonth.year() &&
laterMonthDate.getMonth() <= currentMonth.month();
const handleMonthChange = useCallback(
(num: number) => {
setCurrentMonth(num);
},
[setCurrentMonth]
);
return (
<Container>
<ButtonContainer>
<Button
disabled={isPrevButtonDisabled}
onClick={() => handleMonthChange(-1)}
>
<
</Button>
<Button
disabled={isNextButtonDisabled}
onClick={() => handleMonthChange(1)}
>
>
</Button>
</ButtonContainer>
</Container>
);
};
export default MonthNavigation;
월을 이동하는 기능을 담당하며, 이전 월과 다음 월로 이동하는 버튼을 렌더링하고, 버튼을 클릭하면 월이 변경된다.
이때, 이전 월로 이동하는 버튼은 현재 월이 현재 날짜보다 이전이면 비활성화 되며, 다음 월로 이동하는 버튼은 현재 월이 설정된 최대 월 이상이면 비활성화 된다.
const MonthView = ({ index }: { index: number }) => {
const { currentMonth, calendarSettings } = useContext(CalendarContext);
const { language = "en" } = calendarSettings;
const [dates, setDates] = useState(calculateNewDates(currentMonth, index));
useEffect(() => {
setDates(calculateNewDates(currentMonth, index));
}, [currentMonth]);
return (
<Container>
<WeekdayHeader
year={dates.newYear}
month={dates.newMonth}
language={language}
/>
<BodyContentContainer>
<Weekdays />
<Dates newYear={dates.newYear} newMonth={dates.newMonth} />
</BodyContentContainer>
</Container>
);
};
export default MonthView;
이 컴포넌트는 캘린더의 월별 뷰를 담당하며, 각 월의 헤더와 요일, 날짜를 표시한다.
export const calculateNewDates = (currentMonth: dayjs.Dayjs, index: number) => {
const newMonth = ((currentMonth.month() + index) % 12) + 1;
const newYear =
currentMonth.year() + Math.floor((currentMonth.month() + index) / 12);
return { newMonth, newYear };
};
이 함수는 현재의 월(currentMonth)와 인덱스(index)를 입력으로 받고, 주어진 인덱스를 사용하여 새로운 월과 연도를 계산한다.
이 인덱스는 월을 증가시키거나 감소시키는 데 사용될 수 있으며, 연도의 변경도 고려한다.
- 예를 들어, 현재 월이 12월이고 인덱스가 1인 경우, 이 함수는 1월과 다음 연도를 반환한다. 마찬가지로, 현재 월이 1월이고 인덱스가 -1인 경우, 이 함수는 12월과 이전 연도를 반환한다.
type WeekdayHeaderProps = {
year: number;
month: number;
language: string;
};
const WeekdayHeader = ({ year, month, language }: WeekdayHeaderProps) => {
const getFormattedDateText = (
year: number,
month: number,
language: string
) => (language === "ko" ? `${year}년 ${month}월` : `${year}. ${month}`);
return (
<WeekdayHeaderContainer>
<WeekdayHeaderText>
{getFormattedDateText(year, month, language)}
</WeekdayHeaderText>
</WeekdayHeaderContainer>
);
};
export default WeekdayHeader;
각 월의 헤더 표시
const Weekdays = () => {
const { calendarSettings } = useContext(CalendarContext);
const { language = "en", startDay = 0 } = calendarSettings;
const DAYS_OF_WEEK: string[] = useMemo(() => {
let daysOfWeek = language === "ko" ? DAYS_OF_WEEK_KO : DAYS_OF_WEEK_EN;
return [...daysOfWeek.slice(startDay), ...daysOfWeek.slice(0, startDay)];
}, [language, startDay]);
return (
<Days>
{DAYS_OF_WEEK.map((elm) => (
<div key={elm}>{elm}</div>
))}
</Days>
);
};
export default Weekdays;
한 주 동안의 요일을 표시함, 사용자의 언어 설정과 시작 요일 설정에 따라 요일 표시를 동적으로 변경할 수 있다.
type DatesProps = {
newYear: number;
newMonth: number;
};
const Dates = ({ newYear, newMonth }: DatesProps) => {
const { calendarSettings } = useContext(CalendarContext);
const { startDay = 0 } = calendarSettings;
const totalDate = useMemo(() => {
return generateMonthCalendar(newYear, newMonth, startDay);
}, [newYear, newMonth, startDay]);
const lastDayOfMonth = useMemo(() => {
return new Date(newYear, newMonth, 0).getDate();
}, [newYear, newMonth]);
return (
<DatesContainer>
{totalDate.map((date) => (
<DateCell
key={date.toString()}
year={date.getFullYear()}
month={date.getMonth() + 1}
date={date.getDate()}
isOtherDay={date.getMonth() + 1 !== newMonth}
lastDayOfMonth={lastDayOfMonth}
/>
))}
</DatesContainer>
);
};
export default Dates;
캘린더 한 달 동안의 모든 날짜 표시함, 사용자의 시작 요일 설정에 따라 날짜 표시를 동적으로 변경할 수 있다.
type DateCellProps = {
date: number;
month: number;
year: number;
isOtherDay: boolean;
lastDayOfMonth: number;
};
const DateCell = ({
date,
month,
year,
isOtherDay,
lastDayOfMonth,
}: DateCellProps) => {
const { bookingDates, today, calendarSettings } = useContext(CalendarContext);
const { isRectangular } = calendarSettings;
const currentDate = dayjs(new Date(year, month - 1, date));
const { handleClickDate } = useHandleClickDate(today);
const currentDateString = currentDate.format(DATE_FORMAT);
const todayDateString = today.format(DATE_FORMAT);
const isAfterLastDay = date > lastDayOfMonth;
const checkInDateString = bookingDates.checkIn?.format(DATE_FORMAT);
const checkOutDateString = bookingDates.checkOut?.format(DATE_FORMAT);
const isSelectedDate =
!isOtherDay &&
(checkInDateString === currentDateString ||
checkOutDateString === currentDateString);
const isWithinRange =
!isOtherDay &&
checkInDateString &&
checkOutDateString &&
checkInDateString < currentDateString &&
currentDateString < checkOutDateString;
return (
<DatesContainer
onClick={
!isAfterLastDay && !isOtherDay
? () => handleClickDate(currentDate)
: undefined
}
>
{isSelectedDate && <Highlighting isRectangular={isRectangular} />}
{isWithinRange && <MiddleHighlighting isRectangular={isRectangular} />}
{currentDateString === todayDateString && (
<TodayDot isHighlighting={isSelectedDate} />
)}
<DateNum
isBeforeToday={currentDateString < todayDateString}
isOtherDay={isOtherDay}
isHighlighting={isSelectedDate}
isRectangular={isRectangular}
>
{date}
</DateNum>
</DatesContainer>
);
};
export default DateCell;
이 컴포넌트는 특정 날짜가 선택되었는지, 범위 안에 있는지, 그리고 오늘 날짜인지를 판별합니다. 이 조건에 따라 다양한 스타일을 적용한다.
const useHandleClickDate = (today: dayjs.Dayjs) => {
const { bookingDates, setBookingDates } = useContext(CalendarContext);
const handleClickDate = (date: dayjs.Dayjs) => {
const todayString = today.format("YYYY-MM-DD");
const dateString = date.format("YYYY-MM-DD");
if (todayString > dateString) {
return;
}
if (
!bookingDates.checkIn ||
(bookingDates.checkIn && bookingDates.checkOut) ||
date < bookingDates.checkIn
) {
setBookingDates((prevBookingDates) => ({
...prevBookingDates,
checkIn: date,
checkOut: undefined,
}));
} else if (date > bookingDates.checkIn) {
setBookingDates((prevBookingDates) => ({
...prevBookingDates,
checkOut: date,
}));
}
};
return { handleClickDate };
};
export default useHandleClickDate;
사용자가 캘린더에서 날짜를 클릭했을 때의 동작을 정의하는 커스텀 훅
export const generateMonthCalendar = (
year: number,
month: number,
startDay: number
): Date[] => {
const startOfMonth = new Date(year, month - 1, 1);
const endOfMonth = new Date(year, month, 0);
let startOfWeek = (7 + startOfMonth.getDay() - startDay) % 7;
let endOfWeek = (7 + endOfMonth.getDay() - startDay) % 7;
const startDate = startOfMonth.getDate();
const endDate = endOfMonth.getDate();
const days = Array.from({ length: endDate }, (_, i) => {
return new Date(year, month - 1, i + 1);
});
const previousMonthDays = Array.from({ length: startOfWeek }, (_, i) => {
const date = new Date(year, month - 2, startDate - startOfWeek + i);
return date;
});
const nextMonthDays = Array.from({ length: 6 - endOfWeek }, (_, i) => {
const date = new Date(year, month, endDate + i + 1);
return date;
});
return previousMonthDays.concat(days, nextMonthDays);
};
이 함수는 주어진 연도(year), 월(month), 그리고 달력이 시작되는 요일(startDay)을 입력으로 받는다. 이 함수는 한 달의 날짜 데이터를 생성하는데, 그 달의 첫 주에 속하는 이전 달의 일부 날짜와, 마지막 주에 속하는 다음 달의 일부 날짜를 포함한다. 이를 통해 달력에 전체적으로 일주일 간의 날짜가 항상 표시되도록 한다.