모달 안에 있는 드롭다운이 열려있는 상태에서, 모달 내부지만 드롭다운 관련 요소들 외부를 클릭하면 드롭다운이 꺼지고, 모달 외부를 클릭하면 드롭다운과 모달이 함께 꺼지도록 구현하고 싶었다.
그래서 아래와 같은 로직을 이용했다.
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
const modalContent = document.querySelector('[data-modal-container="true"]'); // 모달 공통 컴포넌트 컨테이너
// 모달 내부 클릭인지 확인
if (modalContent && modalContent.contains(event.target as Node)) {
// 드롭다운이 열려있고, 클릭된 요소가 드롭다운 내부가 아닐 때만 드롭다운 닫기
if (
isStatusOpen &&
!(event.target as Element).closest(`[data-schedule-id="${schedule.id}"]`)
) {
setOpenStatusDropdownId(null);
}
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => {
document.removeEventListener('mousedown', handleClickOutside);
};
}, [isStatusOpen, setOpenStatusDropdownId, schedule.id]);
우선 모달의 컨테이너 부분에 data-modal-container="true" 속성을 추가한 다음 querySelector를 이용해서 모달의 컨테이너를 선택한다.
그리고 contains를 이용해 모달 내부를 선택했는지 여부를 판단한다.
만약 모달 내부 클릭이면, 그 안에서도 내가 연 드롭다운 관련 컴포넌트를 클릭했는지, 아닌지를 판단한다.
이 때는 closest 메서드를 이용한다.
❓ closest
event.target.closest() 메서드는 DOM API의 일부로, 특정 요소와 그 조상 요소들 중에서 지정된 CSS 선택자와 일치하는 가장 가까운 조상 요소를 찾는 기능을 한다.
element.closest(selectors);
element:검색을 시작할 요소
selectors: 찾고자 하는 요소와 일치하는 CSS 선택자 문자열
반환값: 주어진 CSS 선택자와 일치하는 가장 가까운 조상 요소 (자기 자신 포함). 일치하는 요소가 없으면 null 반환
드롭다운 관련 컴포넌트 css 선택자는 다음과 같이 설정했다.
<div className="flex items-center justify-end mt-2 sm:mt-0 sm:min-w-[140px]">
<div className="relative mr-3 z-20">
<button
onClick={toggleStatusDropdown}
onKeyDown={handleKeyDown}
className={`px-3 sm:px-4 py-1.5 sm:py-2 border rounded-md text-xs sm:text-sm flex items-center cursor-pointer ${getStatusTextClass(schedule.status)}`}
aria-label="일정 상태 변경"
aria-expanded={isStatusOpen}
aria-haspopup="listbox"
tabIndex={0}
data-schedule-id={schedule.id}
>
{schedule.status}
<HiChevronDown className="ml-1 w-3 h-3" />
</button>
{isStatusOpen && (
<div
className="bg-white border border-gray-200 rounded-md shadow-lg w-28 sm:w-36 z-50"
role="listbox"
aria-label="일정 상태 옵션"
style={{
position: 'absolute',
right: '0',
top: 'calc(100% + 0.5rem)',
pointerEvents: 'auto',
}}
data-schedule-id={schedule.id}
>
{STATUS_OPTIONS.map((status) => (
<div
key={status}
className={`px-3 sm:px-4 py-1.5 sm:py-2 hover:bg-gray-100 cursor-pointer text-xs sm:text-sm ${
status === schedule.status ? 'font-medium ' + getStatusTextClass(status) : ''
}`}
onClick={(e) => handleStatusChange(status, e)}
onKeyDown={(e) => handleStatusOptionKeyDown(e, status)}
role="option"
aria-selected={status === schedule.status}
tabIndex={0}
data-schedule-id={schedule.id}
>
{status}
</div>
))}
</div>
)}
</div>
드롭다운 요소들,이 요소들을 감싼 컨테이너, 드롭다운 버튼 이렇게 3개를 지정해줬다.
이 훅은 재사용할 일이 있을 것 같아 다음과 같이 컴포넌트로 분리했다.
import { useEffect } from 'react';
type ClickOutsideHandler = () => void;
interface UseClickOutsideOptions {
containerSelector?: string;
exceptSelector?: string;
elementId?: string | number;
isOpen?: boolean;
}
/**
* 특정 영역 외부 클릭을 감지하는 훅
* @param onClickOutside 외부 클릭 시 실행할 콜백 함수
* @param options 설정 옵션
* @param options.containerSelector 컨테이너 요소 선택자 (이 요소 내부 클릭만 감지)
* @param options.exceptSelector 제외할 요소 선택자
* @param options.elementId 제외할 요소의 ID
* @param options.isOpen 드롭다운/모달 등이 열려있는지 여부
*/
export const useClickOutside = (
onClickOutside: ClickOutsideHandler,
options: UseClickOutsideOptions = {},
) => {
const { containerSelector, exceptSelector, elementId, isOpen } = options;
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
// 컨테이너가 지정된 경우 컨테이너 내부 클릭인지 확인
if (containerSelector) {
const container = document.querySelector(containerSelector);
// 컨테이너가 없거나 컨테이너 외부 클릭인 경우 무시
if (!container || !container.contains(event.target as Node)) {
return;
}
}
// 특정 요소를 제외하고 처리
if (exceptSelector && elementId) {
if ((event.target as Element).closest(`${exceptSelector}="${elementId}"`)) {
return;
}
}
onClickOutside();
};
if (isOpen === false) return;
document.addEventListener('mousedown', handleClickOutside);
return () => {
document.removeEventListener('mousedown', handleClickOutside);
};
}, [onClickOutside, containerSelector, exceptSelector, elementId, isOpen]);
};
// 외부 클릭 감지 훅 사용
useClickOutside(() => setOpenStatusDropdownId(null), {
containerSelector: '[data-modal-container="true"]',
exceptSelector: '[data-schedule-id',
elementId: schedule.id,
isOpen: isStatusOpen,
});
모달 바깥을 클릭한 경우 이벤트 핸들러는 다음과 같이 구현했다.
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (modalRef.current && !modalRef.current.contains(event.target as Node)) {
onClose();
}
};
const handleEscKey = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
onClose();
}
};
if (isOpen) {
document.addEventListener('mousedown', handleClickOutside);
document.addEventListener('keydown', handleEscKey);
// 모달이 열릴 때 body 스크롤 방지
document.body.style.overflow = 'hidden';
}
return () => {
document.removeEventListener('mousedown', handleClickOutside);
document.removeEventListener('keydown', handleEscKey);
// 모달이 닫힐 때 body 스크롤 복원
if (isOpen) {
document.body.style.overflow = '';
}
};
}, [isOpen, onClose]);
모달이 열렸을 때 스크롤이 되면 불편할 수도 있으므로 overflow = 'hidden'으로 스크롤 방지를 해주는 것이 좋다.
'React' 카테고리의 다른 글
[React] useEffect의 클린업 함수 (2) | 2025.06.20 |
---|---|
[React] 얕은 복사와 불변성 (2) | 2025.05.12 |
[React] 이벤트 핸들러에 함수를 전달하는 방식 (0) | 2025.04.28 |
[React] 리액트의 폴더구조 (0) | 2025.03.31 |
[React] Fragment (0) | 2025.03.31 |