Modal Patterns
모달 및 다이얼로그 구현 패턴입니다.
목차
- 개요
- Modal 컴포넌트
- 중첩 모달과 Stack 관리
- Scope (탭/라우트 바인딩)
- Config 영속성 (persist)
- React Query와 함께 사용
- Zustand 기반 전역 다이얼로그 시스템
개요
iCignal에서는 @vortex/ui-icignal의 useDialog 훅을 사용하여 전역 다이얼로그를 관리합니다.
주요 기능
| 메서드 | 반환 타입 | 설명 |
|---|---|---|
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>Modal 컴포넌트
@vortex/ui-foundation의 Modal 컴포넌트는 center, fullscreen, bottomsheet, sidebar 타입을 지원하는 통합 모달 컴포넌트입니다.
Props
| Prop | 타입 | 기본값 | 설명 |
|---|---|---|---|
open | boolean | - | 모달 열림 여부 |
onOpenChange | (open: boolean) => void | - | 열림 상태 변경 핸들러 |
type | "center" | "fullscreen" | "bottomsheet" | "sidebar" | 전역 설정 | 모달 유형 |
dim | boolean | 전역 설정 | 딤 처리 여부 |
allowInteraction | boolean | 전역 설정 | 배경 상호작용 허용 여부 |
closeOnOverlayClick | boolean | 전역 설정 | 오버레이 클릭 시 닫힘 여부 (allowInteraction보다 우선) |
title | React.ReactNode | - | 모달 제목 |
description | React.ReactNode | - | 모달 설명 |
footer | React.ReactNode | - | 하단 영역 |
className | string | - | 추가 CSS 클래스 |
showCloseButton | boolean | false | 우상단 닫기(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>참고:
showCloseButton은center/fullscreen타입(Dialog 기반)에서만 동작합니다.bottomsheet/sidebar타입(Drawer 기반)은 해당 prop의 영향을 받지 않습니다.
중첩 모달과 Stack 관리
TL;DR — 같은 화면에서 여러 모달이 중첩될 가능성이 있다면
<Modal>직접 사용을 피하고, 항상useDialog().modal()/lookup()명령형 API로 통일하세요. 그렇지 않으면 z-index 스택이 어긋나 dim 미표시·중복 호출 등의 문제가 발생합니다.
z-index 자동 부여 규칙
DialogProvider는 useDialog().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> → modal() 마이그레이션 매핑
대부분의 <Modal> prop은 modal() 옵션과 1:1로 대응합니다.
<Modal> prop | modal() 옵션 | 비고 |
|---|---|---|
open | (없음) | 호출 시 자동 true. 닫히면 store에서 제거 |
onOpenChange | (없음) | content: ({ close }) => ... 의 close() 사용 |
title | title | |
description | description | |
children | content | ReactNode | (({ close }) => ReactNode) |
footer | footer | ReactNode | (({ close }) => ReactNode) |
type | type | |
dim | dim | |
allowInteraction | allowInteraction | |
closeOnOverlayClick | closeOnOverlayClick | |
footerAlign | footerAlign | |
className | className | |
draggable | draggable | |
resizable | resizable | |
showCloseButton | showCloseButton | |
preservePosition | preservePosition | 미지정 시 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 바인딩을 지원하지 않습니다. RadixAlertDialog기반으로 배경 포커스·상호작용 자체를 차단하기 때문에 탭 이동을 UI로 막아버려 scope 바인딩 의미가 없습니다. 호출 시scope필드를 두지 않으며, 항상 전역으로 표시됩니다. 같은 이유로closeScope도alert/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 Promise | store 에서 item 이 제거되지 않았으므로 resolve 되지 않음 |
실제로 닫을 때 (X / close() / cancel / 탭 닫기로 closeScope) 는 store 에서 제거되어 wrapper 자체가 unmount 되므로, 다음에 같은 scope 를 열면 초기 위치/빈 입력값으로 시작합니다.
// 이 모달은 탭 이동 후 복귀해도 입력값, 드래그 위치가 그대로 유지된다.
modal({
title: "편집",
scope: "path",
draggable: true,
resizable: true,
content: ({ close }) => <EditorWithInputs onDone={close} />,
})내부적으로 modal / lookup 은 DialogPortal.keepMounted 를 사용하며, 이 동작은 provider 에서 자동으로 적용되므로 사용자는 별도 설정 없이 scope 만 지정하면 됩니다.
6. 권장 사용처
- ✅ 탭 단위로 독립된 편집 폼의 확인/저장 다이얼로그
- ✅ 특정 라우트에서 열린 lookup이 탭 전환 시 방해되지 않아야 할 때
- ✅ 여러 탭을 오가며 작업하는 중에 이전 탭의 상태(입력값/위치) 보존이 필요할 때
- ❌ 전역 알림(세션 만료, 공지 등)은
scope없이 전역으로 두어야 함
Config 영속성 (persist)
useDialog 의 config 는 Zustand persist 미들웨어로 localStorage 에 자동 저장됩니다. 사용자가 런타임에 변경한 설정은 새로고침 이후에도 유지됩니다.
저장 정보
| 항목 | 값 |
|---|---|
| localStorage 키 | vortex--config |
| 저장되는 state | config 만 (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에는 이미useDialog와DialogProvider가 내장되어 있습니다. 아래는 직접 구현하는 경우의 상세 가이드입니다.
1. 패키지 설치
pnpm add zustand sonner2. 타입 정의
// 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로 완전한 타입 지원
관련 패턴
- State Management - Zustand 전역 상태
- Data Fetching - React Query 통합