Skip to content

Commit 86bc462

Browse files
committed
feat: 커스텀 토스트 컴포넌트 작성
1 parent 1054309 commit 86bc462

File tree

4 files changed

+169
-3
lines changed

4 files changed

+169
-3
lines changed

src/components/Toast/ToastItem.tsx

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
import clsx from "clsx";
2+
import { motion } from "motion/react";
3+
import { useEffect, type JSX } from "react";
4+
import Button from "../Button";
5+
import { X, CheckCircle, Info, AlertTriangle } from "lucide-react";
6+
7+
const TOAST_TYPE_CONFIG: Record<string, { base: string; icon: JSX.Element }> = {
8+
info: {
9+
base: "text-gray-500 border-2 border-blue-300",
10+
icon: <Info className="w-5 h-5 mr-2 stroke-blue-500" />,
11+
},
12+
success: {
13+
base: "text-gray-500 border-2 border-green-300",
14+
icon: <CheckCircle className="w-5 h-5 mr-2 stroke-green-500" />,
15+
},
16+
error: {
17+
base: "text-gray-500 border-2 border-red-300",
18+
icon: <AlertTriangle className="w-5 h-5 mr-2 stroke-red-500" />,
19+
},
20+
};
21+
22+
const TOAST_COMMON =
23+
"min-w-[260px] w-full rounded-lg px-4 py-3 shadow-lg font-medium flex items-center justify-between bg-white";
24+
25+
export default function ToastItem({
26+
id,
27+
message,
28+
type = "info",
29+
duration = 3000,
30+
onClose,
31+
}: {
32+
id: string;
33+
message: string;
34+
type?: keyof typeof TOAST_TYPE_CONFIG;
35+
duration?: number;
36+
onClose: (id: string) => void;
37+
}) {
38+
useEffect(() => {
39+
const timer = setTimeout(() => onClose(id), duration);
40+
return () => clearTimeout(timer);
41+
}, [duration, onClose, id]);
42+
43+
const config = TOAST_TYPE_CONFIG[type];
44+
45+
return (
46+
<motion.div
47+
layout
48+
key={id}
49+
initial={{ opacity: 0, y: 20, scale: 0.95 }}
50+
animate={{ opacity: 1, y: 0, scale: 1 }}
51+
exit={{ opacity: 0, y: -20, scale: 0.95 }}
52+
transition={{
53+
type: "spring",
54+
damping: 18,
55+
stiffness: 200,
56+
layout: { duration: 0.2, delay: 0 },
57+
}}
58+
className={clsx(TOAST_COMMON, config.base)}
59+
>
60+
<div className="flex items-center">
61+
{config.icon}
62+
<p className="select-none">{message}</p>
63+
</div>
64+
<Button
65+
variant="ghost"
66+
size="icon"
67+
className="p-1"
68+
onClick={() => onClose(id)}
69+
>
70+
<X className="w-4 h-4" />
71+
</Button>
72+
</motion.div>
73+
);
74+
}

src/components/Toast/index.tsx

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import useUiStore from "@/store/useUiStore";
2+
import { AnimatePresence } from "motion/react";
3+
import { createPortal } from "react-dom";
4+
import ToastItem from "./ToastItem";
5+
6+
const placementClasses: Record<string, string> = {
7+
"top-left": "top-4 left-4",
8+
"top-right": "top-4 right-4",
9+
"bottom-left": "bottom-4 left-4",
10+
"bottom-right": "bottom-4 right-4",
11+
"top-center": "top-4 left-1/2 transform -translate-x-1/2",
12+
"bottom-center": "bottom-4 left-1/2 transform -translate-x-1/2",
13+
};
14+
15+
export default function ToastRoot() {
16+
const toasts = useUiStore((s) => s.toasts);
17+
const placement = useUiStore((s) => s.placement || "top-right");
18+
const removeToast = useUiStore((s) => s.removeToast);
19+
20+
return createPortal(
21+
<div
22+
className={`fixed ${placementClasses[placement]} flex flex-col gap-2 z-50`}
23+
>
24+
<AnimatePresence>
25+
{toasts.map((toast) => (
26+
<ToastItem
27+
key={toast.id}
28+
duration={toast.duration!}
29+
onClose={() => removeToast(toast.id)}
30+
{...toast}
31+
/>
32+
))}
33+
</AnimatePresence>
34+
</div>,
35+
document.getElementById("modal-root")!
36+
);
37+
}

src/routes/pages/ButtonPage.tsx

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,33 @@
11
import Button from "@/components/Button";
2+
import ToastRoot from "@/components/Toast";
3+
import useUiStore from "@/store/useUiStore";
24
import { Menu, Loader2 } from "lucide-react";
35

46
export default function ButtonPage() {
7+
const addToast = useUiStore((s) => s.addToast);
8+
59
return (
610
<section>
711
<h1>공통 버튼 컴포넌트</h1>
812
<div className="mt-4 flex flex-col gap-4 lg:flex-row">
913
<Button
1014
className="max-w-20"
11-
onClick={() => console.log("버튼 클릭됨!")}
15+
onClick={() =>
16+
addToast("버튼 클릭됨!", "success", "top-center", 1000)
17+
}
1218
>
1319
버튼
1420
</Button>
15-
<Button variant="danger" onClick={() => console.log("버튼 클릭됨!")}>
21+
<Button
22+
variant="danger"
23+
onClick={() => addToast("버튼 클릭됨!", "error", "top-center", 1000)}
24+
>
1625
버튼
1726
</Button>
1827
<Button
1928
variant="secondary"
2029
className="whitespace-nowrap"
21-
onClick={() => alert("버튼 클릭됨!")}
30+
onClick={() => addToast("버튼 클릭됨!", "info", "top-center", 1000)}
2231
>
2332
넓고 회색인 버튼
2433
</Button>
@@ -34,6 +43,8 @@ export default function ButtonPage() {
3443
<Menu className="size-6" />
3544
</Button>
3645
</div>
46+
47+
<ToastRoot />
3748
</section>
3849
);
3950
}

src/store/useUiStore.ts

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,21 @@ interface ModalData {
66
props?: Record<string, any>;
77
}
88

9+
interface ToastData {
10+
id: string;
11+
message: string;
12+
type: "info" | "success" | "error";
13+
duration?: number;
14+
}
15+
16+
type ToastPlacement =
17+
| "top-left"
18+
| "top-right"
19+
| "bottom-left"
20+
| "bottom-right"
21+
| "top-center"
22+
| "bottom-center";
23+
924
type UiStore = {
1025
isSidebarOpen: boolean;
1126
toggleSidebar: () => void;
@@ -17,6 +32,16 @@ type UiStore = {
1732
props?: Record<string, any>
1833
) => void;
1934
closeModal: (id?: string) => void;
35+
36+
toasts: ToastData[];
37+
placement?: ToastPlacement;
38+
addToast: (
39+
message: string,
40+
type?: "info" | "success" | "error",
41+
placement?: ToastPlacement,
42+
duration?: number
43+
) => void;
44+
removeToast: (id: string) => void;
2045
};
2146

2247
const useUiStore = create<UiStore>((set) => ({
@@ -34,6 +59,25 @@ const useUiStore = create<UiStore>((set) => ({
3459
set((s) => ({
3560
modals: id ? s.modals.filter((m) => m.id !== id) : s.modals.slice(0, -1),
3661
})),
62+
63+
toasts: [],
64+
addToast: (
65+
message,
66+
type = "info",
67+
placement = "top-right",
68+
duration = 1000
69+
) =>
70+
set((s) => ({
71+
toasts: [
72+
...s.toasts,
73+
{ id: crypto.randomUUID(), message, type, duration },
74+
],
75+
placement,
76+
})),
77+
removeToast: (id) =>
78+
set((s) => ({
79+
toasts: s.toasts.filter((t) => t.id !== id),
80+
})),
3781
}));
3882

3983
export default useUiStore;

0 commit comments

Comments
 (0)