mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-28 03:06:57 +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 type ToastType = 'success' | 'error' | 'warning' | 'info' | 'loading';
|
||||||
|
|
||||||
|
export interface ToastAction {
|
||||||
|
label: string;
|
||||||
|
onClick: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ToastOptions {
|
||||||
|
action?: ToastAction;
|
||||||
|
duration?: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface Toast {
|
export interface Toast {
|
||||||
id: string;
|
id: string;
|
||||||
type: ToastType;
|
type: ToastType;
|
||||||
message: string;
|
message: string;
|
||||||
createdAt: number;
|
createdAt: number;
|
||||||
|
action?: ToastAction;
|
||||||
|
duration?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const MAX_BUFFERED = 20;
|
||||||
|
|
||||||
let toasts: Toast[] = [];
|
let toasts: Toast[] = [];
|
||||||
const listeners: Set<() => void> = new Set();
|
const listeners: Set<() => void> = new Set();
|
||||||
let idCounter = 0;
|
let idCounter = 0;
|
||||||
@@ -17,15 +31,28 @@ function notify() {
|
|||||||
listeners.forEach((fn) => fn());
|
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()}`;
|
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();
|
notify();
|
||||||
return id;
|
return id;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function removeToast(id: string) {
|
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();
|
notify();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -45,10 +72,11 @@ export function useToasts() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const toast = {
|
export const toast = {
|
||||||
success: (message: string) => addToast('success', message),
|
success: (message: string, opts?: ToastOptions) => addToast('success', message, opts),
|
||||||
error: (message: string) => addToast('error', message),
|
error: (message: string, opts?: ToastOptions) => addToast('error', message, opts),
|
||||||
warning: (message: string) => addToast('warning', message),
|
warning: (message: string, opts?: ToastOptions) => addToast('warning', message, opts),
|
||||||
info: (message: string) => addToast('info', message),
|
info: (message: string, opts?: ToastOptions) => addToast('info', message, opts),
|
||||||
loading: (message: string) => addToast('loading', message),
|
loading: (message: string, opts?: Omit<ToastOptions, 'duration'>) =>
|
||||||
|
addToast('loading', message, opts),
|
||||||
dismiss: (id: string) => removeToast(id),
|
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 { createPortal } from 'react-dom';
|
||||||
import { AnimatePresence, motion } from 'motion/react';
|
import { AnimatePresence, motion } from 'motion/react';
|
||||||
import { Loader2 } from 'lucide-react';
|
import {
|
||||||
import { useToasts, removeToast, type ToastType } from './toast-store';
|
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 DEFAULT_DURATIONS: Record<ToastType, number> = {
|
||||||
|
|
||||||
const DURATIONS: Record<ToastType, number> = {
|
|
||||||
success: 4000,
|
success: 4000,
|
||||||
error: 6000,
|
error: 6000,
|
||||||
warning: 5000,
|
warning: 5000,
|
||||||
@@ -16,83 +28,81 @@ const DURATIONS: Record<ToastType, number> = {
|
|||||||
|
|
||||||
const MAX_VISIBLE = 5;
|
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 }) => (
|
const TOAST_CONFIG: Record<ToastType, ToastConfig> = {
|
||||||
<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" />,
|
|
||||||
},
|
|
||||||
success: {
|
success: {
|
||||||
iconColor: 'text-success',
|
icon: CheckCircle2,
|
||||||
icon: <SuccessIcon className="h-6 w-6" />,
|
iconClass: 'text-success',
|
||||||
},
|
railClass: 'bg-success',
|
||||||
warning: {
|
progressClass: 'bg-success/50',
|
||||||
iconColor: 'text-warning',
|
kicker: 'Success',
|
||||||
icon: <WarningIcon className="h-6 w-6" />,
|
|
||||||
},
|
},
|
||||||
error: {
|
error: {
|
||||||
iconColor: 'text-destructive',
|
icon: XCircle,
|
||||||
icon: <ErrorIcon className="h-6 w-6" />,
|
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: {
|
loading: {
|
||||||
iconColor: 'text-brand',
|
icon: Loader2,
|
||||||
icon: <Loader2 className="h-6 w-6 animate-spin" strokeWidth={1.5} />,
|
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 [hovered, setHovered] = useState(false);
|
||||||
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
const remainingRef = useRef(DURATIONS[type]);
|
const remainingRef = useRef(duration);
|
||||||
const startRef = useRef(0);
|
const startRef = useRef(0);
|
||||||
const config = notificationConfig[type];
|
|
||||||
const duration = DURATIONS[type];
|
|
||||||
|
|
||||||
const dismiss = useCallback(() => {
|
const dismiss = useCallback(() => {
|
||||||
removeToast(id);
|
removeToast(id);
|
||||||
}, [id]);
|
}, [id]);
|
||||||
|
|
||||||
// Auto-dismiss timer with hover pause (loading toasts never auto-dismiss)
|
const handleAction = useCallback(() => {
|
||||||
|
action?.onClick();
|
||||||
|
removeToast(id);
|
||||||
|
}, [action, id]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (type === 'loading') return;
|
if (type === 'loading' || !Number.isFinite(duration)) return;
|
||||||
if (hovered) {
|
if (hovered) {
|
||||||
if (timerRef.current) {
|
if (timerRef.current) {
|
||||||
clearTimeout(timerRef.current);
|
clearTimeout(timerRef.current);
|
||||||
@@ -106,7 +116,7 @@ function ToastItem({ id, type, message }: { id: string; type: ToastType; message
|
|||||||
return () => {
|
return () => {
|
||||||
if (timerRef.current) clearTimeout(timerRef.current);
|
if (timerRef.current) clearTimeout(timerRef.current);
|
||||||
};
|
};
|
||||||
}, [hovered, dismiss, type]);
|
}, [hovered, dismiss, type, duration]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<motion.div
|
<motion.div
|
||||||
@@ -115,62 +125,92 @@ function ToastItem({ id, type, message }: { id: string; type: ToastType; message
|
|||||||
animate={{ opacity: 1, x: 0 }}
|
animate={{ opacity: 1, x: 0 }}
|
||||||
exit={{ opacity: 0, x: 100 }}
|
exit={{ opacity: 0, x: 100 }}
|
||||||
transition={{ duration: 0.3 }}
|
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)}
|
onMouseEnter={() => setHovered(true)}
|
||||||
onMouseLeave={() => setHovered(false)}
|
onMouseLeave={() => setHovered(false)}
|
||||||
|
role={type === 'error' ? 'alert' : 'status'}
|
||||||
|
aria-atomic="true"
|
||||||
>
|
>
|
||||||
<div className="flex items-center space-x-4">
|
<div
|
||||||
<div className={`flex-shrink-0 ${config.iconColor}`}>
|
className={cn('absolute inset-y-0 left-0 w-[3px] opacity-70', config.railClass)}
|
||||||
{config.icon}
|
aria-hidden
|
||||||
</div>
|
/>
|
||||||
<div className="flex-1">
|
|
||||||
<p className="font-normal text-foreground text-lg">{message}</p>
|
<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>
|
</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
|
<button
|
||||||
|
type="button"
|
||||||
onClick={dismiss}
|
onClick={dismiss}
|
||||||
className="flex-shrink-0 text-muted-foreground hover:text-foreground transition-colors p-1.5 rounded-full hover:bg-accent"
|
aria-label="Dismiss notification"
|
||||||
aria-label="Close 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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Progress bar */}
|
<div className="absolute bottom-0 left-0 h-[2px] w-full overflow-hidden" aria-hidden>
|
||||||
<div className="absolute bottom-0 left-0 h-1 w-full bg-glass-border rounded-b-xl overflow-hidden">
|
|
||||||
{type === 'loading' ? (
|
{type === 'loading' ? (
|
||||||
<motion.div
|
<motion.div
|
||||||
initial={{ x: '-100%' }}
|
initial={{ x: '-100%' }}
|
||||||
animate={{ x: '200%' }}
|
animate={{ x: '200%' }}
|
||||||
transition={{ duration: 1.5, repeat: Infinity, ease: 'easeInOut' }}
|
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
|
<motion.div
|
||||||
initial={{ width: 0 }}
|
initial={{ width: 0 }}
|
||||||
animate={{ width: hovered ? undefined : '100%' }}
|
animate={hovered ? false : { width: '100%' }}
|
||||||
transition={{ duration: duration / 1000, ease: 'linear' }}
|
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>
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
);
|
);
|
||||||
}
|
});
|
||||||
|
|
||||||
/* ── ToastContainer ── */
|
|
||||||
|
|
||||||
export function ToastContainer() {
|
export function ToastContainer() {
|
||||||
const toasts = useToasts();
|
const toasts = useToasts();
|
||||||
const visible = toasts.slice(-MAX_VISIBLE);
|
const visible = toasts.slice(-MAX_VISIBLE);
|
||||||
|
|
||||||
return createPortal(
|
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">
|
<AnimatePresence mode="popLayout">
|
||||||
{visible.map((t) => (
|
{visible.map((t) => (
|
||||||
<ToastItem key={t.id} id={t.id} type={t.type} message={t.message} />
|
<ToastItem key={t.id} {...t} />
|
||||||
))}
|
))}
|
||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
</div>,
|
</div>,
|
||||||
document.body
|
document.body,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user