428 lines
16 KiB
TypeScript
428 lines
16 KiB
TypeScript
// FloatToastContent.tsx — Toast 컴포넌트 카탈로그
|
||
|
||
import { useState, useEffect } from 'react';
|
||
import type { DesignTheme } from '../designTheme';
|
||
|
||
interface FloatToastContentProps {
|
||
theme: DesignTheme;
|
||
}
|
||
|
||
type ToastType = 'success' | 'error' | 'info' | 'warning';
|
||
|
||
interface ToastItem {
|
||
id: number;
|
||
type: ToastType;
|
||
message: string;
|
||
progress: number;
|
||
}
|
||
|
||
const TOAST_CONFIG: Record<ToastType, { color: string; bg: string; icon: string; label: string }> =
|
||
{
|
||
success: { color: '#22c55e', bg: 'rgba(34,197,94,0.12)', icon: '✓', label: 'Success' },
|
||
error: { color: '#ef4444', bg: 'rgba(239,68,68,0.12)', icon: '✕', label: 'Error' },
|
||
info: { color: '#06b6d4', bg: 'rgba(6,182,212,0.12)', icon: 'ℹ', label: 'Info' },
|
||
warning: { color: '#eab308', bg: 'rgba(234,179,8,0.12)', icon: '⚠', label: 'Warning' },
|
||
};
|
||
|
||
const DEMO_MESSAGES: Record<ToastType, string> = {
|
||
success: '저장이 완료되었습니다.',
|
||
error: '요청 처리 중 오류가 발생했습니다.',
|
||
info: '시뮬레이션이 시작되었습니다.',
|
||
warning: '미저장 변경사항이 있습니다.',
|
||
};
|
||
|
||
const TOAST_DURATION = 3000;
|
||
|
||
let toastIdCounter = 0;
|
||
|
||
export const FloatToastContent = ({ theme }: FloatToastContentProps) => {
|
||
const t = theme;
|
||
const isDark = t.mode === 'dark';
|
||
const [toasts, setToasts] = useState<ToastItem[]>([]);
|
||
|
||
const addToast = (type: ToastType) => {
|
||
const id = ++toastIdCounter;
|
||
setToasts((prev) => [...prev, { id, type, message: DEMO_MESSAGES[type], progress: 100 }]);
|
||
};
|
||
|
||
const removeToast = (id: number) => {
|
||
setToasts((prev) => prev.filter((t) => t.id !== id));
|
||
};
|
||
|
||
useEffect(() => {
|
||
if (toasts.length === 0) return;
|
||
|
||
const interval = setInterval(() => {
|
||
setToasts((prev) =>
|
||
prev
|
||
.map((toast) => ({ ...toast, progress: toast.progress - 100 / (TOAST_DURATION / 100) }))
|
||
.filter((toast) => toast.progress > 0),
|
||
);
|
||
}, 100);
|
||
|
||
return () => clearInterval(interval);
|
||
}, [toasts.length]);
|
||
|
||
const toastBg = isDark ? '#1b1f2c' : '#ffffff';
|
||
const toastBorder = isDark ? 'rgba(66,71,84,0.30)' : '#e2e8f0';
|
||
|
||
return (
|
||
<div className="px-8 py-10 flex flex-col gap-12 max-w-[1200px]">
|
||
{/* ── 개요 ── */}
|
||
<div className="flex flex-col gap-3">
|
||
<div className="flex items-center gap-3">
|
||
<h2 className="font-sans text-xl font-bold" style={{ color: t.textPrimary }}>
|
||
Toast
|
||
</h2>
|
||
<span
|
||
className="font-mono text-caption rounded px-2 py-0.5"
|
||
style={{
|
||
backgroundColor: isDark ? 'rgba(234,179,8,0.10)' : 'rgba(234,179,8,0.08)',
|
||
color: '#eab308',
|
||
}}
|
||
>
|
||
미구현 — 설계 사양
|
||
</span>
|
||
</div>
|
||
<p className="font-korean text-sm leading-6" style={{ color: t.textSecondary }}>
|
||
화면을 차단하지 않는 비파괴적 알림.
|
||
<code
|
||
className="font-mono text-xs px-1.5 py-0.5 mx-1 rounded"
|
||
style={{
|
||
backgroundColor: isDark ? 'rgba(76,215,246,0.08)' : 'rgba(6,182,212,0.06)',
|
||
color: t.textAccent,
|
||
}}
|
||
>
|
||
fixed bottom-right
|
||
</code>
|
||
에 위치하며 일정 시간 후 자동으로 사라진다. 현재 프로젝트에서는{' '}
|
||
<code
|
||
className="font-mono text-xs px-1.5 py-0.5 mx-1 rounded"
|
||
style={{
|
||
backgroundColor: isDark ? 'rgba(239,68,68,0.08)' : 'rgba(239,68,68,0.05)',
|
||
color: '#ef4444',
|
||
}}
|
||
>
|
||
window.alert
|
||
</code>
|
||
또는 console.log로 대체하고 있으며 커스텀 구현이 필요하다.
|
||
</p>
|
||
</div>
|
||
|
||
{/* ── Live Preview ── */}
|
||
<div className="flex flex-col gap-4">
|
||
<div className="flex items-center gap-2">
|
||
<h3 className="font-sans text-base font-bold" style={{ color: t.textPrimary }}>
|
||
Live Preview
|
||
</h3>
|
||
<span
|
||
className="font-mono text-caption px-2 py-0.5 rounded"
|
||
style={{
|
||
backgroundColor: isDark ? 'rgba(34,197,94,0.10)' : 'rgba(34,197,94,0.08)',
|
||
color: '#22c55e',
|
||
}}
|
||
>
|
||
interactive
|
||
</span>
|
||
</div>
|
||
<div
|
||
className="rounded-lg border border-solid p-5 flex flex-col gap-4"
|
||
style={{ backgroundColor: t.cardBg, borderColor: t.cardBorder }}
|
||
>
|
||
<p className="font-korean text-xs" style={{ color: t.textMuted }}>
|
||
버튼 클릭 시 화면 우하단에 Toast가 표시됩니다. 3초 후 자동으로 사라집니다.
|
||
</p>
|
||
<div className="flex gap-2 flex-wrap">
|
||
{(Object.keys(TOAST_CONFIG) as ToastType[]).map((type) => {
|
||
const cfg = TOAST_CONFIG[type];
|
||
return (
|
||
<button
|
||
key={type}
|
||
type="button"
|
||
onClick={() => addToast(type)}
|
||
className="px-4 py-2 rounded border border-solid font-mono text-caption font-medium transition-opacity hover:opacity-80"
|
||
style={{
|
||
backgroundColor: cfg.bg,
|
||
borderColor: `${cfg.color}40`,
|
||
color: cfg.color,
|
||
}}
|
||
>
|
||
{cfg.icon} {cfg.label}
|
||
</button>
|
||
);
|
||
})}
|
||
</div>
|
||
{toasts.length > 0 && (
|
||
<p className="font-mono text-caption" style={{ color: t.textMuted }}>
|
||
활성 Toast: {toasts.length}개 — 우측 하단을 확인하세요
|
||
</p>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{/* ── Anatomy ── */}
|
||
<div className="flex flex-col gap-4">
|
||
<h3 className="font-sans text-base font-bold" style={{ color: t.textPrimary }}>
|
||
Anatomy
|
||
</h3>
|
||
<div className="grid grid-cols-2 gap-6">
|
||
{/* 구조 목업 */}
|
||
<div
|
||
className="rounded-lg border border-solid p-4 flex flex-col gap-3"
|
||
style={{ backgroundColor: t.cardBg, borderColor: t.cardBorder }}
|
||
>
|
||
<span
|
||
className="font-mono text-caption uppercase"
|
||
style={{ letterSpacing: '1px', color: t.textMuted }}
|
||
>
|
||
Structure
|
||
</span>
|
||
<div
|
||
className="rounded relative flex items-end justify-end"
|
||
style={{
|
||
backgroundColor: isDark ? 'rgba(0,0,0,0.25)' : 'rgba(0,0,0,0.04)',
|
||
padding: '16px',
|
||
minHeight: '160px',
|
||
}}
|
||
>
|
||
{/* 성공 Toast 목업 */}
|
||
<div className="flex flex-col gap-1.5 w-full max-w-[220px]">
|
||
{(['success', 'info', 'error'] as ToastType[]).map((type, i) => {
|
||
const cfg = TOAST_CONFIG[type];
|
||
return (
|
||
<div
|
||
key={type}
|
||
className="rounded border border-solid flex items-center gap-2 px-3 py-2"
|
||
style={{
|
||
backgroundColor: toastBg,
|
||
borderColor: toastBorder,
|
||
borderLeft: `3px solid ${cfg.color}`,
|
||
opacity: 1 - i * 0.2,
|
||
}}
|
||
>
|
||
<span
|
||
className="font-mono text-caption shrink-0"
|
||
style={{ color: cfg.color }}
|
||
>
|
||
{cfg.icon}
|
||
</span>
|
||
<span className="font-korean text-caption" style={{ color: t.textSecondary }}>
|
||
{DEMO_MESSAGES[type].slice(0, 14)}…
|
||
</span>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* 위치 규칙 */}
|
||
<div
|
||
className="rounded-lg border border-solid p-4 flex flex-col gap-3"
|
||
style={{ backgroundColor: t.cardBg, borderColor: t.cardBorder }}
|
||
>
|
||
<span
|
||
className="font-mono text-caption uppercase"
|
||
style={{ letterSpacing: '1px', color: t.textMuted }}
|
||
>
|
||
Position Rules
|
||
</span>
|
||
<div className="flex flex-col gap-2">
|
||
{[
|
||
{ label: 'position', value: 'fixed', desc: '뷰포트 기준 고정' },
|
||
{ label: 'bottom', value: '24px', desc: '화면 하단에서 24px' },
|
||
{ label: 'right', value: '24px', desc: '화면 우측에서 24px' },
|
||
{ label: 'z-index', value: 'z-60', desc: '콘텐츠 위, Modal(9999) 아래' },
|
||
{ label: 'width', value: '320px (고정)', desc: '일정한 너비 유지' },
|
||
{ label: 'gap', value: '8px (스택)', desc: '복수 Toast 간격' },
|
||
].map((item) => (
|
||
<div key={item.label} className="flex items-center gap-2">
|
||
<span
|
||
className="font-mono text-caption rounded px-1.5 py-0.5 shrink-0 w-24 text-right"
|
||
style={{
|
||
backgroundColor: isDark ? 'rgba(76,215,246,0.08)' : 'rgba(6,182,212,0.06)',
|
||
color: t.textAccent,
|
||
}}
|
||
>
|
||
{item.label}
|
||
</span>
|
||
<span className="font-mono text-caption" style={{ color: t.textPrimary }}>
|
||
{item.value}
|
||
</span>
|
||
<span className="font-korean text-caption ml-auto" style={{ color: t.textMuted }}>
|
||
{item.desc}
|
||
</span>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* ── 타입별 색상 ── */}
|
||
<div className="flex flex-col gap-4">
|
||
<h3 className="font-sans text-base font-bold" style={{ color: t.textPrimary }}>
|
||
타입별 색상
|
||
</h3>
|
||
<div className="grid grid-cols-4 gap-3">
|
||
{(Object.entries(TOAST_CONFIG) as [ToastType, (typeof TOAST_CONFIG)[ToastType]][]).map(
|
||
([type, cfg]) => (
|
||
<div
|
||
key={type}
|
||
className="rounded-lg border border-solid p-4 flex flex-col gap-3"
|
||
style={{
|
||
backgroundColor: t.cardBg,
|
||
borderColor: t.cardBorder,
|
||
borderLeft: `3px solid ${cfg.color}`,
|
||
}}
|
||
>
|
||
<div className="flex items-center gap-2">
|
||
<span className="font-mono text-lg" style={{ color: cfg.color }}>
|
||
{cfg.icon}
|
||
</span>
|
||
<span className="font-mono text-sm font-bold" style={{ color: cfg.color }}>
|
||
{cfg.label}
|
||
</span>
|
||
</div>
|
||
<span className="font-mono text-caption" style={{ color: t.textMuted }}>
|
||
{cfg.color}
|
||
</span>
|
||
<span
|
||
className="font-korean text-caption leading-5"
|
||
style={{ color: t.textSecondary }}
|
||
>
|
||
{type === 'success' && '저장 완료, 복사 완료, 전송 성공'}
|
||
{type === 'error' && 'API 오류, 저장 실패, 권한 없음'}
|
||
{type === 'info' && '작업 시작, 업데이트 알림'}
|
||
{type === 'warning' && '미저장 변경, 만료 임박'}
|
||
</span>
|
||
</div>
|
||
),
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{/* ── 구현 패턴 제안 ── */}
|
||
<div className="flex flex-col gap-4">
|
||
<h3 className="font-sans text-base font-bold" style={{ color: t.textPrimary }}>
|
||
구현 패턴 제안 — useToast Hook
|
||
</h3>
|
||
<div
|
||
className="rounded-lg border border-solid p-5 flex flex-col gap-4"
|
||
style={{ backgroundColor: t.cardBg, borderColor: t.cardBorder }}
|
||
>
|
||
<p className="font-korean text-sm leading-6" style={{ color: t.textSecondary }}>
|
||
Toast는 앱 어디서든 호출해야 하므로 <strong>Zustand store + useToast hook</strong>{' '}
|
||
패턴을 권장한다. ToastContainer는 App.tsx 최상위에 한 번만 렌더링한다.
|
||
</p>
|
||
<div className="flex flex-col gap-3">
|
||
{[
|
||
{
|
||
title: 'toastStore.ts',
|
||
code: 'const useToastStore = create<ToastStore>()\naddToast(type, message, duration?)\nremoveToast(id)',
|
||
desc: 'Zustand store — Toast 큐 관리',
|
||
},
|
||
{
|
||
title: 'useToast.ts',
|
||
code: 'const { success, error, info, warning } = useToast()\nsuccess("저장 완료") // duration 기본값 3000ms',
|
||
desc: '컴포넌트에서 호출하는 hook',
|
||
},
|
||
{
|
||
title: 'ToastContainer.tsx',
|
||
code: '<div className="fixed bottom-6 right-6 z-[60] flex flex-col gap-2">\n {toasts.map(t => <ToastItem key={t.id} {...t} />)}\n</div>',
|
||
desc: 'App.tsx 최상위에 배치',
|
||
},
|
||
].map((item) => (
|
||
<div
|
||
key={item.title}
|
||
className="rounded border border-solid p-3 flex flex-col gap-1.5"
|
||
style={{ borderColor: t.cardBorder }}
|
||
>
|
||
<div className="flex items-center justify-between">
|
||
<span
|
||
className="font-mono text-caption font-bold"
|
||
style={{ color: t.textPrimary }}
|
||
>
|
||
{item.title}
|
||
</span>
|
||
<span className="font-korean text-caption" style={{ color: t.textMuted }}>
|
||
{item.desc}
|
||
</span>
|
||
</div>
|
||
<pre
|
||
className="font-mono text-caption leading-5 rounded p-2 overflow-x-auto"
|
||
style={{
|
||
backgroundColor: isDark ? 'rgba(0,0,0,0.30)' : 'rgba(0,0,0,0.04)',
|
||
color: t.textSecondary,
|
||
}}
|
||
>
|
||
{item.code}
|
||
</pre>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* ── 실제 Toast 렌더링 (fixed 위치) ── */}
|
||
{toasts.length > 0 && (
|
||
<div
|
||
className="fixed bottom-6 right-6 z-[60] flex flex-col gap-2"
|
||
style={{ width: '300px' }}
|
||
>
|
||
{toasts.map((toast) => {
|
||
const cfg = TOAST_CONFIG[toast.type];
|
||
return (
|
||
<div
|
||
key={toast.id}
|
||
className="rounded border border-solid flex flex-col overflow-hidden"
|
||
style={{
|
||
backgroundColor: toastBg,
|
||
borderColor: toastBorder,
|
||
borderLeft: `3px solid ${cfg.color}`,
|
||
boxShadow: '0 4px 16px rgba(0,0,0,0.30)',
|
||
}}
|
||
>
|
||
<div className="flex items-center gap-2.5 px-3 py-2.5">
|
||
<span className="font-mono text-base shrink-0" style={{ color: cfg.color }}>
|
||
{cfg.icon}
|
||
</span>
|
||
<span className="font-korean text-sm flex-1" style={{ color: t.textPrimary }}>
|
||
{toast.message}
|
||
</span>
|
||
<button
|
||
type="button"
|
||
onClick={() => removeToast(toast.id)}
|
||
className="w-5 h-5 rounded flex items-center justify-center shrink-0 hover:opacity-70 transition-opacity"
|
||
style={{ color: t.textMuted }}
|
||
>
|
||
<span className="font-mono text-caption">✕</span>
|
||
</button>
|
||
</div>
|
||
{/* Progress bar */}
|
||
<div
|
||
style={{
|
||
height: '2px',
|
||
backgroundColor: isDark ? 'rgba(66,71,84,0.30)' : '#f1f5f9',
|
||
}}
|
||
>
|
||
<div
|
||
style={{
|
||
height: '2px',
|
||
width: `${toast.progress}%`,
|
||
backgroundColor: cfg.color,
|
||
transition: 'width 0.1s linear',
|
||
}}
|
||
/>
|
||
</div>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
};
|
||
|
||
export default FloatToastContent;
|