Modal Patterns
모달 및 다이얼로그 구현 패턴입니다.
목차
개요
iCignal에서는 @vortex/ui-icignal의 useDialog 훅을 사용하여 전역 다이얼로그를 관리합니다.
주요 기능
| 메서드 | 반환 타입 | 설명 |
|---|---|---|
alert(message) | Promise<void> | 알림 다이얼로그 표시 |
confirm(options) | Promise<boolean> | 확인/취소 다이얼로그, 결과 반환 |
toast(message) | Promise<void> | 토스트 메시지 표시 |
showLoading() | void | 로딩 다이얼로그 표시 |
hideLoading() | 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>
}React Query와 함께 사용
모달 내에서 데이터를 페칭하는 패턴입니다.
import { useState } from "react"
import { useQuery } from "@tanstack/react-query"
import {
Dialog,
DialogTrigger,
DialogContent,
DialogHeader,
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>
{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>
)}
<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"
onConfirm?: () => void
onCancel?: () => void
}
// Toast 아이템 타입
export interface ToastItem extends ExternalToast {
type?: "success" | "error" | "loading" | "info" | "warning"
id?: string
title?: string
description?: string
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 라이브러리로 토스트 메시지
- 타입 안전성: TypeScript로 완전한 타입 지원
관련 패턴
- State Management - Zustand 전역 상태
- Data Fetching - React Query 통합
Last updated on