(오픈소스 분석) Radix DismissableLayer
@radix-ui/react-dismissable-layer
라이브러리 코드를 살펴본다.
용도
현재 레이어와 분리된 UI 레이어를 렌더링할 수 있고, 외부 레이어와 인터랙션 할 때 제거할 수 있는 컴포넌트이다. 이와 같이 열고 닫는 방식의 사용 방법은 모달이나 팝오버 같은 UI에 응용할 수 있다.
Context
DismissableLayer
의 컨텍스트는 내부 상태를 저장하는 용도로 사용되지 않고, 렌더링된 모든 DismissableLayer
엘리먼트의 참조를 관리하는 용도로 사용된다. 그러므로 별도의 Provider
를 사용하지 않는, DOM에 렌더링된 모든 DismissableLayer
가 공유하는 전역 상태이다.
const DismissableLayerContext = createContext({
// DOM에 렌더링된 DismissableLayer 모음
layers: new Set(),
// DOM에 렌더링된 DismissableLayer중
// props.disableOutsidePointerEvents가 true인 DismissableLayer 모음
layersWithOutsidePointerEventsDisabled: new Set(),
// DOM에 렌더링된 DismissableLayerBranch 모음
branches: new Set(),
});
context.layers
layers
에는 늦게 마운트된 DismissableLayer
가 더 나중에 추가된다. 내부 구현에서 set
을 array
로 바꾼 뒤 index
로 레이어의 우선순위를 판단하기 때문에, 추가되는 순서를 제대로 이해하는게 중요하다.
layers
가 추가되는 useEffect
코드는 다음과 같다.
const context = useContext(DismissableLayerContext);
useEffect(() => {
...
context.layers.add(node);
...
// context.layers.delete(node);는
// 정확히 unmount시점에만 node를 제거하기 위해서
// (deps의 변경으로 node가 제거 -> 추가되어 순서가 바뀌는 경우를 방지하려고)
// 별도의 useEffect에서 작성된다.
}, [...])
context
에 DismissableLayer
가 추가될 때는 마운트 될 때이다. 그 이후에는 여러번 useEffect
가 실행되더라도 중복을 제거하는 set의 특성때문에 순서가 바뀌지는 않는다. (아래 코드 참고)
const set = new Set();
set.add(1);
set.add(2);
set.add(1);
[...set]; // [1, 2]
또한, layer
의 순서를 단순히 JSX구조만 보고 판단하기 보다는, DismissableLayer
가 사용되는 예시를 생각해보는게 더 중요하다. DismissableLayer
의 용도를 생각해보면, 실제로는 Trigger
버튼과 연계되어 하나씩 렌더링되는 경우가 훨씬 많기 때문이다.
예를들어 다음 JSX는
<DismissableLayer>
Outer
<DismissableLayer>
Inner
</DismissableLayer>
</DismissableLayer>
한꺼번에 렌더링된 상황으로 가정하면, layers
에 추가되는 순서가 Inner
-> Outer
지만(useEffect
가 실행되는 순서가 Inner
-> Outer
이므로)
Trigger
버튼을 통해 하나씩 렌더링된 상황을 가정하면 OuterTrigger
을 통해 OuterLayer
가 열리고, InnerTrigger
를 통해 InnerLayer
가 열릴 것이기 때문에 layers
에 추가되는 순서는 Outer
-> Inner
가 된다.
<DismissableLayer>
Outer
<InnerTrigger onClick={openInnerDismissableLayer} />
{isOpen && (
<DismissableLayer>
Inner
</DismissableLayer>
}
</DismissableLayer>
context.layersWithOutsidePointerEventsDisabled
layers
와 비슷하게 DismissableLayer
엘리먼트를 저장하는 용도이다.
props.disableOutsidePointerEvents
가 true
인 DismissableLayer
만 추가한다는 점이 다르다. 또한, 여기에 추가된 레이어는 layers
에도 동일하게 추가된다.
useEffect(() => {
if (props.disableOutsidePointerEvents) {
...
context.layersWithOutsidePointerEventsDisabled.add(node);
}
context.layers.add(node);
...
}, [...]);
props.disableOutsidePointerEvents
의 역할은 DismissableLayer
가 렌더링되었을 때, 해당 레이어 외부를 클릭할 수 없게 만드는 것이다.
context.branches
DismissableLayer
의 구조는 다음과 같다.
<DismissableLayer>
<DismissableLayerBranch>
...
</DismissableLayerBranch>
</DismissableLayer>
branches
는 DOM에 렌더링된 모든 DismissableLayerBranch
엘리먼트를 추가한 set이다.
layers 관련 코드
const context = useContext(DismissableLayerContext);
// set -> array
const layers = Array.from(context.layers);
// props.disableOutsidePointerEvents가 true인 DismissableLayer중
// 가장 늦게 추가된 엘리먼트와, 그 엘리먼트가 layers에서 몇 번째 인덱스인지
const [highestLayerWithOutsidePointerEventsDisabled] =
[...context.layersWithOutsidePointerEventsDisabled].slice(-1);
const highestLayerWithOutsidePointerEventsDisabledIndex =
layers.indexOf(highestLayerWithOutsidePointerEventsDisabled);
// 현재 DismissableLayer의 인덱스
const index = node ? layers.indexOf(node) : -1;
// `document.body`는 props.disableOutsidePointerEvents가 true인
// DismissableLayer가 하나라도 있다면
// pointerEvents css의 값이 none이 된다.
const isBodyPointerEventsDisabled =
context.layersWithOutsidePointerEventsDisabled.size > 0;
// 여러개의 DismissableLayer가 존재할 때
// props.disableOutsidePointerEvents가 true인 레이어보다
// 늦게 추가된 레이어는 클릭 가능하다.
const isPointerEventsEnabled =
index >= highestLayerWithOutsidePointerEventsDisabledIndex;
위 코드가 여러개의 DismissableLayer
가 렌더링 되어있을 때, 유저의 인터랙션을 처리하기 위한 상태를 정의하는 부분이다.
예시
Body
안에 첫번째 레이어가 추가되었고, 이 레이어의 props.disableOutsidePointerEvents
가 true
라고 하자. 이 상태에선 Body
에 있는 엘리먼트중 1번 레이어를 제외하곤 클릭이 불가능하다.
다음으로 2번 레이어가 추가되었고, 이 레이어의 props.disableOutsidePointerEvents
는 false
라고 하자.
highestLayerWithOutsidePointerEventsDisabled
는 1번이다. 1번보다 2번의 index
가 더 크기 때문에 2번은 현재 클릭이 가능한 레이어이다. 대신 props.disableOutsidePointerEvents
는 false
이기 때문에, 1번도 여전히 클릭 가능하다.
3번 레이어가 추가되었고, 이 레이어의 props.disableOutsidePointerEvents
는 false
라고 하자. highestLayerWithOutsidePointerEventsDisabled
는 1번이고, 2번과 3번은 1번보다 index
가 더 크기때문에 1번, 2번, 3번 셋 다 클릭 가능하다.
4번 레이어가 추가되었고, 이 레이어의 props.disableOutsidePointerEvents
는 true
라고 하자. highestLayerWithOutsidePointerEventsDisabled
는 4번이고, 1번, 2번, 3번은 4번보다 index
가 작기 때문에 클릭할 수 있는 레이어는 4번밖에 없다.
useEffect(() => {
if (!node) return;
// 현재 렌더링된 DismissableLayer중에 disableOutsidePointerEvents가 true인게
// 하나라도 있다면 body의 pointerEvents css를 none으로
if (disableOutsidePointerEvents) {
if (context.layersWithOutsidePointerEventsDisabled.size === 0) {
originalBodyPointerEvents = ownerDocument.body.style.pointerEvents;
ownerDocument.body.style.pointerEvents = 'none';
}
context.layersWithOutsidePointerEventsDisabled.add(node);
}
context.layers.add(node);
// 강제로 렌더링을 발생시키는 함수
dispatchUpdate();
// body의 pointerEvents css관련 기능을 정리하는 부분
return () => {
if (
disableOutsidePointerEvents &&
context.layersWithOutsidePointerEventsDisabled.size === 1
) {
ownerDocument.body.style.pointerEvents = originalBodyPointerEvents;
}
};
}, [node, ownerDocument, disableOutsidePointerEvents, context]);
// layers, layersWithOutsidePointerEventsDisabled를 정리하는 부분
useEffect(() => {
return () => {
if (!node) return;
context.layers.delete(node);
context.layersWithOutsidePointerEventsDisabled.delete(node);
dispatchUpdate();
};
}, [node, context]);
layers
를 정리하는 부분을 따로 분리한 것은, disableOutsidePointerEvents
의 변경으로 인해 이펙트가 재실행될 때 클린업 함수가 호출되면, layers
에서 엘리먼트가 제거되었다가 다시 추가되는 과정에서 index
가 변경되기 때문에 이를 막으려는 목적이다.
outside
를 클릭하지 못하게 하는 기능은 body
의 pointerEvents
를 none
으로 바꿔서 모든 엘리먼트의 클릭을 막은 다음, 클릭 가능한 후보들만 pointerEvents
를 auto
로 오버라이드 하는 방식으로 구현된다.
return (
<Primitive.div
style={{
pointerEvents: isBodyPointerEventsDisabled
? isPointerEventsEnabled
? 'auto'
: 'none'
: undefined,
...props.style,
}}
/>
)
Handler Hook
DismissableLayer
의 props
중엔 여러 이벤트 핸들러가 있다.
const {
disableOutsidePointerEvents,
onEscapeKeyDown,
onPointerDownOutside,
onFocusOutside,
onInteractOutside,
onDismiss,
} = props;
onEscapeKeyDown
은, KeyDown
에서 눌린 키가 ESC인지를 감지하면 되기 때문에 패스한다.
onPointerDownOutside
와 onFocusOutside
는 커스텀 훅과 커스텀 이벤트로 구현되어있으며, 구현 방식이 매우 유사하다.
Capture페이즈에서 실행될 핸들러를 Target 컴포넌트에 등록한다.
Effect내에서 Document에 핸들러를 등록한다.
DismissableLayer
의 pointerDown
이벤트는 컴포넌트의 외부를 클릭하는 경우와, 내부를 클릭하는 두 가지 케이스가 있다. 컴포넌트 내부를 클릭하면, 위 핸들러중 1번에서 등록한 핸들러가 먼저 실행되고 그다음 2번에서 등록한 핸들러가 실행된다. 컴포넌트 외부를 클릭하면, 2번에서 등록한 핸들러만 실행된다.
따라서, 1번에서 플래그변수를 on/off해주고, 2번에선 플래그변수에 따라 핸들러를 수행할지 말지를 결정하면 된다.
const onPointerDownCapture = () => { isPointerDownInside = true };
useEffect(() => {
const handler = () => {
if (!isPointerDownInside) { props.onPointerDownOutside(); }
else {}
isPointerDownInside = false;
}
document.addEventListener('pointerdown', handler);
return () => document.removeEventListener('pointerdown', handler);
})
return (
<Component
onPointerDownCapture={onPointerDownCapture}
/>
)
포커스 이벤트도 동일한 원리다. onBlurCapture
와 onFocusCapture
를 Target 컴포넌트에 등록하고, Effect내부에서 Document에 focusin
이벤트를 등록한다.
const onFocusCapture = () => { isFocusInside = true };
const onBlurCapture = () => { isFocusInside = false };
useEffect(() => {
const handler = () => {
if (!isFocusInside) { props.onFocusOutside(); }
else {}
document.addEvenetListener('focusin', handler);
return () => document.removeEventListener('focusin', handler);
}
})
return (
<Component
onFocusCapture={onFocusCapture}
onBlurCapture={onBlurCapture}
/>
)
결론적으로 컴포넌트 내부로 포커스가 이동하는 경우, isFocusInside
가 true
가 되고, 외부로 이동하는 경우 false
가 된다.
하지만 위 코드를 정확히 이해하기 위해서는 focus
와 blur
이벤트가 모두 등록되어 있으므로, 두 이벤트가 동시에 발생할 때, 어떤 이벤트가 먼저 dispatch
되는지를 알아야한다.
focus
, blur
이벤트 순서는 여기서 확인이 가능하다.
1번과 2번은 어떤 엘리먼트에도 포커스가 없을 때에서 시작하여 첫 엘리먼트로 포커스가 이동할때를 나타내고,
3번~6번은 어떤 엘리먼트에 포커스가 있는 상태에서 시작하여 다른 엘리먼트로 포커스가 이동할 때를 설명한다.
그러므로 3번~6번을 보면 되는데, blur/focus와, focusout/focusin을 따로 확인해보면, 항상 blur(focusout)이벤트가 먼저 발생한다는 것을 알 수 있다. (리액트에서의 focus
와 blur
이벤트는 실제로 focusin
, focusout
으로 처리되므로 focusout
, focusin
의 순서를 보면 된다.)
그러므로 위 코드에서 blur
이후에 focus
핸들러가 실행된다. 레이어 내부에 focus
이벤트가 발생한 경우 isFocusInside
가 false
->true
로 변한 뒤 onFocusOutside
의 실행을 건너 뛰고, 레이어 외부에 focus
이벤트가 발생한 경우 레이어엔 blur
이벤트만 발생하였기 때문에 isFocusInside
가 false
가 되어 onFocusOutside
가 실행된다.
usePointerDownOutside
의 특수한 처리
- 터치 디바이스는 유저가 터치를 끝낸 뒤 이벤트 핸들러를 실행하기 까지 약 0.3초 딜레이를 주기 때문에,
pointerDown
대신click
이벤트를 등록한다.
const handlePointerDown = (event) => {
...
if (event.pointerType === 'touch') {
ownerDocument.removeEventListener('click', handleClickRef.current);
handleClickRef.current = handleAndDispatchPointerDownOutsideEvent;
ownerDocument.addEventListener('click', handleClickRef.current, { once: true });
} else {
handleAndDispatchPointerDownOutsideEvent();
}
...
}
- 어떤 엘리먼트에서의
pointerdown
이벤트의 결과로 이 훅을 포함하는 컴포넌트가 마운트되는 경우, 이벤트 버블링에 의해 Document에서pointerDownOutside
이벤트가 발생한다. 따라서 DOM에DismissableLayer
가 마운트되자마자 사라질 수 있는데, 이를 방지하기 위해setTimeout
을 사용하여 이벤트를 살짝 늦게 등록한다.
const usePointerDownOutside = (
...
) => {
useEffect(() => {
...
const timerId = window.setTimeout(() => {
ownerDocument.addEventListener('pointerdown', handlePointerDown);
}, 0);
return () => {
window.clearTimeout(timerId);
ownerDocument.removeEventListener('pointerdown', handlePointerDown);
ownerDocument.removeEventListener('click', handleClickRef.current);
};
}, [...])
}
이건 리액트에서만 발생하는 특별한 버그가 아니라, 원래 DOM이 동작하는 방식이 이렇다.
// 동일한 현상을 나타내는 예제
button.addEventListener('pointerdown', () => {
console.log('I will log');
document.addEventListener('pointerdown', () => {
console.log('I will also log');
});
});
위 두가지 이벤트 핸들러(onFocusOutside
, onPointerDownOutside
)가 DismissableLayer
에서 적용된 코드는 다음과 같다.
const pointerDownOutside = usePointerDownOutside((event) => {
const target = event.target as HTMLElement;
const isPointerDownOnBranch = [...context.branches].some((branch) => branch.contains(target));
if (!isPointerEventsEnabled || isPointerDownOnBranch) return;
onPointerDownOutside?.(event);
onInteractOutside?.(event);
if (!event.defaultPrevented) onDismiss?.();
}, ownerDocument);
const focusOutside = useFocusOutside((event) => {
const target = event.target as HTMLElement;
const isFocusInBranch = [...context.branches].some((branch) => branch.contains(target));
if (isFocusInBranch) return;
onFocusOutside?.(event);
onInteractOutside?.(event);
if (!event.defaultPrevented) onDismiss?.();
}, ownerDocument);
return (
<div
onFocusCapture={composeEventHandlers(
props.onFocusCapture, focusOutside.onFocusCapture)}
onBlurCapture={composeEventHandlers(
props.onBlurCapture, focusOutside.onBlurCapture)}
onPointerDownCapture={composeEventHandlers(
props.onPointerDownCapture,
pointerDownOutside.onPointerDownOutsideCapture,
)}
/>
)
onInteractOutside
는 focusOutside
, pointerDownOutside
양쪽 모두에서 발생하는 이벤트에 대한 핸들러이다.
onDismiss
는 DismissableLayer
가 닫혀야 하는 상황에 호출되며(=DismissableLayer
의 역할), prevent
될 수도 있다.