Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion src/app/layout.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import localFont from 'next/font/local';

import { PORTAL_ROOT_ID } from '@/shared/constants/portal';
import '@/shared/styles/globals.css';

const pretendard = localFont({
Expand All @@ -15,7 +16,10 @@ export default function RootLayout({
}>) {
return (
<html className={`${pretendard.variable} ${pretendard.className}`} lang="ko">
<body>{children}</body>
<body>
{children}
<div id={PORTAL_ROOT_ID} />
</body>
</html>
);
}
4 changes: 4 additions & 0 deletions src/shared/constants/portal.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
/** 앱 전역 Portal 대상 root element id입니다. */
const PORTAL_ROOT_ID = 'portal-root';

export { PORTAL_ROOT_ID };
43 changes: 43 additions & 0 deletions src/shared/ui/portal/Portal.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
'use client';

import { useMemo } from 'react';

import { createPortal } from 'react-dom';

import { PORTAL_ROOT_ID } from '@/shared/constants/portal';

import type { PortalContainer, PortalProps } from './Portal.types';

const getDefaultPortalContainer = (): PortalContainer | null => {
if (typeof document === 'undefined') {
return null;
}

return document.getElementById(PORTAL_ROOT_ID) ?? document.body;
};

/**
* ## Portal
*
* @description
* Portal 기반 공통 UI가 동일한 렌더링 root를 사용하도록 portal 생성 로직을 중앙화합니다.
*
* ### 주요 내용
*
* 기본 대상은 `layout.tsx`에서 제공하는 앱 전역 Portal Root입니다.
* Storybook, 테스트처럼 Root가 없는 환경에서는 `document.body`를 fallback으로 사용합니다.
*
* @param container - 직접 지정할 portal 대상 요소입니다. `null`이면 렌더링하지 않습니다.
*/
export function Portal({ children, container }: PortalProps) {
const portalContainer = useMemo(
() => (container === undefined ? getDefaultPortalContainer() : container),
[container]
);

if (!portalContainer) {
return null;
}

return createPortal(children, portalContainer);
}
13 changes: 13 additions & 0 deletions src/shared/ui/portal/Portal.types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import type { ReactNode } from 'react';

/** Portal이 렌더링될 수 있는 DOM container 타입입니다. */
type PortalContainer = DocumentFragment | Element;

/** Portal props입니다. */
interface PortalProps {
children: ReactNode;
/** portal을 렌더링할 대상 요소입니다. 생략하면 앱 전역 Portal Root를 사용합니다. */
container?: PortalContainer | null;
}

export type { PortalContainer, PortalProps };
2 changes: 2 additions & 0 deletions src/shared/ui/portal/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export { Portal } from './Portal';
export type { PortalContainer, PortalProps } from './Portal.types';
13 changes: 8 additions & 5 deletions src/shared/ui/surface/Surface.types.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import type { ComponentPropsWithRef, ReactElement, ReactNode } from 'react';

import type { PortalContainer } from '@/shared/ui/portal';

/** Surface가 화면에 표시되는 형태입니다. */
type SurfaceType = 'modal' | 'panel';

Expand Down Expand Up @@ -52,14 +54,15 @@ interface SurfaceProps {
variant?: SurfaceType;
}

/** Surface.Portal이 렌더링될 수 있는 DOM container 타입입니다. */
type SurfacePortalContainer = DocumentFragment | Element;

/** Surface.Portal props입니다. */
interface SurfacePortalProps {
children: ReactNode;
/** portal을 렌더링할 대상 요소입니다. */
container?: SurfacePortalContainer | null;
/**
* portal을 렌더링할 대상 요소입니다.
*
* 생략하면 앱 전역 Portal Root를 사용하고, `null`이면 렌더링하지 않습니다.
*/
container?: PortalContainer | null;
}

/** Surface.Trigger가 직접 button을 렌더링할 때 사용하는 props입니다. */
Expand Down
10 changes: 4 additions & 6 deletions src/shared/ui/surface/SurfacePortal.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
'use client';

import { createPortal } from 'react-dom';
import { Portal } from '@/shared/ui/portal';

import { useSurfaceContext } from './SurfaceContext';

Expand All @@ -16,17 +16,15 @@ import type { SurfacePortalProps } from './Surface.types';
*
* 기본적으로 Surface가 열려 있을 때만 children을 렌더링합니다.
*
* @param container - portal을 렌더링할 대상 요소입니다. 생략하면 `document.body`를 사용하고,
* @param container - portal을 렌더링할 대상 요소입니다. 생략하면 앱 전역 Portal Root를 사용하고,
* `null`이면 렌더링하지 않습니다.
*/
export function SurfacePortal({ children, container }: SurfacePortalProps) {
const { state } = useSurfaceContext();
const portalContainer =
container === undefined ? (typeof document === 'undefined' ? null : document.body) : container;

if (!portalContainer || !state.isOpen) {
if (!state.isOpen) {
return null;
}

return createPortal(children, portalContainer);
return <Portal container={container}>{children}</Portal>;
}
2 changes: 1 addition & 1 deletion src/shared/ui/tooltip/Tooltip.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ const getTooltipPosition = (
*
* `Tooltip.Trigger`는 hover와 focus로 열림 상태를 요청하고, `Tooltip.Content`는
* trigger 위치를 기준으로 fixed 좌표에 렌더링됩니다.
* `Tooltip.Content`는 기본적으로 portal을 사용해 `document.body`에 렌더링되므로 부모 요소의
* `Tooltip.Content`는 기본적으로 앱 전역 Portal Root에 렌더링되므로 부모 요소의
* `overflow: hidden` 영향을 줄일 수 있습니다.
*
* `placement`는 `top`, `right`, `bottom`, `left`를 지원합니다.
Expand Down
8 changes: 4 additions & 4 deletions src/shared/ui/tooltip/Tooltip.types.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import type { ComponentPropsWithRef, PointerEventHandler, ReactElement, ReactNode } from 'react';

import type { PortalContainer } from '@/shared/ui/portal';

type TooltipPlacement = 'bottom' | 'left' | 'right' | 'top';

type TooltipOpenChangeReason = 'blur' | 'focus' | 'hover';
Expand Down Expand Up @@ -100,8 +102,6 @@ type TooltipTriggerProps =
})
| (TooltipTriggerButtonProps & TooltipTriggerAsChildProps);

type TooltipPortalContainer = DocumentFragment | Element;

interface TooltipContentProps extends Omit<ComponentPropsWithRef<'div'>, 'children' | 'role'> {
/**
* Tooltip에 표시할 안내 문구 또는 조합 UI입니다.
Expand All @@ -113,9 +113,9 @@ interface TooltipContentProps extends Omit<ComponentPropsWithRef<'div'>, 'childr
/**
* portal을 렌더링할 대상 요소입니다.
*
* 생략하면 `document.body`를 사용합니다. `null`을 전달하면 portal 렌더링을 하지 않습니다.
* 생략하면 앱 전역 Portal Root를 사용합니다. `null`을 전달하면 portal 렌더링을 하지 않습니다.
*/
container?: TooltipPortalContainer | null;
container?: PortalContainer | null;
/**
* false이면 portal을 사용하지 않고 현재 React 트리 위치에 렌더링합니다.
*
Expand Down
14 changes: 4 additions & 10 deletions src/shared/ui/tooltip/TooltipContent.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
'use client';

import { cva } from 'class-variance-authority';
import { createPortal } from 'react-dom';

import { cn } from '@/shared/styles/utils/cn';
import { Portal } from '@/shared/ui/portal';

import { useTooltipContext } from './TooltipContext';

Expand Down Expand Up @@ -43,7 +43,7 @@ const tooltipContentVariants = cva(
* 표시 위치는 Root의 `placement` 값에 따라 `top`, `right`, `bottom`, `left` 중 하나로
* 정해지며, 상태에 따라 fade/scale 애니메이션이 적용됩니다.
*
* `usePortal`이 true이면 `document.body` 또는 `container`에 portal로 렌더링합니다.
* `usePortal`이 true이면 앱 전역 Portal Root 또는 `container`에 portal로 렌더링합니다.
* 특정 테스트 환경이나 제한된 레이아웃 안에서 portal을 쓰지 않아야 할 때만 `usePortal={false}`를
* 사용합니다.
*
Expand All @@ -53,7 +53,7 @@ const tooltipContentVariants = cva(
* id를 Root에서 주입합니다.
*
* @param container - portal을 렌더링할 DOM 요소입니다.
* 생략하면 `document.body`를 사용합니다.
* 생략하면 앱 전역 Portal Root를 사용합니다.
* @param usePortal - false이면 현재 React 트리 위치에 그대로 렌더링합니다.
*
* @example
Expand All @@ -74,8 +74,6 @@ export function TooltipContent({
...props
}: TooltipContentProps) {
const { meta, state } = useTooltipContext();
const portalContainer =
container === undefined ? (typeof document === 'undefined' ? null : document.body) : container;

if (!state.shouldRenderContent) {
return null;
Expand Down Expand Up @@ -111,9 +109,5 @@ export function TooltipContent({
return content;
}

if (!portalContainer) {
return null;
}

return createPortal(content, portalContainer);
return <Portal container={container}>{content}</Portal>;
}
Loading