React Hover UI 제대로 구현하기 – 공백 이탈 문제와 모바일 터치 대응

chaen·2025년 4월 20일

REACT / NEXT.js

목록 보기
20/22

🔍 목표

웹 상단 네비게이션의 "Cart" 텍스트에 마우스를 올렸을 때, Popover UI 컴포넌트가 자연스럽게 열리고, 모바일에서도 터치로 동작하며, Popover UI 내부로 마우스가 이동했을 때도 닫히지 않도록 구현합니다.

또한 바깥을 클릭하면 Cart가 닫히는 동작도 추가하여 UX를 완성합니다.


💡 주요 고려사항

1. PC vs 모바일

  • PC에서는 마우스 hover (onMouseEnter, onMouseLeave) 기반으로 Popover를 열고 닫음
  • 모바일에서는 hover가 없기 때문에 터치(onTouchStart)로 toggle함

2. 마우스가 Popover 내부로 이동할 때 닫히지 않도록 처리

  • Trigger(텍스트)와 Popover 사이에 공백이 있을 경우, 잠깐 mouseleave가 발생해서 Popover가 닫혀버릴 수 있음
  • 이를 해결하기 위해 setTimeoutclearTimeout을 사용하여 약간의 유예 시간을 줌

3. Popover 바깥 클릭 시 닫히도록 구현

  • 열린 상태에서 바깥 클릭 감지 (mousedown)로 닫기 처리

🧱 기술 포인트 설명

✅ 모바일 기기 감지 - matchMedia('(pointer: coarse)')

useEffect(() => {
  if (typeof window !== 'undefined') {
    setIsMobile(window.matchMedia('(pointer: coarse)').matches);
  }
}, []);
  • 모바일 기기는 일반적으로 정밀하지 않은 포인터(coarse) 를 사용
  • 추가 설명은 링크에서 참고 가능

✅ setTimeout + clearTimeout 로 닫힘 유예 처리

//타입스크립트에서 setTimeout의 반환 타입은 환경에 따라 다르기 때문에 ReturnType을 사용함
const closeTimeout = useRef<ReturnType<typeof setTimeout> | null>(null);

const handleMouseLeave = (e: React.MouseEvent) => {
  if (isMobile) return;

  closeTimeout.current = setTimeout(() => {
    // 마우스가 Popover 내부에 있었고, Popover 내부에 있지 않을거라면 (외부로 이동한다면) 닫기
    const target = e.relatedTarget;
    
    if (
        cartRef.current &&
        (!target ||
          !(target instanceof Node) ||
          !cartRef.current.contains(target))
      )
        setIsCartOpen(false);
    }, 100);
};

const openCart = () => {
  if (closeTimeout.current) clearTimeout(closeTimeout.current);
  setIsPopoverOpen(true);
};
  • 마우스가 Popover Trigger(텍스트)에서 떠났을 때 바로 닫지 않고 100ms 후에 닫기를 예약
  • 만약 그 사이에 Popover 내부로 마우스가 이동하면, clearTimeout으로 닫기 예약을 취소함

e.relatedTarget에 대하여

  • onMouseLeaveonMouseOut 이벤트에서만 존재하는 속성
  • 위 두 이벤트는 두 요소 사이의 마우스 이동 순간에 발생함
  • 따라서, e.currentTarget : 지금 떠나는 요소
  • e.relatedTarget: 지금 이동한 대상 요소
  • 예시) 마우스가 Trigger에서 → Popover로 이동 중이라면 e.relatedTargetPopover 내부 요소 중 하나가 됨

✅ 바깥 클릭 시 닫기

useEffect(() => {
  if (!isCartOpen) return;

  const handleClickOutside = (e: MouseEvent) => {
    if (
      popoverRef.current &&
      !popoverRef.current.contains(e.target as Node) &&
      popoverTriggerRef.current &&
      !popoverTriggerRef.current.contains(e.target as Node)
    ) {
      setIsPopoverOpen(false);
    }
  };

  document.addEventListener('mousedown', handleClickOutside);
  return () => {
    document.removeEventListener('mousedown', handleClickOutside);
  };
}, [isPopoverOpen]);
  • 열린 상태에서 document 전체를 대상으로 mousedown 이벤트 등록
  • 클릭한 대상이 Cart 영역이나 Cart 트리거가 아니라면 닫음

✅ 전체 작동 순서 요약

  1. 초기 렌더링 시 pointer: coarse 여부를 체크해 모바일인지 판별 → isMobile 상태 설정
  2. PC일 경우:
    • Trigger에 마우스를 올리면 setIsPopoverOpen(true)
    • 마우스를 떼면 100ms 후 닫기 예약
    • 마우스가 Popover 내부로 들어오면 예약 취소 (열림 유지)
  3. 모바일일 경우:
    • Trigger 터치 시 toggle 방식으로 열고 닫음
  4. Popover가 열렸을 때:
    • 바깥 영역 클릭 시 닫힘 처리됨

0개의 댓글