Skip to Content
PatternsModal Patterns

Modal Patterns

모달 및 다이얼로그 구현 패턴입니다.


목차

  1. 개요
  2. Modal 컴포넌트
  3. 중첩 모달과 Stack 관리
  4. Scope (탭/라우트 바인딩)
  5. Config 영속성 (persist)
  6. React Query와 함께 사용
  7. Zustand 기반 전역 다이얼로그 시스템

개요

iCignal에서는 @vortex/ui-icignaluseDialog 훅을 사용하여 전역 다이얼로그를 관리합니다.

주요 기능

메서드반환 타입설명
alert(message)Promise<void>알림 다이얼로그 표시
confirm(options)Promise<boolean>확인/취소 다이얼로그, 결과 반환
modal(options)Promise<void>커스텀 모달 표시 (content/footer 지정)
lookup<T>(options)Promise<T | null>조회 팝업, 선택값 반환
toast(message)Promise<void>토스트 메시지 표시
showLoading()void로딩 다이얼로그 표시
hideLoading()void로딩 다이얼로그 숨기기
closeModal(id)void특정 모달 닫기
closeLookup(id)void특정 lookup 닫기
closeAllModals(options)void모든 모달 닫기 ({ scope } 전달 시 해당 scope만)
closeAllLookups(options)void모든 lookup 닫기 ({ scope } 전달 시 해당 scope만)
closeScope(scope?)void해당 scope의 modal / lookup 닫기 (alert/confirm 제외). scope 생략 시 currentScope 기본값 사용
setCurrentScope(scope?)void현재 scope 지정 (보통 라우터 pathname을 주입)
setConfig(config)void전역 다이얼로그 설정 변경

기본 사용법

import { useDialog } from "@vortex/ui-icignal" function Example() { const { alert, confirm, toast } = useDialog() const handleDelete = async () => { const result = await confirm({ title: "삭제 확인", description: "정말 삭제하시겠습니까?", confirmText: "삭제", confirmVariant: "destructive", }) if (result) { // 삭제 로직 toast("삭제되었습니다") } } return <button onClick={handleDelete}>삭제</button> }

버튼 순서 제어 (buttonOrder)

확인/취소 버튼의 위치 순서를 제어할 수 있습니다.

설명시각적 순서
"cancel-confirm"기본값취소 | 확인
"confirm-cancel"확인 버튼을 왼쪽에 배치확인 | 취소
// 개별 다이얼로그에서 제어 const result = await confirm({ title: "저장 확인", description: "변경사항을 저장하시겠습니까?", buttonOrder: "confirm-cancel", // 확인 | 취소 }) // 전역 기본값 설정 (DialogProvider) <DialogProvider buttonOrder="confirm-cancel"> {children} </DialogProvider>

@vortex/ui-foundationModal 컴포넌트는 center, fullscreen, bottomsheet, sidebar 타입을 지원하는 통합 모달 컴포넌트입니다.

Props

Prop타입기본값설명
openboolean-모달 열림 여부
onOpenChange(open: boolean) => void-열림 상태 변경 핸들러
type"center" | "fullscreen" | "bottomsheet" | "sidebar"전역 설정모달 유형
dimboolean전역 설정딤 처리 여부
allowInteractionboolean전역 설정배경 상호작용 허용 여부
closeOnOverlayClickboolean전역 설정오버레이 클릭 시 닫힘 여부 (allowInteraction보다 우선)
titleReact.ReactNode-모달 제목
descriptionReact.ReactNode-모달 설명
footerReact.ReactNode-하단 영역
classNamestring-추가 CSS 클래스
showCloseButtonbooleanfalse우상단 닫기(X) 버튼 표시 여부

기본 사용

import { useState } from "react" import { Modal, Button } from "@vortex/ui-foundation" function Example() { const [open, setOpen] = useState(false) return ( <> <Button onClick={() => setOpen(true)}>열기</Button> <Modal open={open} onOpenChange={setOpen} title="모달 제목" description="모달 설명" footer={<Button onClick={() => setOpen(false)}>닫기</Button>} > <p>모달 내용</p> </Modal> </> ) }

showCloseButton

기본적으로 Modal은 닫기 버튼을 표시하지 않습니다. showCloseButton prop으로 우상단 X 버튼을 활성화할 수 있습니다.

<Modal open={open} onOpenChange={setOpen} title="닫기 버튼이 있는 모달" showCloseButton > <p>X 버튼으로 닫을 수 있습니다.</p> </Modal>

참고: showCloseButtoncenter / fullscreen 타입(Dialog 기반)에서만 동작합니다. bottomsheet / sidebar 타입(Drawer 기반)은 해당 prop의 영향을 받지 않습니다.


중첩 모달과 Stack 관리

TL;DR — 같은 화면에서 여러 모달이 중첩될 가능성이 있다면 <Modal> 직접 사용을 피하고, 항상 useDialog().modal() / lookup() 명령형 API로 통일하세요. 그렇지 않으면 z-index 스택이 어긋나 dim 미표시·중복 호출 등의 문제가 발생합니다.

z-index 자동 부여 규칙

DialogProvideruseDialog().modal() / lookup()으로 띄운 항목에 다음 공식으로 stackIndex를 자동 부여합니다.

overlay z-index = 40 + stackIndex × 20 content z-index = 50 + stackIndex × 20 modal[i].stackIndex = i // visibleModalItems 내 순번 lookup[i].stackIndex = visibleModalItems.length + i // modal 위에 항상 쌓임 alert/confirm[i] = visibleModalItems.length + visibleLookupItems.length + i loading = 9999 (최상단 고정)

같은 store에 등록된 항목들끼리는 stack이 자동으로 어긋나지 않게 계산됩니다.

충돌이 발생하는 시나리오 — 직접 <Modal> + useDialog() 혼합

페이지가 직접 그린 <Modal>은 store의 modalItems에 등록되지 않으므로 DialogProvider는 그 모달의 존재를 모릅니다. 결과적으로 그 안에서 useDialog().lookup()을 호출하면 visibleModalItems.length === 0 으로 계산되어 부모 modal과 같은 z-index 층에 깔립니다.

[detail-modal: 직접 <Modal>로 띄움 → store에 없음] └── [lookup1: useDialog().lookup() 호출] ├── i.length=0, c=0 → stackIndex=0 ├── overlay z=40, content z=50 ← 부모와 같은 층! └── 결과: lookup의 dim이 부모 modal 박스에 가려서 화면 가운데에서 안 보임 부모 trigger가 클릭 가능해 lookup이 중복 호출됨

두 번째 lookup이 떠야만 stackIndex=1을 받아 overlay z=60으로 정상화되는 비대칭 동작이 관찰됩니다.

❌ 위험한 패턴

// page.tsx - 부모 모달을 직접 <Modal>로 그린 상태에서 // 그 안에서 useDialog().lookup() 호출 function DetailPage() { const [open, setOpen] = useState(false) const { lookup } = useDialog() return ( <> <Button onClick={() => setOpen(true)}>상세 보기</Button> <Modal open={open} onOpenChange={setOpen} title="상세"> <Button onClick={async () => { // ⚠️ 이 lookup은 부모 modal을 인식하지 못해 z-index가 어긋남 const user = await lookup({ ... }) }} > 담당자 선택 </Button> </Modal> </> ) }

✅ 권장 패턴 — 부모 모달도 명령형 API로

function DetailPage() { const { modal, lookup } = useDialog() const openDetail = () => modal({ title: "상세", content: ({ close }) => ( <Button onClick={async () => { const user = await lookup({ ... }) // ... }} > 담당자 선택 </Button> ), }) return <Button onClick={openDetail}>상세 보기</Button> }

이 형태로 옮기면:

  • 부모 modal이 modalItems[0]에 등록 → visibleModalItems.length === 1
  • lookup의 stackIndex = 1 + 0 = 1 → overlay z=60, content z=70
  • 부모(40/50)를 정상적으로 덮어 dim·overlay 클릭 모두 의도대로 동작
  • 추가 가드(stackIndex={-1} 같은 트릭) 불필요

대부분의 <Modal> prop은 modal() 옵션과 1:1로 대응합니다.

<Modal> propmodal() 옵션비고
open(없음)호출 시 자동 true. 닫히면 store에서 제거
onOpenChange(없음)content: ({ close }) => ...close() 사용
titletitle
descriptiondescription
childrencontentReactNode | (({ close }) => ReactNode)
footerfooterReactNode | (({ close }) => ReactNode)
typetype
dimdim
allowInteractionallowInteraction
closeOnOverlayClickcloseOnOverlayClick
footerAlignfooterAlign
classNameclassName
draggabledraggable
resizableresizable
showCloseButtonshowCloseButton
preservePositionpreservePosition미지정 시 provider 기본값 true
stackIndex(없음)DialogProvider가 자동 부여

Before / After 예시

// Before — useState로 open 토글 + 직접 <Modal> function DetailPage({ rid }: { rid: string }) { const [open, setOpen] = useState(false) return ( <> <Button onClick={() => setOpen(true)}>상세 보기</Button> <Modal open={open} onOpenChange={setOpen} type="center" title="상세" description="레코드를 확인하세요" showCloseButton footer={ <Button onClick={() => setOpen(false)}>닫기</Button> } > <DetailContent rid={rid} /> </Modal> </> ) }
// After — modal() 명령형 호출 function DetailPage({ rid }: { rid: string }) { const { modal } = useDialog() const openDetail = () => modal({ type: "center", title: "상세", description: "레코드를 확인하세요", showCloseButton: true, content: <DetailContent rid={rid} />, footer: ({ close }) => <Button onClick={close}>닫기</Button>, }) return <Button onClick={openDetail}>상세 보기</Button> }

open state를 호출부에서 들고 있을 필요가 없어지고, 닫힘은 close() 콜백 또는 X 버튼이 담당합니다.

외부에서 모달을 닫아야 할 때

modal()Promise<void>만 반환하고 호출자에게 id를 노출하지 않습니다. 다음 중 하나를 사용하세요.

  • scope 부여 후 closeScope(scope) / closeAllModals({ scope }) — 가장 일반적
  • content 함수의 close 콜백을 클로저로 보존해 외부에서 호출
  • closeAllModals() — 모든 modal 일괄 종료 (전역 상황에서만)
// scope로 그룹 묶고 외부에서 일괄 닫기 modal({ scope: "wizard-step-2", title: "2단계", content: <Step2 />, }) // 다른 곳에서 useDialog.getState().closeScope("wizard-step-2")

직접 <Modal>을 써도 되는 경우

다음 조건을 모두 만족할 때만 직접 사용을 고려하세요.

  • <DialogProvider> 트리 외부에서 standalone으로 띄울 때 (예: 부분 렌더링 미리보기)
  • 다른 useDialog() 다이얼로그(특히 lookup)와 같은 화면에 동시 등장하지 않음을 보장할 수 있을 때
  • scope 기반 라우팅 보존이 불필요한 단순 케이스

같은 화면에 lookup이 등장할 가능성이 있다면 명령형 API로 옮기는 것이 안전합니다.


Scope (탭/라우트 바인딩)

modal / lookup 호출 시 scope 필드로 해당 다이얼로그를 특정 라우트(탭) 또는 논리 그룹에 묶을 수 있습니다. 탭 네비게이션 기반 SPA에서, 특정 탭에서 연 다이얼로그를 탭을 벗어나면 숨겼다가 돌아오면 그대로 복원하고, 탭이 닫히면 자동으로 resolve되도록 할 때 유용합니다.

alert / confirm 은 scope 바인딩을 지원하지 않습니다. Radix AlertDialog 기반으로 배경 포커스·상호작용 자체를 차단하기 때문에 탭 이동을 UI로 막아버려 scope 바인딩 의미가 없습니다. 호출 시 scope 필드를 두지 않으며, 항상 전역으로 표시됩니다. 같은 이유로 closeScopealert / confirm 은 닫지 않습니다. 중요한 확정 절차에만 사용하고, 탭 이동 중에도 살아있어야 할 UI는 modal 로 전환하세요.

정책 요약

scope동작
undefined (미지정)전역 다이얼로그. 탭/경로와 무관하게 항상 표시 (기존 동작).
"path" (sentinel)호출 시점의 currentScope(보통 라우터 pathname)를 스냅샷으로 저장. 해당 경로에서만 표시.
그 외 문자열명시적 scope. 특정 path("/users")나 논리 그룹 ID("wizard-checkout") 등을 사용.

탭을 벗어나면 아이템은 store에 남고 DOM 은 유지(display:none 으로만 숨김)됩니다. 같은 scope 로 돌아오면 이전 상태 그대로 다시 보이며, 내부의 React state(입력값, 스크롤, 탭 선택 등)드래그·리사이즈로 이동한 위치/크기가 모두 보존됩니다. 그 사이의 Promise 는 계속 pending 상태로 유지됩니다. 사용자가 실제로 닫을 때 (close() / cancel / X 버튼 / 탭 닫기) 는 store 에서 항목이 제거되어 다음 호출 시 자연스럽게 초기 상태로 리셋됩니다.

1. 현재 scope 주입

DialogProvider에 현재 라우트 pathname을 주입합니다.

// React Router import { useLocation } from "react-router" import { DialogProvider } from "@vortex/ui-icignal" function AppProviders({ children }: { children: React.ReactNode }) { const { pathname } = useLocation() return <DialogProvider currentScope={pathname}>{children}</DialogProvider> }

Next.js의 경우 usePathname()으로 대체합니다. 또는 provider 외부에서 직접 store 액션을 호출해도 됩니다.

const setCurrentScope = useDialog((s) => s.setCurrentScope) useEffect(() => { setCurrentScope(pathname) }, [pathname, setCurrentScope])

2. 호출 시 scope 지정

const { modal, lookup } = useDialog() // 현재 탭(path)에 바인딩 — 탭 이동 시 숨김, 복귀 시 재노출 modal({ title: "편집", scope: "path", content: ({ close }) => <EditorView onDone={close} />, }) lookup({ title: "사용자 선택", scope: "path", content: ({ select, cancel }) => <UserTable onSelect={select} onCancel={cancel} />, }) // 명시적 path에 바인딩 — 해당 경로로 이동해야만 보임 modal({ title: "알림", scope: "/users/detail", content: <p>Users 상세 페이지 전용 모달</p>, }) // 논리 scope (탭/path와 무관한 그룹) modal({ title: "마법사 2단계", scope: "wizard-checkout", content: <Step2 />, })

3. scope 기반 닫기

const { closeAllModals, closeAllLookups, closeScope } = useDialog() // 특정 scope의 모달만 closeAllModals({ scope: "/users/detail" }) // 특정 scope의 modal / lookup 전부 (alert / confirm 은 제외) closeScope("/users/detail") // scope 생략 시 현재 scope(= 현재 pathname)에 속한 모든 다이얼로그 closeScope() // 인자 없이 전체 닫기 (기존 동작과 호환) closeAllModals()

4. 탭 네비게이션과 연동

iCignal의 useMenu 스토어는 탭이 제거될 때 (pop / closeOthers / closeAll) 해당 탭의 url을 scope로 사용하여 자동으로 closeScope를 호출합니다. 따라서 scope: "path" 로 연 modal / lookup 은 사용자가 탭을 X 버튼으로 닫으면 자동으로 cancel 되어 promise가 resolve됩니다.

// 탭 A(/orders)에서 아래처럼 연 경우: const user = await lookup<User>({ title: "담당자 선택", scope: "path", content: ({ select, cancel }) => <UserTable onSelect={select} onCancel={cancel} />, }) // 사용자가 아직 선택하기 전에 탭을 닫으면: // → user === null (cancel) 로 resolve 되어 후속 로직이 안전하게 진행

alert / confirm 은 탭 닫기에 반응하지 않고 그대로 표시됩니다. 사용자가 직접 확인/취소를 눌러 닫아야 합니다.

5. 상태 보존 (state / position)

scope 바인딩된 modal / lookup 은 탭 이동으로 숨겨질 때 DOM 과 React state 가 모두 유지됩니다.

보존되는 것이유
React state (입력값, 스크롤 위치 등)Popup DOM 이 display:none 으로만 숨겨지므로 useState 값이 그대로 유지
드래그로 이동한 위치wrapper <DialogContent> 가 mount 상태 유지, position state 보존
리사이즈로 변경한 크기동일 이유로 size state 보존
pending Promisestore 에서 item 이 제거되지 않았으므로 resolve 되지 않음

실제로 닫을 때 (X / close() / cancel / 탭 닫기로 closeScope) 는 store 에서 제거되어 wrapper 자체가 unmount 되므로, 다음에 같은 scope 를 열면 초기 위치/빈 입력값으로 시작합니다.

// 이 모달은 탭 이동 후 복귀해도 입력값, 드래그 위치가 그대로 유지된다. modal({ title: "편집", scope: "path", draggable: true, resizable: true, content: ({ close }) => <EditorWithInputs onDone={close} />, })

내부적으로 modal / lookupDialogPortal.keepMounted 를 사용하며, 이 동작은 provider 에서 자동으로 적용되므로 사용자는 별도 설정 없이 scope 만 지정하면 됩니다.

6. 권장 사용처

  • ✅ 탭 단위로 독립된 편집 폼의 확인/저장 다이얼로그
  • ✅ 특정 라우트에서 열린 lookup이 탭 전환 시 방해되지 않아야 할 때
  • ✅ 여러 탭을 오가며 작업하는 중에 이전 탭의 상태(입력값/위치) 보존이 필요할 때
  • ❌ 전역 알림(세션 만료, 공지 등)은 scope 없이 전역으로 두어야 함

Config 영속성 (persist)

useDialogconfig 는 Zustand persist 미들웨어로 localStorage 에 자동 저장됩니다. 사용자가 런타임에 변경한 설정은 새로고침 이후에도 유지됩니다.

저장 정보

항목
localStorage 키vortex--config
저장되는 stateconfig 만 (partialize 적용)
저장되지 않는 것items, modalItems, lookupItems, currentScope, loading 등 런타임 UI 상태

변경 방법

const { setConfig, resetConfig } = useDialog() // 부분 업데이트 — 변경한 필드만 localStorage 에 반영 setConfig({ dim: false, allowInteraction: true, buttonOrder: "confirm-cancel" }) // 기본값으로 복원 resetConfig()

DialogProvider prop 과의 관계

DialogProvider 의 config 관련 prop (type, dim, allowInteraction, buttonOrder 등) 은 “초기 default override” 로만 동작합니다.

  • persist 로 복원된 사용자 설정을 절대 덮어쓰지 않습니다.
  • 각 필드에 대해 현재 store 값이 defaultConfig 값과 같은 경우에만 prop 값이 반영됩니다 (아직 사용자가 조정하지 않은 필드).
  • 따라서 사용자가 setConfig 또는 ThemeGenerator 등으로 한 번 바꾼 값은 새로고침 후에도 유지됩니다.
// 앱 기본값을 지정하고 싶을 때: <DialogProvider dim={false} allowInteraction={true}> ... </DialogProvider> // 사용자가 나중에 setConfig({ dim: true }) 를 호출하면 // 이후 새로고침에서도 dim=true 가 유지됨 (prop 은 무시됨).

: 초기화된 상태로 테스트하려면 브라우저 콘솔에서 localStorage.removeItem("vortex--config") 또는 useDialog.getState().resetConfig() 를 실행하세요.


React Query와 함께 사용

모달 내에서 데이터를 페칭하는 패턴입니다.

import { useState } from "react" import { useQuery } from "@tanstack/react-query" import { Dialog, DialogTrigger, DialogContent, DialogHeader, DialogBody, DialogTitle, DialogDescription, DialogFooter, DialogClose, Button, Skeleton, } from "@vortex/ui-foundation" function useUser(id?: number) { return useQuery({ queryKey: ["user", id], queryFn: () => fetch(`/api/users/${id}`).then((res) => res.json()), enabled: !!id, }) } export function UserDetailModal({ userId }: { userId: number }) { const [open, setOpen] = useState(false) const { data: user, isLoading, error } = useUser(open ? userId : undefined) return ( <Dialog open={open} onOpenChange={setOpen}> <DialogTrigger asChild> <Button variant="outline" size="sm"> 상세 보기 </Button> </DialogTrigger> <DialogContent> <DialogHeader> <DialogTitle>사용자 상세 정보</DialogTitle> <DialogDescription>사용자 정보를 확인하세요</DialogDescription> </DialogHeader> <DialogBody> {isLoading ? ( <div className="space-y-2"> <Skeleton className="h-4 w-full" /> <Skeleton className="h-4 w-3/4" /> <Skeleton className="h-4 w-1/2" /> </div> ) : error ? ( <div className="text-destructive">에러: {error.message}</div> ) : ( <div className="space-y-2"> <p> <strong>ID:</strong> {user?.id} </p> <p> <strong>이름:</strong> {user?.name} </p> <p> <strong>이메일:</strong> {user?.email} </p> <p> <strong>역할:</strong> {user?.role} </p> </div> )} </DialogBody> <DialogFooter> <DialogClose asChild> <Button variant="outline">닫기</Button> </DialogClose> </DialogFooter> </DialogContent> </Dialog> ) }

Zustand 기반 전역 다이얼로그 시스템

💡 @vortex/ui-icignal에는 이미 useDialogDialogProvider가 내장되어 있습니다. 아래는 직접 구현하는 경우의 상세 가이드입니다.

1. 패키지 설치

pnpm add zustand sonner

2. 타입 정의

// stores/dialog.ts import { create } from "zustand" import { toast, type ExternalToast } from "sonner" import { type ReactNode } from "react" // Alert/Confirm 아이템 타입 export interface AlertConfirmItem { id?: string type?: "alert" | "confirm" title?: string description?: string confirmText?: string cancelText?: string confirmVariant?: "default" | "outline" | "destructive" cancelVariant?: "default" | "outline" | "destructive" confirmSize?: "sm" | "default" | "lg" cancelSize?: "sm" | "default" | "lg" buttonOrder?: "cancel-confirm" | "confirm-cancel" onConfirm?: () => void onCancel?: () => void } // Toast 아이템 타입 export interface ToastItem extends ExternalToast { type?: "success" | "error" | "loading" | "info" | "warning" id?: string title?: string description?: ReactNode dismissible?: boolean position?: | "top-left" | "top-center" | "top-right" | "bottom-left" | "bottom-center" | "bottom-right" icon?: ReactNode confirmText?: string cancelText?: string onConfirm?: () => void onCancel?: () => void }

3. Store 상태 및 액션 정의

// stores/dialog.ts (계속) type State = { items: AlertConfirmItem[] loading?: boolean defaultToastPosition?: ToastItem["position"] } type Action = { alert: (item: AlertConfirmItem | string) => Promise<void> confirm: (item: AlertConfirmItem | string) => Promise<boolean> showLoading: () => void hideLoading: () => void toast: (item: ToastItem | string) => Promise<void> setDefaultToastPosition: (position: ToastItem["position"]) => void }

4. Zustand Store 구현

// stores/dialog.ts (계속) export const useDialog = create<State & Action>((set) => ({ items: [], loading: false, defaultToastPosition: "top-right", // Alert: 알림만 표시 (확인 버튼만) alert: (item: AlertConfirmItem | string) => new Promise((resolve) => { const id = Math.random().toString(36).substring(2, 9) function onConfirm() { resolve() set((state: State) => ({ items: state.items.filter((i) => i.id !== id), })) if (typeof item !== "string") item.onConfirm?.() } function onCancel() { resolve() set((state: State) => ({ items: state.items.filter((i) => i.id !== id), })) if (typeof item !== "string") item.onCancel?.() } const newItem: AlertConfirmItem = typeof item === "string" ? { title: item, type: "alert", id, onConfirm, onCancel } : { ...item, type: "alert", id, onConfirm, onCancel } set((state: State) => ({ items: [...state.items, newItem] })) }), // Confirm: 확인/취소 선택, boolean 반환 confirm: (item: AlertConfirmItem | string) => new Promise((resolve) => { const id = Math.random().toString(36).substring(2, 9) function onConfirm() { resolve(true) set((state: State) => ({ items: state.items.filter((i) => i.id !== id), })) if (typeof item !== "string") item.onConfirm?.() } function onCancel() { resolve(false) set((state: State) => ({ items: state.items.filter((i) => i.id !== id), })) if (typeof item !== "string") item.onCancel?.() } const newItem: AlertConfirmItem = typeof item === "string" ? { title: item, type: "confirm", id, onConfirm, onCancel } : { ...item, type: "confirm", id, onConfirm, onCancel } set((state: State) => ({ items: [...state.items, newItem] })) }), // 로딩 다이얼로그 showLoading: () => set({ loading: true }), hideLoading: () => set({ loading: false }), // Toast 메시지 (sonner 사용) toast: (item: ToastItem | string) => new Promise<void>((resolve) => { if (typeof item === "string") { toast(item, { onDismiss: () => resolve() }) return } const method = item.type ? toast[item.type] : toast method(item.title, { ...item, onDismiss: () => resolve(), action: item.confirmText ? { label: item.confirmText, onClick: item.onConfirm } : undefined, cancel: item.cancelText ? { label: item.cancelText, onClick: item.onCancel } : undefined, }) }), setDefaultToastPosition: (position) => set({ defaultToastPosition: position }), }))

5. DialogProvider 구현

// providers/dialog-provider.tsx import { useDialog } from "@/stores/dialog" import { AlertDialog, AlertDialogContent, AlertDialogHeader, AlertDialogTitle, AlertDialogDescription, AlertDialogFooter, AlertDialogCancel, AlertDialogAction, } from "@vortex/ui-foundation" import { Toaster } from "sonner" import { CircleCheckIcon, InfoIcon, TriangleAlertIcon, OctagonXIcon, Loader2Icon, } from "lucide-react" // 로딩 다이얼로그 컴포넌트 function LoadingDialog() { return ( <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50"> <div className="rounded-lg bg-background p-6"> <Loader2Icon className="h-8 w-8 animate-spin" /> </div> </div> ) } export function DialogProvider({ children }: { children: React.ReactNode }) { const { items, loading, defaultToastPosition } = useDialog() return ( <> {children} {/* Alert/Confirm 다이얼로그 렌더링 */} {items.map((item) => ( <AlertDialog key={item.id} open onOpenChange={item.onCancel}> <AlertDialogContent> <AlertDialogHeader> <AlertDialogTitle>{item.title}</AlertDialogTitle> {item.description && ( <AlertDialogDescription> {item.description} </AlertDialogDescription> )} </AlertDialogHeader> <AlertDialogFooter> {item.type === "confirm" && ( <AlertDialogCancel onClick={item.onCancel}> {item.cancelText || "취소"} </AlertDialogCancel> )} <AlertDialogAction onClick={item.onConfirm} className={ item.confirmVariant === "destructive" ? "bg-destructive text-destructive-foreground hover:bg-destructive/90" : "" } > {item.confirmText || "확인"} </AlertDialogAction> </AlertDialogFooter> </AlertDialogContent> </AlertDialog> ))} {/* 로딩 다이얼로그 */} {loading && <LoadingDialog />} {/* Toast 컨테이너 */} <Toaster position={defaultToastPosition} richColors icons={{ success: <CircleCheckIcon className="size-4" />, info: <InfoIcon className="size-4" />, warning: <TriangleAlertIcon className="size-4" />, error: <OctagonXIcon className="size-4" />, loading: <Loader2Icon className="size-4 animate-spin" />, }} /> </> ) }

6. App에 Provider 연결

// main.tsx 또는 App.tsx import { DialogProvider } from "@/providers/dialog-provider" function App() { return ( <DialogProvider> <Router /> </DialogProvider> ) }

7. 컴포넌트에서 사용

import { useDialog } from "@/stores/dialog" export function UserList() { const { alert, confirm, toast, showLoading, hideLoading } = useDialog() const handleDelete = async (user: User) => { // 1. 확인 다이얼로그 const result = await confirm({ title: "사용자 삭제", description: `${user.name} 사용자를 삭제하시겠습니까?`, confirmText: "삭제", cancelText: "취소", confirmVariant: "destructive", }) if (!result) return // 2. 로딩 표시 showLoading() try { await deleteUser(user.id) hideLoading() // 3. 성공 토스트 toast({ type: "success", title: "사용자가 삭제되었습니다" }) } catch (error) { hideLoading() // 4. 에러 알림 await alert({ title: "삭제 실패", description: "사용자 삭제에 실패했습니다. 다시 시도해주세요.", }) } } return <button onClick={() => handleDelete(user)}>삭제</button> }

8. Toast 타입별 사용

const { toast } = useDialog() // 기본 토스트 toast("메시지") // 성공 토스트 toast({ type: "success", title: "저장되었습니다" }) // 에러 토스트 toast({ type: "error", title: "오류가 발생했습니다" }) // 경고 토스트 toast({ type: "warning", title: "주의가 필요합니다" }) // 정보 토스트 toast({ type: "info", title: "알림", description: "추가 정보입니다" }) // 액션 버튼이 있는 토스트 toast({ title: "변경사항이 있습니다", confirmText: "저장", cancelText: "취소", onConfirm: () => save(), onCancel: () => discard(), })

주요 특징

  • Promise 기반: await confirm()으로 동기적 코드 스타일
  • 다중 다이얼로그: 여러 다이얼로그 동시 표시 가능
  • 로딩 상태: 전역 로딩 다이얼로그 지원
  • Toast 통합: sonner 라이브러리로 토스트 메시지
  • 버튼 순서 제어: buttonOrder로 확인/취소 버튼 위치 제어
  • Scope 바인딩: scope로 탭/라우트에 묶어 이동 시 자동 숨김·복원, 탭 닫힘 시 자동 resolve
  • 상태 보존: scope 바인딩된 modal/lookup은 탭 이동 시 DOM 유지(keepMounted), 입력값·드래그 위치·pending Promise 모두 그대로
  • Config 영속성: setConfig 변경값을 localStorage(vortex--config)에 자동 저장, provider prop이 덮어쓰지 않음
  • 타입 안전성: TypeScript로 완전한 타입 지원

관련 패턴

Last updated on