[React] 리액트 라이브러리 없이 캘린더 구현 (+) date-fns

subbni·2024년 9월 27일
post-thumbnail

배경

BAW 웹 프로젝트를 진행하면서 사용자가 글을 얼마나 꾸준히 작성하였는가를 시각적으로, 날짜별로 보여줄 수 있도록 캘린더를 구현하기로 했다.

React-Calendar 등 라이브러리를 사용하여 로직을 빌려오고, css를 수정하는 방법도 있다.
하지만 지금 내가 구현하고자 하는 것은 하나의 페이지를 차지하는 어플 내의 중요 기능이므로 직접 구현하여 자유도를 가져가는 것이 맞다고 판단하였기에, 캘린더를 직접 구현하게 되었다.

요구사항

  1. year를 prev, next로 이동 가능하여야 한다.
  2. month를 prev, next로 이동 가능하여야 한다.
  3. 특정 날짜(date)를 클릭하면 해당 날짜 정보를 react state에 담을 수 있어야 한다.
  4. article이 작성된 날짜엔 구분되는 css가 적용되어야 한다.

캘린더 구현

기본 조회 로직

목표 : 선택한 year / month 의 첫째주 ~ 마지막 주 정보를 알아낸다.

  1. 이를 위해 해당 year의 month의 첫째 주와 마지막 주 정보를 가져온다.
  2. 첫째 주의 첫번째 일(월요일 혹은 일요일)을 알아낸다. 마지막 주의 마지막 일(일요일 혹은 토요일)을 알아낸다.
  3. 첫번째 일 ~ 마지말 일 사이의 날짜 정보를 가져와서 7씩 나누어 week별로 보여준다.

단순히 month의 시작일 ~ 마지막일 날짜 정보를 가져오지 않고, 첫째 '주', 마지막 '주'의 시작일, 마지막일을 가져오는 이유에 대해서 생각해봤다.
결론은 UI를 구현할 때 용이하게 하기 위해서이다.
만일 무조건 첫째 주의 시작일(월요일)부터 가져오지 않는다면, 선택한 달의 시작일이 월요일이 아닐 수 있으므로 시작일에 해당하는 요일이 무엇인지를 확인하고, 그 요일에 해당하는 위치부터 배정하여 출력하는 로직을 따로 작성하여야 할 것이다.

위 로직을 date-fns 가 제공하는 함수들을 통해 쉽게 구현할 수 있다.
다음은 1~3번을 구현할 때 사용한 date-fns 제공 함수들이다.

더 자세한 정보를 위해서는 date-fns 공식문서를 참고하면 된다!

사용한 date-fns 함수

  • eachDayOfInterval : (StartDate, endDate) 사이의 Date 들을 배열로 반환
  • startOfMonth : (Date) 해당 Date가 속하는 Month의 시작일 반환
  • endOfMonth : (Date) 해당 Date가 속하는 Month의 마지막일 반환
  • startOfWeek : (Date) 해당 Date가 속하는 주의 첫번째 날짜 반환
    • (option) weekStartsOn : 0(일요일), 1(월요일) , … , 6(토요일)
  • endOfWeek : (Date) 해당 Date가 속하는 주의 마지막 날짜 반환
    • (option) weekStartsOn : 0(일요일), 1(월요일) , … , 6(토요일)

Year/Month 이동 로직

  • selectedYear / selectedMonth 등 현재 state에서 state±1 의 값으로 바꿔준다.

사용한 date-fns 함수

  • subYears / addYears : (Date, amount) amount 만큼의 year를 Date에 더한(뺀) 날짜를 반환한다.
  • subMonths /addMonths : (Date ,amount) amount만큼의 month를 Date에 더한(뺀) 날짜를 반환한다.

useCalendar 작성

위 로직과 react의 useState를 사용하여 useCalendar 훅을 만들어 사용하였다.
어려운 로직이 들어가지 않아 코드를 읽는 데 어려움은 없을 것이다.

  • selectedYear와 selectedMonth가 현재 날짜로 초기화 되도록 하였다.
  • year과 month가 변경될 경우, day는 null로 다시 초기화 되도록 작성하였다.
    (year/month 변경 시 해당 month의 전체 articles 리스트가 조회되도록 하기 위함)
import { useEffect, useState } from 'react';
import {
	addMonths,
	addYears,
	eachDayOfInterval,
	endOfMonth,
	endOfWeek,
	getDate,
	getMonth,
	getYear,
	startOfMonth,
	startOfWeek,
	subMonths,
	subYears,
} from 'date-fns';

const useCalendar = () => {
	const [selectedYear, setSelectedYear] = useState(null);
	const [selectedMonth, setSelectedMonth] = useState(null);
	const [selectedDay, setSelectedDay] = useState(null);

	useEffect(() => {
      /* 현재 날짜를 바탕으로 초기화 */
		const currentDate = new Date();
		setSelectedYear(getYear(currentDate));
		setSelectedMonth(getMonth(currentDate));
	}, []);

	const handlePrevYear = () => {
		const prevYear = subYears(new Date(selectedYear, selectedMonth), 1);
		setSelectedYear(getYear(prevYear));
		setSelectedMonth(getMonth(prevYear));
		setSelectedDay(null);
	};

	const handleNextYear = () => {
		const nextMonth = addYears(new Date(selectedYear, selectedMonth), 1);
		setSelectedYear(getYear(nextMonth));
		setSelectedMonth(getMonth(nextMonth));
		setSelectedDay(null);
	};

	const handlePrevMonth = () => {
		const prevMonth = subMonths(new Date(selectedYear, selectedMonth), 1);
		setSelectedYear(getYear(prevMonth));
		setSelectedMonth(getMonth(prevMonth));
		setSelectedDay(null);
	};

	const handleNextMonth = () => {
		const nextMonth = addMonths(new Date(selectedYear, selectedMonth), 1);
		setSelectedYear(getYear(nextMonth));
		setSelectedMonth(getMonth(nextMonth));
		setSelectedDay(null);
	};

	const handleSelectedDay = (date) => {
      /* 사용자가 특정 날짜를 클릭하여 선택한 경우 사용 */
		console.log('current date', date);
		setSelectedDay(getDate(date));
	};

  /* 현재 캘린더 날짜 정보 */
	const days = eachDayOfInterval({
		start: startOfWeek(startOfMonth(new Date(selectedYear, selectedMonth)), {
			weekStartsOn: 1,
		}),
		end: endOfWeek(endOfMonth(new Date(selectedYear, selectedMonth)), {
			weekStartsOn: 1,
		}),
	});

	const weeks = [];
	for (let i = 0; i < days.length; i += 7) {
		weeks.push(days.slice(i, i + 7));
	}

	return {
		selectedYear,
		selectedMonth,
		selectedDay,
		weeks,
		handlePrevYear,
		handleNextYear,
		handlePrevMonth,
		handleNextMonth,
		handleSelectedDay,
	};
};

export default useCalendar;

사용은 다음과 같이 하면 된다.


const ArticleCalendarView = ({ articles, onDateChange, articlesForMonth }) => {
	const {
		selectedYear,
		selectedMonth,
		selectedDay,
		weeks,
		handlePrevYear,
		handleNextYear,
		handlePrevMonth,
		handleNextMonth,
		handleSelectedDay,
	} = useCalendar();
  
  중략 ...
  
};

export default ArticleCalendarView;

UI 구현

구현 모습

피그마실제 구현

구성

  • Header : 현재 selecetdYear, selectedMonth를 보여주며 화살표 클릭 시 이동 가능
  • Day List : 월 ~ 일 정보
  • Body : 실제 날짜 정보

코드

const CalendarBlock = styled.div`
	min-width: 350px;
	max-height: 400px;
	padding: 1rem;
	margin: 1rem;
	/* border: 1px solid gray; */
	/* background-color: var(--color-light-gray); */
`;

/**
 * Header
 */

const CalendarHeader = styled.div`
	height: 45px;
	display: flex;
	flex-direction: row;
	align-items: center;
	justify-content: space-between;
	/* border: 1px solid gray; */
	.left {
		display: flex;
		flex-direction: row;
		justify-content: center;
	}
	.right {
		display: flex;
		flex-direction: row;
		justify-content: center;
	}
	.btn {
		padding: 0.125rem;
		cursor: pointer;
	}
	span {
		padding: 0.125rem;
		font-size: large;
		font-weight: 600;
	}
`;

/**
 * Day List
 */
const dayList = ['mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun'];
const CalendarDayListWrapper = styled.div`
	display: flex;
	flex-direction: row;
	align-items: center;
	justify-content: space-between;
	padding: 0.5rem 0;
	/* border: 1px solid gray; */
`;
const CalendarDayList = styled.span`
	/* border: 1px solid gray; */
	width: 12%;
	text-align: center;
	line-height: 100%;
	${({ $sat }) =>
		$sat &&
		css`
			color: royalblue;
		`}
	${({ $sun }) =>
		$sun &&
		css`
			color: tomato;
		`}
`;

/**
 * Body
 */

const CalendarBody = styled.div`
	display: flex;
	flex-direction: column;
	align-items: center;
`;

const WeekItem = styled.div`
	width: 100%;
	padding: 0.5rem 0;
	display: flex;
	flex-direction: row;
	align-items: center;
	justify-content: space-between;
`;
const DayItem = styled.div`
	width: 30px;
	height: 30px;
	text-align: center;
	line-height: 30px;
	border-radius: 100px;
	scale: 0.9;
	${({ $notThisMonth }) =>
		$notThisMonth &&
		css`
			visibility: hidden;
		`}
	${({ $hasArticle }) =>
		$hasArticle &&
		css`
			cursor: pointer;
			border: 1px solid gray;
			&:hover {
				scale: 1;
			}
		`}
		${({ $clicked }) =>
		$clicked &&
		css`
			background-color: var(--color-light-gray);
		`}
`;
  • DayItem의 $notThisMonth : month의 첫째주와 마지막 주에 현재 month에 해당하지 않는 날짜가 들어가 있을 수 있다.
    ex) 아래의 경우 첫째 주의 8/26~8/31, 마지막 주의 10/1~10/6에 해당
    이 경우 색을 흐리게 바꿔줄 수도 있지만, 나는 아예 화면에서 보이지 않도록 설정하였다.

구현 결과

  • 페이지 진입 시

  • 특정 날짜 클릭 시

profile
개발콩나물

0개의 댓글