(오픈소스 분석) Radix Popper

·

6 min read

@radix-ui/react-popper 라이브러리 코드를 살펴본다.

용도

Anchor를 클릭했을 때, 해당 Anchor에 연결되어있는 UI를 렌더링할 수 있으며, Viewport와의 Collision을 감지하여 자동으로 콘텐츠 위치가 조절된다. 연결되어있는 Anchor를 가리키기 위한 Arrow도 표현 가능하다.

구조

<Popper.Root>
  <Popper.Anchor>open</Popper.Anchor>
  <Popper.Content>
    <Popper.Arrow />
    Content
  </Popper.Content>
</Popper.Root>
  • Popper.ContentPopper.Anchor주위에서 팝업된다. Popper는 내부적으로 open상태를 따로 관리하는게 아니기 때문에, Anchor를 클릭했을 때 Content를 렌더링하는 동작은 Consumer가 구현해야 한다.

Popper.Anchor

Popper의 루트레벨 Context에는 anchor가 있으며, Popper.Anchor가 렌더링될 때 Anchor역할을 하는 엘리먼트가 동기화된다.

Anchor의 타입은 getBoundingClientRect메서드를 갖고 있는 객체로, DOM 노드가 될 수도 있고, 노드가 아닌 단순히 getBoundingClientRect를 갖고 있는 객체(virtualRef)일 수도 있다. 중요한건 여기서 반환되는 Position 정보로 Popper.Content가 어디에 렌더링될지가 결정되기 때문에, getBoundingClientRect는 의미있는 값을 반환해야 한다. 아래는 Popper.Anchor의 구현중 일부분이다.

const PopperAnchor = forwardRef((props, ref) => {
    const { virtualRef, ...anchorProps } = props;
    const context = usePopperContext();

    useEffect(() => {
        context.onAnchorChange(virtualRef?.current || ref.current);
    })

    // virtualRef인 경우엔, 아무 엘리먼트도 렌더링하지 않는다.
    return virtualRef ? null : <div {...anchorProps} />
})

Anchor의 목적은 Content가 렌더링될 위치정보를 포함하는 것이기 때문에, 실제 Element일 필요가 없는 것이다.

virtualRef가 활용되는 컴포넌트는 대표적으로 ContextMenu가 있는데, ContextMenuAnchor역할을 하는 엘리먼트가 따로 없기 때문에 virtualRef를 사용하고, 특정 Area를 우클릭했을 때, 우클릭한 곳의 좌표값을 virtualRefgetBoundingClientRect가 반환하도록 코드가 작성되어 있다. (참고)

floating-ui

Floating Element의 배치 및 인터랙션을 처리하는 라이브러리로, Radix의 Popper는 floating-ui에 의존한다.

floating-ui의 핵심 API는 computePosition(리액트에선 useFloating)인데, 이 함수는 Reference Element와 Floating Element를 전달받아서 Floating Element에 적용할 위치를 계산한다. Popper에서는 Popper.Anchor가 Reference Element, Popper.Content가 Floating Element로 취급된다.

Popper.Content

Floating Element가 Reference Element(Anchor)와 연결되어 있음을 나타내기 위해, Floating Element에서 Reference Element로 향하는 Arrow 모양의 svg를 Popper.Arrow를 통해 사용할 수 있다. 이 Arrow에 대한 세부적인 제어를 위해 Popper.Content는 arrow에 대한 참조를 상태로 갖고 있다.

// Popper.Content의 앞 부분
const [arrow, setArrow] = React.useState<HTMLSpanElement | null>(null);
const arrowSize = useSize(arrow);
const arrowWidth = arrowSize?.width ?? 0;
const arrowHeight = arrowSize?.height ?? 0;

useSize는 Radix의 internal utility로, ResizeObserver를 사용하여 엘리먼트의 width, height를 감시 및 반환하는 커스텀 훅이다.

arrowWidtharrowHeight는 나중에 Popper.ContenttransformOrigin계산을 위해 사용된다.

이후에 등장하는 변수들도 살펴보자.

const desiredPlacement = (side + (align !== 'center' ? '-' + align : '')) as Placement;

desiredPlacementprops.sideprops.align을 기반으로 Popper.ContentPopper.Anchor에 대해 상대적으로 어떤 위치에 있어야할지 계산한 값이다.

floating-ui는 placement를 기본적으로 top, bottom, left, right이렇게 네 방향을 지원하며, suffix(-start, -end)를 통해 align위치를 조절할 수 있다. (기본 align은 suffix가 없으며, 중앙 정렬된다.) startend는 logical value를 의미하므로 쓰기방향(RTL, LTR)에 따라 달라진다.

const collisionPadding =
  typeof collisionPaddingProp === 'number'
    ? collisionPaddingProp
    : { top: 0, right: 0, bottom: 0, left: 0, ...collisionPaddingProp };

collisionPadding은 Floating Element가 Viewport와 같은 Boundary를 넘어갔는지를 감시할 때 가상의 Padding을 추가적으로 사용한다. 기존에는 Boundary를 넘어가야 Overflow가 되었다면, collisionPadding을 사용하는 경우 Boundary주변에 추가된 Padding을 넘어갈 때 Overflow가 된다.

const boundary = Array.isArray(collisionBoundary) ? collisionBoundary : [collisionBoundary];
const hasExplicitBoundaries = boundary.length > 0;

Floating Element또는 Reference Element가 넘어갔을 때 감지할 Boundary를 커스텀할 수 있다. Boundary는 여러개일 수 있으므로 배열로도 전달할 수 있다.

const detectOverflowOptions = {
  padding: collisionPadding,
  boundary: boundary.filter(isNotNull),
  altBoundary: hasExplicitBoundaries,
};

Floating Element또는 Reference Element가 Overflow되는걸 감지할 때 옵션으로 전달된다. (Docs 참고)

다음으로 위에 선언된 변수들을 활용하여 useFloating을 사용하는 코드를 살펴보자.

const { refs, floatingStyles, placement, isPositioned, middlewareData } = useFloating({
  strategy: 'fixed',
  placement: desiredPlacement,
  whileElementsMounted: (...args) => {
    const cleanup = autoUpdate(...args, {
      animationFrame: updatePositionStrategy === 'always',
    });
    return cleanup;
  },
  elements: {
    reference: context.anchor,
  },
  middleware: [
    // 미들웨어는 따로 살펴봄
    ...
  ],
});
  • strategy: absolute이거나 fixed인데, focus scroll 이슈를 피하기 위해 fixed를 사용한다.

  • whileElementsMounted: 마운트시 수행할 함수를 호출하고, 언마운트시 수행할 클린업을 반환해야 한다. autoUpdate는 floating-ui에서 제공하는 함수인데, Floating Element의 좌표를 유저의 스크롤과 같은, 좌표 업데이트가 필요한 상황에 자동으로 업데이트를 해주는 함수이다. animationFrame은 성능을 최적화하기 위한 옵션이다.

  • elements: Reference Element와 Floating Element를 명시할 수 있다. 명시하지 않아도 useFloating에서 반환하는 refs.floatingrefs.reference를 컴포넌트의 ref에 전달하면 동일하게 동작한다. 여기서는 Popper.Content의 외부에서 렌더링되는 anchor컴포넌트만 Reference Element로서 전달하였다.

    • anchorvirtualRef가 될 수도 있는데, floating-ui에선 virtualRef도 지원하기 때문에 사용할 수 있다. (참고)

middleware

Floating Element의 좌표를 계산할 때, 중간에 함수를 추가시켜, 최종적으로 계산되는 x, y값에 영향을 줄 수 있는 방법이 미들웨어이다. 예를들어 다음과 같은 미들웨어를 사용하면 최종적으로 각 좌표값을 +1 한다.

const shiftByOnePixel = {
  name: 'shiftByOnePixel',
  // 인자로 MiddlewareState를 전달받는 함수.
  // x, y 이외에도 다양한 인자가 존재한다.
  fn({x, y}) { 
    return {
      x: x + 1,
      y: y + 1,
    };
  },
};

또한, data프로퍼티를 추가적으로 반환하는 경우 useFloating의 반환값인 middlewareData를 통해 해당 데이터에 접근할 수 있으며, 다음에 실행되는 미들웨어에서도 참조 가능하다.

const shiftByOnePixel = {
  name: 'shiftByOnePixel',
  fn({x, y}) { 
    return {
      x: x + 1,
      y: y + 1,
      data: { value: 1 },
    };
  },
};

const anotherMiddleware = {
  name: '',
  fn({middlewareData}) {
    // shiftByOnePixel가 순서상 이전 미들웨어라면
    // 다음 미들웨어에서 이런식으로 미들웨어 데이터에 접근이 가능하다.
    middlewareData.shiftByOnePixel.value
    ...
  }
}

// 여기서도 접근이 가능하다.
const { middlwareData } = useFloating({
  ...,
  middleware: [
    shiftByOnePixel,
    anotherMiddleware,
  ] 
})

Popper.Content에 적용된 미들웨어를 하나씩 살펴보자

middleware: [
    offset({ mainAxis: sideOffset + arrowHeight, alignmentAxis: alignOffset }),
  avoidCollisions &&
    shift({
      mainAxis: true,
      crossAxis: false,
      limiter: sticky === 'partial' ? limitShift() : undefined,
      ...detectOverflowOptions,
    }),
  avoidCollisions && flip({ ...detectOverflowOptions }),
  size({
    ...detectOverflowOptions,
    apply: ({ elements, rects, availableWidth, availableHeight }) => {
      const { width: anchorWidth, height: anchorHeight } = rects.reference;
      const contentStyle = elements.floating.style;
      contentStyle.setProperty('--radix-popper-available-width', `${availableWidth}px`);
      contentStyle.setProperty('--radix-popper-available-height', `${availableHeight}px`);
      contentStyle.setProperty('--radix-popper-anchor-width', `${anchorWidth}px`);
      contentStyle.setProperty('--radix-popper-anchor-height', `${anchorHeight}px`);
    },
  }),
  arrow && floatingUIarrow({ element: arrow, padding: arrowPadding }),
  transformOrigin({ arrowWidth, arrowHeight }),
  hideWhenDetached && hide({ strategy: 'referenceHidden', ...detectOverflowOptions }),
]

floating-ui에는 유용한 내장 middleware가 존재한다. Popper에서 사용된 미들웨어는 다음과 같다.

  • offset: Floating Element의 위치를 Reference Element로부터 main축과 cross축에 대해 조절할 수 있게 해준다.

  • shift: 다음과 같이 Floating Element가 Boundary를 벗어나려고 할 때, 일정 거리를 이동시킴으로써 화면에 계속 보이도록 만든다. 전달하는 옵션에 따라 Reference Element가 Viewport 바깥으로 사라지는 경우 Floating Element도 사라지게 만들 수 있고, 화면에 계속 보이도록 할 수도 있다. (limiter 옵션)

  • flip: 다음과 같이 Boundary를 벗어나려고 할 때 Floating Element를 뒤집는다. fallbackStrategy옵션에 따라 최대한 초기 Placement를 유지할 수도 있고(initialPlacement), 공간이 많은 쪽으로 Placement를 바꿀 수도 있다(bestFit).

  • size: 옵션으로 apply메서드를 전달하여, 미들웨어의 life cycle동안 작업을 수행할 수 있다. apply는 Floating Element가 Clipping Container(Viewport)를 넘어가지 않도록 하는 최대 widthheight를 인자로 전달받는다. Popper.Content에선 이 두 값과, Popper.Anchorwidthheight를 css variable에 동기화한다.

  • arrow: Radix의 Popperarrow와 동일한 이름의 변수가 있기 때문에, floatingUIarrow라는 이름으로 바꾸어서 사용한다. arrow역할을 하는 엘리먼트가 항상 Popper.Anchor의 중간을 가리키게끔 위치를 조절한다.

  • transformOrigin: 커스텀 미들웨어이기 때문에 따로 살펴본다.

  • hide: Reference Element가 Viewport를 벗어났을 때 Content를 숨기는 용도로 사용한다. (참고)

transformOrigin middleware

위에서 언급한 미들웨어 목록에 들어있는 transformOriginPopper.ContenttransformOrigin을 계산하는 커스텀 미들웨어이다.

transformOriginPopper.Arrow가 Reference Element를 가리키는 점이 된다(아래 사진에서 빨간색 점). 이 지점은 Popper.Content에 적용된 placement과 align상태가 무엇인지 등, Arrow의 위치가 달라진다면 항상 달라진다. (flip, shift의 영향을 받는다)

예를들어 placement가 top일 때는 transformOrigin의 x가 Content Width / 2, y는 Content Height + Arrow의 Height가 된다.

align이 start인지 end인지에 따라서도 다른데, 이건 arrow가 이동한 offset의 값을 middleware내부에서 접근할 수 있기 때문에 계산 가능한 값이다.

예를들어 위 예시와 같이 arrow가 중앙에 있다면, offset은 0이다. start -> end 방향으로 이동하면(예시에선 오른쪽) +가 되고, end -> start방향으로 이동하면(예시에선 왼쪽) -가 된다.

예시로 placement가 left, align이 start일 때를 살펴보자. 이 경우, transformOrigin의 x는 Content Width + Arrow Width, y는 (Content Height / 2) + Arrow의 y축 오프셋이 되며, end -> start방향인 윗쪽으로 Arrow가 이동되어 있기 때문에, Arrow의 y축 오프셋은 음수가 된다.

transformOrigin은 이 값을 계산하는 역할을 한다. 그리고 계산된 transformOriginuseFloating의 반환값인 middlewareData를 통해 Popper.Content에 적용된다. (참고)

Popper.Content의 Wrapper

Popper.Content는 다음과 같이 Wrapper가 있다.

<div>
  <PopperContentProvider>
    <Primitive.div 
      {...props}
    />
  </PopperContentProvider>
</div>

floating ui는 Floating Element의 위치를 transform으로 적용하는 방법과 left, top을 사용해서 적용하는 방법 모두 지원하지만, Radix의 Poppertransform을 사용한다.

이 때는 유저가 transform을 오버라이드하게 되면, 기존의 transform스타일과 충돌이 발생할 수 있으므로 이를 별도로 적용시킬 Wrapper를 추가한 것이다.