mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-07-27 20:29:10 +00:00
feat(ui): redesign toast with accent rail and action slot (#693)
* feat(ui): redesign toast with accent rail and action slot Align toast with the overlay-surface language from the user menu and notification panel: left accent rail, tracked-mono kicker above the message, lucide icons at 1.5px stroke, status-tinted progress bar. Extend the toast store with an optional options object so callers can pass an inline action button and override the default duration. Guard removeToast against no-op dismissals so subscribers are not notified when nothing changed, and cap the buffered queue at 20 to prevent unbounded growth under spam. * fix(ui): avoid impure Date.now in toast ref initializer useRef(Date.now()) re-evaluates the impure call on every render and trips the react-hooks/purity lint rule, even though only the first render's value is retained. Initialize to 0 since the auto-dismiss effect writes startRef.current = Date.now() before any read path consumes it.
This commit is contained in:
@@ -2,13 +2,27 @@ import { useSyncExternalStore } from 'react';
|
||||
|
||||
export type ToastType = 'success' | 'error' | 'warning' | 'info' | 'loading';
|
||||
|
||||
export interface ToastAction {
|
||||
label: string;
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
export interface ToastOptions {
|
||||
action?: ToastAction;
|
||||
duration?: number;
|
||||
}
|
||||
|
||||
export interface Toast {
|
||||
id: string;
|
||||
type: ToastType;
|
||||
message: string;
|
||||
createdAt: number;
|
||||
action?: ToastAction;
|
||||
duration?: number;
|
||||
}
|
||||
|
||||
const MAX_BUFFERED = 20;
|
||||
|
||||
let toasts: Toast[] = [];
|
||||
const listeners: Set<() => void> = new Set();
|
||||
let idCounter = 0;
|
||||
@@ -17,15 +31,28 @@ function notify() {
|
||||
listeners.forEach((fn) => fn());
|
||||
}
|
||||
|
||||
function addToast(type: ToastType, message: string): string {
|
||||
function addToast(type: ToastType, message: string, opts?: ToastOptions): string {
|
||||
const id = `toast-${++idCounter}-${Date.now()}`;
|
||||
toasts = [...toasts, { id, type, message, createdAt: Date.now() }];
|
||||
const next: Toast[] = [
|
||||
...toasts,
|
||||
{
|
||||
id,
|
||||
type,
|
||||
message,
|
||||
createdAt: Date.now(),
|
||||
action: opts?.action,
|
||||
duration: opts?.duration,
|
||||
},
|
||||
];
|
||||
toasts = next.length > MAX_BUFFERED ? next.slice(-MAX_BUFFERED) : next;
|
||||
notify();
|
||||
return id;
|
||||
}
|
||||
|
||||
export function removeToast(id: string) {
|
||||
toasts = toasts.filter((t) => t.id !== id);
|
||||
const next = toasts.filter((t) => t.id !== id);
|
||||
if (next.length === toasts.length) return;
|
||||
toasts = next;
|
||||
notify();
|
||||
}
|
||||
|
||||
@@ -45,10 +72,11 @@ export function useToasts() {
|
||||
}
|
||||
|
||||
export const toast = {
|
||||
success: (message: string) => addToast('success', message),
|
||||
error: (message: string) => addToast('error', message),
|
||||
warning: (message: string) => addToast('warning', message),
|
||||
info: (message: string) => addToast('info', message),
|
||||
loading: (message: string) => addToast('loading', message),
|
||||
success: (message: string, opts?: ToastOptions) => addToast('success', message, opts),
|
||||
error: (message: string, opts?: ToastOptions) => addToast('error', message, opts),
|
||||
warning: (message: string, opts?: ToastOptions) => addToast('warning', message, opts),
|
||||
info: (message: string, opts?: ToastOptions) => addToast('info', message, opts),
|
||||
loading: (message: string, opts?: Omit<ToastOptions, 'duration'>) =>
|
||||
addToast('loading', message, opts),
|
||||
dismiss: (id: string) => removeToast(id),
|
||||
};
|
||||
|
||||
@@ -1,12 +1,24 @@
|
||||
import React, { useEffect, useRef, useState, useCallback } from 'react';
|
||||
import { memo, useEffect, useRef, useState, useCallback } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { AnimatePresence, motion } from 'motion/react';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
import { useToasts, removeToast, type ToastType } from './toast-store';
|
||||
import {
|
||||
CheckCircle2,
|
||||
XCircle,
|
||||
AlertTriangle,
|
||||
Info,
|
||||
Loader2,
|
||||
X,
|
||||
type LucideIcon,
|
||||
} from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import {
|
||||
useToasts,
|
||||
removeToast,
|
||||
type Toast,
|
||||
type ToastType,
|
||||
} from './toast-store';
|
||||
|
||||
/* ── Durations & Config ── */
|
||||
|
||||
const DURATIONS: Record<ToastType, number> = {
|
||||
const DEFAULT_DURATIONS: Record<ToastType, number> = {
|
||||
success: 4000,
|
||||
error: 6000,
|
||||
warning: 5000,
|
||||
@@ -16,83 +28,81 @@ const DURATIONS: Record<ToastType, number> = {
|
||||
|
||||
const MAX_VISIBLE = 5;
|
||||
|
||||
/* ── SVG Icons (matching Sera UI exactly — h-6 w-6, stroke-based) ── */
|
||||
type ToastConfig = {
|
||||
icon: LucideIcon;
|
||||
iconClass: string;
|
||||
railClass: string;
|
||||
progressClass: string;
|
||||
kicker: string;
|
||||
spin?: boolean;
|
||||
};
|
||||
|
||||
const InfoIcon: React.FC<{ className?: string }> = ({ className }) => (
|
||||
<svg className={className} xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const SuccessIcon: React.FC<{ className?: string }> = ({ className }) => (
|
||||
<svg className={className} xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const WarningIcon: React.FC<{ className?: string }> = ({ className }) => (
|
||||
<svg className={className} xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const ErrorIcon: React.FC<{ className?: string }> = ({ className }) => (
|
||||
<svg className={className} xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const CloseIcon: React.FC<{ className?: string }> = ({ className }) => (
|
||||
<svg className={className} xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
/* ── Type config (matching Sera UI's notificationConfig) ── */
|
||||
|
||||
const notificationConfig: Record<ToastType, {
|
||||
iconColor: string;
|
||||
icon: React.ReactNode;
|
||||
}> = {
|
||||
info: {
|
||||
iconColor: 'text-info',
|
||||
icon: <InfoIcon className="h-6 w-6" />,
|
||||
},
|
||||
const TOAST_CONFIG: Record<ToastType, ToastConfig> = {
|
||||
success: {
|
||||
iconColor: 'text-success',
|
||||
icon: <SuccessIcon className="h-6 w-6" />,
|
||||
},
|
||||
warning: {
|
||||
iconColor: 'text-warning',
|
||||
icon: <WarningIcon className="h-6 w-6" />,
|
||||
icon: CheckCircle2,
|
||||
iconClass: 'text-success',
|
||||
railClass: 'bg-success',
|
||||
progressClass: 'bg-success/50',
|
||||
kicker: 'Success',
|
||||
},
|
||||
error: {
|
||||
iconColor: 'text-destructive',
|
||||
icon: <ErrorIcon className="h-6 w-6" />,
|
||||
icon: XCircle,
|
||||
iconClass: 'text-destructive',
|
||||
railClass: 'bg-destructive',
|
||||
progressClass: 'bg-destructive/50',
|
||||
kicker: 'Error',
|
||||
},
|
||||
warning: {
|
||||
icon: AlertTriangle,
|
||||
iconClass: 'text-warning',
|
||||
railClass: 'bg-warning',
|
||||
progressClass: 'bg-warning/50',
|
||||
kicker: 'Warning',
|
||||
},
|
||||
info: {
|
||||
icon: Info,
|
||||
iconClass: 'text-info',
|
||||
railClass: 'bg-info',
|
||||
progressClass: 'bg-info/50',
|
||||
kicker: 'Info',
|
||||
},
|
||||
loading: {
|
||||
iconColor: 'text-brand',
|
||||
icon: <Loader2 className="h-6 w-6 animate-spin" strokeWidth={1.5} />,
|
||||
icon: Loader2,
|
||||
iconClass: 'text-brand',
|
||||
railClass: 'bg-brand',
|
||||
progressClass: 'bg-brand/50',
|
||||
kicker: 'Working',
|
||||
spin: true,
|
||||
},
|
||||
};
|
||||
|
||||
/* ── ToastItem — faithful Sera UI Notification replica ── */
|
||||
const ToastItem = memo(function ToastItem({
|
||||
type,
|
||||
message,
|
||||
action,
|
||||
duration: explicitDuration,
|
||||
id,
|
||||
}: Toast) {
|
||||
const config = TOAST_CONFIG[type];
|
||||
const Icon = config.icon;
|
||||
const duration = explicitDuration ?? DEFAULT_DURATIONS[type];
|
||||
|
||||
function ToastItem({ id, type, message }: { id: string; type: ToastType; message: string }) {
|
||||
const [hovered, setHovered] = useState(false);
|
||||
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const remainingRef = useRef(DURATIONS[type]);
|
||||
const remainingRef = useRef(duration);
|
||||
const startRef = useRef(0);
|
||||
const config = notificationConfig[type];
|
||||
const duration = DURATIONS[type];
|
||||
|
||||
const dismiss = useCallback(() => {
|
||||
removeToast(id);
|
||||
}, [id]);
|
||||
|
||||
// Auto-dismiss timer with hover pause (loading toasts never auto-dismiss)
|
||||
const handleAction = useCallback(() => {
|
||||
action?.onClick();
|
||||
removeToast(id);
|
||||
}, [action, id]);
|
||||
|
||||
useEffect(() => {
|
||||
if (type === 'loading') return;
|
||||
if (type === 'loading' || !Number.isFinite(duration)) return;
|
||||
if (hovered) {
|
||||
if (timerRef.current) {
|
||||
clearTimeout(timerRef.current);
|
||||
@@ -106,7 +116,7 @@ function ToastItem({ id, type, message }: { id: string; type: ToastType; message
|
||||
return () => {
|
||||
if (timerRef.current) clearTimeout(timerRef.current);
|
||||
};
|
||||
}, [hovered, dismiss, type]);
|
||||
}, [hovered, dismiss, type, duration]);
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
@@ -115,62 +125,92 @@ function ToastItem({ id, type, message }: { id: string; type: ToastType; message
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
exit={{ opacity: 0, x: 100 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
className="relative w-full max-w-sm rounded-xl p-4 backdrop-blur-[10px] backdrop-saturate-[1.15] bg-popover/95 border border-glass-border overflow-hidden ring-1 ring-glass-border drop-shadow-xl transition-all duration-300 ease-in-out transform hover:scale-105 font-[family-name:var(--font-sans)]"
|
||||
className="pointer-events-auto relative w-full max-w-sm overflow-hidden rounded-md border border-glass-border bg-popover/95 ring-1 ring-glass-border drop-shadow-xl backdrop-blur-[10px] backdrop-saturate-[1.15]"
|
||||
onMouseEnter={() => setHovered(true)}
|
||||
onMouseLeave={() => setHovered(false)}
|
||||
role={type === 'error' ? 'alert' : 'status'}
|
||||
aria-atomic="true"
|
||||
>
|
||||
<div className="flex items-center space-x-4">
|
||||
<div className={`flex-shrink-0 ${config.iconColor}`}>
|
||||
{config.icon}
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<p className="font-normal text-foreground text-lg">{message}</p>
|
||||
<div
|
||||
className={cn('absolute inset-y-0 left-0 w-[3px] opacity-70', config.railClass)}
|
||||
aria-hidden
|
||||
/>
|
||||
|
||||
<div className="flex items-start gap-3 px-4 py-3 pl-5">
|
||||
<Icon
|
||||
className={cn(
|
||||
'mt-0.5 h-4 w-4 flex-shrink-0',
|
||||
config.iconClass,
|
||||
config.spin && 'animate-spin',
|
||||
)}
|
||||
strokeWidth={1.5}
|
||||
aria-hidden
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div
|
||||
className={cn(
|
||||
'font-mono text-[10px] uppercase tracking-[0.14em]',
|
||||
config.iconClass,
|
||||
)}
|
||||
>
|
||||
{config.kicker}
|
||||
</div>
|
||||
<p className="mt-1 break-words text-sm leading-snug text-stat-value">
|
||||
{message}
|
||||
</p>
|
||||
</div>
|
||||
{action ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleAction}
|
||||
className="flex-shrink-0 self-start rounded-sm px-2 py-1 font-mono text-[10px] uppercase tracking-[0.14em] text-brand transition-colors hover:bg-brand/10 focus-visible:bg-brand/10 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-brand/60"
|
||||
>
|
||||
{action.label}
|
||||
</button>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
onClick={dismiss}
|
||||
className="flex-shrink-0 text-muted-foreground hover:text-foreground transition-colors p-1.5 rounded-full hover:bg-accent"
|
||||
aria-label="Close notification"
|
||||
aria-label="Dismiss notification"
|
||||
className="flex-shrink-0 self-start rounded-sm p-1 text-stat-icon transition-colors hover:bg-accent hover:text-stat-value focus-visible:bg-accent focus-visible:outline-none"
|
||||
>
|
||||
<CloseIcon className="h-5 w-5" />
|
||||
<X className="h-3.5 w-3.5" strokeWidth={1.5} aria-hidden />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Progress bar */}
|
||||
<div className="absolute bottom-0 left-0 h-1 w-full bg-glass-border rounded-b-xl overflow-hidden">
|
||||
<div className="absolute bottom-0 left-0 h-[2px] w-full overflow-hidden" aria-hidden>
|
||||
{type === 'loading' ? (
|
||||
<motion.div
|
||||
initial={{ x: '-100%' }}
|
||||
animate={{ x: '200%' }}
|
||||
transition={{ duration: 1.5, repeat: Infinity, ease: 'easeInOut' }}
|
||||
className="h-full w-1/3 bg-gradient-to-r from-transparent via-brand to-transparent"
|
||||
className={cn('h-full w-1/3', config.progressClass)}
|
||||
/>
|
||||
) : (
|
||||
) : Number.isFinite(duration) ? (
|
||||
<motion.div
|
||||
initial={{ width: 0 }}
|
||||
animate={{ width: hovered ? undefined : '100%' }}
|
||||
animate={hovered ? false : { width: '100%' }}
|
||||
transition={{ duration: duration / 1000, ease: 'linear' }}
|
||||
className="h-full bg-gradient-to-r from-transparent via-brand to-transparent"
|
||||
className={cn('h-full', config.progressClass)}
|
||||
/>
|
||||
)}
|
||||
) : null}
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── ToastContainer ── */
|
||||
});
|
||||
|
||||
export function ToastContainer() {
|
||||
const toasts = useToasts();
|
||||
const visible = toasts.slice(-MAX_VISIBLE);
|
||||
|
||||
return createPortal(
|
||||
<div className="fixed p-4 space-y-2 w-full max-w-sm z-50 bottom-4 right-4">
|
||||
<div className="pointer-events-none fixed bottom-4 right-4 z-50 flex w-full max-w-sm flex-col gap-2 p-4">
|
||||
<AnimatePresence mode="popLayout">
|
||||
{visible.map((t) => (
|
||||
<ToastItem key={t.id} id={t.id} type={t.type} message={t.message} />
|
||||
<ToastItem key={t.id} {...t} />
|
||||
))}
|
||||
</AnimatePresence>
|
||||
</div>,
|
||||
document.body
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user