From 00f23034ede3301a786303465c2a5886c5e644c6 Mon Sep 17 00:00:00 2001 From: Leeseojeong Date: Wed, 1 Jul 2026 23:29:49 +0900 Subject: [PATCH 1/2] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20Refactor:=20Portal=20R?= =?UTF-8?q?oot=20=EA=B3=B5=ED=86=B5=20=EA=B4=80=EB=A6=AC=20=EA=B5=AC?= =?UTF-8?q?=EC=A1=B0=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/layout.tsx | 6 +++- src/shared/constants/portal.ts | 4 +++ src/shared/ui/portal/Portal.tsx | 38 ++++++++++++++++++++++++ src/shared/ui/portal/Portal.types.ts | 13 ++++++++ src/shared/ui/portal/index.ts | 2 ++ src/shared/ui/surface/Surface.types.ts | 7 ++--- src/shared/ui/surface/SurfacePortal.tsx | 10 +++---- src/shared/ui/tooltip/Tooltip.tsx | 2 +- src/shared/ui/tooltip/Tooltip.types.ts | 8 ++--- src/shared/ui/tooltip/TooltipContent.tsx | 14 +++------ 10 files changed, 78 insertions(+), 26 deletions(-) create mode 100644 src/shared/constants/portal.ts create mode 100644 src/shared/ui/portal/Portal.tsx create mode 100644 src/shared/ui/portal/Portal.types.ts create mode 100644 src/shared/ui/portal/index.ts diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 0630733..2216edc 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -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({ @@ -15,7 +16,10 @@ export default function RootLayout({ }>) { return ( - {children} + + {children} +
+ ); } diff --git a/src/shared/constants/portal.ts b/src/shared/constants/portal.ts new file mode 100644 index 0000000..1954c17 --- /dev/null +++ b/src/shared/constants/portal.ts @@ -0,0 +1,4 @@ +/** 앱 전역 Portal 대상 root element id입니다. */ +const PORTAL_ROOT_ID = 'portal-root'; + +export { PORTAL_ROOT_ID }; diff --git a/src/shared/ui/portal/Portal.tsx b/src/shared/ui/portal/Portal.tsx new file mode 100644 index 0000000..aed1eb5 --- /dev/null +++ b/src/shared/ui/portal/Portal.tsx @@ -0,0 +1,38 @@ +'use client'; + +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 = container === undefined ? getDefaultPortalContainer() : container; + + if (!portalContainer) { + return null; + } + + return createPortal(children, portalContainer); +} diff --git a/src/shared/ui/portal/Portal.types.ts b/src/shared/ui/portal/Portal.types.ts new file mode 100644 index 0000000..e62fb3b --- /dev/null +++ b/src/shared/ui/portal/Portal.types.ts @@ -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 }; diff --git a/src/shared/ui/portal/index.ts b/src/shared/ui/portal/index.ts new file mode 100644 index 0000000..3f07162 --- /dev/null +++ b/src/shared/ui/portal/index.ts @@ -0,0 +1,2 @@ +export { Portal } from './Portal'; +export type { PortalContainer, PortalProps } from './Portal.types'; diff --git a/src/shared/ui/surface/Surface.types.ts b/src/shared/ui/surface/Surface.types.ts index 34ba7a3..acf0303 100644 --- a/src/shared/ui/surface/Surface.types.ts +++ b/src/shared/ui/surface/Surface.types.ts @@ -1,5 +1,7 @@ import type { ComponentPropsWithRef, ReactElement, ReactNode } from 'react'; +import type { PortalContainer } from '@/shared/ui/portal'; + /** Surface가 화면에 표시되는 형태입니다. */ type SurfaceType = 'modal' | 'panel'; @@ -52,14 +54,11 @@ interface SurfaceProps { variant?: SurfaceType; } -/** Surface.Portal이 렌더링될 수 있는 DOM container 타입입니다. */ -type SurfacePortalContainer = DocumentFragment | Element; - /** Surface.Portal props입니다. */ interface SurfacePortalProps { children: ReactNode; /** portal을 렌더링할 대상 요소입니다. */ - container?: SurfacePortalContainer | null; + container?: PortalContainer | null; } /** Surface.Trigger가 직접 button을 렌더링할 때 사용하는 props입니다. */ diff --git a/src/shared/ui/surface/SurfacePortal.tsx b/src/shared/ui/surface/SurfacePortal.tsx index 240be6c..007b9b2 100644 --- a/src/shared/ui/surface/SurfacePortal.tsx +++ b/src/shared/ui/surface/SurfacePortal.tsx @@ -1,6 +1,6 @@ 'use client'; -import { createPortal } from 'react-dom'; +import { Portal } from '@/shared/ui/portal'; import { useSurfaceContext } from './SurfaceContext'; @@ -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 {children}; } diff --git a/src/shared/ui/tooltip/Tooltip.tsx b/src/shared/ui/tooltip/Tooltip.tsx index af7b273..c621004 100644 --- a/src/shared/ui/tooltip/Tooltip.tsx +++ b/src/shared/ui/tooltip/Tooltip.tsx @@ -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`를 지원합니다. diff --git a/src/shared/ui/tooltip/Tooltip.types.ts b/src/shared/ui/tooltip/Tooltip.types.ts index f9e6e30..514c223 100644 --- a/src/shared/ui/tooltip/Tooltip.types.ts +++ b/src/shared/ui/tooltip/Tooltip.types.ts @@ -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'; @@ -100,8 +102,6 @@ type TooltipTriggerProps = }) | (TooltipTriggerButtonProps & TooltipTriggerAsChildProps); -type TooltipPortalContainer = DocumentFragment | Element; - interface TooltipContentProps extends Omit, 'children' | 'role'> { /** * Tooltip에 표시할 안내 문구 또는 조합 UI입니다. @@ -113,9 +113,9 @@ interface TooltipContentProps extends Omit, 'childr /** * portal을 렌더링할 대상 요소입니다. * - * 생략하면 `document.body`를 사용합니다. `null`을 전달하면 portal 렌더링을 하지 않습니다. + * 생략하면 앱 전역 Portal Root를 사용합니다. `null`을 전달하면 portal 렌더링을 하지 않습니다. */ - container?: TooltipPortalContainer | null; + container?: PortalContainer | null; /** * false이면 portal을 사용하지 않고 현재 React 트리 위치에 렌더링합니다. * diff --git a/src/shared/ui/tooltip/TooltipContent.tsx b/src/shared/ui/tooltip/TooltipContent.tsx index d0123a3..f3ac90d 100644 --- a/src/shared/ui/tooltip/TooltipContent.tsx +++ b/src/shared/ui/tooltip/TooltipContent.tsx @@ -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'; @@ -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}`를 * 사용합니다. * @@ -53,7 +53,7 @@ const tooltipContentVariants = cva( * id를 Root에서 주입합니다. * * @param container - portal을 렌더링할 DOM 요소입니다. - * 생략하면 `document.body`를 사용합니다. + * 생략하면 앱 전역 Portal Root를 사용합니다. * @param usePortal - false이면 현재 React 트리 위치에 그대로 렌더링합니다. * * @example @@ -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; @@ -111,9 +109,5 @@ export function TooltipContent({ return content; } - if (!portalContainer) { - return null; - } - - return createPortal(content, portalContainer); + return {content}; } From eb1234b9b1cf6bb9143eb3197c931bbb16f2a882 Mon Sep 17 00:00:00 2001 From: Leeseojeong Date: Wed, 1 Jul 2026 23:53:47 +0900 Subject: [PATCH 2/2] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20Refactor:=20Portal=20?= =?UTF-8?q?=EA=B8=B0=EB=B3=B8=20=EC=BB=A8=ED=85=8C=EC=9D=B4=EB=84=88=20?= =?UTF-8?q?=EC=B2=98=EB=A6=AC=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/shared/ui/portal/Portal.tsx | 7 ++++++- src/shared/ui/surface/Surface.types.ts | 6 +++++- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/src/shared/ui/portal/Portal.tsx b/src/shared/ui/portal/Portal.tsx index aed1eb5..bd96682 100644 --- a/src/shared/ui/portal/Portal.tsx +++ b/src/shared/ui/portal/Portal.tsx @@ -1,5 +1,7 @@ 'use client'; +import { useMemo } from 'react'; + import { createPortal } from 'react-dom'; import { PORTAL_ROOT_ID } from '@/shared/constants/portal'; @@ -28,7 +30,10 @@ const getDefaultPortalContainer = (): PortalContainer | null => { * @param container - 직접 지정할 portal 대상 요소입니다. `null`이면 렌더링하지 않습니다. */ export function Portal({ children, container }: PortalProps) { - const portalContainer = container === undefined ? getDefaultPortalContainer() : container; + const portalContainer = useMemo( + () => (container === undefined ? getDefaultPortalContainer() : container), + [container] + ); if (!portalContainer) { return null; diff --git a/src/shared/ui/surface/Surface.types.ts b/src/shared/ui/surface/Surface.types.ts index acf0303..305ba9e 100644 --- a/src/shared/ui/surface/Surface.types.ts +++ b/src/shared/ui/surface/Surface.types.ts @@ -57,7 +57,11 @@ interface SurfaceProps { /** Surface.Portal props입니다. */ interface SurfacePortalProps { children: ReactNode; - /** portal을 렌더링할 대상 요소입니다. */ + /** + * portal을 렌더링할 대상 요소입니다. + * + * 생략하면 앱 전역 Portal Root를 사용하고, `null`이면 렌더링하지 않습니다. + */ container?: PortalContainer | null; }