개발/프로젝트

dayjs를 활용하여 생년월일 선택하는 드롭다운 생성하기

xuwon 2024. 12. 28. 00:47

처음에는 select 태그를 활용해서 만들었는데, hover 될 때 파란 배경이 싫어서 바꾸려니까
select 태그는 스타일 적용이 제한적이라고 해서...

직접 드롭다운 컴포넌트를 만든 후에
이걸로 생년월일 드롭다운을 만들었다.

// SelectDropdown Props 타입 정의
export type SelectDropdownProps<T> = {
  options: T[];
  selectedValue: T | string | null;
  placeholder: string;
  onSelect: (value: T) => void;
};

우선 드롭다운 컴포넌트의 props 타입을 정해주었다.

options에는 배열이 들어가고(연, 월, 일),
selectedValue는 현재 선택되는 값이 들어간다.
placeholder는 말 그대로 placeholder,
onSelect에는 선택되었을 때 실행할 함수가 들어간다.

제네릭을 사용해서 컴포넌트를 사용하는 시점에 구체적인 타입을 전달할 수 있도록 만들었다.

 

여기서 selectedValue에서 문제가 생겼었다...

원래는 생년월일에 number만 들어갈 예정이었기에 selectedValue의 타입을 T | null로 지정했었는데,
년, 월, 일 텍스트를 붙이게 돼서... selectedValue 타입에만 string을 추가했다.

 

'use client';

import { useEffect, useRef, useState } from 'react';
import { SelectDropdownProps } from './types';
import clsx from 'clsx';

// SelectDropdown 컴포넌트
export const SelectDropdown = <T extends number>({
  options, // 옵션 배열
  selectedValue, // 현재 선택된 값 표시
  placeholder,
  onSelect, // 선택 시 실행할 함수
}: SelectDropdownProps<T>) => {
  const [isOpen, setIsOpen] = useState(false);
  const dropdownRef = useRef<HTMLDivElement>(null); // 드롭다운을 감지하기 위한 ref

  // 외부 클릭 감지
  useEffect(() => {
    const handleClickOutside = (event: MouseEvent) => {
      if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
        // Node는 DOM 트리의 모든 요소를 포함하므로 Node로 타입 단언
        // 클릭한 요소가 드롭다운 내부인지 확인
        setIsOpen(false); // 외부 클릭 시 드롭다운 닫기
      }
    };

    document.addEventListener('mousedown', handleClickOutside); // 클릭 이벤트 등록
    return () => {
      document.removeEventListener('mousedown', handleClickOutside); // 클릭 이벤트 제거
    };
  }, []);
  
  const handleOptionClick = (value: T) => {
    onSelect(value);
    setIsOpen(false);
  };

  return (
    <div className="relative inline-block w-32" ref={dropdownRef}>
      {/* 드롭다운에 ref 연결 */}
      <div
        className={clsx(
          'flex justify-between items-center border border-green rounded-2xl px-4 py-2 h-11',
          options.length > 0 ? 'bg-white' : 'bg-lighterGray',
        )}
        onClick={() => {
          if (options.length > 0) {
            setIsOpen(prev => !prev);
          }
        }}
      >
        <span className="text-darkGray text-sm">
          {selectedValue ? selectedValue.toString() : placeholder}
        </span>
        {/* 화살표 모양 */}
        <svg
          xmlns="http://www.w3.org/2000/svg"
          fill="none"
          viewBox="0 0 24 24"
          strokeWidth={2}
          stroke="currentColor"
          className={`w-4 h-4 transition-transform ${
            isOpen && options.length > 0 ? 'rotate-180' : 'rotate-0'
          }`}
        >
          <path strokeLinecap="round" strokeLinejoin="round" d="M19 9l-7 7-7-7" />
        </svg>
      </div>
      {isOpen && options.length > 0 && (
        <ul className="absolute z-10 overflow-y-scroll w-full h-48 border border-green bg-white scrollbar-hide">
          {options.map(option => (
            <li
              key={option}
              className="px-4 py-2 text-darkGray text-sm hover:bg-fadedOrange"
              onClick={() => handleOptionClick(option)}
            >
              {option}
            </li>
          ))}
        </ul>
      )}
    </div>
  );
};

하나씩 뜯어보겠다

// SelectDropdown 컴포넌트
export const SelectDropdown = <T extends number>({
  options, // 옵션 배열
  selectedValue, // 현재 선택된 값 표시
  placeholder,
  onSelect, // 선택 시 실행할 함수
}: SelectDropdownProps<T>) => {...}

SelectDropdown 컴포넌트는 number 타입만 사용이 가능하다.
그리고 미리 만들어둔 SelectDropdownProps 타입으로 props의 타입을 지정해 주었다.

 

const handleOptionClick = (value: T) => { // 클릭 이벤트가 발생했을 때 실행될 함수
    onSelect(value); // onSelect 함수 실행
    setIsOpen(false); // 드롭다운 닫음
};

return (
    <div className="relative inline-block w-32" ref={dropdownRef}>
      {/* 드롭다운에 ref 연결 */}
      <div
        className={clsx(
          'flex justify-between items-center border border-green rounded-2xl px-4 py-2 h-11',
          options.length > 0 ? 'bg-white' : 'bg-lighterGray',
          // options 배열의 길이를 기준으로 배경 변화
        )}
        onClick={() => {
          if (options.length > 0) { 
          // options 배열의 길이가 0보다 크면 isOpen 상태 바꿈
            setIsOpen(prev => !prev);
          }
        }}
      >
        <span className="text-darkGray text-sm">
          // 선택한 값이 있다면 그 값을 보여주고, 아니면 placeholder
          {selectedValue ? selectedValue : placeholder}
        </span>
        {/* 화살표 모양 */}
        <svg
          xmlns="http://www.w3.org/2000/svg"
          fill="none"
          viewBox="0 0 24 24"
          strokeWidth={2}
          stroke="currentColor"
          className={`w-4 h-4 transition-transform ${
            isOpen && options.length > 0 ? 'rotate-180' : 'rotate-0'
          }`}
        >
          <path strokeLinecap="round" strokeLinejoin="round" d="M19 9l-7 7-7-7" />
        </svg>
      </div>
      {isOpen && options.length > 0 && (
        <ul className="absolute z-10 overflow-y-scroll w-full h-48 border border-green bg-white scrollbar-hide">
          {options.map(option => (
            <li
              key={option}
              className="px-4 py-2 text-darkGray text-sm hover:bg-fadedOrange"
              onClick={() => handleOptionClick(option)}
            >
              {option}
            </li>
          ))}
        </ul>
      )}
    </div>
 );

 

{isOpen && options.length > 0 && (
  <ul className="absolute z-10 overflow-y-scroll w-full h-48 border border-green bg-white scrollbar-hide">
    {options.map(option => (
      <li
        key={option}
        className="px-4 py-2 text-darkGray text-sm hover:bg-fadedOrange"
        onClick={() => handleOptionClick(option)} // 특정 수 클릭 시 실행
      >
        {option}
      </li>
    ))}
  </ul>
)}

isOpentrue이고, options.length가 0보다 클 때만 드롭다운 메뉴를 보여주도록 했다.
dayjs를 활용하여 일수를 계산하기 때문에, 연도와 월을 지정하지 않으면 일이 안 나오기 때문에, 그걸 구분하려고 이렇게 작성했다.
(일이 없다면 options 배열은 비어있기 때문!)

optionsmap으로 순회하여 하나씩 <li> 태그로 끄집어내면 된다.

const dropdownRef = useRef<HTMLDivElement>(null); // 드롭다운을 감지하기 위한 ref

// 외부 클릭 감지
useEffect(() => {
  const handleClickOutside = (event: MouseEvent) => {
    if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
    	// Node는 DOM 트리의 모든 요소를 포함하므로 Node로 타입 단언
    	// 클릭한 요소가 드롭다운 내부인지 확인
    	setIsOpen(false); // 외부 클릭 시 드롭다운 닫기
  	}
  };
  
  document.addEventListener('mousedown', handleClickOutside); // 클릭 이벤트 등록
    return () => {
      document.removeEventListener('mousedown', handleClickOutside); // 클릭 이벤트 제거
    };
}, []);

드롭다운 메뉴가 아닌 다른 곳을 클릭했을 때 드롭다운이 닫히게 만들고 싶었다.

그래서 useRef를 사용해서 null로 초기화를 해주고, 드롭다운 <div>ref를 연결하였다.
그러면 드롭다운 DOM 요소와 dropdownRef는 연결된 상태!

dropdownRef.current는 드롭다운의 실제 DOM 요소를 가리킨다.

!dropdownRef.current.contains(event.target as Node)

event.target, 즉 마우스로 클릭한 곳이 dropdownRef.current (DOM 요소)에 포함되어 있는지?
를 확인하면 끝!

포함 안 돼있다면 isOpenfalse로 해서 닫아주면 된다.

그리고 containsNode 타입이 필요하기 때문에 as Node로 타입 단언을 해주었다.

'use client';

import React, { useState, useEffect } from 'react';
import dayjs from 'dayjs';
import { SelectDropdown } from './SelectDropdown';

// Select 컴포넌트
const Select = () => {
  const [year, setYear] = useState<number | null>(null); // null로 초기화
  const [month, setMonth] = useState<number | null>(null);
  const [day, setDay] = useState<number | null>(null);

  const generateYears = () => Array.from({ length: 101 }, (_, i) => 2024 - i); // 2024부터 1924까지
  const generateMonths = () => Array.from({ length: 12 }, (_, i) => i + 1);
  const generateDays = (year: number | null, month: number | null) => {
    if (!year || !month) return [];
    const daysInMonth = dayjs(`${year}-${month}`).daysInMonth(); // 연도, 월에 맞는 일수 생성

    return Array.from({ length: daysInMonth }, (_, i) => i + 1);
  };

  const years = generateYears();
  const months = generateMonths();
  const days = generateDays(year, month);

  // 날짜 선택 후 콘솔에 날짜 타입으로 반환
  useEffect(() => {
    if (year && month && day) {
      const selectedDate = dayjs(`${year}-${month}-${day}`, 'YYYY-MM-DD');
      console.log('Selected Date:', selectedDate.toDate());
    }
  }, [year, month, day]);

  return (
    <div>
      <p className="px-2 py-1 text-logo text-sm">생년월일</p>
      <div className="flex gap-2">
        {/* 년도 드롭다운 */}
        <SelectDropdown
          options={years}
          selectedValue={year ? `${year}년` : null}
          placeholder="년도"
          onSelect={value => {
            setYear(value);
            setDay(null); // 년도 변경 시 일 초기화
          }}
        />
        {/* 월 드롭다운 */}
        <SelectDropdown
          options={months}
          selectedValue={month ? `${month}월` : null}
          placeholder="월"
          onSelect={value => {
            setMonth(value);
            setDay(null); // 월 변경 시 일 초기화
          }}
        />
        {/* 일 드롭다운 */}
        <SelectDropdown
          options={days}
          selectedValue={day ? `${day}일` : null}
          placeholder="일"
          onSelect={value => setDay(value)}
        />
      </div>
    </div>
  );
};

export default Select;

 

우선 연, 월, 일을 상태로 만들어놓고 시작

const generateYears = () => Array.from({ length: 101 }, (_, i) => 2024 - i); // 2024부터 1924까지
const generateMonths = () => Array.from({ length: 12 }, (_, i) => i + 1);
const generateDays = (year: number | null, month: number | null) => {
  if (!year || !month) return [];
  const daysInMonth = dayjs(`${year}-${month}`).daysInMonth(); // 연도, 월에 맞는 일수 생성

  return Array.from({ length: daysInMonth }, (_, i) => i + 1);
};

Array.from을 활용해서 배열을 만들자.

 

Array.from

유사 배열 객체나 이터러블 객체를 배열로 변환하는 데 사용하는 JavaScript 메서드

Array.from(arrayLike, mapFn, thisArg)

1. arrayLike

: 배열로 변환하려는 유사 배열 객체 또는 이터러블 객체
(ex. NodeList, Set, Map 등)

2. mapFn (optional)

: 배열의 각 요소에 대해 호출할 맵핑 함수!

3. thisArg (optional)

: mapFn을 실행할 때 this로 사용할 값.

 

Array.from({ length: 12 }, (_, i) => i + 1);

이 코드로 예시를 들어보면,

길이가 12인 유사 배열 객체 → arrayLike
(_, i) => i + 1mapFn

맵핑 함수에서 _는 현재 값, i는 현재 인덱스!

따라서 i0부터 시작하게 된다.

0 + 1 = 1
1 + 1 = 2
2 + 1 = 3
.
.
11 + 1 = 12

이렇게 1부터 12까지의 배열이 완성되는 것!


아무튼 Array.from 메서드로 연도와 월 배열을 만들어 주면 되고,
일 배열은 조금 다르다.

왜냐하면 연도와 월에 따라 일수가 달라지기 때문이다.

const generateDays = (year: number | null, month: number | null) => {
  if (!year || !month) return []; // 둘 중 하나라도 null이면 빈 배열 반환
  const daysInMonth = dayjs(`${year}-${month}`).daysInMonth(); // 연도, 월에 맞는 일수 생성

  return Array.from({ length: daysInMonth }, (_, i) => i + 1);
};

yearmonth를 일단 매개변수로 받는다.

dayjs(`${year}-${month}`).daysInMonth();

dayjsdaysInMonth 메서드는 해당 연-월에 존재하는 일수를 반환한다.

따라서 `${year}-${month}`dayjs를 사용해서 날짜 객체로 변환하고,
여기서 dayInMonth 메서드로 해당 월에 존재하는 일수를 반환해주면 된다.

아주 편리한 라이브러리이군!

Moment.js의 대안으로 설계되었는데, 더 가볍고 빠른 라이브러리라고 한다.


<div>
  <p className="px-2 py-1 text-logo text-sm">생년월일</p>
  <div className="flex gap-2">
    {/* 년도 드롭다운 */}
    <SelectDropdown
      options={years}
      selectedValue={year ? `${year}년` : null}
      placeholder="년도"
      onSelect={value => {
        setYear(value);
        setDay(null); // 년도 변경 시 일 초기화
      }}
    />
    {/* 월 드롭다운 */}
    <SelectDropdown
      options={months}
      selectedValue={month ? `${month}월` : null}
      placeholder="월"
      onSelect={value => {
        setMonth(value);
        setDay(null); // 월 변경 시 일 초기화
      }}
    />
    {/* 일 드롭다운 */}
    <SelectDropdown
      options={days}
      selectedValue={day ? `${day}일` : null}
      placeholder="일"
      onSelect={value => setDay(value)}
    />
  </div>
</div>

selectedValue는 상태로 관리하는 year, month, day가 존재한다면 `${day}일` 이렇게 문자열로 보내고,
존재하지 않는다면 아직 선택되지 않았으므로 null로 보낸다.
(placeholder를 보여줘야 하므로)

그리고 onSelect 함수도 연도, 월, 일에 따라 작성해주면 된다.
연도, 월에 따라 일수가 달라지므로 둘 중 하나라도 변경될 경우 일을 초기화 시켰다.


이렇게 하면 생년월일 컴포넌트를 만들 수 있다!