feat(toast): slide-in toast with undo + progress ring — 7C.5
Single-instance ToastProvider with requestAnimationFrame countdown, hover-pause, per-variant SVG icons, circular SVG progress ring, and slide-in-from-bottom animation. Wraps children in layout.tsx. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -3,6 +3,7 @@ import React from 'react';
|
||||
import { Inter, JetBrains_Mono } from 'next/font/google';
|
||||
import StyledComponentsRegistry from '@/styles/registry';
|
||||
import GlobalStyle from '@/styles/GlobalStyle';
|
||||
import { ToastProvider } from '@/components/ui/Toast';
|
||||
|
||||
const inter = Inter({
|
||||
subsets: ['latin'],
|
||||
@@ -44,7 +45,9 @@ export default function RootLayout({
|
||||
<body>
|
||||
<StyledComponentsRegistry>
|
||||
<GlobalStyle />
|
||||
{children}
|
||||
<ToastProvider>
|
||||
{children}
|
||||
</ToastProvider>
|
||||
</StyledComponentsRegistry>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
380
frontend/src/components/ui/Toast.tsx
Normal file
380
frontend/src/components/ui/Toast.tsx
Normal file
@@ -0,0 +1,380 @@
|
||||
'use client';
|
||||
|
||||
import React, {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
import styled, { css, keyframes } from 'styled-components';
|
||||
import { theme, animations } from '@/styles/theme';
|
||||
|
||||
// ─── Types ─────────────────────────────────────────────────────────────────
|
||||
|
||||
export type ToastVariant = 'success' | 'info' | 'warning' | 'danger';
|
||||
|
||||
export interface ToastOptions {
|
||||
message: string;
|
||||
variant?: ToastVariant;
|
||||
undoLabel?: string;
|
||||
onUndo?: () => void;
|
||||
durationMs?: number; // default 2000
|
||||
}
|
||||
|
||||
interface ToastContextValue {
|
||||
showToast: (opts: ToastOptions) => void;
|
||||
dismiss: () => void;
|
||||
}
|
||||
|
||||
// ─── Context ────────────────────────────────────────────────────────────────
|
||||
|
||||
const ToastContext = createContext<ToastContextValue | null>(null);
|
||||
|
||||
export function useToast() {
|
||||
const ctx = useContext(ToastContext);
|
||||
if (!ctx) throw new Error('useToast must be used inside ToastProvider');
|
||||
return ctx;
|
||||
}
|
||||
|
||||
// ─── Variant helpers ────────────────────────────────────────────────────────
|
||||
|
||||
const VARIANT_COLOR: Record<ToastVariant, string> = {
|
||||
success: theme.color.success,
|
||||
info: theme.color.info,
|
||||
warning: theme.color.warning,
|
||||
danger: theme.color.danger,
|
||||
};
|
||||
|
||||
function VariantIcon({ variant }: { variant: ToastVariant }) {
|
||||
const color = VARIANT_COLOR[variant];
|
||||
|
||||
if (variant === 'success') {
|
||||
return (
|
||||
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" aria-hidden="true">
|
||||
<circle cx="9" cy="9" r="8.25" stroke={color} strokeWidth="1.5" />
|
||||
<path
|
||||
d="M5.5 9.25l2.5 2.5 4.5-5"
|
||||
stroke={color}
|
||||
strokeWidth="1.75"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
if (variant === 'info') {
|
||||
return (
|
||||
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" aria-hidden="true">
|
||||
<circle cx="9" cy="9" r="8.25" stroke={color} strokeWidth="1.5" />
|
||||
<path
|
||||
d="M9 8v5M9 6v.01"
|
||||
stroke={color}
|
||||
strokeWidth="1.75"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
if (variant === 'warning') {
|
||||
return (
|
||||
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" aria-hidden="true">
|
||||
<path
|
||||
d="M9 2L16.5 15.5H1.5L9 2z"
|
||||
stroke={color}
|
||||
strokeWidth="1.5"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M9 7v4M9 12.5v.01"
|
||||
stroke={color}
|
||||
strokeWidth="1.75"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
// danger
|
||||
return (
|
||||
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" aria-hidden="true">
|
||||
<circle cx="9" cy="9" r="8.25" stroke={color} strokeWidth="1.5" />
|
||||
<path
|
||||
d="M6 6l6 6M12 6l-6 6"
|
||||
stroke={color}
|
||||
strokeWidth="1.75"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Progress Ring ──────────────────────────────────────────────────────────
|
||||
|
||||
const RING_SIZE = 24;
|
||||
const RING_STROKE = 4;
|
||||
const RING_RADIUS = (RING_SIZE - RING_STROKE) / 2; // 10
|
||||
const RING_CIRCUMFERENCE = 2 * Math.PI * RING_RADIUS; // ~62.83
|
||||
|
||||
function ProgressRing({ progress, variant }: { progress: number; variant: ToastVariant }) {
|
||||
const color = VARIANT_COLOR[variant];
|
||||
// progress 0 = full ring, progress 1 = empty ring (countdown)
|
||||
const dashOffset = RING_CIRCUMFERENCE * progress;
|
||||
|
||||
return (
|
||||
<svg
|
||||
width={RING_SIZE}
|
||||
height={RING_SIZE}
|
||||
viewBox={`0 0 ${RING_SIZE} ${RING_SIZE}`}
|
||||
style={{ flexShrink: 0 }}
|
||||
aria-hidden="true"
|
||||
>
|
||||
{/* Track */}
|
||||
<circle
|
||||
cx={RING_SIZE / 2}
|
||||
cy={RING_SIZE / 2}
|
||||
r={RING_RADIUS}
|
||||
fill="none"
|
||||
stroke="rgba(255,255,255,0.12)"
|
||||
strokeWidth={RING_STROKE}
|
||||
/>
|
||||
{/* Progress arc — starts from top, goes clockwise */}
|
||||
<circle
|
||||
cx={RING_SIZE / 2}
|
||||
cy={RING_SIZE / 2}
|
||||
r={RING_RADIUS}
|
||||
fill="none"
|
||||
stroke={color}
|
||||
strokeWidth={RING_STROKE}
|
||||
strokeLinecap="round"
|
||||
strokeDasharray={RING_CIRCUMFERENCE}
|
||||
strokeDashoffset={dashOffset}
|
||||
transform={`rotate(-90 ${RING_SIZE / 2} ${RING_SIZE / 2})`}
|
||||
style={{ transition: 'stroke-dashoffset 0.05s linear' }}
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Provider ───────────────────────────────────────────────────────────────
|
||||
|
||||
export function ToastProvider({ children }: { children: React.ReactNode }) {
|
||||
const [current, setCurrent] = useState<ToastOptions | null>(null);
|
||||
const [paused, setPaused] = useState(false);
|
||||
const [progress, setProgress] = useState(0); // 0..1
|
||||
|
||||
// refs to avoid stale closures in rAF loop
|
||||
const pausedRef = useRef(false);
|
||||
const startTimeRef = useRef<number>(0);
|
||||
const accumulatedRef = useRef<number>(0); // ms already elapsed before a pause
|
||||
const durationRef = useRef<number>(2000);
|
||||
const rafRef = useRef<number | null>(null);
|
||||
const currentRef = useRef<ToastOptions | null>(null);
|
||||
|
||||
pausedRef.current = paused;
|
||||
currentRef.current = current;
|
||||
|
||||
const cancelRaf = useCallback(() => {
|
||||
if (rafRef.current !== null) {
|
||||
cancelAnimationFrame(rafRef.current);
|
||||
rafRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const dismiss = useCallback(() => {
|
||||
cancelRaf();
|
||||
setCurrent(null);
|
||||
setProgress(0);
|
||||
accumulatedRef.current = 0;
|
||||
}, [cancelRaf]);
|
||||
|
||||
const startRaf = useCallback(
|
||||
(duration: number) => {
|
||||
cancelRaf();
|
||||
startTimeRef.current = performance.now();
|
||||
|
||||
const tick = (now: number) => {
|
||||
if (pausedRef.current) {
|
||||
// Snapshot how far we've gone, then wait
|
||||
accumulatedRef.current += now - startTimeRef.current;
|
||||
startTimeRef.current = now; // keep updating so delta stays 0 while paused
|
||||
rafRef.current = requestAnimationFrame(tick);
|
||||
return;
|
||||
}
|
||||
|
||||
const elapsed = accumulatedRef.current + (now - startTimeRef.current);
|
||||
const p = Math.min(elapsed / duration, 1);
|
||||
setProgress(p);
|
||||
|
||||
if (p >= 1) {
|
||||
setCurrent(null);
|
||||
setProgress(0);
|
||||
accumulatedRef.current = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
rafRef.current = requestAnimationFrame(tick);
|
||||
};
|
||||
|
||||
rafRef.current = requestAnimationFrame(tick);
|
||||
},
|
||||
[cancelRaf],
|
||||
);
|
||||
|
||||
const showToast = useCallback(
|
||||
(opts: ToastOptions) => {
|
||||
cancelRaf();
|
||||
accumulatedRef.current = 0;
|
||||
setProgress(0);
|
||||
setPaused(false);
|
||||
pausedRef.current = false;
|
||||
|
||||
const duration = opts.durationMs ?? 2000;
|
||||
durationRef.current = duration;
|
||||
|
||||
setCurrent(opts);
|
||||
// start rAF after state settles
|
||||
startTimeRef.current = performance.now();
|
||||
startRaf(duration);
|
||||
},
|
||||
[cancelRaf, startRaf],
|
||||
);
|
||||
|
||||
// Resume rAF when paused goes false→true direction handled inside tick.
|
||||
// When paused changes to false we need to reset startTimeRef so delta
|
||||
// doesn't accumulate missed frames.
|
||||
useEffect(() => {
|
||||
if (!paused) {
|
||||
startTimeRef.current = performance.now();
|
||||
}
|
||||
}, [paused]);
|
||||
|
||||
// Cleanup on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
cancelRaf();
|
||||
};
|
||||
}, [cancelRaf]);
|
||||
|
||||
const handleUndo = useCallback(() => {
|
||||
if (currentRef.current?.onUndo) {
|
||||
currentRef.current.onUndo();
|
||||
}
|
||||
dismiss();
|
||||
}, [dismiss]);
|
||||
|
||||
const handleMouseEnter = useCallback(() => {
|
||||
setPaused(true);
|
||||
}, []);
|
||||
|
||||
const handleMouseLeave = useCallback(() => {
|
||||
setPaused(false);
|
||||
}, []);
|
||||
|
||||
const variant: ToastVariant = current?.variant ?? 'success';
|
||||
const hasUndo = !!current?.onUndo;
|
||||
|
||||
return (
|
||||
<ToastContext.Provider value={{ showToast, dismiss }}>
|
||||
{children}
|
||||
|
||||
{current && (
|
||||
<ToastWrapper
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
aria-atomic="true"
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
>
|
||||
<IconSlot>
|
||||
<VariantIcon variant={variant} />
|
||||
</IconSlot>
|
||||
|
||||
<MessageText>{current.message}</MessageText>
|
||||
|
||||
{hasUndo && (
|
||||
<>
|
||||
<Divider />
|
||||
<UndoButton onClick={handleUndo} aria-label="실행 취소">
|
||||
{current.undoLabel ?? '실행 취소'}
|
||||
</UndoButton>
|
||||
<ProgressRing progress={progress} variant={variant} />
|
||||
</>
|
||||
)}
|
||||
</ToastWrapper>
|
||||
)}
|
||||
</ToastContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Styled components ──────────────────────────────────────────────────────
|
||||
|
||||
const slideIn = keyframes`
|
||||
0% { opacity: 0; transform: translate(-50%, 20px); }
|
||||
100% { opacity: 1; transform: translate(-50%, 0); }
|
||||
`;
|
||||
|
||||
const ToastWrapper = styled.div`
|
||||
position: fixed;
|
||||
bottom: 32px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
z-index: 9999;
|
||||
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 12px 20px;
|
||||
border-radius: ${theme.radius.pill};
|
||||
|
||||
background: rgba(42, 42, 53, 0.95);
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
border: 1px solid ${theme.color.borderBrightAlpha};
|
||||
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.5);
|
||||
|
||||
animation: ${slideIn} 0.25s cubic-bezier(0.22, 1, 0.36, 1) both;
|
||||
|
||||
white-space: nowrap;
|
||||
pointer-events: all;
|
||||
`;
|
||||
|
||||
const IconSlot = styled.span`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
`;
|
||||
|
||||
const MessageText = styled.span`
|
||||
font-family: ${theme.font.sans};
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: ${theme.color.textBright};
|
||||
line-height: 1.4;
|
||||
`;
|
||||
|
||||
const Divider = styled.div`
|
||||
width: 1px;
|
||||
height: 16px;
|
||||
background: ${theme.color.borderBrightAlpha};
|
||||
flex-shrink: 0;
|
||||
`;
|
||||
|
||||
const UndoButton = styled.button`
|
||||
font-family: ${theme.font.sans};
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: ${theme.color.textSub};
|
||||
background: transparent;
|
||||
border: none;
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
transition: color 0.15s ease;
|
||||
|
||||
&:hover {
|
||||
color: ${theme.color.textMain};
|
||||
}
|
||||
`;
|
||||
Reference in New Issue
Block a user