|
1 | | -'use client' |
2 | | - |
3 | | -import { cn } from '@lib/cn' // 클래스 이름 병합 유틸리티 |
4 | | -import { ReactNode, useEffect, useRef, useState } from 'react' |
| 1 | +import { useEffect, useRef, useState } from 'react' |
| 2 | +import { createPortal } from 'react-dom' |
5 | 3 |
|
| 4 | +// 드롭다운 컴포넌트 타입 정의 |
6 | 5 | type DropdownProps = { |
7 | | - trigger: ReactNode |
8 | | - children: ReactNode |
9 | | - align?: 'left' | 'right' | 'center' |
10 | | - width?: string |
| 6 | + trigger: React.ReactNode // 드롭다운을 열기 위한 트리거 요소 (버튼, 아이콘 등) |
| 7 | + children: React.ReactNode // 드롭다운 내부 콘텐츠 (메뉴 아이템 등) |
| 8 | + width?: string // Tailwind 클래스 기반의 너비 설정 (예: 'w-5', 'w-6') |
| 9 | + align?: 'left' | 'center' | 'right' // 드롭다운 정렬 방향 |
11 | 10 | } |
12 | 11 |
|
| 12 | +// 드롭다운 컴포넌트 정의 |
13 | 13 | export default function Dropdown({ |
14 | 14 | trigger, |
15 | 15 | children, |
16 | | - align = 'center', |
17 | | - width = 'w-40', |
| 16 | + width = 'w-6', // 기본 너비 설정 |
| 17 | + align = 'left', // 기본 정렬 방향 설정 |
18 | 18 | }: DropdownProps) { |
19 | | - const [isOpen, setIsOpen] = useState(false) |
20 | | - const ref = useRef<HTMLDivElement>(null) |
| 19 | + const [open, setOpen] = useState(false) // 드롭다운 열림 여부 상태 |
| 20 | + const triggerRef = useRef<HTMLDivElement>(null) // 반응형에 따른 위치 조정을 위한 ref 객체 |
| 21 | + const menuRef = useRef<HTMLDivElement>(null) // 외부 클릭 감지를 위한 드롭다운 메뉴 ref 객체 |
| 22 | + const [coords, setCoords] = useState<{ top: number; left: number }>({ |
| 23 | + top: 0, |
| 24 | + left: 0, |
| 25 | + }) // 드롭다운 메뉴 위치 좌표 상태 |
| 26 | + |
| 27 | + // 드롭다운 열기/닫기 토글 |
| 28 | + const toggleOpen = () => setOpen((prev) => !prev) |
| 29 | + |
| 30 | + // Tailwind width 클래스 값을 실제 CSS 너비 값으로 변환 |
| 31 | + const getWidthValue = (width: string) => { |
| 32 | + switch (width) { |
| 33 | + case 'w-5': // 할 일 카드 모달에서 사용 |
| 34 | + return '5rem' |
| 35 | + case 'w-6': |
| 36 | + return '6rem' |
| 37 | + default: |
| 38 | + return undefined // 정의되지 않은 값은 기본 width 적용 |
| 39 | + } |
| 40 | + } |
21 | 41 |
|
| 42 | + // 드롭다운이 열릴 때 위치 계산 및 윈도우 이벤트 바인딩 |
22 | 43 | useEffect(() => { |
23 | | - const handleClickOutside = (e: MouseEvent) => { |
24 | | - if (ref.current && !ref.current.contains(e.target as Node)) { |
25 | | - setIsOpen(false) |
| 44 | + function updateCoords() { |
| 45 | + if (open && triggerRef.current) { |
| 46 | + const rect = triggerRef.current.getBoundingClientRect() // 트리거 위치 측정 |
| 47 | + |
| 48 | + let left = rect.left |
| 49 | + if (align === 'center') left = rect.left + rect.width / 2 |
| 50 | + else if (align === 'right') left = rect.right |
| 51 | + |
| 52 | + setCoords({ |
| 53 | + top: rect.bottom + window.scrollY, // 화면 스크롤 반영 |
| 54 | + left, |
| 55 | + }) |
26 | 56 | } |
27 | 57 | } |
28 | 58 |
|
29 | | - document.addEventListener('mousedown', handleClickOutside) |
30 | | - return () => document.removeEventListener('mousedown', handleClickOutside) |
31 | | - }, []) |
| 59 | + if (open) { |
| 60 | + updateCoords() |
| 61 | + window.addEventListener('resize', updateCoords) |
| 62 | + window.addEventListener('scroll', updateCoords) |
| 63 | + } |
| 64 | + |
| 65 | + return () => { |
| 66 | + window.removeEventListener('resize', updateCoords) |
| 67 | + window.removeEventListener('scroll', updateCoords) |
| 68 | + } |
| 69 | + }, [open, align]) |
| 70 | + |
| 71 | + // 드롭다운 외부 클릭 시 닫기 |
| 72 | + useEffect(() => { |
| 73 | + function handleClickOutside(event: MouseEvent) { |
| 74 | + if ( |
| 75 | + menuRef.current && |
| 76 | + !menuRef.current.contains(event.target as Node) && |
| 77 | + triggerRef.current && |
| 78 | + !triggerRef.current.contains(event.target as Node) |
| 79 | + ) { |
| 80 | + setOpen(false) |
| 81 | + } |
| 82 | + } |
32 | 83 |
|
| 84 | + if (open) { |
| 85 | + document.addEventListener('mousedown', handleClickOutside) |
| 86 | + } else { |
| 87 | + document.removeEventListener('mousedown', handleClickOutside) |
| 88 | + } |
| 89 | + |
| 90 | + return () => { |
| 91 | + document.removeEventListener('mousedown', handleClickOutside) |
| 92 | + } |
| 93 | + }, [open]) |
| 94 | + |
| 95 | + // 드롭다운 메뉴 렌더링 (포탈을 이용하여 body로 위치) |
| 96 | + const menu = open |
| 97 | + ? createPortal( |
| 98 | + <div |
| 99 | + ref={menuRef} |
| 100 | + style={{ |
| 101 | + position: 'absolute', |
| 102 | + top: coords.top, |
| 103 | + left: coords.left, |
| 104 | + transform: |
| 105 | + align === 'center' |
| 106 | + ? 'translateX(-50%)' |
| 107 | + : align === 'right' |
| 108 | + ? 'translateX(-100%)' |
| 109 | + : undefined, |
| 110 | + width: getWidthValue(width), |
| 111 | + minWidth: getWidthValue(width), |
| 112 | + boxShadow: '0 2px 8px rgba(0,0,0,0.15)', // 그림자 |
| 113 | + borderRadius: 8, // 모서리 둥글게 |
| 114 | + zIndex: 9999, // 위로 띄우기 |
| 115 | + }} |
| 116 | + className="BG-white" // 커스텀 배경 클래스 (Tailwind 예시) |
| 117 | + > |
| 118 | + {children} |
| 119 | + </div>, |
| 120 | + document.body, // body에 포탈로 삽입 |
| 121 | + ) |
| 122 | + : null |
| 123 | + |
| 124 | + // 트리거 요소 + 드롭다운 메뉴 렌더링 |
33 | 125 | return ( |
34 | | - <div ref={ref} className="relative inline-block"> |
| 126 | + <> |
35 | 127 | <div |
36 | | - onClick={() => setIsOpen((prev) => !prev)} |
37 | | - className="cursor-pointer" |
| 128 | + ref={triggerRef} |
| 129 | + onClick={toggleOpen} |
| 130 | + className="inline-block cursor-pointer" |
38 | 131 | > |
39 | 132 | {trigger} |
40 | 133 | </div> |
41 | | - |
42 | | - {isOpen && ( |
43 | | - <div |
44 | | - className={cn( |
45 | | - 'BG-gray absolute z-50 mt-4 rounded border shadow-md', |
46 | | - width, |
47 | | - { |
48 | | - 'left-0': align === 'left', |
49 | | - 'right-0': align === 'right', |
50 | | - 'left-1/2 -translate-x-1/2': align === 'center', |
51 | | - }, |
52 | | - )} |
53 | | - > |
54 | | - {children} |
55 | | - </div> |
56 | | - )} |
57 | | - </div> |
| 134 | + {menu} |
| 135 | + </> |
58 | 136 | ) |
59 | 137 | } |
0 commit comments