veza/apps/web/src/components/feedback/Toast.tsx
senke 3fb12b2ce2 aesthetic-improvements: automated replacement of decorative cyan with steel (80/20 rule, Action 11.3.1.3)
- Created automated script (scripts/replace-decorative-cyan.py) to systematically replace decorative/informational kodo-cyan instances with kodo-steel variants
- Script intelligently preserves active/functional states, design system variants, semantic indicators, and interactive states
- Modified 85 files, replaced 145 decorative instances, preserved 47 functional instances
- No linter errors, type safety maintained
- Action 11.3.1.3 significantly advanced (total: ~302 instances replaced across ~229 files including previous batches)
2026-01-16 11:40:13 +01:00

96 lines
2.9 KiB
TypeScript

import { useEffect, useState, useCallback } from 'react';
import { X, CheckCircle, AlertCircle, AlertTriangle, Info } from 'lucide-react';
import { cn } from '@/lib/utils';
export interface Toast {
id: string;
message: string;
type?: 'success' | 'error' | 'warning' | 'info';
duration?: number;
}
export interface ToastProps {
toast: Toast;
onDismiss: (id: string) => void;
}
const TOAST_ICONS = {
success: CheckCircle,
error: AlertCircle,
warning: AlertTriangle,
info: Info,
};
const TOAST_STYLES = {
success:
'bg-kodo-lime/10 border-kodo-lime/30 text-kodo-text-main dark:bg-kodo-lime/20 dark:border-kodo-lime/40 dark:text-kodo-lime',
error:
'bg-kodo-red/10 border-kodo-red/30 text-kodo-text-main dark:bg-kodo-red/20 dark:border-kodo-red/40 dark:text-kodo-red',
warning:
'bg-kodo-gold/10 border-kodo-gold/30 text-kodo-text-main dark:bg-kodo-gold/20 dark:border-kodo-gold/40 dark:text-kodo-gold',
info: 'bg-kodo-steel/10 border-kodo-steel/30 text-kodo-text-main dark:bg-kodo-steel/20 dark:border-kodo-steel/40 dark:text-kodo-steel',
};
const DEFAULT_DURATION = 5000;
/**
* Composant Toast individuel pour afficher une notification.
*/
export function ToastComponent({ toast, onDismiss }: ToastProps) {
const [isVisible, setIsVisible] = useState(false);
const [isLeaving, setIsLeaving] = useState(false);
const handleDismiss = useCallback(() => {
setIsLeaving(true);
setTimeout(() => {
onDismiss(toast.id);
}, 300);
}, [toast.id, onDismiss]);
useEffect(() => {
// Animation d'entrée - rendre visible immédiatement
setIsVisible(true);
// Auto-dismiss
const duration = toast.duration ?? DEFAULT_DURATION;
let dismissTimer: NodeJS.Timeout | null = null;
if (duration > 0) {
dismissTimer = setTimeout(() => {
handleDismiss();
}, duration);
}
return () => {
if (dismissTimer) clearTimeout(dismissTimer);
};
}, [toast.duration, toast.id, handleDismiss]);
const Icon = toast.type ? TOAST_ICONS[toast.type] : Info;
const styles = toast.type ? TOAST_STYLES[toast.type] : TOAST_STYLES.info;
return (
<div
className={cn(
'relative flex min-w-[300px] max-w-md items-start gap-3 rounded-lg border p-4 shadow-lg transition-all duration-300',
styles,
isVisible && !isLeaving
? 'opacity-100 translate-x-0'
: 'opacity-0 translate-x-full',
)}
role="alert"
aria-live="polite"
>
<Icon className="h-5 w-5 flex-shrink-0" />
<div className="flex-1">
<p className="text-sm font-medium">{toast.message}</p>
</div>
<button
onClick={handleDismiss}
className="flex-shrink-0 rounded-md p-1 text-current opacity-70 transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-offset-2"
aria-label="Fermer"
>
<X className="h-4 w-4" />
</button>
</div>
);
}