
MUI의 Popper 컴포넌트는 기본적으로 Fade와 ClickAwayListener 컴포넌트를 추가로 필요로 합니다. 이는 다음과 같은 문제점을 야기합니다:
이러한 문제점들을 해결하기 위해 단일 컴포넌트로 모든 기능을 처리하는 커스텀 Popper를 개발했습니다.
import { Box, Grid, SxProps, Theme } from "@mui/material";
import {
FC,
useCallback,
useEffect,
useRef,
useState,
memo,
TransitionEvent
} from "react";
// Position 인터페이스 정의
interface Position {
top: number;
left: number;
}
// Placement 타입 정의
type Placement = "top" | "bottom";
// Props 인터페이스 정의
interface PopperProps {
anchorEl: HTMLElement | null; // Popper가 연결될 요소
children: React.ReactNode; // Popper 내부 콘텐츠
open: boolean; // Popper 표시 여부
placement?: Placement; // Popper 위치 (top/bottom)
offset?: number; // 기준점으로부터의 거리
sx?: SxProps<Theme>; // 추가 스타일
onClickOutside?: (e: MouseEvent) => void; // 외부 클릭 핸들러
onTransitionEnd?: (e: TransitionEvent<HTMLDivElement>) => void; // 트랜지션 완료 핸들러
}
// 위치 계산 함수
const calculatePosition = (
anchor: HTMLElement,
placement: Placement,
offset: number
): Position => {
const rect = anchor.getBoundingClientRect();
return {
top: {
top: -rect.top + offset,
left: rect.left,
},
bottom: {
top: rect.bottom + offset,
left: rect.left,
},
}[placement];
};
// Popper 컴포넌트 구현
const Popper: FC<PopperProps> = memo(
({
anchorEl,
children,
open,
placement = "bottom",
offset = 8,
sx,
onClickOutside,
onTransitionEnd
}) => {
// 상태 관리
const [{ top, left }, setPosition] = useState<Position>({ top: 0, left: 0 });
const [mounted, setMounted] = useState(false);
const popperRef = useRef<HTMLDivElement>(null);
// 위치 업데이트 함수
const updatePosition = useCallback(() => {
if (anchorEl && popperRef.current) {
setPosition(calculatePosition(anchorEl, placement, offset));
}
}, [anchorEl, placement, offset]);
// 스크롤/리사이즈 이벤트 처리
useEffect(() => {
if (open && anchorEl) {
updatePosition();
setMounted(true);
// 모든 가능한 위치 변경 이벤트에 대한 리스너 등록
window.addEventListener("scroll", updatePosition);
window.addEventListener("resize", updatePosition);
window.addEventListener("wheel", updatePosition);
window.addEventListener("touchmove", updatePosition);
// 클린업 함수
return () => {
window.removeEventListener("scroll", updatePosition);
window.removeEventListener("resize", updatePosition);
window.removeEventListener("wheel", updatePosition);
window.removeEventListener("touchmove", updatePosition);
};
}
}, [open, anchorEl, updatePosition]);
// 외부 클릭 감지
useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
if (
popperRef.current &&
!popperRef.current.contains(e.target as Node) &&
anchorEl &&
!anchorEl.contains(e.target as Node)
) {
onClickOutside?.(e);
}
};
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, [anchorEl, onClickOutside]);
// 조건부 렌더링
if (!open && !mounted) return null;
// 컴포넌트 렌더링
return (
<Box
ref={popperRef}
sx={{
position: "fixed",
zIndex: 1300,
top,
left,
opacity: 0,
visibility: "hidden",
transition: "opacity 200ms ease-out, visibility 200ms ease-out",
...(open &&
mounted && {
opacity: 1,
visibility: "visible",
}),
...sx,
}}
onTransitionEnd={e => {
if (e.propertyName === "opacity" && !open) {
setMounted(false);
onTransitionEnd?.(e);
}
}}
>
<Grid container>{children}</Grid>
</Box>
);
}
);
// 컴포넌트 디스플레이 네임 설정
Popper.displayName = "Popper";
export default Popper;
const calculatePosition = (
anchor: HTMLElement,
placement: Placement,
offset: number
): Position => {
const rect = anchor.getBoundingClientRect();
return {
top: {
top: -rect.top + offset,
left: rect.left,
},
bottom: {
top: rect.bottom + offset,
left: rect.left,
},
}[placement];
};
getBoundingClientRect()를 사용하여 뷰포트 기준 위치 계산placement에 따라 다른 위치 계산 로직 적용offset 값으로 여백 조정 가능useEffect(() => {
if (open && anchorEl) {
updatePosition();
setMounted(true);
// 이벤트 리스너 등록
const events = ["scroll", "resize", "wheel", "touchmove"];
events.forEach(event => window.addEventListener(event, updatePosition));
// 클린업
return () => {
events.forEach(event => window.removeEventListener(event, updatePosition));
};
}
}, [open, anchorEl, updatePosition]);
useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
if (
popperRef.current &&
!popperRef.current.contains(e.target as Node) &&
anchorEl &&
!anchorEl.contains(e.target as Node)
) {
onClickOutside?.(e); // setOpen(false) 호출
}
};
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, [anchorEl, onClickOutside]);
<Box
sx={{
opacity: 0,
visibility: "hidden",
transition: "opacity 200ms ease-out, visibility 200ms ease-out",
...(open && mounted && {
opacity: 1,
visibility: "visible",
}),
}}
onTransitionEnd={e => {
if (e.propertyName === "opacity" && !open) {
setMounted(false);
onTransitionEnd?.(e); // setAnchorEl(null) 호출
}
}}
>
const App = () => {
const [open, setOpen] = useState(false);
const [anchorEl, setAnchorEl] = useState<HTMLElement | null>(null);
const handleClick = (event: React.MouseEvent<HTMLElement>) => {
setAnchorEl(event.currentTarget);
setOpen(true);
};
return (
<>
<button onClick={handleClick}>
Open Popper
</button>
<Popper
open={open}
anchorEl={anchorEl}
onClickOutside={() => setOpen(false)} // 애니메이션 시작
onTransitionEnd={() => setAnchorEl(null)} // 애니메이션 완료 후 정리
>
<div>Popper Content</div>
</Popper>
</>
);
};
열기
클릭 → setAnchorEl(element) → setOpen(true) → mounted true →
opacity 0 → transition → opacity 1
닫기
외부 클릭 → onClickOutside → setOpen(false) →
opacity 1 → transition → opacity 0 →
onTransitionEnd → setAnchorEl(null)
메모이제이션
memo 처리updatePosition 함수 useCallback 처리이벤트 최적화
렌더링 최적화
이 구현은 MUI Popper의 복잡성을 제거하면서도, 더 나은 성능과 사용자 경험을 제공합니다. 특히 애니메이션 처리와 위치 계산에 있어 효율적인 방식을 채택하고 있습니다.