- 부모의 overflow 속성에 영향을 받지 않으면서, 최상단 고정 배치
- body 및 모든 엘리먼트 스크롤 방지
- 콘텐츠 외부 영역 클릭 시 닫힘
- 콘텐츠 위치 자동 변경 (콘텐츠의 남은 공간에 따라 위치 변경)
- 합성 컴포넌트로 구현
- 마운트/언마운트 시 transition 적용
이번 글에서는 DropDown의 콘텐츠의 외부 영역 클릭 시 닫힘을 구현하려고 한다.
외부 영역 클릭 시 닫히는 여러가지 방법을 알아보자
먼저 간략하게 구조를 살펴보자. ("외부 영역 클릭 시 닫힘" 기능과 무관한 이전 코드는 제외)
구조와 사진을 보면 스크롤을 막기 위해 추가한 전체 영역을 차지하는 <Layer>
(붉은색 영역)를 클릭하면 쉽게 구현할 수 있다는 생각이 들 것이다.
const DropDown = () => {
const [show, setShow] = useState(false);
//...함수
return (
<S.Container>
<button onClick={() => setShow(true)}>🍡 탕후루 과일 추가</button>
{show && (
<Portal id='dropdown'>
<S.Layer>
<S.Content>
//...콘텐츠
</S.Content>
</S.Layer>
</Portal>
)}
</S.Container>
);
};
export default DropDown;
가장 쉬운 방법은 <Layer>
클릭 시 setShow의 상태를 false
로 변경하여 닫히게 하는 것이다.
하지만 이렇게 처리할 경우 <Layer>
의 자식 클릭 시에도 닫히기 때문에
외부 영역 클릭 시 닫힘이 아니라 전체 영역 클릭 시 닫힘이 된다.
<S.Layer onClick={() => setShow(false)} />
<S.Content>
//...콘텐츠
</S.Content>
</S.Layer>
위 영상을 보면 자식 엘리먼트인 <Content>
를 클릭하면 그의 부모인 <Layer>
엘리먼트도 발생함을 볼 수 있다.
이러한 현상을 이벤트 전파(Event Propagation)라 부르며, 전파 방향에 따라 버블링과 캡처링으로 구분한다.
버블링(Bubbling) : 자식 요소에서 발생한 이벤트가 바깥 부모 요소로 전파 (기본값)
캡쳐링(Capturing) : 자식 요소에서 발생한 이벤트가 부모 요소부터 시작하여 안쪽 자식 요소까지 도달
위와 같은 상황은 버블링 상황이며 stopPropagation()
를 사용해 이벤트 전파를 제어할 수 있다.
이벤트가 캡처링/버블링 단계에서 더 이상 전파되지 않도록 방지하는 기능이다.
사용법은 간단하다. <Content>
의 클릭 이벤트에 e.stopPropagation()
를 추가하는 것이다.
<S.Layer onClick={() => setShow(false)} />
<S.Content onClick={(e) => e.stopPropagation()}> //이벤트 전파 방지
//...콘텐츠
</S.Content>
</S.Layer>
사실 더 쉬운 방법이 있는데, 이벤트 전파 방지 보여주려고 어그로 끌었다.
구조를 아래와 같이 부모-자식 구조에서 형제 구조로 변경하면 이벤트 전파가 더 이상 적용되지 않으므로,
<Content>
컴포넌트에 클릭 이벤트를 추가하지 않고도 useState
를 활용하여 동일한 기능을 구현할 수 있다.
이렇게 변경함으로써 컴포넌트 간의 직접적인 의존성을 줄이고, 더 모듈화된 구조를 갖출 수 있다.
<S.Layer onClick={() => setShow(false)} />
<S.Content>
//...콘텐츠
</S.Content>
두 방법 모두 아래 영상과 같이 기능이 잘 동작한다.
"바깥 클릭 시 닫힘" 기능을 구현하기 위해 유명한 기능 중 하나는 addEventListener
와 contains
를 활용해 useRef
를 활용해 내부영역을 지정한 후 클릭하는 영역이 내부 영역을 제외한 외부 영역인지 확인하는 기능이다.
또한 컴포넌트의 언마운트 시점에 removeEventListener
를 사용하여 이벤트를 제거하여 메모리 누수를 방지하고, 불필요한 이벤트가 발생하지 않도록 해야한다.
만약 Portal
과 전체 영역을 차지하는 <Layer>
가 없다면 아래 코드를 활용해 Custom Hook으로 만들어서 재사용하기 좋은 기능이다.
useEffect(() => {
function handleInteraction(e: Event) {
//ref = 내부영역
if (ref?.current && !ref.current.contains(e.target as HTMLElement)) {
setShow(false);
}
}
document.addEventListener('click', handleInteraction);
return () => {
document.removeEventListener('click', handleInteraction);
};
}, [ref]);
DropDown 컴포넌트와 같이 Portal
을 사용하여 <Container>
와 <Content>
가 분리된 경우, 아래 코드와 같이 useRef
를 활용하여 버튼과 콘텐츠 두 영역을 지정한 후, addEventListener
를 사용하여 클릭 영역이 <Container>
(버튼) 또는 <Content>
(콘텐츠)가 아닌 경우 setShow(false)
를 호출하도록 수정했다.
const [show, setShow] = useState(false);
const containerRef = useRef<HTMLDivElement>(null); //내부 버튼 영역
const contentRef = useRef<HTMLDivElement>(null); //내부 콘텐츠 영역
useEffect(() => {
function handleInteraction(e: Event) {
const target = e.target as HTMLElement;
if (!containerRef?.current || !contentRef?.current) return;
if (!containerRef.current.contains(target) && !contentRef.current.contains(target)) {
//내부 영역이 아닐 경우 실행
setShow(false);
}
}
document.addEventListener('click', handleInteraction);
return () => {
document.removeEventListener('click', handleInteraction);
};
}, [containerRef, contentRef]);
return (
<S.Container ref={containerRef}>
<button onClick={() => setShow(true)}>🍡 탕후루 과일 추가</button>
{show && (
<Portal id='dropdown'>
<S.Layer/>
<S.Content ref={contentRef}>
//...콘텐츠
</S.Content>
</Portal>
)}
</S.Container>
);
};
이 방법도 외부 영역 클릭 시 닫히는 기능이 잘 동작한다.
하지만 DropDown 컴포넌트에서 사용하기에 <Layer>
클릭 이벤트로 처리하는 것이 더 가볍고 적합한 방법으로 보인다.
<Layer>
가 없을 경우 사용하기 적합한 방법이다.
하지만 위 두 방법에는 같은 이슈가 있다.
브라우저 내에서 외부 영역 클릭만 감지되기 때문에, 브라우저 창 크기 조정이나 브라우저 외부를 클릭하는 경우에는 콘텐츠가 자동으로 닫히지 않는다.
따라서, 콘텐츠가 활성화된 상태에서 브라우저 창 크기를 조정하면 콘텐츠의 위치가 고정되어 레이아웃이 적절하게 조정되고 있지 않다.
해당 문제를 해결하기 위해서는 resize 이벤트 window.addEventListener('resize', ...)
를 활용하여,
브라우저 창 크기가 변경될 때마다 콘텐츠의 위치 값을 동적으로 조정함으로써 이슈를 처리할 수 있다.
그러나 이번 글의 주제는 "바깥 클릭 시 닫힘" 이므로, 브라우저 외부 클릭 시에도 콘텐츠가 닫히도록 하는 방법을 구현하고자 한다.
"바깥 클릭 시 닫힘" 위한 기능을 구현하기 위해 focus
와 blur
이벤트를 활용하는 방법도 있다.
이 방법은 DropDown이 활성 상태일 때, <Content>
에 focus
이벤트를 주고, 해당 요소가 blur
이벤트를 받을 때 비활성화하는 방식으로 작동한다.
다시 말해, 사용자가 DropDown의 콘텐츠 외부를 클릭하면 blur
이벤트가 내부 요소에 발생하고, 이 이벤트를 감지하여 닫힘 상태로 만들 수 있는 것이다. 코드를 살펴보자
const [show, setShow] = useState(false);
const contentRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (show) contentRef.current?.focus();
}, [show, contentRef]);
return (
<S.Container>
<button onClick={() => setShow(true)}>🍡 탕후루 과일 추가</button>
{show && (
<Portal id='dropdown'>
<S.Layer />
<S.Content
ref={contentRef}
onBlur={() => setShow(false)}
>
//...콘텐츠
</S.Content>
</Portal>
)}
</S.Container>
);
위 설명처럼 show=true
상태일 경우 <Content>
엘리먼트가 focus
상태가 되고,
focus
가 해제 될 경우 onBlur
이벤트가 발생해 show=false
상태로 변경된다.
❗️ 하지만 위 코드는 두가지 문제로 인해 작동하지 않을 것 이다.
div
태그인 <Content>
요소에 tabIndex가 설정되어 있지 않다. show
상태가 true
로 변경될 때, DOM 트리에 <Content>
가 동적으로 생성되지만, contentRef
의 값은 업데이트되지 않아 여전히 null
상태를 유지한다.<button>
, <input>
, <select>
, <a>
와 같이 사용자가 웹 페이지와 상호작용 할 수 있게 도와주는 요소를 제외한 대다수의 요소는 기본적으로 포커싱을 지원하지 않는다.
따라서 <div>
요소엔 el.focus()
메서드가 동작하지 않고 focus
, blur
이벤트도 트리거도 작동되지 않는다.
그럼에도 불구하고 포커스를 하고 싶다면 tabindex
HTML 속성을 사용하면 된다.
tabindex
속성이 있는 요소는 종류와 상관없이 포커스가 가능하다. 속성값은 숫자인데, 이 숫자는 Tab
키를 눌러 요소 사이를 이동할 때 순서가 된다.
tabIndex 1~
tabindex
가 1 이상일 경우 1인 요소부터 시작해 점점 큰 숫자가 매겨진 요소로 이동하고 그다음 tabindex
가 없는 요소(평범한 <input>
요소 등)로 이동한다.
tabIndex 0
tabindex
가 1 이상인 요소보다 나중에 포커스를 받는다.
tabindex="0"
은 요소를 포커스 가능하게 만들지만 포커스 순서는 기본 순서 그대로 유지하고 싶을 때 사용한다. 요소의 포커스 우선 순위를 일반 <input>
과 같아지도록 할 수 있다
tabIndex -1
스크립트로만 포커스 하고 싶은 요소에 사용한다.
Tab
키를 사용하면 이 요소는 무시되지만 el.focus()
메서드를 사용하면 포커싱이 가능하다.
<Content>
에 tabIndex
의 값을 "-1"로 추가해서 스크립트로만 포커싱이 가능하도록 설정하는 것이 적합할 것 같다.
//tabIndex 추가
<S.Content
ref={contentRef}
tabIndex={-1}
onBlur={() => setShow(false)}
>
show
상태가 true
로 변경될 때, DOM 트리에 <Content>
가 동적으로 생성되지만, contentRef
의 값은 업데이트되지 않아 여전히 null
상태를 유지하는 문제를 해결하기 위해서는 useRef
의 특징을 잘 알고 있어야한다.
useRef
란 React에서 사용되는 Hook 중 하나로, 주로 저장 공간 또는 DOM 요소에 접근하기 위해 활용된다.
"Ref"는 "reference(참조)"의 줄임말로, useRef
는 이러한 참조를 관리하는 Hook이다.
useRef
Hook을 사용하면 변경 가능한 객체를 반환하며, 이 객체는 current
라는 속성을 가진다. 이 속성은 컴포넌트가 unmount될 때까지 유지되며, 여기에 데이터를 저장할 수 있다.
useRef
는 내용이 변경될 때 그것을 알려주지는 않는다는 것을 유념하세요..current
프로퍼티를 변형하는 것이 리렌더링을 발생시키지는 않습니다. React가 DOM 노드에 ref를 attach하거나 detach할 때 어떤 코드를 실행하고 싶다면 대신 "콜백 ref"를 사용하세요. - React 공식 홈페이지
예를 들어 아래 코드와 같이 show
상태에 따라 동적으로 생성되는 <Content>
의 ref
값을 업데이트를 해야하는 상황일 경우,
객체 ref
가 현재 ref
값의 변경 사항에 대해 알려주지 않기 때문에 useRef
를 선택하지 않고 "콜백 ref"를 사용하는 것이 더 적합하다.
const contentRef = useRef<HTMLDivElement>(null);
{show && (
<S.Content ref={contentRef}>
//...콘텐츠
</S.Content>
)}
DOM 노드의 위치나 크기를 측정하는 기본적인 방법의 하나는 "콜백 ref"를 사용하는 것이다. ref
가 다른 노드에 연결될 때마다 해당 콜백을 호출한다.
"콜백 ref"를 사용하면 컴포넌트가 나중에 측정된 노드를 표시하더라도 이에 대한 알림을 받고 측정을 업데이트 할 수 있다.
const contentRef = useRef<HTMLDivElement>(null);
useEffect(() => {
// contentRef값 === null
if (show) contentRef.current?.focus();
}, [show, contentRef]);
{show && (
<S.Content ref={contentRef}>
//...콘텐츠
</S.Content>
)}
const callbackRef = useCallback((current: HTMLDivElement) => {
current?.focus();
}, []);
{show && (
<S.Content ref={callbackRef}>
//...콘텐츠
</S.Content>
)}
위와 같이 "콜백 ref"를 활용한다면 <Content>
가 동적 생성됐을 때 실행되어 업데이트된 ref
에 focus
가 가능하다.
위 영상을 보다시피 잘 동작한다.
( 이해를 돕기 위해 <Content>
요소가 :focus-within
상태일 경우 outline
파란색을 추가 )
아래 영상과 같이 <Content>
내부 요소에 포커싱이 될 경우에도 onBlur
이벤트가 실행된다.
해당 문제를 해결하기 위해서는 onBlur
이벤트가 발생할 때, 해당 이벤트가 현재 요소의 내부 또는 외부에서 발생했는지를 구분해야 한다.
즉, onBlur 이벤트가 현재 요소 내부에서 발생한 경우와 현재 요소 외부에서 발생한 경우를 식별해야 합니다. 이것은 포커스 이벤트의 범위를 판단하고 처리하는 중요한 부분이다.
react 공식문서에 아주 상세히 설명이 되어있다.
내외부를 식별하기 위해 currentTarget
과 relatedTarget
을 사용해야 하는데 한번 알아보도록하자
먼저 자주 쓰는 target
과 currentTarget
의 차이점을 간략하게 알아보자
이 두 프로퍼티는 이벤트 버블링과 캡처링이 발생하는 순간에 차이가 명확해진다.
즉 위 UI에서 체크박스를 클릭했을 경우
event.target
: 클릭한 체크박스(<input type="checkbox">
) 요소를 반환.event.currentTarget
: 이벤트 핸들러가 연결된 요소인 <Content>
(부모 <div>
)를 반환.❗️ 하지만 onblur
시 target
과 currentTarget
은 <Content>
의 외부 영역을 클릭해도 위와 같은 값이 반환된다.
relatedTarget
은 마우스 이벤트에서 선택적으로 제공되는 보조 대상을 나타낸다.
이러한 보조 대상이 없는 경우, relatedTarget
은 null
값을 반환한다.
따라서 relatedTarget
이 null
인 경우, 이벤트가 외부 영역에서 발생했음을 알 수 있다.
onBlur
이벤트 내외부를 구분할 수 있게 아래 코드처럼 수정할 수 있다.
<S.Content onBlur={(e) => !e.currentTarget.contains(e.relatedTarget) && setShow(false);}>
//...콘텐츠
</S.Content>
또는
<S.Content onBlur={(e) => !e.relatedTarget && setShow(false)}>
//...콘텐츠
</S.Content>
아래 코드와 같이 callbackRef
함수와 onBlur
이벤트 그리고 relatedTarget
을 활용해서 "바깥 클릭 시 닫힘" 기능을 잘 구현할 수 있다.
const [show, setShow] = useState(false);
const callbackRef = useCallback((current: HTMLDivElement) => {
current?.focus();
}, []);
return (
<S.Container>
<button onClick={() => setShow(true)}>🍡 탕후루 과일 추가</button>
{show && (
<Portal id='dropdown'>
<S.Layer />
<S.Content
ref={callbackRef}
tabIndex={-1}
onBlur={(e) => !e.relatedTarget && setShow(false)}
>
//...콘텐츠
</S.Content>
</Portal>
)}
</S.Container>
);
( 이해를 돕기 위해 <Content>
요소가 :focus-within
상태일 경우 outline
파란색을 추가 )
위 영상과 같이 자식 요소를 포커싱해도 닫히지 않으며, tab
키를 사용하여 자식요소도 잘 포커싱할 수 있다.
더 이상 tabindex
가 없을 경우는 닫히게된다. (큰 이슈는 아니라고 생각)
브라우저 창 크기 조정이나 브라우저 외부를 클릭하는 경우에도 onBlur
이벤트가 발생하면서 콘텐츠가 자동으로 닫힐 수 있게 되었다.
아직 처리해야 할 한 가지 이슈가 남아있다.
콘텐츠가 절대적으로 아래쪽에 배치되므로 화면 하단에 배치할 경우 일부가 잘려 보이게 될 것이다.
이러한 문제는 다음 글에서 동적으로 포지션을 변경해 해결할 예정이다.
모달과 같이 콘텐츠가 무조건 가운데에 배치가 된다면,
브라우저의 창 크기가 변경되더라도 약간의 CSS를 추가해 1번, 2번 방법을 사용해도 무관했을 것이다.
하지만 콘텐츠는 상대 좌표의 값으로 고정된 위치를 사용하고 있기 때문에 resize 이벤트를 추가 여부를 결정해야 하는 상황이 생겼고
resize 이벤트를 사용하지 않고 나름 최적화된 방법으로 해결한 것 같다.
외부 영역 클릭 시 닫힘 기능을 구현하는 데는 다양한 방법이 존재하며,
이러한 다양한 방법을 알고 있다면 주어진 상황에 맞게 적절한 접근 방식을 선택하고 구현할 수 있을 것 같다.
🙌 또 다른 좋은 방법이 있으면 댓글로 알려주세요!
래퍼런스