리액트로 드롭다운 메뉴를 만들어보자.
기능 설계
드롭다운은 버튼 클릭 등의 상호작용으로 요소를 활성화하면 그 아래 하위 메뉴들이 펼쳐지는 것이다. 본문에서 구현할 기능은 크게 세 가지로 나눌 수 있다.
check list
버튼으로 메뉴 열고 닫기
외부 영역 클릭 시 메뉴 닫기
메뉴 오픈 상태에서 외부 스크롤 방지
기본 틀 구성
메뉴 폼
일단 버튼 활성화와 상관없이 메뉴를 활성화했을 때의 상태로 기본 틀을 잡아준다.
export default function DropdownMenu() {
const category = ['C', 'C++', 'Javascript', 'Python', 'JAVA'];
return (
<div className="container">
<label>MenuBar</label>
<div className="menu-wrap">
<div className="menu-form">
<div className="title">Language</div>
<button className="title">▾</button>
</div>
<ul className="category-list">
{category.map((category) => (
<li key={category}>{category}</li>
))}
</ul>
</div>
</div>
);
}
기능 구현
버튼 활성화
버튼으로 메뉴를 열고 닫을 수 있게 hook을 만들어 보자. 버튼을 클릭할 때마다 isOpened
의 상태가 반대로 변하는 onButtonClick
함수를 생성하고 상탯값과 함수를 전달한다.
const useDropdown = (initialState) => {
const [isOpened, setIsOpened] = useState(initialState);
// 버튼 클릭 시 메뉴 열고 닫기
const onButtonClick = () => {
setIsOpened(!isOpened);
};
return { isOpened, onButtonClick };
};
이를 DropdownMenu
에 적용해준다. isOpened
가 false일 때 메뉴는 비활성화 상태이고 활성화되면 category-list 목록이 보이게 된다.
className으로 상태에 따라 다른 네임을 전달하면 css도 메뉴 활성화에 따라 다르게 구현할 수 있다.
export default function DropdownMenu() {
//...
const { isOpened, onButtonClick } = useDropdown(false);
return (
<div className="container">
<label className={`${isOpened ? 'active' : ''}`}>MenuBar</label>
<div className="menu-wrap">
<div className="menu-form" className={`${isOpened ? 'active' : ''}`}>
<div className="title">Language</div>
<button className="title" onClick={onButtonClick}>
▾
</button>
</div>
{isOpened && (
<ul className="category-list">
{category.map((category) => (
<li key={category}>{category}</li>
))}
</ul>
)}
</div>
</div>
);
}
외부 클릭 시 메뉴 닫기
메뉴가 열려 있을 때 다시 버튼을 클릭하지 않고도 외부 화면을 클릭하면 메뉴 창이 닫히도록 만들 것이다. 아까 만든 useDropdown
hook에서 useEffect로 해당 기능을 추가해주자.
ref가 가리키는 요소에 위치하지 않으면 메뉴 창을 닫도록 하는 handleClickOutside
함수를 생성하고 메뉴 창이 열려 있을 때만 실행되게 addEventListener를 추가한다.
const useDropdown = (initialState) => {
const [isOpened, setIsOpened] = useState(initialState);
const dropdownRef = useRef(null);
// ...
useEffect(() => {
// 외부 영역 클릭 시 메뉴 닫기
const handleClickOutside = (e) => {
if (dropDownRef.current && !dropDownRef.current.contains(e.target)) {
setIsOpened(false);
}
};
if (isOpened) {
document.addEventListener('mousedown', handleClickOutside);
} else {
document.removeEventListener('mousedown', handleClickOutside);
}
return () => {
document.removeEventListener('mousedown', handleClickOutside);
};
}, [isOpened]);
return { isOpened, dropdownRef, onButtonClick };
};
그리고 DropdownMenu
로 돌아가 ref를 메뉴의 가장 바깥 컴포넌트에 추가한다.
export default function DropdownMenu() {
//...
const { isOpened, dropdownRef, onButtonClick } = useDropdown(false);
return (
<div className="container">
<label className={`${isOpened ? 'active' : ''}`}>MenuBar</label>
<div className="menu-wrap" ref={dropdownRef}>
// ...
</div>
</div>
);
}
외부 스크롤 방지
모바일 환경에서는 메뉴를 펼쳤을 때 화면에서 차지하는 비율이 높기 때문에 메뉴 이외의 화면 스크롤이 함께 동작하면 불편할 수 있다. 그래서 메뉴가 열려 있는 동안에는 외부 스크롤을 막아두도록 하자.
document.documentElement.style
로 루트 요소에 접근해 원하는 속성을 제어할 수 있다.
const useDropdown = (initialState) => {
// ...
useEffect(() => {
//...
// 메뉴 오픈 상태에서 외부 스크롤 막기
if (isOpened) {
document.documentElement.style.overflow = 'hidden';
document.addEventListener('mousedown', handleClickOutside);
} else {
document.documentElement.style.overflow = '';
document.removeEventListener('mousedown', handleClickOutside);
}
return () => {
document.documentElement.style.overflow = '';
document.removeEventListener('mousedown', handleClickOutside);
};
}, [isOpened]);
return { isOpened, dropdownRef, onButtonClick };
};
모바일과 pc 모두 사용하기 편리한 드롭다운 메뉴가 완성됐다!