처음에는 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>
)}
isOpen이 true이고, options.length가 0보다 클 때만 드롭다운 메뉴를 보여주도록 했다.
dayjs를 활용하여 일수를 계산하기 때문에, 연도와 월을 지정하지 않으면 일이 안 나오기 때문에, 그걸 구분하려고 이렇게 작성했다.
(일이 없다면 options 배열은 비어있기 때문!)
options를 map으로 순회하여 하나씩 <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 요소)에 포함되어 있는지?
를 확인하면 끝!
포함 안 돼있다면 isOpen을 false로 해서 닫아주면 된다.
그리고 contains는 Node 타입이 필요하기 때문에 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 + 1 → mapFn
맵핑 함수에서 _는 현재 값, i는 현재 인덱스!
따라서 i는 0부터 시작하게 된다.
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);
};
year와 month를 일단 매개변수로 받는다.
dayjs(`${year}-${month}`).daysInMonth();
dayjs의 daysInMonth 메서드는 해당 연-월에 존재하는 일수를 반환한다.
따라서 `${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 함수도 연도, 월, 일에 따라 작성해주면 된다.
연도, 월에 따라 일수가 달라지므로 둘 중 하나라도 변경될 경우 일을 초기화 시켰다.
이렇게 하면 생년월일 컴포넌트를 만들 수 있다!
'개발 > 프로젝트' 카테고리의 다른 글
redux-persist로 Redux 상태 유지하기 (0) | 2025.01.08 |
---|---|
RTK Query로 로그인 API 구현하고 RTK로 유저 정보 관리하기 (0) | 2025.01.07 |
RTK Query를 사용하여 회원가입 API 구현하기 (1) | 2025.01.06 |
react-hook-form, zod를 활용하여 회원정보 수정 페이지 제작하기 (1) | 2025.01.04 |
cva, clsx를 활용하여 레이어팝업 컴포넌트 만들기 (2) | 2024.12.27 |