React로 날짜 선택 컴포넌트 만들기

React로 날짜 선택 컴포넌트 만들기

·

9 min read

인턴 생활을 시작한 지 벌써 1달하고 2주가 지났다. 요즘 회사에서는 프론트엔드 개발자로서 UI 컴포넌트 구현 및 리팩터링을 담당하고 있다. 그 중에서 오늘 만든 날짜 선택 컴포넌트가 인상 깊어서 따로 정리해보고자 한다.

내 개인 프로젝트인 네컷사진에서 날짜 선택 기능을 구현한 적 있는데 자유롭지 못한 포맷이 꽤 아쉬웠던 기억이 있다. 근데 이번 테스크를 통해 더욱 유연한 날짜 선택 컴포넌트를 만들 수 있게 되었다.

지금부터 유연한 DateSelection 컴포넌트를 만들어보자.

1. React 설치 및 파일 생성

DateSelection 컴포넌트를 사용할 App.jsx 파일과 DateSelection.jsx 파일로 분리한다.

// App.jsx
import DateSelection from './components/DateSelection';

function App() {
  return (
    <div style={{ margin: '20px' }}>
      <DateSelection />
    </div>
  );
}

export default App;
// ./components/DateSelection.jsx
import React from 'react';
import { BsFillCalendarHeartFill } from 'react-icons/bs';

const DateSelection = () => {
  return (
    <div>
      <input
        type='text'
        value=''
        placeholder='placeholder'
      />
      <button type='button'>
        <BsFillCalendarHeartFill />
      </button>
    </div>
  );
};

export default DateSelection;

React-icons

npm install react-icons
공식문서에서 더 많은 아이콘을 볼 수 있다.

2. 기본적인 기능 구현

input에 키보드를 입력하면 date, 즉 value가 바뀌도록 한다.

// ./components/DateSelection.jsx
...
const DateSelection = () => {
  const [date, setDate] = useState('');

  const handleChangeDate = (e) => {
    const currentValue = e.target.value;
    setDate(currentValue);
  };

  return (
    <div>
      <input
        type='text'
        value={date}
        placeholder='placeholder'
        onChange={handleChangeDate}
      />
      ...

3. 날짜 format 정보 저장하기

날짜 형식인 format을 정한다. (추후에 format을 자유롭게 변경할 수 있도록 한다.)

getSeparator 함수를 통해 format에 사용된 날짜 구분 symbol과 그것의 위치를 담은 indexes를 구한다.

날짜 구분은 숫자나 문자가 될 수 없기 때문에 /[^0-9a-zA-Z]+/과 같은 정규표현식을 사용한다. matchformat에서 정규표현식에 매치되는 것을 나타내며, ['-', index: 4, input: 'YYYY-MM-DD', groups: undefined]이다.

symbol은 매치되는 기호인 match[0], 즉 '-'이다.

format의 문자를 하나씩 돌려 해당 문자가 symbol 일 경우 해당 위치를 indexes에 넣는다.

정규표현식으로 매치되는 값이 없다면 빈 값을 반환한다.

반환한 값은 separator에 저장한다.

// ./components/DateSelection.jsx
const DateSelection = () => {
  const [date, setDate] = useState('');
  const format = 'YYYY-MM-DD';

  const getSeparator = () => {
    const regex = /[^0-9a-zA-Z]+/;
    const match = format.match(regex);

    if (match) {
      const symbol = match[0];
      const indexes = [];

      for (let i = 0; i < format.length; i++) {
        if (format[i] === symbol) {
          indexes.push(i);
        }
      }

      return { symbol, indexes };
    }
    return { symbol: undefined, indexes: [] };
  };

  const separator = getSeparator();

  // separator 값이 맞는지 확인
  React.useEffect(() => {
    console.log(separator);
    // { symbol: '-', indexes: [4, 7] }
  }, []);

  ...

4. 입력 값 formatting하기

input 값을 변경하는 handleChangeDate 함수에 separator가 존재하는 경우에 대한 기능을 추가한다.

separator.indexs를 돌려 currentDatesymbol이 들어가야 하는지 판단한다.

만약 currentDate 길이가 해당 symbol 위치보다 크다면 당 index를 기준으로 currentDate를 나누고 그 사이에 symbol을 넣는다.

예를 들어, '20221010'과 같은 값이 들어오면 '2022-10-10'으로 바꾼다.

// ./components/DateSelection.jsx
const DateSelection = () => {
  ...
  const separator = getSeparator();

  const handleChangeDate = (e) => {
    let currentDate = e.target.value;

    if (separator.symbol && separator.indexes.length > 0) {
      separator.indexes.forEach((index) => {
        if (currentDate.length > index) {
          currentDate =
            currentDate.slice(0, index) +
            separator.symbol +
            currentDate.slice(index);
        }
      });
    }

    setDate(currentDate);
  };

  return (
  ...

5. Calendar 위젯 기능

text 타입의 input은 달력을 제공하지 않기 때문에 리액트 라이브러리인 react-datetime을 사용한다.

button을 클릭하면 캘린더를 열고 닫히도록 한다.

open 일 경우 캘린더인 Datetime 컴포넌트가 보이며 필요한 props를 넣어준다.
값을 넣는 input은 앞에서 이미 만들었기 때문에 false로 지정한다.
시간 조절하는 기능은 넣지 않을 것이기 때문에 dateFormatfalse로 지정한다. dateFormat은 위에 미리 선언한 format으로 지정한다.

Datetime 내 날짜를 클릭하면 date가 변경되도록 한다.
formattedDateselected, 즉 선택된 날짜가format에 맞게 포멧팅 된 값이다.
dateformattedDate로 바꾸고 Datetime을 닫아주면 기능이 끝난다.

...
import Datetime from 'react-datetime';

const DateSelection = () => {
  const [date, setDate] = useState('');
  const [open, setOpen] = useState(false);
  ...

  const handleClickButton = () => {
    setOpen(!open);
  };

  const handleChangeCalendar = (selected) => {
    const formattedDate = selected.format(format);
    setDate(formattedDate);
    setOpen(false);
  };

  return (
    ...
      <button type='button' onClick={handleClickButton}>
        <BsFillCalendarHeartFill />
      </button>
      {open && (
          <Datetime
            input={false}
            timeFormat={false}
            dateFormat={format}
            value={date}
            onChange={handleChangeCalendar}
          />
      )}
    </section>
  );
};

export default DateSelection;

React-datetime

npm install --save react-datetime 공식문서에서 다양한 속성을 볼 수 있다.

6. 날짜 유효성 체크

input의 focus가 해제(blur)되면 checkValidDate 함수를 호출시켜 날짜 유효성을 검사한다.

이것은 Moment.js 라이브러리를 활용하면 간단하게 검사할 수 있다.

그 중 moment 함수로 날짜나 시간을 포맷팅 할 수 있다.
매개변수는 값, 형식, 값이 형식에 맞는지 확인하는가(옵션)로 이루어져 있으며,
우리가 만들고 있는 컴포넌트는 value가 format에 정확히 맞아야 하기 때문에 moment(value, format, true)라고 한다.
selectedDateMoment {_isAMomentObject: true, _i: '2023/07/30', _f: 'YYYY/MM/DD', _strict: true, _isUTC: false, …}와 같은 Object를 나타낸다.

Moment.js 라이브러리의 메서드 중 하나인 isValid로 해당 객체가 유효한 날짜인지 알 수 있다.

유효하지 않은 경우 빈 값으로 초기화한다.

// ./components/DateSelection.jsx
...
import moment from 'moment';

const DateSelection = () => {
  ...
  const checkValidDate = (e) => {
    const { value } = e.target;
    const selectedDate = moment(value, format, true);
    const isValid = selectedDate.isValid();

    if (!isValid) {
      setDate('');
    }
  };

  return (
    <section>
      <input
        ...
        onBlur={checkValidDate}
      />
  ...

Moment.js

npm install moment --save 공식문서에서 다양한 기능과 속성을 볼 수 있다.

7. format 및 autoFormatting 사용자 커스텀

// ./components/DateSelection.jsx
...
const DateSelection = ({
  format = 'YYYY-MM-DD',
  autoFormatting = true
}) => {
  // const format = 'YYYY-MM-DD';
  ...
    const handleChangeDate = (e) => {
    let currentDate = e.target.value;

    if (autoFormatting) {
      if (separator.symbol && separator.indexes.length > 0) {
        ...
      }
    }

    setDate(currentDate);
  };
  ...
// ./App.jsx
function App() {
  return (
    <div style={{ margin: '20px' }}>
      <DateSelection format='YYYY/MM/DD' autoFormatting={false} />
    </div>
  );
}

DateSelection.jsx 파일 안에서 format을 선언해 사용했지만, DateSelection을 사용할 다른 컴포넌트에서 format을 커스텀 할 수 있도록 props로 전달한다.

format은 YYYY/MM/DD, YYYY-MM-DD, DD.MM.YY, YYMMDD과 같이 년(YY/YYYY)월(MM)일(DD)만 제대로 제공하면 원하는 대로 사용할 수 있다. Moment.js 덕분이다 :)

autoFormatting은 날짜 입력 시 format에 맞게 자동으로 수정해주는 지 선택하는 것이다.

seperator.indexes를 순환하여 숫자 사이에 symbol을 넣어주는 코드를 조건문 안에 옮겨준다.

8. 타이핑 오류 수정

autoFormatting이 true인 경우에 한 가지 오류가 발견된다.
입력된 날짜의 중간에 커서를 두고 값을 수정하면 형식이 완전히 깨져버린다.

값을 수정할 때마다 indexes 위치에 symbal이 존재하지 않으면 symbol을 추가하는 기능만 있을 뿐,
symbal이 제자리에 없으면 삭제하는 기능은 없다.

따라서 값을 입력 받으면 symbol을 모두 빼서 숫자만 남긴 후에 symbol을 다시 추가한다.
더 이상 date[index]symbol과 다른지 체크할 필요도 없기 때문에 삭제한다.

// ./components/DateSelection.jsx
  ...
  const handleChangeDate = (e) => {
    const { target } = e
    const { value } = target;

    if (autoFormatting) {
      let currentDate = [...value]
        .filter((str) => str !== separator.symbol)
        .join('');

      if (separator.symbol && separator.indexes.length > 0) {
        separator.indexes.forEach((index) => {
          // if (currentDate.length > index && date[index] !== separator.symbol) {
          if (currentDate.length > index) {
            currentDate =
              currentDate.slice(0, index) +
              separator.symbol +
              currentDate.slice(index);
          }
        });
      }
      setDate(currentDate);
    } else {
      setDate(value);
    }
  };
  ...

형식이 깨지는 오류는 해결하였지만 또 다른 문제가 발생했다. 중간 값을 수정하면 커서가 맨 뒤로 이동하는 것이다.

이것은 handleChangeDate 함수를 거치고 값이 date로 통째로 바뀌었기 때문이다.

그렇다면 중간에 수정한 부분의 커서 위치를 알아내야 한다.

e.target.selectionStart를 통해 cursor가 선택된 첫 번째 자리를 알 수 있다.

cursor 위치인 cursorPosition은 cursor가 있는 경우 selectionStart로, 없는 경우 맨 뒤인 value.length로 지정한다.

cursorPositionvalue의 길이가 동일할 때 cursor 위치를 오른쪽으로 한 칸 이동하는 이유는 중간에 기호가 들어오면 selectionStart가 그것을 인지하지 못하기 때문이다.

커서 위치가 옮겨지는 시간을 벌기 위해 setTimeout을 사용한다.

e.target.setSelectionRange를 통해 해당 커서로 이동한다.
첫 번째 매개변수는 커서의 시작 위치, 두 번째 매개변수는 커서의 마지막 위치를 나타낸다.

// ./components/DateSelection.jsx
  ...
  const handleChangeDate = (e) => {
    const { target } = e;
    const { value, selectionStart } = target;

    if (autoFormatting) {
      let cursorPosition = selectionStart || value.length;

      if (cursorPosition === value.length) {
        cursorPosition += 1;
      }

      setTimeout(() => {
        target.setSelectionRange(cursorPosition, cursorPosition);
      });

      let currentDate = [...value]
        .filter((str) => str !== separator.symbol)
        .join('');
      ...

handleChangeDate 함수에서는 날짜 값을 바꾸는 역할만 하기 때문에 커서 위치를 바꾸는 기능을 handleCursorPosition 함수로 리팩터링한다.

// ./components/DateSelection.jsx
  ...
  const handleCursorPosition = (target) => {
    const { value, selectionStart } = target;
    let cursorPosition = selectionStart || value.length;

    if (cursorPosition === value.length) {
      cursorPosition += 1;
    }

    setTimeout(() => {
      target.setSelectionRange(cursorPosition, cursorPosition);
    });
  };

  const handleChangeDate = (e) => {
    const { target } = e;
    const { value } = target;

    if (autoFormatting) {
      handleCursorPosition(target);

      let currentDate = [...value]
        .filter((str) => str !== separator.symbol)
        .join('');
      ...

9. 마우스 이벤트

많은 사용자들은 Modal이나 Popover와 같은 컴포넌트가 뜨면 컴포넌트 바깥을 눌러 닫는 행동을 한다.

대략적인 로직은 마우스가 가리킨 위치가 입력창이나 달력이 아닌 곳일 경우에 달력을 닫아주는 것이다.

useRef를 사용하여 DOM에 접근할 수 있다. inputbutton을 감싸는 divinputRef를, Datetime을 감싸는 divcalendarRef를 부여한다.

open 상태이면서 inputRefcalendarRef의 요소가 존재한다면 document 전체에 click 이벤트를 넣어준다. dependency 안에는 open, inputRef, calendarRef를 넣으며, 이 값들 중 하나만 바뀌더라고 코드가 실행된다.

click 이벤트가 발생하면 handleClickout으로 클릭 위치에 따른 조작을 한다. useCallback을 사용한 이유는 useEffect의 dependency가 변경될 때마다 handleClickout이 호출되므로 불필요한 호출을 방지하기 위함이다.

inputRef 영역과 calendarRef 영역에 둘 다 클릭한 영역인 target이 포함되어 있지 않다면 그 곳이 바깥 영역인 outArea이다. outArea가 true인 경우 열려 있는 달력을 닫아준다.

// ./components/DateSelection.jsx
const DateSelection = ({ format = 'YYYY-MM-DD', autoFormatting = true }) => {
  const inputRef = useRef(null);
  const calendarRef = useRef(null);

  ...

  const handleClickOut = useCallback(
    (e) => {
      if (open && inputRef?.current && calendarRef?.current) {
        const inputArea = inputRef.current;
        const calendarArea = calendarRef.current;
        const { target } = e;
        const outArea =
          !inputArea.contains(target) && !calendarArea.contains(target);

        if (outArea) {
          setOpen(false);
        }
      }
    },
    [open, inputRef, calendarRef]
  );

  useEffect(() => {
    if (open && inputRef?.current && calendarRef?.current) {
      document.addEventListener('click', handleClickOut);
    }
  }, [open, inputRef, calendarRef, handleClickOut]);

  return (
    <section>
      <div ref={inputRef}>
        <input ... />
        <button type='button' onClick={handleClickButton}> ...
      </div>
      {open && (
        <div className='calendar' ref={calendarRef}>
        ...

10. 키보드 이벤트

input에 focus가 된 상태에서 키보드를 입력할 때 해당 이벤트의 코드가 Enter인 경우에 달력을 열고 닫아준다.

button에 focus가 된 상태에서 키보드를 입력할 때 해당 이벤트의 코드가 Tab이고 달력이 열려 있는 경우에 달력을 닫아준다.

개인적으로 달력에도 키보드 이벤트를 넣고 싶었지만 react-datetime에서 제공하지 않는 것 같다.

// ./components/DateSelection.jsx
  ...

  const handleKeyDownInput = (e) => {
    if (e.code === 'Enter') {
      setOpen(!open);
    }
  };

  const handleKeyDownButton = (e) => {
    if (e.code === 'Tab' && open) {
      setOpen(false);
    }
  };

  ...

  return (
    <section>
      <div ref={inputRef}>
        <input
          ...
          onKeyDown={handleKeyDownInput}
        />
        <button
          ...
          onKeyDown={handleKeyDownButton}
        >
          <BsFillCalendarHeartFill />
        </button>
      ...

11. 예쁘게 만들기 ✨

보기에 깔끔한 정도로만 CSS를 작성해보았다.

react-datetime에서 제공하는 기본적인 스타일을 넣어주고 추가로 커스텀하였다. !important를 꼭 넣어야만 기본 스타일을 덮어 씌운다.

// ./components/DateSelection.jsx
import 'react-datetime/css/react-datetime.css';
import '../App.css';
...

  return (
    <section className='dateselection'>
      <div className='input-wrapper' ref={inputRef}>
        <input
          className='input'
          ...
        />
        <button
          className='input-button'
          ...
        >
          <BsFillCalendarHeartFill />
        </button>
      </div>
      {open && (
        <div className='calendar' ...>
        ...
// ./DateSelection.css
.dateselection {
  position: relative;
  width: 272px;
}

.input-wrapper {
  position: relative;
  width: 100%;
}

.input {
  width: calc(100% - 34px);
  padding: 8px 16px;
  border-radius: 4px;
  border: 1px solid #e0e2e7;
  outline: none;
}

.input-button {
  position: absolute;
  top: 50%;
  right: 8px;
  transform: translateY(-50%);
  padding: 0;
  background: transparent;
  border: none;
  cursor: pointer;
}

.calendar {
  width: 100%;
  position: absolute;
  left: 50%;
  transform: translateX(-50%);
}

.calendar td {
  border-radius: 100px;
}

.rdtPicker {
  border: 1px solid #e0e2e7 !important;
  border-radius: 4px !important;
}

td.rdtToday {
  border: 1px solid #428bca !important;
}

td.rdtToday::before {
  display: none !important;
}

마치며

사내에서 사용될 UI를 구축하다보니 여러 경우의 수를 생각하고 더욱 완벽한 컴포넌트를 구현하기 위해 힘을 쓰게 되었다. 아직까지는 기능 구현에 최대한 집중하고 있지만, 후에는 유지보수가 원활하도록 적절하게 리팩터링에도 힘써야겠다.