(오픈소스 분석) Radix DismissableLayer

·

7 min read

@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가 더 나중에 추가된다. 내부 구현에서 setarray로 바꾼 뒤 index로 레이어의 우선순위를 판단하기 때문에, 추가되는 순서를 제대로 이해하는게 중요하다.

layers가 추가되는 useEffect코드는 다음과 같다.

const context = useContext(DismissableLayerContext);
useEffect(() => {
  ...

  context.layers.add(node);

  ...
  // context.layers.delete(node);는 
  // 정확히 unmount시점에만 node를 제거하기 위해서 
  // (deps의 변경으로 node가 제거 -> 추가되어 순서가 바뀌는 경우를 방지하려고)
  // 별도의 useEffect에서 작성된다.
}, [...])

contextDismissableLayer가 추가될 때는 마운트 될 때이다. 그 이후에는 여러번 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.disableOutsidePointerEventstrueDismissableLayer만 추가한다는 점이 다르다. 또한, 여기에 추가된 레이어는 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.disableOutsidePointerEventstrue라고 하자. 이 상태에선 Body에 있는 엘리먼트중 1번 레이어를 제외하곤 클릭이 불가능하다.

다음으로 2번 레이어가 추가되었고, 이 레이어의 props.disableOutsidePointerEventsfalse라고 하자.

highestLayerWithOutsidePointerEventsDisabled는 1번이다. 1번보다 2번의 index가 더 크기 때문에 2번은 현재 클릭이 가능한 레이어이다. 대신 props.disableOutsidePointerEventsfalse이기 때문에, 1번도 여전히 클릭 가능하다.

3번 레이어가 추가되었고, 이 레이어의 props.disableOutsidePointerEventsfalse라고 하자. highestLayerWithOutsidePointerEventsDisabled는 1번이고, 2번과 3번은 1번보다 index가 더 크기때문에 1번, 2번, 3번 셋 다 클릭 가능하다.

4번 레이어가 추가되었고, 이 레이어의 props.disableOutsidePointerEventstrue라고 하자. 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를 클릭하지 못하게 하는 기능은 bodypointerEventsnone으로 바꿔서 모든 엘리먼트의 클릭을 막은 다음, 클릭 가능한 후보들만 pointerEventsauto로 오버라이드 하는 방식으로 구현된다.

return (
  <Primitive.div
    style={{
      pointerEvents: isBodyPointerEventsDisabled
        ? isPointerEventsEnabled
          ? 'auto'
          : 'none'
        : undefined,
      ...props.style,
    }} 
  />
)

Handler Hook

DismissableLayerprops중엔 여러 이벤트 핸들러가 있다.

const {
  disableOutsidePointerEvents,

  onEscapeKeyDown,
  onPointerDownOutside,
  onFocusOutside,
  onInteractOutside,
  onDismiss,
} = props;

onEscapeKeyDown은, KeyDown에서 눌린 키가 ESC인지를 감지하면 되기 때문에 패스한다.

onPointerDownOutsideonFocusOutside는 커스텀 훅과 커스텀 이벤트로 구현되어있으며, 구현 방식이 매우 유사하다.

  1. Capture페이즈에서 실행될 핸들러를 Target 컴포넌트에 등록한다.

  2. Effect내에서 Document에 핸들러를 등록한다.

DismissableLayerpointerDown이벤트는 컴포넌트의 외부를 클릭하는 경우와, 내부를 클릭하는 두 가지 케이스가 있다. 컴포넌트 내부를 클릭하면, 위 핸들러중 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}
  />
)

포커스 이벤트도 동일한 원리다. onBlurCaptureonFocusCapture를 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}
  />
)

결론적으로 컴포넌트 내부로 포커스가 이동하는 경우, isFocusInsidetrue가 되고, 외부로 이동하는 경우 false가 된다.

하지만 위 코드를 정확히 이해하기 위해서는 focusblur이벤트가 모두 등록되어 있으므로, 두 이벤트가 동시에 발생할 때, 어떤 이벤트가 먼저 dispatch되는지를 알아야한다.

focus, blur이벤트 순서는 여기서 확인이 가능하다.

1번과 2번은 어떤 엘리먼트에도 포커스가 없을 때에서 시작하여 첫 엘리먼트로 포커스가 이동할때를 나타내고,

3번~6번은 어떤 엘리먼트에 포커스가 있는 상태에서 시작하여 다른 엘리먼트로 포커스가 이동할 때를 설명한다.

그러므로 3번~6번을 보면 되는데, blur/focus와, focusout/focusin을 따로 확인해보면, 항상 blur(focusout)이벤트가 먼저 발생한다는 것을 알 수 있다. (리액트에서의 focusblur이벤트는 실제로 focusin, focusout으로 처리되므로 focusout, focusin의 순서를 보면 된다.)

그러므로 위 코드에서 blur이후에 focus핸들러가 실행된다. 레이어 내부에 focus이벤트가 발생한 경우 isFocusInsidefalse->true로 변한 뒤 onFocusOutside의 실행을 건너 뛰고, 레이어 외부에 focus이벤트가 발생한 경우 레이어엔 blur이벤트만 발생하였기 때문에 isFocusInsidefalse가 되어 onFocusOutside가 실행된다.

usePointerDownOutside의 특수한 처리

  1. 터치 디바이스는 유저가 터치를 끝낸 뒤 이벤트 핸들러를 실행하기 까지 약 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();
  }
  ...
}
  1. 어떤 엘리먼트에서의 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,
    )}
  />
)

onInteractOutsidefocusOutside, pointerDownOutside양쪽 모두에서 발생하는 이벤트에 대한 핸들러이다.

onDismissDismissableLayer가 닫혀야 하는 상황에 호출되며(=DismissableLayer의 역할), prevent될 수도 있다.