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:
reloop
2026-04-12 03:23:15 +09:00
parent b9874ad25a
commit eb10dfe34f
2 changed files with 384 additions and 1 deletions

View File

@@ -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>

View 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};
}
`;