Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions index.html
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
</head>
<body>
<div id="root"></div>
<div id="toast-root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
8 changes: 7 additions & 1 deletion src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,14 @@
import { RouterProvider } from "react-router-dom";

import { router } from "./Router";
import ToastContainer from "@/components/Toast/ToastContainer";

function App() {
return <RouterProvider router={router} />;
return (
<>
<RouterProvider router={router} />
<ToastContainer />
</>
);
}
export default App;
19 changes: 19 additions & 0 deletions src/components/Toast.tsx
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Toast라고 하는 UI가 할 수 있어야 할 동작들이 있을 것 같아요! 🤔
사용자가 어떤 행동(ex: 버튼 클릭 등)을 했을 때,
해당 행동이 정상적으로 잘 이뤄졌는지 피드백을 하기 위해서
브라우저 어딘가에 작은 창으로 메시지를 안내하는 것처럼요!
그래서 UI뿐만 아니라 전역적으로 해당 메시지를 바로 나타나게 할 수 있는 훅 등도 같이 구현되면 좋을 것 같아요 😅

UI는 다르지만, 참고할 만한 링크도 같이 첨부해봅니다.
참고1
참고2

혹시 어려우신 부분이 있으시다면, 알려주시면 같이 구현해봐도 좋을 것 같습니다! 👍

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

아 넵 링크 참고해서 코드 다시 수정해보도록 하겠습니다!👍👍

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

연속적으로 토스트 메시지를 나타내면 메시지간의 간격이 너무 붙어 있는 것 같습니다 하핳
조금 간격을 띄우면 더 좋을 것 같아요! 👍

Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { cn } from "@/utils/cn";

interface ToastProps {
label: string;
className?: string;
}

export default function Toast({ label, className }: ToastProps) {
return (
<div
className={cn(
"inline-block rounded-md bg-red-30 px-4 py-[10px] text-white body1-regular",
className,
)}
>
{label}
</div>
);
}
20 changes: 20 additions & 0 deletions src/components/Toast/Toast.tsx
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

src/comopnents/Toast/Toast.tsx가 있고
src/components/Toast.tsx 두 가지 파일이 존재해보입니다!
필요하지 않은 하나는 제거 해주세요! 👍

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

헉 넵 수정하겠습니다!

Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
interface ToastProps {
label: string;
onClose?: () => void;
}

export default function Toast({ label, onClose }: ToastProps) {
return (
<div className="relative inline-block rounded-md bg-red-30 px-4 py-[10px] text-white body1-regular">
{label}
{onClose && (
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

피그마 상으로 닫는 버튼을 쓸 일이 없는 것 같은데 구현하신 이유가 궁금합니당

어차피 아래 ToastContainer 코드에서 핸들러로 onClose를 누락시키는 것 같기도 하구요!!

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

혹시 나중에 필요할까 싶어 남겨두긴 했는데 없어도 괜찮을 것 같네요ㅎㅎ😊 해당 부분 수정하겠습니다!

<button
onClick={onClose}
className="absolute top-1 right-2 text-white text-xs"
>
</button>
)}
</div>
);
}
24 changes: 24 additions & 0 deletions src/components/Toast/ToastContainer.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { useToast } from "@/hooks/useToast";
import ToastPortal from "@/components/Toast/ToastPortal";
import Toast from "@/components/Toast/Toast";

export default function ToastContainer() {
const { toasts } = useToast();

return (
<ToastPortal>
<div className="fixed inset-0 z-50 flex flex-col items-center justify-center pointer-events-none">
{toasts.map((toast) => (
<div
key={toast.id}
className={`transition-opacity duration-500 ${
toast.isVisible ? "opacity-100" : "opacity-0"
}`}
>
<Toast label={toast.label} />
</div>
Copy link
Collaborator

@cozy-ito cozy-ito Apr 29, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Toast 컴포넌트에 className prop을 하나 추가해서 아래처럼 적용하면
불필요한 div 태그를 제거할 수 있을 것 같아요!

Suggested change
<div
key={toast.id}
className={`transition-opacity duration-500 ${
toast.isVisible ? "opacity-100" : "opacity-0"
}`}
>
<Toast label={toast.label} />
</div>
<Toast
key={toast.id}
label={toast.label}
className={cn(
"transition-opacity duration-500",
toast.isVisible ? "opacity-100" : "opacity-0",
)}
/>

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

넵! 말씀해주신 부분들 반영하여 코드 수정해보겠습니다!!😊

))}
</div>
</ToastPortal>
);
}
21 changes: 21 additions & 0 deletions src/components/Toast/ToastPortal.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { createPortal } from "react-dom";
import { ReactNode, useEffect, useState } from "react";

interface ToastPortalProps {
children: ReactNode;
}

export default function ToastPortal({ children }: ToastPortalProps) {
const [mounted, setMounted] = useState(false);

useEffect(() => {
setMounted(true);
}, []);

if (typeof window === "undefined") return null;

const el = document.getElementById("toast-root");
if (!el || !mounted) return null;

return createPortal(children, el);
}
41 changes: 41 additions & 0 deletions src/hooks/useToast.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { create } from "zustand";

interface ToastItem {
id: number;
label: string;
isVisible: boolean;
}

interface ToastState {
toasts: ToastItem[];
showToast: (label: string) => void;
removeToast: (id: number) => void;
}

export const useToast = create<ToastState>((set) => ({
toasts: [],
showToast: (label) => {
const id = Date.now();
set((state) => ({
toasts: [...state.toasts, { id, label, isVisible: true }],
}));

setTimeout(() => {
set((state) => ({
toasts: state.toasts.map((toast) =>
toast.id === id ? { ...toast, isVisible: false } : toast,
),
}));

setTimeout(() => {
set((state) => ({
toasts: state.toasts.filter((toast) => toast.id !== id),
}));
}, 500);
}, 1000);
},
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

제 생각이지만 .. 중첩 setTimeoutasync/await으로 개선이 가능해보여요. 아래처럼 작성하면 구조가 간결화되어서 가독성이 좋아지지 않을까 합니당. Just 제안입니다!!

Suggested change
showToast: (label) => {
const id = Date.now();
set((state) => ({
toasts: [...state.toasts, { id, label, isVisible: true }],
}));
setTimeout(() => {
set((state) => ({
toasts: state.toasts.map((toast) =>
toast.id === id ? { ...toast, isVisible: false } : toast,
),
}));
setTimeout(() => {
set((state) => ({
toasts: state.toasts.filter((toast) => toast.id !== id),
}));
}, 500);
}, 1000);
},
showToast: async (label) => {
const id = Date.now();
set((state) => ({
toasts: [...state.toasts, { id, label, isVisible: true }],
}));
await new Promise((r) => setTimeout(r, 1000));
set((state) => ({
toasts: state.toasts.map((t) =>
t.id === id ? { ...t, isVisible: false } : t
),
}));
await new Promise((r) => setTimeout(r, 500));
set((state) => ({
toasts: state.toasts.filter((t) => t.id !== id),
}));
};

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

말씀해주신 부분들 반영하여 코드 수정하도록 하겠습니다!!😊

removeToast: (id) =>
set((state) => ({
toasts: state.toasts.filter((toast) => toast.id !== id),
})),
}));