서론
이번에 진행하는 프로젝트에서 공통 컴포넌트 설계를 맡아 진행하였습니다. 그중 검색 페이지에서 기간 선택을 하는 기능이 있어 캘린더 컴포넌트가 필요하여 캘린더 컴포넌트를 제작할 일이 생겼습니다.
라이브러리를 사용해서 구현할 수 있으나 상담 부분 커스텀이 필요하였고, 커스텀하였을 때 결과가 원하는 디자인대로 나올지 또한 의문이었습니다. 또 한 번 만들어두면 나중에 사용할 일이 있을 거 같아서...ㅎㅎ 직접 구현하기로 결정하였습니다.
사용한 기술스택
- TypeScript
- React
- Styled-components
- date-fns
폴더 구조
├── calendar
│ ├── CalendarLayout.tsx
│ ├── CommonStyle.ts
│ ├── DateButton.tsx
│ ├── DateDisplay.tsx
│ ├── DatePicker.tsx
│ ├── Month.tsx
│ ├── Year.tsx
│ └── index.tsx
├── hooks
│ └── useCalendar.tsx
고려했던 점
이번 캘린더 컴포넌트를 구현하면서 폴더 구조를 보면 아시다시피 사용된 세부 컴포넌트가 많았습니다. 그렇기에 같은 변수가 여러 파일에서 사용되었고, 상당한 prop drilling이 발생하였습니다. 그렇기에 변수를 전역으로 관리하고자 고민하였으나 전역으로 관리하게 되면 변수가 캘린더 컴포넌트 내부 의존도가 매우 높아질 것을 우려하였고, 외부에서 컨트롤하기가 매우 힘들어졌습니다. 또한 추후 재사용성을 고려하여 prop drilling이 발생하더라도 props로 전달하는 방식으로 구현하였습니다.
구현
캘린더 날짜 구하기
컴포넌트 구현에 앞서 캘린더에 사용될 날짜를 계산하는 훅를 구현하였습니다. 디자인 상 2개의 캘린더를 사용해야 하기 때문에 startMonth, endMonth로 두 개의 연속된 달을 계산하는 방식으로 구현해 주었습니다.
import {
addDays,
endOfMonth,
endOfWeek,
startOfMonth,
startOfWeek,
eachDayOfInterval,
format,
addMonths,
startOfYear,
} from 'date-fns';
const useCalender = (startMonth: Date, endMonth: Date) => {
const weekDays = [];
const weekStartDate = startOfWeek(new Date());
for (let day = 0; day < 7; day += 1) {
weekDays.push(format(addDays(weekStartDate, day), 'EEEEE'));
}
const allMonth = [];
const baseMonth = startOfYear(startMonth);
for (let month = 0; month < 12; month += 1) {
allMonth.push(addMonths(baseMonth, month));
}
const generateMonthDates = (month: Date) =>
eachDayOfInterval({
start: startOfWeek(startOfMonth(month)),
end: endOfWeek(endOfMonth(month)),
});
const startMonthDates = generateMonthDates(startMonth);
const endMonthDates = generateMonthDates(endMonth);
return { weekDays, startMonthDates, endMonthDates, allMonth };
};
export default useCalender;
정상적으로 반환되는지 테스트 -> 잘 된다
콘솔로도 반환해 보았는데 정상적으로 잘 나오는 걸 확인할 수 있었습니다
describe('useCalender Hook', () => {
it('요일 포맷 테스트', () => {
const { result } = renderHook(() => useCalender(new Date(), new Date()));
const weekStartDate = startOfWeek(new Date());
const expectedWeekDays = [];
for (let day = 0; day < 7; day += 1) {
expectedWeekDays.push(format(addDays(weekStartDate, day), 'EEEEE'));
}
expect(result.current.weekDays).toEqual(expectedWeekDays);
});
it('12개월이 정상적으로 반환되는지 테스트', () => {
const selectedDate = new Date('2024-01-01');
const { result } = renderHook(() => useCalender(selectedDate, selectedDate));
expect(result.current.allMonth.length).toBe(12);
expect(result.current.allMonth[0].getMonth()).toBe(0);
expect(result.current.allMonth[11].getMonth()).toBe(11);
});
it('시작 월의 날짜가 정상적으로 반환되는지 테스트', () => {
const startDate = new Date('2024-01-01');
const { result } = renderHook(() => useCalender(startDate, new Date()));
const expectedDates = eachDayOfInterval({
start: startOfWeek(startOfMonth(startDate)),
end: endOfWeek(endOfMonth(startDate)),
});
expect(result.current.startMonthDates).toEqual(expectedDates);
});
it('끝 월의 날짜가 정상적으로 반환되는지 테스트', () => {
const endDate = new Date('2024-02-01');
const { result } = renderHook(() => useCalender(new Date(), endDate));
const expectedDates = eachDayOfInterval({
start: startOfWeek(startOfMonth(endDate)),
end: endOfWeek(endOfMonth(endDate)),
});
expect(result.current.endMonthDates).toEqual(expectedDates);
});
});
캘린더 레이아웃 구현
캘린더에 사용할 데이터는 준비되었고, 이제 캘린더를 구현하면 된다. 먼저 캘린더 레이아웃을 구현해 주었습니다. 위에 사진에는 달력 부분만 표현해 두었지만 최종적으로 아래 사진처럼 월 / 년을 선택하는 컴포넌트도 포함시켜 구현하는 게 목표이기에 이 3가지를 관리하는 레이아웃을 구현해 주었습니다
전체적인 컨테이너를 설정하고 pickerType에 따른 컴포넌트를 보여주는 방식으로 구현하였습니다
// index.tsx
export type PickerType = 'date' | 'month' | 'year';
export interface DatePickerProps {
setPickerType: Dispatch<SetStateAction<PickerType>>;
startDate: Date | null;
setStartDate: Dispatch<SetStateAction<Date | null>>;
endDate: Date | null;
setEndDate: Dispatch<SetStateAction<Date | null>>;
}
export default function Calendar({
startDate,
setStartDate,
endDate,
setEndDate,
}: Omit<DatePickerProps, 'setPickerType'>) {
return (
<div>
<DateDisplay startDate={startDate} endDate={endDate} />
<CalendarLayout startDate={startDate} endDate={endDate} setStartDate={setStartDate} setEndDate={setEndDate} />
</div>
);
}
export default function CalendarLayout({
startDate,
setStartDate,
endDate,
setEndDate,
}: Omit<DatePickerProps, 'setPickerType'>) {
const ref = useRef(null);
const [pickerType, setPickerType] = useState<PickerType>('date');
const renderPickerByType = (type: PickerType) => {
switch (type) {
case 'date':
return (
<DateCol
setPickerType={setPickerType}
startDate={startDate}
endDate={endDate}
setStartDate={setStartDate}
setEndDate={setEndDate}
/>
);
case 'month':
return (
<Month
setPickerType={setPickerType}
startDate={startDate}
endDate={endDate}
setStartDate={setStartDate}
setEndDate={setEndDate}
/>
);
case 'year':
return (
<Year setPickerType={setPickerType} startDate={startDate} endDate={endDate} setStartDate={setStartDate} />
);
default:
return (
<DateCol
setPickerType={setPickerType}
startDate={startDate}
endDate={endDate}
setStartDate={setStartDate}
setEndDate={setEndDate}
/>
);
}
};
return <Container ref={ref}>{renderPickerByType(pickerType)}</Container>;
}
const Container = styled.div`
position: relative;
display: flex;
justify-content: center;
width: 100%;
height: 100%;
padding: 16px;
`;
DateCol 구현
레이아웃을 설정해 주었고, 그다음으로 구현해 두었던 useCalendar를 이용하여 달력을 구현해 주었습니다.
startDate와 endDate를 date-fns 라이브러리를 사용하여 달을 계산하고, useCalendar를 사용해 그에 해당하는 날짜를 가져와 준 다음 DatePicker로 달력을 표현해 주었습니다.
상당한 prop drilling이 발생한다... 흐음
//DateCol.tsx
export default function DateCol({ setPickerType, startDate, setStartDate, endDate, setEndDate }: DatePickerProps) {
const [startMonth, setStartMonth] = useState(startDate || endDate || new Date());
const [endMonth, setEndMonth] = useState(addMonths(startMonth, 1));
const { startMonthDates, endMonthDates, weekDays } = useCalendar(startMonth, endMonth);
const nextMonths = () => {
setStartMonth(addMonths(startMonth, 1));
setEndMonth(addMonths(endMonth, 1));
};
const prevMonths = () => {
setStartMonth(subMonths(startMonth, 1));
setEndMonth(subMonths(endMonth, 1));
};
const onChangeDate = (date: Date) => {
if (startDate && endDate) {
setStartDate(null);
setEndDate(null);
}
if (!startDate) {
setStartDate(date);
} else if (!endDate) {
setEndDate(date);
}
};
const isDateDisabled = (date: Date) => {
// 프로젝트에서 지정되어 있는 달력 범위
const minDate = new Date(1970, 0, 1);
const maxDate = new Date();
return date < minDate || date > maxDate;
};
const isInRange = (date: Date) => {
if (!startDate || !endDate) return false;
return isWithinInterval(date, { start: startDate, end: endDate });
};
useEffect(() => {
if (startDate && endDate) {
if (startDate > endDate) {
setStartDate(null);
setEndDate(null);
}
}
// 프로젝트에서 지정되어 있는 선택 가능한 범위
if (startDate && endDate && Math.abs(differenceInDays(startDate, endDate)) > 90) {
setStartDate(null);
setEndDate(null);
alert('90일 이상 선택할 수 없습니다.');
}
}, [startDate, endDate]);
return (
<Container>
<DatePicker
title={format(startMonth, 'MMM yyyy')}
dates={startMonthDates}
weekDays={weekDays}
currentMonth={startMonth}
onChangeDate={onChangeDate}
isDateDisabled={isDateDisabled}
isInRange={isInRange}
startDate={startDate}
endDate={endDate}
setPickerType={setPickerType}
/>
<DatePicker
title={format(endMonth, 'MMM yyyy')}
dates={endMonthDates}
weekDays={weekDays}
currentMonth={endMonth}
onChangeDate={onChangeDate}
isDateDisabled={isDateDisabled}
isInRange={isInRange}
startDate={startDate}
endDate={endDate}
setPickerType={setPickerType}
/>
<DateButton prevMonths={prevMonths} nextMonths={nextMonths} />
</Container>
);
}
// DatePicker.tsx
interface DatePickerProps {
title: string;
dates: Date[];
weekDays: string[];
currentMonth: Date;
onChangeDate: (date: Date) => void;
isDateDisabled: (date: Date) => boolean;
isInRange: (date: Date) => boolean;
startDate: Date | null;
endDate: Date | null;
setPickerType: (type: PickerType) => void;
}
interface DateButtonProps {
isCurrentMonth: boolean;
isSelectedDay: boolean;
isInRange: boolean;
disabled: boolean;
}
export default function DatePicker({
title,
dates,
weekDays,
currentMonth,
onChangeDate,
isDateDisabled,
isInRange,
startDate,
endDate,
setPickerType,
}: DatePickerProps) {
return (
<Wrapper>
<Navigation>
<button
onClick={() => {
setPickerType('month');
}}
>
<Title>{title}</Title>
</button>
</Navigation>
<WeekdaysGrid>
{weekDays.map((day, index) => (
<Text key={index}>{day}</Text>
))}
</WeekdaysGrid>
<DatesGrid>
{dates.map((date, index) => {
const disabled = !isSameMonth(currentMonth, date) || isDateDisabled(date);
return (
<DateButton
key={index}
onClick={() => !disabled && onChangeDate(date)}
isCurrentMonth={isSameMonth(currentMonth, date)}
isSelectedDay={
(startDate ? isSameDay(startDate, date) : false) || (endDate ? isSameDay(endDate, date) : false)
}
isInRange={isInRange(date)}
disabled={disabled}
>
<span>{date.getDate()}</span>
</DateButton>
);
})}
</DatesGrid>
</Wrapper>
);
}
월 선택 컴포넌트 구현
이렇게 달력을 구현하였고, 그다음으로 월을 선택할 수 있는 컴포넌트를 구현해 주었습니다.
useCalendar를 이용해 해당하는 연도의 월을 가져와주었고, 월을 선택하면 해당 월로 startDate를 변경해 주는 방식( startDate를 변경하면 그에 해당하는 월로 데이터가 변경되도록 구현하였기 때문에 )으로 구현하였습니다
export default function Month({ setPickerType, startDate, endDate, setStartDate, setEndDate }: DatePickerProps) {
const curDate = startDate || endDate || new Date();
const { allMonth } = useCalendar(curDate, curDate);
const onNextYear = () => {
if (new Date().getFullYear() === curDate.getFullYear()) return;
setStartDate(addYears(startDate || new Date(), 1));
};
const onPrevYear = () => {
if (1970 === curDate.getFullYear()) return;
setStartDate(subYears(startDate || new Date(), 1));
};
const onChangeMonth = (month: Date) => {
setStartDate(month || new Date());
};
useEffect(() => {
setEndDate(null);
}, []);
return (
<Container>
<Header>
<YearDisplay>
<button
type="button"
onClick={() => {
setPickerType('year');
}}
>
<Title>{format(curDate, 'MMM yyyy')}</Title>
</button>
<button
type="button"
onClick={() => {
setPickerType('date');
}}
>
<Img src={CaretDownIcon} />
</button>
</YearDisplay>
<ButtonGroup>
<button type="button" onClick={onPrevYear}>
<Img src={CaretLeftIcon} />
</button>
<button type="button" onClick={onNextYear}>
<Img src={CaretRightIcon} />
</button>
</ButtonGroup>
</Header>
<MonthGrid>
{allMonth.map((month: Date, index: number) => (
<MonthButton
type="button"
key={index}
isSelected={isSameMonth(curDate, month)}
onClick={() => onChangeMonth(month)}
>
{format(month, 'MMM')}
</MonthButton>
))}
</MonthGrid>
</Container>
);
}
년도 선택 컴포넌트 구현
그다음으로 연도 선택 컴포넌트를 구현해 주었습니다. 해당하는 년도를 선택하면 startDate를 바꿔주는 방식으로 구현하였습니다.
export default function Year({ setPickerType, startDate, endDate, setStartDate }: Omit<DatePickerProps, 'setEndDate'>) {
const currentDate = startDate || endDate || new Date();
const currentYear = currentDate.getFullYear();
// 프로젝트에서 지정된 검색 가능한 범위
const startYear = 1970;
const endYear = new Date().getFullYear();
// 10개씩 보여준다
const years = Array.from({ length: endYear - startYear + 1 }, (_, i) => startYear + i);
const visibleYears = years.filter((year) => year >= currentYear - 5 && year <= currentYear + 5);
const onPrevYears = () => {
const newYear = Math.max(startYear, currentDate.getFullYear() - 10);
setStartDate(new Date((startDate || new Date()).setFullYear(newYear)));
};
const onNextYears = () => {
const newYear = Math.min(endYear, currentDate.getFullYear() + 10);
setStartDate(new Date(currentDate.setFullYear(newYear)));
};
const onSelectYear = (year: number) => {
const newDate = new Date(currentDate);
newDate.setFullYear(year);
setStartDate(newDate);
setPickerType('month');
};
return (
<Container>
<Header>
<button type="button" onClick={onPrevYears} disabled={visibleYears[0] === startYear}>
<Img src={CaretLeftIcon} />
</button>
<Title>{`${visibleYears[0]} - ${visibleYears[visibleYears.length - 1]}`}</Title>
<button type="button" onClick={onNextYears} disabled={visibleYears[visibleYears.length - 1] === endYear}>
<Img src={CaretRightIcon} />
</button>
</Header>
<YearGrid>
{visibleYears.map((year) => (
<YearButton key={year} isSelected={currentYear === year} onClick={() => onSelectYear(year)}>
{year}
</YearButton>
))}
</YearGrid>
</Container>
);
}
선택한 날짜를 보여주는 DateDisplay 컴포넌트 구현
이렇게 달력을 모두 구현하였고, 마지막으로 선택한 날짜를 사용자에게 보여주기 위해 DateDisplay 컴포넌트를 구현하였습니다. 단순히 startDate와 endDate를 표현하는 방식으로 구현하였습니다.
export default function DateDisplay({ startDate, endDate }: Pick<DatePickerProps, 'startDate' | 'endDate'>) {
return (
<Wrapper>
<Container>
{startDate || endDate ? (
<>
<Text>{startDate ? format(startDate, 'yyyy. MM. dd') : '선택되지 않음'}</Text>
<Text>~</Text>
<Text>{endDate ? format(endDate, 'yyyy. MM. dd') : '선택되지 않음'}</Text>
</>
) : (
<Text title="true">기간을 선택해주세요</Text>
)}
</Container>
</Wrapper>
);
}
결과물
이렇게 캘린더를 완성하였고, 아래 영상처럼 잘 작동하는 걸 확인할 수 있었습니다.
이번에 캘린더 컴포넌트를 처음 구현해 봤는데, 처음에는 금방 구현할 줄 알았는데 생각보다 복잡한 컴포넌트였습니다... 고려해야 할 점이 너무 많았다ㅜㅜ
https://github.com/dec-project/haeyum-client/tree/develop/src/common/components/calendar
참고한 문서
'React' 카테고리의 다른 글
[React] 재사용 가능한 AppBar 제작하기 (0) | 2025.01.13 |
---|---|
[Eslint] Eslint flat config 적용기 (0) | 2025.01.12 |
[React] Kakao Map Api 사용하기 (5) - debounce를 활용한 Api 호출 최적화하기 (1) | 2024.09.15 |
[React] Kakao Map API 사용하기 (4) - customoverlay 클릭 이벤트 등록하기 (1) | 2024.08.30 |
[React] Kakao Map API 사용하기 (3) - customoverlay를 활용하여 마커 커스텀 하기 (1) | 2024.08.29 |