(오픈소스 분석) Radix Popper
@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.Content
가Popper.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
가 있는데, ContextMenu
는 Anchor
역할을 하는 엘리먼트가 따로 없기 때문에 virtualRef
를 사용하고, 특정 Area를 우클릭했을 때, 우클릭한 곳의 좌표값을 virtualRef
의 getBoundingClientRect
가 반환하도록 코드가 작성되어 있다. (참고)
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를 감시 및 반환하는 커스텀 훅이다.
arrowWidth
와 arrowHeight
는 나중에 Popper.Content
의 transformOrigin
계산을 위해 사용된다.
이후에 등장하는 변수들도 살펴보자.
const desiredPlacement = (side + (align !== 'center' ? '-' + align : '')) as Placement;
desiredPlacement
는 props.side
와 props.align
을 기반으로 Popper.Content
가 Popper.Anchor
에 대해 상대적으로 어떤 위치에 있어야할지 계산한 값이다.
floating-ui는 placement를 기본적으로 top, bottom, left, right이렇게 네 방향을 지원하며, suffix(-start
, -end
)를 통해 align위치를 조절할 수 있다. (기본 align은 suffix가 없으며, 중앙 정렬된다.) start
와 end
는 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.floating
과refs.reference
를 컴포넌트의 ref에 전달하면 동일하게 동작한다. 여기서는Popper.Content
의 외부에서 렌더링되는anchor
컴포넌트만 Reference Element로서 전달하였다.anchor
는virtualRef
가 될 수도 있는데, 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)를 넘어가지 않도록 하는 최대width
및height
를 인자로 전달받는다.Popper.Content
에선 이 두 값과,Popper.Anchor
의width
및height
를 css variable에 동기화한다.arrow
: Radix의Popper
엔arrow
와 동일한 이름의 변수가 있기 때문에,floatingUIarrow
라는 이름으로 바꾸어서 사용한다.arrow
역할을 하는 엘리먼트가 항상Popper.Anchor
의 중간을 가리키게끔 위치를 조절한다.transformOrigin
: 커스텀 미들웨어이기 때문에 따로 살펴본다.hide
: Reference Element가 Viewport를 벗어났을 때 Content를 숨기는 용도로 사용한다. (참고)
transformOrigin middleware
위에서 언급한 미들웨어 목록에 들어있는 transformOrigin
는 Popper.Content
의 transformOrigin
을 계산하는 커스텀 미들웨어이다.
transformOrigin
은 Popper.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
은 이 값을 계산하는 역할을 한다. 그리고 계산된 transformOrigin
은 useFloating
의 반환값인 middlewareData
를 통해 Popper.Content
에 적용된다. (참고)
Popper.Content의 Wrapper
Popper.Content
는 다음과 같이 Wrapper가 있다.
<div>
<PopperContentProvider>
<Primitive.div
{...props}
/>
</PopperContentProvider>
</div>
floating ui는 Floating Element의 위치를 transform
으로 적용하는 방법과 left
, top
을 사용해서 적용하는 방법 모두 지원하지만, Radix의 Popper
는 transform
을 사용한다.
이 때는 유저가 transform
을 오버라이드하게 되면, 기존의 transform
스타일과 충돌이 발생할 수 있으므로 이를 별도로 적용시킬 Wrapper를 추가한 것이다.