프로젝트/react

react 캘린더 구현(date range picker/date-fns)

코딩연습중 2024. 10. 16. 11:41

여행 계획 페이지로 넘어가기 전에 여행 계획 일정을 사용자가 설정할 수 있도록 하기 위해 캘린더를 띄워야 하는데, 화면 설계를 다음과 같이 하였다.

 

달력 컴포넌트를 다음과 같이 구성했다.

import React, { useState } from 'react';
import { DateRangePicker } from 'react-date-range-ts';
import { addDays } from 'date-fns';
import 'react-date-range-ts/dist/styles.css'; // main style file
import 'react-date-range-ts/dist/theme/default.css'; // theme css file

const CalendarModal = () => {
  const [selectionRange, setSelectionRange] = useState({
    startDate: new Date(),
    endDate: addDays(new Date(), 1),
    key: 'selection',
  });

  const handleSelect = (ranges: any) => {
    console.log(ranges);
    setSelectionRange(ranges.selection);
  };

  return (
    <div>
      <h2>여행 날짜 선택</h2>
      <DateRangePicker
        months={2}
        direction="horizontal"
        //disabledDates={}
        ranges={[selectionRange]}
        moveRangeOnFirstSelection={true}
        onChange={handleSelect}
      />
    </div>
  );
};

export default CalendarModal;

 

그리고 테스트로 실행시켜봤는데 빈 화면이 나온다.

 

보니까 react-date-range-ts를 사용해서 생긴 오류같다. react-date-range-ts보다 react-date-range를 사용하는 것이 더 낫겠다고 판단되어 라이브러리를 재설치하였다.

npm install --save react-date-range
npm install --save date-fns

이와 같이 라이브러리를 다운받았다.

 

근데 이렇게 하면 나는 타입스크립트를 사용하기 때문에 타입이 없다고 오류가 난다. 따라서 src폴더 밑에 react-date-range.d.ts 파일을 생성하고 다음과 같이 타입을 직접 등록해주면 된다.

 

declare module 'react-date-range' {
  import * as React from 'react';

  export interface DateRange {
    startDate: Date | null;
    endDate: Date | null;
    key: string;
  }

  export interface DateRangePickerProps {
    months?: number;
    direction?: 'horizontal' | 'vertical';
    ranges: DateRange[];
    moveRangeOnFirstSelection?: boolean;
    onChange: (range: { selection: DateRange }) => void;
  }

  export class DateRangePicker extends React.Component<DateRangePickerProps> {}
}

 

위에서 props는 내가 사용할 것들에 따라 설정해주면 된다.

 

이렇게 만들어서 실행시켜보면 그냥 기본 캘린더 화면이 나온다. 하지만 나는 이런 느낌의 달력을 원한 게 아니기 때문에 css를 변경해주어야 한다.

 

우선 app.tsx에 내가 작성한 css파일을 먼저 import 해주겠다.

import '../src/styles/date-range-picker.css';

const queryClient = new QueryClient();

export default function App() {
  return (

 

css 파일에서 첫번째로, 내가 사용하지 않는 div들을 display: none으로 설정한다.

.rdrDefinedRangesWrapper {
  display: none;
}

.rdrDateDisplayWrapper {
  display: none;
}

.rdrMonthAndYearPickers {
  display: none;
}

 

원래는 prevButton, nextButton을 위의 설계도와 같이 MonthName 옆에 두고 싶었는데 그렇게 하려면 date range picker 말고 그냥 date-range를 사용해서 header를 내가 직접 custom하든가 calendar을 사용하는 게 더 나았을 것 같아서 그 부분은 그냥 넘어가기로 했다..

 

그리고 어찌저찌 디자인을 조금 바꿔봤는데 문제는 내가 날짜 범위를 선택할 때 hover 시에 글자색이 변경되어야하는데 변경이 안 되는 문제가 생겼다. 

보니까 내가 구조를 잘못 이해하고 있었다.

.rdrDayStartPreview .rdrDayNumber span,
.rdrDayInPreview .rdrDayNumber span,
.rdrDayEndPreview .rdrDayNumber span {
  color: white !important;
}

이렇게 쓰면 될 줄 알았는데 보니까

 

구조가 이러했다. Preview랑 rdrDayNumber은 형제 노드인데 내가 부모 자식 관계라고 착각하고 있었다.

 

그래서 형제 관계에 있을 때만 스타일을 적용시킬 수 있는지 찾아보니까 :has()라는 게 있다는 걸 알게 되었다.

:has()는 최근에 추가된 선택자라서 지원하지 않는 브라우저가 존재하기 때문에 권장하지는 않는다고 한다. 웬만해서는 자바스크립트로 작성하는 걸 추천하지만 유용한 기능인 것 같아 써보기로 했다.

 

.rdrDayNumber:has(+ .rdrDayStartPreview) span,
.rdrDayNumber:has(+ .rdrDayInPreview) span,
.rdrDayNumber:has(+ .rdrDayEndPreview) span {
  color: white !important;
}

 

이렇게 썼는데 적용이 안 되길래

 

.rdrDay:has(.rdrDayStartPreview) .rdrDayNumber span,
.rdrDay:has(.rdrDayInPreview) .rdrDayNumber span,
.rdrDay:has(.rdrDayEndPreview) .rdrDayNumber span {
  color: white !important;
}

이렇게 변경하니까 잘 된다.

 

 

전체 코드

react-date-range.d.ts

declare module 'react-date-range' {
  import * as React from 'react';

  export interface DateRange {
    startDate: Date | null;
    endDate: Date | null;
    key: string;
  }

  export interface DateRangePickerProps {
    locale: object;
    months?: number;
    direction?: 'horizontal' | 'vertical';
    ranges: DateRange[];
    moveRangeOnFirstSelection?: boolean;
    onChange: (range: { selection: DateRange }) => void;
    minDate: Date;
    maxDate: Date;
  }

  export class DateRangePicker extends React.Component<DateRangePickerProps> {}
}

CalendarModal.tsx

import React, { useState } from 'react';
import { DateRangePicker } from 'react-date-range';
import { addDays, addYears, subDays } from 'date-fns';
import { ko } from 'date-fns/locale';
import 'react-date-range/dist/styles.css'; // main style file
import 'react-date-range/dist/theme/default.css'; // theme css file

const CalendarModal = () => {
  const today = new Date();

  const [selectionRange, setSelectionRange] = useState({
    startDate: today,
    endDate: today,
    key: 'selection',
  });

  const [isRangeSelected, setIsRangeSelected] = useState(true);

  const [minDate, setMinDate] = useState(today);
  const [maxDate, setMaxDate] = useState(addYears(today, 5));

  const handleSelect = (ranges: any) => {
    setSelectionRange(ranges.selection);
    setIsRangeSelected(!isRangeSelected);
    console.log(isRangeSelected);
    if (isRangeSelected) {
      //아직 set~~이 반영되지 않았기 때문에 조건이 !isRangeSelected가 아님.
      if (today.getTime() < subDays(ranges.selection.startDate.getTime(), 13)) {
        setMinDate(subDays(ranges.selection.startDate.getTime(), 13));
      }
      setMaxDate(addDays(ranges.selection.startDate, 13));
    } else {
      setMinDate(today);
      setMaxDate(addYears(today, 5));
    }
  };

  return (
    <div>
      <h2>여행 날짜 선택</h2>
      <DateRangePicker
        locale={ko}
        months={2}
        direction="horizontal"
        ranges={[selectionRange]}
        moveRangeOnFirstSelection={false}
        onChange={handleSelect}
        minDate={minDate}
        maxDate={maxDate}
      />
    </div>
  );
};

export default CalendarModal;

 

date-range-picker.css

/* DataRangePicker */
.rdrDefinedRangesWrapper {
  display: none;
}

.rdrDateDisplayWrapper {
  display: none;
}

.rdrMonthAndYearPickers {
  display: none;
}

.rdrMonthAndYearWrapper {
  height: 0px;
}

.rdrMonthAndYearWrapper {
  background-color: white;
  border-radius: 4px 4px 0px 0px;
}
.rdrMonthPicker,
.rdrYearPicker {
  background-color: white;
  border-radius: 3px;
}
.rdrNextPrevButton,
.rdrPprevButton {
  background-color: white !important;
}

.rdrDayToday .rdrDayNumber span:after {
  position: absolute;
  bottom: 4px;
  left: 50%;
  transform: translate(-50%, 0);
  width: 18px;
  height: 2px;
  border-radius: 2px;
  background: none !important;
}

.rdrMonth {
  width: 400px;
}

.rdrMonthName {
  text-align: center;
  font-size: 20px;
  font-weight: 600;
  color: #1b1b1b;
}

.rdrWeekDay {
  font-size: 15px;
  color: #1b1b1b;
}

.rdrDay {
  height: 50px;
}

.rdrDayNumber {
  font-size: 15px;
}

.rdrSelected,
.rdrStartEdge,
.rdrEndEdge {
  background: #1b1d1f;
}
.rdrInRange {
  background: #b2b9c0;
}

.rdrSelected,
.rdrInRange,
.rdrStartEdge,
.rdrEndEdge {
  left: 3px;
  right: 3px;
  border-radius: 5px;
}

.rdrDayStartOfMonth .rdrInRange,
.rdrDayStartOfMonth .rdrEndEdge,
.rdrDayStartOfWeek .rdrInRange,
.rdrDayStartOfWeek .rdrEndEdget {
  border-radius: 5px;
}

.rdrDayEndOfMonth .rdrInRange,
.rdrDayEndOfMonth .rdrStartEdge,
.rdrDayEndOfWeek .rdrInRange,
.rdrDayEndOfWeek .rdrStartEdge {
  border-radius: 5px;
}

.rdrDayStartOfMonth .rdrDayInPreview,
.rdrDayStartOfMonth .rdrDayEndPreview,
.rdrDayStartOfWeek .rdrDayInPreview,
.rdrDayStartOfWeek .rdrDayEndPreview {
  border-radius: 5px;
}
.rdrDayEndOfMonth .rdrDayInPreview,
.rdrDayEndOfMonth .rdrDayStartPreview,
.rdrDayEndOfWeek .rdrDayInPreview,
.rdrDayEndOfWeek .rdrDayStartPreview {
  border-radius: 5px;
}

.rdrDayInPreview {
  background: #b2b9c0;
  border-radius: 5px;
  left: 3px;
  border: none;
  z-index: 0;
}

.rdrDayStartPreview,
.rdrDayEndPreview {
  background: #1b1d1f;
  border-radius: 5px;
  left: 3px;
  border: none;
  z-index: 0;
}

.rdrDay:has(.rdrDayStartPreview) .rdrDayNumber span,
.rdrDay:has(.rdrDayInPreview) .rdrDayNumber span,
.rdrDay:has(.rdrDayEndPreview) .rdrDayNumber span {
  color: white !important;
}

.rdrDayStartOfWeek:not(.rdrDayDisabled, .rdrDayPassive) .rdrDayNumber span {
  color: red;
}

.rdrDayEndOfWeek:not(.rdrDayDisabled, .rdrDayPassive) .rdrDayNumber span {
  color: blue;
}

 

dateRangePicker을 사용한 캘린더 결과물(참고로 날짜는 최대 2주만 선택할 수 있도록 설정함)

 

이렇게 date range picker을 사용해서 날짜 선택 기능이 있는 캘린더를 만들었지만 만약에 다음에 또 만들어야 한다면 date-picker 또는 calendar을 사용하는 게 더 편할 것 같다. 

 

참고

1. https://www.npmjs.com/package/react-date-range

2. https://velog.io/@e_juhee/data-range-picker-datepicker-custom-1

3. https://inpa.tistory.com/entry/%F0%9F%8C%9F-css-where-is-has-not#is_%EA%B0%80%EC%83%81_%EC%84%A0%ED%83%9D%EC%9E%90